1use std::collections::HashSet;
4use std::fmt;
5
6use chrono::{Duration, NaiveDateTime};
7use serde::{Deserialize, Deserializer, Serialize};
8
9use crate::profile::RawSignal;
10use crate::report::BacktestResult;
11
12use super::annotation::deserialize_research_annotations;
13use super::config::{PriceBasis, SeriesId, StrategyRetentionLimits, Timeframe, WarmupRequirement};
14use super::{StrategyAnnotation, StrategyJournalOutput};
15
16pub const MAX_STRATEGY_ID_BYTES: usize = 64;
17pub const MAX_STRATEGY_REVISION_BYTES: usize = 64;
18pub const MAX_STRATEGY_TITLE_BYTES: usize = 256;
19pub const MAX_INSTRUMENT_BYTES: usize = 64;
20pub const MAX_TRADE_ID_BYTES: usize = qs_strategy::MAX_GENERATED_ID_BYTES;
21pub const MAX_DECISION_LATENCY_MS: u64 = 86_400_000;
22
23#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
25pub enum StrategyDomainError {
26 #[error("strategy ID must contain 1 to {MAX_STRATEGY_ID_BYTES} ASCII identifier bytes")]
27 InvalidStrategyId,
28 #[error(
29 "strategy revision must contain 1 to {MAX_STRATEGY_REVISION_BYTES} ASCII identifier bytes"
30 )]
31 InvalidRevision,
32 #[error(
33 "strategy title must contain 1 to {MAX_STRATEGY_TITLE_BYTES} trimmed non-control bytes"
34 )]
35 InvalidTitle,
36 #[error("strategy requirements must declare at least one instrument")]
37 MissingInstruments,
38 #[error("strategy requirements must declare at least one series")]
39 MissingSeries,
40 #[error("invalid instrument symbol '{symbol}'")]
41 InvalidInstrument { symbol: String },
42 #[error("instrument symbol '{symbol}' is declared more than once")]
43 DuplicateInstrument { symbol: String },
44 #[error("series ID '{series_id}' is declared more than once")]
45 DuplicateSeriesId { series_id: SeriesId },
46 #[error("series '{series_id}' references undeclared instrument '{symbol}'")]
47 UndeclaredSeriesInstrument { series_id: SeriesId, symbol: String },
48 #[error(
49 "series '{series_id}' duplicates another symbol, timeframe, and price-basis requirement"
50 )]
51 DuplicateSeriesDefinition { series_id: SeriesId },
52 #[error("decision latency {value} ms exceeds the maximum {MAX_DECISION_LATENCY_MS} ms")]
53 DecisionLatencyTooLarge { value: u64 },
54 #[error(
55 "decision timestamp {timestamp} plus {latency_ms} ms is outside the supported time range"
56 )]
57 DecisionTimestampOverflow {
58 timestamp: NaiveDateTime,
59 latency_ms: u64,
60 },
61 #[error("decision reason must contain 1 to {maximum} trimmed non-control bytes")]
62 InvalidDecisionReason { maximum: usize },
63 #[error("related trade ID must contain 1 to {MAX_TRADE_ID_BYTES} trimmed non-control bytes")]
64 InvalidTradeId,
65 #[error("decision returned {actual} signals, exceeding the callback limit {maximum}")]
66 TooManySignals { actual: usize, maximum: usize },
67 #[error("decision sequence {current} must be greater than the prior sequence {previous}")]
68 NonMonotonicDecisionSequence { previous: u64, current: u64 },
69 #[error("decision omitted counter overflowed")]
70 OmittedCounterOverflow,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
75#[serde(transparent)]
76pub struct StrategyId(String);
77
78impl StrategyId {
79 pub fn new(value: impl Into<String>) -> Result<Self, StrategyDomainError> {
80 let value = value.into();
81 if valid_identifier(&value, MAX_STRATEGY_ID_BYTES) {
82 Ok(Self(value))
83 } else {
84 Err(StrategyDomainError::InvalidStrategyId)
85 }
86 }
87
88 pub fn as_str(&self) -> &str {
89 &self.0
90 }
91}
92
93impl fmt::Display for StrategyId {
94 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95 formatter.write_str(&self.0)
96 }
97}
98
99impl<'de> Deserialize<'de> for StrategyId {
100 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101 where
102 D: Deserializer<'de>,
103 {
104 let value = String::deserialize(deserializer)?;
105 Self::new(value).map_err(serde::de::Error::custom)
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111pub struct StrategyDescriptor {
112 id: StrategyId,
113 revision: String,
114 title: String,
115}
116
117impl StrategyDescriptor {
118 pub fn new(
119 id: StrategyId,
120 revision: impl Into<String>,
121 title: impl Into<String>,
122 ) -> Result<Self, StrategyDomainError> {
123 let revision = revision.into();
124 let title = title.into();
125 if !valid_identifier(&revision, MAX_STRATEGY_REVISION_BYTES) {
126 return Err(StrategyDomainError::InvalidRevision);
127 }
128 if !valid_text(&title, MAX_STRATEGY_TITLE_BYTES) {
129 return Err(StrategyDomainError::InvalidTitle);
130 }
131 Ok(Self {
132 id,
133 revision,
134 title,
135 })
136 }
137
138 pub fn id(&self) -> &StrategyId {
139 &self.id
140 }
141
142 pub fn revision(&self) -> &str {
143 &self.revision
144 }
145
146 pub fn title(&self) -> &str {
147 &self.title
148 }
149}
150
151#[derive(Deserialize)]
152#[serde(deny_unknown_fields)]
153struct StrategyDescriptorDef {
154 id: StrategyId,
155 revision: String,
156 title: String,
157}
158
159impl<'de> Deserialize<'de> for StrategyDescriptor {
160 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161 where
162 D: Deserializer<'de>,
163 {
164 let value = StrategyDescriptorDef::deserialize(deserializer)?;
165 Self::new(value.id, value.revision, value.title).map_err(serde::de::Error::custom)
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
171pub struct SeriesRequirement {
172 id: SeriesId,
173 symbol: String,
174 timeframe: Timeframe,
175 price_basis: PriceBasis,
176 warmup: WarmupRequirement,
177}
178
179impl SeriesRequirement {
180 pub fn new(
181 id: SeriesId,
182 symbol: impl Into<String>,
183 timeframe: Timeframe,
184 price_basis: PriceBasis,
185 warmup: WarmupRequirement,
186 ) -> Result<Self, StrategyDomainError> {
187 let symbol = symbol.into();
188 validate_instrument(&symbol)?;
189 Ok(Self {
190 id,
191 symbol,
192 timeframe,
193 price_basis,
194 warmup,
195 })
196 }
197
198 pub fn id(&self) -> &SeriesId {
199 &self.id
200 }
201
202 pub fn symbol(&self) -> &str {
203 &self.symbol
204 }
205
206 pub fn timeframe(&self) -> Timeframe {
207 self.timeframe
208 }
209
210 pub fn price_basis(&self) -> PriceBasis {
211 self.price_basis
212 }
213
214 pub fn warmup(&self) -> WarmupRequirement {
215 self.warmup
216 }
217}
218
219#[derive(Deserialize)]
220#[serde(deny_unknown_fields)]
221struct SeriesRequirementDef {
222 id: SeriesId,
223 symbol: String,
224 timeframe: Timeframe,
225 price_basis: PriceBasis,
226 warmup: WarmupRequirement,
227}
228
229impl<'de> Deserialize<'de> for SeriesRequirement {
230 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
231 where
232 D: Deserializer<'de>,
233 {
234 let value = SeriesRequirementDef::deserialize(deserializer)?;
235 Self::new(
236 value.id,
237 value.symbol,
238 value.timeframe,
239 value.price_basis,
240 value.warmup,
241 )
242 .map_err(serde::de::Error::custom)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
248pub struct StrategyRequirements {
249 instruments: Vec<String>,
250 series: Vec<SeriesRequirement>,
251 decision_latency_ms: u64,
252 needs_tick_execution: bool,
253 needs_execution_feedback: bool,
254}
255
256impl StrategyRequirements {
257 pub fn new(
258 instruments: Vec<String>,
259 series: Vec<SeriesRequirement>,
260 decision_latency_ms: u64,
261 needs_tick_execution: bool,
262 needs_execution_feedback: bool,
263 ) -> Result<Self, StrategyDomainError> {
264 validate_requirements(&instruments, &series, decision_latency_ms)?;
265 Ok(Self {
266 instruments,
267 series,
268 decision_latency_ms,
269 needs_tick_execution,
270 needs_execution_feedback,
271 })
272 }
273
274 pub fn instruments(&self) -> &[String] {
275 &self.instruments
276 }
277
278 pub fn series(&self) -> &[SeriesRequirement] {
279 &self.series
280 }
281
282 pub fn decision_latency_ms(&self) -> u64 {
283 self.decision_latency_ms
284 }
285
286 pub fn needs_tick_execution(&self) -> bool {
287 self.needs_tick_execution
288 }
289
290 pub fn needs_execution_feedback(&self) -> bool {
291 self.needs_execution_feedback
292 }
293
294 pub fn warmup_complete<F>(&self, mut available_bars: F) -> bool
296 where
297 F: FnMut(&SeriesId) -> usize,
298 {
299 self.series.iter().all(|requirement| {
300 available_bars(requirement.id()) >= requirement.warmup().required_bars()
301 })
302 }
303
304 pub fn effective_timestamp(
305 &self,
306 decision_timestamp: NaiveDateTime,
307 ) -> Result<NaiveDateTime, StrategyDomainError> {
308 let latency = i64::try_from(self.decision_latency_ms)
309 .expect("validated strategy latency always fits i64");
310 decision_timestamp
311 .checked_add_signed(Duration::milliseconds(latency))
312 .ok_or(StrategyDomainError::DecisionTimestampOverflow {
313 timestamp: decision_timestamp,
314 latency_ms: self.decision_latency_ms,
315 })
316 }
317}
318
319#[derive(Deserialize)]
320#[serde(deny_unknown_fields)]
321struct StrategyRequirementsDef {
322 instruments: Vec<String>,
323 series: Vec<SeriesRequirement>,
324 decision_latency_ms: u64,
325 needs_tick_execution: bool,
326 needs_execution_feedback: bool,
327}
328
329impl<'de> Deserialize<'de> for StrategyRequirements {
330 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
331 where
332 D: Deserializer<'de>,
333 {
334 let value = StrategyRequirementsDef::deserialize(deserializer)?;
335 Self::new(
336 value.instruments,
337 value.series,
338 value.decision_latency_ms,
339 value.needs_tick_execution,
340 value.needs_execution_feedback,
341 )
342 .map_err(serde::de::Error::custom)
343 }
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
348#[serde(rename_all = "snake_case")]
349pub enum StrategyDecisionKind {
350 NoAction,
351 Entry,
352 Management,
353 Exit,
354 Annotation,
355 Rejected,
356}
357
358#[derive(Debug, Clone, Serialize)]
360pub struct StrategyDecisionRecord {
361 sequence: u64,
362 observed_through: NaiveDateTime,
363 kind: StrategyDecisionKind,
364 reason: String,
365 related_trade_id: Option<String>,
366 emitted_signals: Vec<RawSignal>,
367}
368
369impl StrategyDecisionRecord {
370 pub fn new(
371 sequence: u64,
372 observed_through: NaiveDateTime,
373 kind: StrategyDecisionKind,
374 reason: impl Into<String>,
375 related_trade_id: Option<String>,
376 emitted_signals: Vec<RawSignal>,
377 limits: StrategyRetentionLimits,
378 ) -> Result<Self, StrategyDomainError> {
379 let reason = reason.into();
380 validate_decision_fields(
381 &reason,
382 related_trade_id.as_deref(),
383 emitted_signals.len(),
384 limits,
385 )?;
386 Ok(Self {
387 sequence,
388 observed_through,
389 kind,
390 reason,
391 related_trade_id,
392 emitted_signals,
393 })
394 }
395
396 pub fn sequence(&self) -> u64 {
397 self.sequence
398 }
399
400 pub fn observed_through(&self) -> NaiveDateTime {
401 self.observed_through
402 }
403
404 pub fn kind(&self) -> StrategyDecisionKind {
405 self.kind
406 }
407
408 pub fn reason(&self) -> &str {
409 &self.reason
410 }
411
412 pub fn related_trade_id(&self) -> Option<&str> {
413 self.related_trade_id.as_deref()
414 }
415
416 pub fn emitted_signals(&self) -> &[RawSignal] {
417 &self.emitted_signals
418 }
419}
420
421#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
423pub struct StrategyDecisionRetention {
424 pub retained: usize,
425 pub omitted: usize,
426}
427
428#[derive(Debug, Clone, Serialize)]
430pub struct StrategyDecisionOutput {
431 pub records: Vec<StrategyDecisionRecord>,
432 pub retention: StrategyDecisionRetention,
433}
434
435pub struct StrategyDecisionRecorder {
437 limits: StrategyRetentionLimits,
438 records: Vec<StrategyDecisionRecord>,
439 omitted: usize,
440 last_sequence: Option<u64>,
441}
442
443impl StrategyDecisionRecorder {
444 pub fn new(limits: StrategyRetentionLimits) -> Self {
445 Self {
446 limits,
447 records: Vec::new(),
448 omitted: 0,
449 last_sequence: None,
450 }
451 }
452
453 pub fn push(
454 &mut self,
455 record: StrategyDecisionRecord,
456 ) -> Result<Vec<RawSignal>, StrategyDomainError> {
457 if let Some(previous) = self.last_sequence
458 && record.sequence() <= previous
459 {
460 return Err(StrategyDomainError::NonMonotonicDecisionSequence {
461 previous,
462 current: record.sequence(),
463 });
464 }
465 let executable_signals = record.emitted_signals.clone();
466 self.last_sequence = Some(record.sequence());
467 if self.records.len() < self.limits.max_decisions() {
468 self.records.push(record);
469 } else {
470 self.omitted = self
471 .omitted
472 .checked_add(1)
473 .ok_or(StrategyDomainError::OmittedCounterOverflow)?;
474 }
475 Ok(executable_signals)
476 }
477
478 pub fn finish(self) -> StrategyDecisionOutput {
479 StrategyDecisionOutput {
480 retention: StrategyDecisionRetention {
481 retained: self.records.len(),
482 omitted: self.omitted,
483 },
484 records: self.records,
485 }
486 }
487}
488
489#[derive(Debug, Clone, Default, PartialEq, Serialize)]
491pub struct StrategyResearchOutput {
492 pub journal: StrategyJournalOutput,
493 pub research_annotations: Vec<StrategyAnnotation>,
494}
495
496#[derive(Deserialize)]
497#[serde(deny_unknown_fields)]
498struct StrategyResearchOutputDef {
499 journal: StrategyJournalOutput,
500 #[serde(deserialize_with = "deserialize_research_annotations")]
501 research_annotations: Vec<StrategyAnnotation>,
502}
503
504impl<'de> Deserialize<'de> for StrategyResearchOutput {
505 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
506 where
507 D: Deserializer<'de>,
508 {
509 let value = StrategyResearchOutputDef::deserialize(deserializer)?;
510 Ok(Self {
511 journal: value.journal,
512 research_annotations: value.research_annotations,
513 })
514 }
515}
516
517#[derive(Debug, Clone, Serialize)]
519pub struct StrategyBacktestResult {
520 pub replay: BacktestResult,
521 pub descriptor: StrategyDescriptor,
522 pub decisions: StrategyDecisionOutput,
523 pub research: StrategyResearchOutput,
524}
525
526pub(crate) fn validate_decision_fields(
527 reason: &str,
528 related_trade_id: Option<&str>,
529 signal_count: usize,
530 limits: StrategyRetentionLimits,
531) -> Result<(), StrategyDomainError> {
532 if !valid_text(reason, limits.max_reason_bytes()) {
533 return Err(StrategyDomainError::InvalidDecisionReason {
534 maximum: limits.max_reason_bytes(),
535 });
536 }
537 if let Some(trade_id) = related_trade_id {
538 validate_trade_id(trade_id)?;
539 }
540 if signal_count > limits.max_signals_per_callback() {
541 return Err(StrategyDomainError::TooManySignals {
542 actual: signal_count,
543 maximum: limits.max_signals_per_callback(),
544 });
545 }
546 Ok(())
547}
548
549pub(crate) fn validate_trade_id(trade_id: &str) -> Result<(), StrategyDomainError> {
550 if valid_text(trade_id, MAX_TRADE_ID_BYTES) {
551 Ok(())
552 } else {
553 Err(StrategyDomainError::InvalidTradeId)
554 }
555}
556
557fn validate_requirements(
558 instruments: &[String],
559 series: &[SeriesRequirement],
560 decision_latency_ms: u64,
561) -> Result<(), StrategyDomainError> {
562 if instruments.is_empty() {
563 return Err(StrategyDomainError::MissingInstruments);
564 }
565 if series.is_empty() {
566 return Err(StrategyDomainError::MissingSeries);
567 }
568 if decision_latency_ms > MAX_DECISION_LATENCY_MS {
569 return Err(StrategyDomainError::DecisionLatencyTooLarge {
570 value: decision_latency_ms,
571 });
572 }
573
574 let mut instrument_set = HashSet::with_capacity(instruments.len());
575 for symbol in instruments {
576 validate_instrument(symbol)?;
577 if !instrument_set.insert(symbol.as_str()) {
578 return Err(StrategyDomainError::DuplicateInstrument {
579 symbol: symbol.clone(),
580 });
581 }
582 }
583
584 let mut series_ids = HashSet::with_capacity(series.len());
585 let mut definitions = HashSet::with_capacity(series.len());
586 for requirement in series {
587 if !series_ids.insert(requirement.id()) {
588 return Err(StrategyDomainError::DuplicateSeriesId {
589 series_id: requirement.id().clone(),
590 });
591 }
592 if !instrument_set.contains(requirement.symbol()) {
593 return Err(StrategyDomainError::UndeclaredSeriesInstrument {
594 series_id: requirement.id().clone(),
595 symbol: requirement.symbol().to_string(),
596 });
597 }
598 let definition = (
599 requirement.symbol(),
600 requirement.timeframe(),
601 requirement.price_basis(),
602 );
603 if !definitions.insert(definition) {
604 return Err(StrategyDomainError::DuplicateSeriesDefinition {
605 series_id: requirement.id().clone(),
606 });
607 }
608 }
609 Ok(())
610}
611
612fn validate_instrument(symbol: &str) -> Result<(), StrategyDomainError> {
613 if symbol.is_empty()
614 || symbol.len() > MAX_INSTRUMENT_BYTES
615 || !symbol.bytes().all(|byte| {
616 byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b'/' | b':')
617 })
618 {
619 return Err(StrategyDomainError::InvalidInstrument {
620 symbol: symbol.to_string(),
621 });
622 }
623 Ok(())
624}
625
626fn valid_identifier(value: &str, maximum: usize) -> bool {
627 !value.is_empty()
628 && value.len() <= maximum
629 && value
630 .bytes()
631 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
632}
633
634fn valid_text(value: &str, maximum: usize) -> bool {
635 !value.is_empty()
636 && value.len() <= maximum
637 && value.trim() == value
638 && !value.chars().any(char::is_control)
639}