Skip to main content

qs_backtest/strategy/
portfolio.rs

1//! Several configured strategy instances replayed against one account, optionally under a portfolio supervisor.
2//!
3//! Every instance keeps its own series, analysis, decisions, and entry-profile routes, while fills, balance, costs, marks, and drawdown are shared. Each instance sees feedback only for the commands it issued, because configured command identifiers are scoped by strategy and instance.
4
5use std::convert::Infallible;
6
7use chrono::NaiveDateTime;
8use qs_risk::{HaltCommand, HaltInterval, IntentKind, RiskConfigError, Verdict};
9use serde::Serialize;
10
11use super::{
12    AnalysisPipeline, BacktestConfiguredStrategyAdapter, BarSeriesSpec,
13    ConfiguredStrategyAdapterError, HistoricalStrategy, StrategyDecisionOutput, StrategyDescriptor,
14    StrategyReplayError, StrategyReplayInputError, StrategyResearchOutput,
15};
16use crate::profile::PreparedEntryProfiles;
17use crate::report::BacktestResult;
18
19/// Largest number of instances one portfolio replay accepts.
20pub const MAX_PORTFOLIO_INSTANCES: usize = 64;
21
22/// Position tag the portfolio replay owns: every position records the instance that opened it.
23pub const INSTANCE_POSITION_TAG: &str = "instance";
24
25/// One configured strategy instance of a portfolio replay.
26pub struct ConfiguredInstance {
27    pub adapter: BacktestConfiguredStrategyAdapter,
28    pub analysis: AnalysisPipeline,
29    /// Profiles this instance's Entries select from, by entry class or as the instance default.
30    pub entry_profiles: PreparedEntryProfiles,
31    /// First instant whose market events this instance's series read, normally its own derived warmup start. A shared feed may begin earlier for another instance, and without this bound a strategy with a shorter warmup would become ready, and could trade, before the window it was asked to evaluate.
32    pub feed_from: Option<NaiveDateTime>,
33}
34
35impl ConfiguredInstance {
36    pub fn new(adapter: BacktestConfiguredStrategyAdapter, analysis: AnalysisPipeline) -> Self {
37        Self {
38            adapter,
39            analysis,
40            entry_profiles: PreparedEntryProfiles::default(),
41            feed_from: None,
42        }
43    }
44
45    pub fn with_entry_profiles(mut self, profiles: PreparedEntryProfiles) -> Self {
46        self.entry_profiles = profiles;
47        self
48    }
49
50    pub fn with_feed_from(mut self, from: NaiveDateTime) -> Self {
51        self.feed_from = Some(from);
52        self
53    }
54
55    pub fn strategy_id(&self) -> &str {
56        self.adapter.configured_strategy().strategy_id()
57    }
58
59    pub fn instance_id(&self) -> &str {
60        self.adapter.configured_strategy().instance_id()
61    }
62}
63
64/// One caller-compiled direct strategy instance in a mixed portfolio replay.
65pub struct DirectPortfolioInstance<E> {
66    pub instance_id: String,
67    pub strategy: Box<dyn HistoricalStrategy<Error = E> + Send>,
68    pub series: Vec<BarSeriesSpec>,
69    pub analysis: AnalysisPipeline,
70    pub entry_profiles: PreparedEntryProfiles,
71    pub feed_from: Option<NaiveDateTime>,
72}
73
74impl<E> DirectPortfolioInstance<E> {
75    pub fn new(
76        instance_id: impl Into<String>,
77        strategy: Box<dyn HistoricalStrategy<Error = E> + Send>,
78        series: Vec<BarSeriesSpec>,
79        analysis: AnalysisPipeline,
80    ) -> Self {
81        Self {
82            instance_id: instance_id.into(),
83            strategy,
84            series,
85            analysis,
86            entry_profiles: PreparedEntryProfiles::default(),
87            feed_from: None,
88        }
89    }
90
91    pub fn with_entry_profiles(mut self, profiles: PreparedEntryProfiles) -> Self {
92        self.entry_profiles = profiles;
93        self
94    }
95
96    pub fn with_feed_from(mut self, from: NaiveDateTime) -> Self {
97        self.feed_from = Some(from);
98        self
99    }
100}
101
102/// Economic and supervision output of configured and direct instances sharing one account.
103#[derive(Debug)]
104pub struct MixedPortfolioBacktestResult {
105    pub replay: BacktestResult,
106    pub supervisor: Option<SupervisorOutput>,
107}
108
109/// What one instance decided and recorded during a portfolio replay.
110#[derive(Debug, Clone, Serialize)]
111pub struct PortfolioInstanceOutput {
112    pub strategy_id: String,
113    pub instance_id: String,
114    pub descriptor: StrategyDescriptor,
115    pub decisions: StrategyDecisionOutput,
116    pub research: StrategyResearchOutput,
117}
118
119/// One review of a request for new exposure.
120#[derive(Debug, Clone, PartialEq, Serialize)]
121pub struct SupervisorEvent {
122    pub ts: NaiveDateTime,
123    pub instance_id: String,
124    pub action_id: String,
125    pub kind: IntentKind,
126    pub symbol: String,
127    /// Account-currency risk the request asked for, when it was known before the fill.
128    pub requested_risk: Option<f64>,
129    pub verdict: Verdict,
130}
131
132/// A bulk action the supervisor scheduled when a halt began.
133#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct SupervisorHaltAction {
135    pub ts: NaiveDateTime,
136    pub action_id: String,
137    pub command: HaltCommand,
138}
139
140/// Everything the supervisor decided during a portfolio replay.
141#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct SupervisorOutput {
143    pub events: Vec<SupervisorEvent>,
144    pub halt_actions: Vec<SupervisorHaltAction>,
145    /// Halt periods; a period without an end lasted until the run ended.
146    pub halts: Vec<HaltInterval>,
147    /// Boundaries at which no marked equity existed yet, during which a drawdown policy could not act.
148    pub unmarked_boundaries: u64,
149}
150
151impl SupervisorOutput {
152    /// Number of Entries the supervisor rejected, leaving out rejected scale-ins.
153    pub fn rejected_entries(&self) -> usize {
154        self.events
155            .iter()
156            .filter(|event| event.kind == IntentKind::Entry && !event.verdict.is_approved())
157            .count()
158    }
159
160    /// Total halted time in whole minutes, where a period still in force at the end of the run runs to `run_end`.
161    pub fn halt_minutes(&self, run_end: NaiveDateTime) -> i64 {
162        self.halts
163            .iter()
164            .map(|halt| {
165                let end = halt.to.unwrap_or(run_end).max(halt.from);
166                (end - halt.from).num_minutes()
167            })
168            .sum()
169    }
170}
171
172/// Result of a portfolio replay: one shared account and one output per instance in declared order.
173#[derive(Debug)]
174pub struct PortfolioBacktestResult {
175    pub replay: BacktestResult,
176    pub instances: Vec<PortfolioInstanceOutput>,
177    pub supervisor: Option<SupervisorOutput>,
178}
179
180/// Failures of a portfolio replay.
181#[derive(Debug, thiserror::Error)]
182pub enum PortfolioReplayError<FeedError> {
183    #[error("a portfolio replay needs at least one instance")]
184    NoInstances,
185    #[error("a portfolio replay accepts at most {MAX_PORTFOLIO_INSTANCES} instances, got {0}")]
186    TooManyInstances(usize),
187    #[error(
188        "instance '{instance_id}' appears more than once; every instance of a portfolio needs its own identifier"
189    )]
190    DuplicateInstanceIdentity { instance_id: String },
191    #[error("portfolio replay input is invalid: {0}")]
192    Input(#[from] StrategyReplayInputError),
193    #[error("portfolio supervisor is invalid: {0}")]
194    Supervisor(String),
195    #[error("instance '{instance_id}' failed: {source}")]
196    Instance {
197        instance_id: String,
198        #[source]
199        source: StrategyReplayError<Infallible, ConfiguredStrategyAdapterError>,
200    },
201    #[error(
202        "the portfolio feed mixes ticks and stored bars from {timestamp}; a portfolio replays one kind of primary input"
203    )]
204    MixedPrimaryInput { timestamp: NaiveDateTime },
205    #[error("market-data stream failed: {0}")]
206    Feed(FeedError),
207    #[error("portfolio replay was cancelled")]
208    Cancelled,
209}
210
211#[derive(Debug, thiserror::Error)]
212pub enum MixedPortfolioReplayError<FeedError> {
213    #[error("a mixed portfolio replay needs at least one instance")]
214    NoInstances,
215    #[error(
216        "a mixed portfolio replay accepts at most {MAX_PORTFOLIO_INSTANCES} instances, got {0}"
217    )]
218    TooManyInstances(usize),
219    #[error("instance '{instance_id}' appears more than once")]
220    DuplicateInstanceIdentity { instance_id: String },
221    #[error("mixed portfolio replay input is invalid: {0}")]
222    Input(String),
223    #[error("instance '{instance_id}' failed: {reason}")]
224    Instance { instance_id: String, reason: String },
225    #[error("the mixed portfolio feed mixes ticks and stored bars from {timestamp}")]
226    MixedPrimaryInput { timestamp: NaiveDateTime },
227    #[error("market-data stream failed: {0}")]
228    Feed(FeedError),
229    #[error("mixed portfolio replay was cancelled")]
230    Cancelled,
231}
232
233impl<FeedError> From<RiskConfigError> for PortfolioReplayError<FeedError> {
234    fn from(error: RiskConfigError) -> Self {
235        Self::Supervisor(error.to_string())
236    }
237}