1use 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
19pub const MAX_PORTFOLIO_INSTANCES: usize = 64;
21
22pub const INSTANCE_POSITION_TAG: &str = "instance";
24
25pub struct ConfiguredInstance {
27 pub adapter: BacktestConfiguredStrategyAdapter,
28 pub analysis: AnalysisPipeline,
29 pub entry_profiles: PreparedEntryProfiles,
31 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
64pub 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#[derive(Debug)]
104pub struct MixedPortfolioBacktestResult {
105 pub replay: BacktestResult,
106 pub supervisor: Option<SupervisorOutput>,
107}
108
109#[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#[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 pub requested_risk: Option<f64>,
129 pub verdict: Verdict,
130}
131
132#[derive(Debug, Clone, PartialEq, Serialize)]
134pub struct SupervisorHaltAction {
135 pub ts: NaiveDateTime,
136 pub action_id: String,
137 pub command: HaltCommand,
138}
139
140#[derive(Debug, Clone, PartialEq, Serialize)]
142pub struct SupervisorOutput {
143 pub events: Vec<SupervisorEvent>,
144 pub halt_actions: Vec<SupervisorHaltAction>,
145 pub halts: Vec<HaltInterval>,
147 pub unmarked_boundaries: u64,
149}
150
151impl SupervisorOutput {
152 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 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#[derive(Debug)]
174pub struct PortfolioBacktestResult {
175 pub replay: BacktestResult,
176 pub instances: Vec<PortfolioInstanceOutput>,
177 pub supervisor: Option<SupervisorOutput>,
178}
179
180#[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}