Skip to main content

qs_backtest/strategy/
annotation.rs

1//! Validated causal and research-only historical annotations.
2
3use std::collections::BTreeSet;
4use std::fmt;
5
6use chrono::NaiveDateTime;
7use serde::de::{SeqAccess, Visitor};
8use serde::{Deserialize, Deserializer, Serialize};
9
10use super::SeriesId;
11use super::analysis::{
12    AnalysisError, MAX_OBSERVATION_SOURCE_SERIES, StrategyObservationValue, validate_source_series,
13    validate_symbol,
14};
15
16pub const MAX_ANNOTATION_ID_BYTES: usize = 64;
17pub const MAX_ANNOTATIONS: usize = 1_000_000;
18pub const MAX_ANNOTATION_NOTE_BYTES: usize = 4096;
19
20/// Stable caller-supplied identity for one annotation.
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
22#[serde(transparent)]
23pub struct AnnotationId(String);
24
25impl AnnotationId {
26    pub fn new(value: impl Into<String>) -> Result<Self, AnnotationError> {
27        let value = value.into();
28        if valid_identifier(&value, MAX_ANNOTATION_ID_BYTES) {
29            Ok(Self(value))
30        } else {
31            Err(AnnotationError::InvalidAnnotationId)
32        }
33    }
34
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl fmt::Display for AnnotationId {
41    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
42        formatter.write_str(&self.0)
43    }
44}
45
46impl<'de> Deserialize<'de> for AnnotationId {
47    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
48    where
49        D: Deserializer<'de>,
50    {
51        let value = String::deserialize(deserializer)?;
52        Self::new(value).map_err(serde::de::Error::custom)
53    }
54}
55
56/// Eligibility classification shared with later research output.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum AnnotationUse {
60    CausalDecisionInput,
61    HindsightLabel,
62    JournalOnly,
63}
64
65/// Caller-visible annotation storage and text bounds.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub struct AnnotationLimits {
68    max_annotations: usize,
69    max_note_bytes: usize,
70    max_source_series: usize,
71}
72
73impl AnnotationLimits {
74    pub fn new(
75        max_annotations: usize,
76        max_note_bytes: usize,
77        max_source_series: usize,
78    ) -> Result<Self, AnnotationError> {
79        validate_limit("max_annotations", max_annotations, MAX_ANNOTATIONS)?;
80        validate_limit("max_note_bytes", max_note_bytes, MAX_ANNOTATION_NOTE_BYTES)?;
81        validate_limit(
82            "max_source_series",
83            max_source_series,
84            MAX_OBSERVATION_SOURCE_SERIES,
85        )?;
86        Ok(Self {
87            max_annotations,
88            max_note_bytes,
89            max_source_series,
90        })
91    }
92
93    pub fn max_annotations(self) -> usize {
94        self.max_annotations
95    }
96
97    pub fn max_note_bytes(self) -> usize {
98        self.max_note_bytes
99    }
100
101    pub fn max_source_series(self) -> usize {
102        self.max_source_series
103    }
104}
105
106impl Default for AnnotationLimits {
107    fn default() -> Self {
108        Self {
109            max_annotations: 10_000,
110            max_note_bytes: 1024,
111            max_source_series: 16,
112        }
113    }
114}
115
116/// One validated manual causal input or research-only record.
117#[derive(Debug, Clone, PartialEq, Serialize)]
118pub struct StrategyAnnotation {
119    annotation_id: AnnotationId,
120    input_sequence: u64,
121    created_at: NaiveDateTime,
122    observed_through: NaiveDateTime,
123    valid_from: Option<NaiveDateTime>,
124    use_kind: AnnotationUse,
125    symbol: String,
126    source_series: Vec<SeriesId>,
127    value: StrategyObservationValue,
128    note: Option<String>,
129}
130
131impl StrategyAnnotation {
132    #[allow(clippy::too_many_arguments)]
133    pub fn new(
134        annotation_id: AnnotationId,
135        input_sequence: u64,
136        created_at: NaiveDateTime,
137        observed_through: NaiveDateTime,
138        valid_from: Option<NaiveDateTime>,
139        use_kind: AnnotationUse,
140        symbol: impl Into<String>,
141        source_series: Vec<SeriesId>,
142        value: StrategyObservationValue,
143        note: Option<String>,
144        limits: AnnotationLimits,
145    ) -> Result<Self, AnnotationError> {
146        let symbol = symbol.into();
147        validate_symbol(&symbol).map_err(|error| AnnotationError::InvalidValue(Box::new(error)))?;
148        validate_source_series(&source_series, limits.max_source_series)
149            .map_err(|error| AnnotationError::InvalidValue(Box::new(error)))?;
150        if created_at < observed_through {
151            return Err(AnnotationError::CreatedBeforeObservation {
152                created_at,
153                observed_through,
154            });
155        }
156        match use_kind {
157            AnnotationUse::CausalDecisionInput => {
158                let valid_from = valid_from.ok_or(AnnotationError::MissingValidFrom)?;
159                if valid_from < observed_through {
160                    return Err(AnnotationError::CausalBackdating {
161                        observed_through,
162                        valid_from,
163                    });
164                }
165            }
166            AnnotationUse::HindsightLabel | AnnotationUse::JournalOnly => {
167                if valid_from.is_some() {
168                    return Err(AnnotationError::UnexpectedValidFrom);
169                }
170            }
171        }
172        if note.as_deref().is_some_and(|value| {
173            value.len() > limits.max_note_bytes
174                || value.trim() != value
175                || value.chars().any(char::is_control)
176        }) {
177            return Err(AnnotationError::InvalidNote {
178                maximum: limits.max_note_bytes,
179            });
180        }
181        let value_boundary = match &value {
182            StrategyObservationValue::Zone(_) => valid_from.unwrap_or(created_at),
183            _ => observed_through,
184        };
185        value
186            .validate_at(value_boundary)
187            .map_err(|error| AnnotationError::InvalidValue(Box::new(error)))?;
188        Ok(Self {
189            annotation_id,
190            input_sequence,
191            created_at,
192            observed_through,
193            valid_from,
194            use_kind,
195            symbol,
196            source_series,
197            value,
198            note,
199        })
200    }
201
202    pub fn annotation_id(&self) -> &AnnotationId {
203        &self.annotation_id
204    }
205
206    pub fn input_sequence(&self) -> u64 {
207        self.input_sequence
208    }
209
210    pub fn created_at(&self) -> NaiveDateTime {
211        self.created_at
212    }
213
214    pub fn observed_through(&self) -> NaiveDateTime {
215        self.observed_through
216    }
217
218    pub fn valid_from(&self) -> Option<NaiveDateTime> {
219        self.valid_from
220    }
221
222    pub fn use_kind(&self) -> AnnotationUse {
223        self.use_kind
224    }
225
226    pub fn symbol(&self) -> &str {
227        &self.symbol
228    }
229
230    pub fn source_series(&self) -> &[SeriesId] {
231        &self.source_series
232    }
233
234    pub fn value(&self) -> &StrategyObservationValue {
235        &self.value
236    }
237
238    pub fn note(&self) -> Option<&str> {
239        self.note.as_deref()
240    }
241}
242
243#[derive(Deserialize)]
244#[serde(deny_unknown_fields)]
245struct StrategyAnnotationDef {
246    annotation_id: AnnotationId,
247    input_sequence: u64,
248    created_at: NaiveDateTime,
249    observed_through: NaiveDateTime,
250    valid_from: Option<NaiveDateTime>,
251    use_kind: AnnotationUse,
252    symbol: String,
253    source_series: Vec<SeriesId>,
254    value: StrategyObservationValue,
255    note: Option<String>,
256}
257
258impl<'de> Deserialize<'de> for StrategyAnnotation {
259    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
260    where
261        D: Deserializer<'de>,
262    {
263        let value = StrategyAnnotationDef::deserialize(deserializer)?;
264        Self::new(
265            value.annotation_id,
266            value.input_sequence,
267            value.created_at,
268            value.observed_through,
269            value.valid_from,
270            value.use_kind,
271            value.symbol,
272            value.source_series,
273            value.value,
274            value.note,
275            AnnotationLimits::new(
276                MAX_ANNOTATIONS,
277                MAX_ANNOTATION_NOTE_BYTES,
278                MAX_OBSERVATION_SOURCE_SERIES,
279            )
280            .expect("hard annotation limits are valid"),
281        )
282        .map_err(serde::de::Error::custom)
283    }
284}
285
286/// Deterministic annotation schedule and research-only retention.
287#[derive(Debug, Clone)]
288pub struct AnnotationTimeline {
289    pending_causal: Vec<StrategyAnnotation>,
290    research_only: Vec<StrategyAnnotation>,
291    ids: BTreeSet<AnnotationId>,
292    input_sequences: BTreeSet<u64>,
293    limits: AnnotationLimits,
294}
295
296impl AnnotationTimeline {
297    pub fn new(limits: AnnotationLimits) -> Self {
298        Self {
299            pending_causal: Vec::new(),
300            research_only: Vec::new(),
301            ids: BTreeSet::new(),
302            input_sequences: BTreeSet::new(),
303            limits,
304        }
305    }
306
307    pub fn add(
308        &mut self,
309        annotation: StrategyAnnotation,
310        advanced_through: Option<NaiveDateTime>,
311    ) -> Result<(), AnnotationError> {
312        if annotation
313            .note()
314            .is_some_and(|note| note.len() > self.limits.max_note_bytes)
315        {
316            return Err(AnnotationError::InvalidNote {
317                maximum: self.limits.max_note_bytes,
318            });
319        }
320        if annotation.source_series().len() > self.limits.max_source_series {
321            return Err(AnnotationError::InvalidValue(Box::new(
322                AnalysisError::TooManySourceSeries {
323                    actual: annotation.source_series().len(),
324                    maximum: self.limits.max_source_series,
325                },
326            )));
327        }
328        let count = self
329            .ids
330            .len()
331            .checked_add(1)
332            .ok_or(AnnotationError::AnnotationCountOverflow)?;
333        if count > self.limits.max_annotations {
334            return Err(AnnotationError::TooManyAnnotations {
335                actual: count,
336                maximum: self.limits.max_annotations,
337            });
338        }
339        if self.ids.contains(annotation.annotation_id()) {
340            return Err(AnnotationError::DuplicateAnnotationId {
341                annotation_id: annotation.annotation_id().clone(),
342            });
343        }
344        if self.input_sequences.contains(&annotation.input_sequence()) {
345            return Err(AnnotationError::DuplicateInputSequence {
346                input_sequence: annotation.input_sequence(),
347            });
348        }
349        if annotation.use_kind() == AnnotationUse::CausalDecisionInput
350            && advanced_through.is_some_and(|advanced| {
351                annotation
352                    .valid_from()
353                    .is_some_and(|valid_from| valid_from <= advanced)
354            })
355        {
356            return Err(AnnotationError::RetroactiveCausalInsertion {
357                valid_from: annotation
358                    .valid_from()
359                    .expect("causal annotations always have valid_from"),
360                advanced_through: advanced_through.expect("checked as present"),
361            });
362        }
363
364        self.ids.insert(annotation.annotation_id().clone());
365        self.input_sequences.insert(annotation.input_sequence());
366        match annotation.use_kind() {
367            AnnotationUse::CausalDecisionInput => {
368                self.pending_causal.push(annotation);
369                self.pending_causal.sort_by(|left, right| {
370                    left.valid_from()
371                        .cmp(&right.valid_from())
372                        .then_with(|| left.input_sequence().cmp(&right.input_sequence()))
373                        .then_with(|| left.annotation_id().cmp(right.annotation_id()))
374                });
375            }
376            AnnotationUse::HindsightLabel | AnnotationUse::JournalOnly => {
377                self.research_only.push(annotation);
378            }
379        }
380        Ok(())
381    }
382
383    pub fn pending_causal(&self) -> &[StrategyAnnotation] {
384        &self.pending_causal
385    }
386
387    pub fn research_only(&self) -> &[StrategyAnnotation] {
388        &self.research_only
389    }
390
391    pub(crate) fn into_research_only(self) -> Vec<StrategyAnnotation> {
392        self.research_only
393    }
394
395    pub fn total_count(&self) -> usize {
396        self.ids.len()
397    }
398
399    pub(crate) fn activate(
400        &mut self,
401        observed_through: NaiveDateTime,
402    ) -> Result<Vec<StrategyAnnotation>, AnnotationError> {
403        let eligible = self.pending_causal.partition_point(|annotation| {
404            annotation
405                .valid_from()
406                .is_some_and(|valid_from| valid_from <= observed_through)
407        });
408        Ok(self.pending_causal.drain(..eligible).collect())
409    }
410}
411
412/// Typed validation, conflict, bound, and causality errors for annotations.
413#[derive(Debug, thiserror::Error)]
414pub enum AnnotationError {
415    #[error("annotation ID must contain 1 to {MAX_ANNOTATION_ID_BYTES} ASCII identifier bytes")]
416    InvalidAnnotationId,
417    #[error("{field} must be greater than zero")]
418    ZeroLimit { field: &'static str },
419    #[error("{field} {actual} exceeds maximum {maximum}")]
420    LimitTooLarge {
421        field: &'static str,
422        actual: usize,
423        maximum: usize,
424    },
425    #[error("annotation creation time {created_at} precedes observed data time {observed_through}")]
426    CreatedBeforeObservation {
427        created_at: NaiveDateTime,
428        observed_through: NaiveDateTime,
429    },
430    #[error("causal annotation requires valid_from")]
431    MissingValidFrom,
432    #[error("research-only annotation must not declare valid_from")]
433    UnexpectedValidFrom,
434    #[error("causal valid_from {valid_from} precedes observed data time {observed_through}")]
435    CausalBackdating {
436        observed_through: NaiveDateTime,
437        valid_from: NaiveDateTime,
438    },
439    #[error("annotation note must be trimmed non-control text within {maximum} bytes")]
440    InvalidNote { maximum: usize },
441    #[error("annotation value is invalid: {0}")]
442    InvalidValue(Box<AnalysisError>),
443    #[error("annotation ID '{annotation_id}' is already present")]
444    DuplicateAnnotationId { annotation_id: AnnotationId },
445    #[error("annotation input sequence {input_sequence} is already present")]
446    DuplicateInputSequence { input_sequence: u64 },
447    #[error("causal decision annotation cannot enter research-only output")]
448    CausalAnnotationInResearchOutput,
449    #[error("annotation count overflowed")]
450    AnnotationCountOverflow,
451    #[error("annotation count {actual} exceeds maximum {maximum}")]
452    TooManyAnnotations { actual: usize, maximum: usize },
453    #[error(
454        "causal annotation valid from {valid_from} cannot be inserted after replay advanced through {advanced_through}"
455    )]
456    RetroactiveCausalInsertion {
457        valid_from: NaiveDateTime,
458        advanced_through: NaiveDateTime,
459    },
460}
461
462pub(crate) fn deserialize_research_annotations<'de, D>(
463    deserializer: D,
464) -> Result<Vec<StrategyAnnotation>, D::Error>
465where
466    D: Deserializer<'de>,
467{
468    struct ResearchAnnotationsVisitor;
469
470    impl<'de> Visitor<'de> for ResearchAnnotationsVisitor {
471        type Value = Vec<StrategyAnnotation>;
472
473        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
474            formatter.write_str("a bounded sequence of unique research-only annotations")
475        }
476
477        fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
478        where
479            A: SeqAccess<'de>,
480        {
481            if let Some(actual) = sequence.size_hint()
482                && actual > MAX_ANNOTATIONS
483            {
484                return Err(serde::de::Error::custom(
485                    AnnotationError::TooManyAnnotations {
486                        actual,
487                        maximum: MAX_ANNOTATIONS,
488                    },
489                ));
490            }
491
492            let capacity = sequence.size_hint().unwrap_or(0).min(MAX_ANNOTATIONS);
493            let mut annotations = Vec::with_capacity(capacity);
494            let mut ids = BTreeSet::new();
495            let mut input_sequences = BTreeSet::new();
496            while let Some(annotation) = sequence.next_element::<StrategyAnnotation>()? {
497                let count = annotations.len().checked_add(1).ok_or_else(|| {
498                    serde::de::Error::custom(AnnotationError::AnnotationCountOverflow)
499                })?;
500                if count > MAX_ANNOTATIONS {
501                    return Err(serde::de::Error::custom(
502                        AnnotationError::TooManyAnnotations {
503                            actual: count,
504                            maximum: MAX_ANNOTATIONS,
505                        },
506                    ));
507                }
508                if annotation.use_kind() == AnnotationUse::CausalDecisionInput {
509                    return Err(serde::de::Error::custom(
510                        AnnotationError::CausalAnnotationInResearchOutput,
511                    ));
512                }
513                if !ids.insert(annotation.annotation_id().clone()) {
514                    return Err(serde::de::Error::custom(
515                        AnnotationError::DuplicateAnnotationId {
516                            annotation_id: annotation.annotation_id().clone(),
517                        },
518                    ));
519                }
520                if !input_sequences.insert(annotation.input_sequence()) {
521                    return Err(serde::de::Error::custom(
522                        AnnotationError::DuplicateInputSequence {
523                            input_sequence: annotation.input_sequence(),
524                        },
525                    ));
526                }
527                annotations.push(annotation);
528            }
529            Ok(annotations)
530        }
531    }
532
533    deserializer.deserialize_seq(ResearchAnnotationsVisitor)
534}
535
536fn validate_limit(
537    field: &'static str,
538    actual: usize,
539    maximum: usize,
540) -> Result<(), AnnotationError> {
541    if actual == 0 {
542        return Err(AnnotationError::ZeroLimit { field });
543    }
544    if actual > maximum {
545        return Err(AnnotationError::LimitTooLarge {
546            field,
547            actual,
548            maximum,
549        });
550    }
551    Ok(())
552}
553
554fn valid_identifier(value: &str, maximum: usize) -> bool {
555    !value.is_empty()
556        && value.len() <= maximum
557        && value
558            .bytes()
559            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
560}