Skip to main content

qs_backtest/strategy/
journal.rs

1//! Bounded non-economic research journal output.
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use chrono::NaiveDateTime;
7use serde::de::{MapAccess, Visitor};
8use serde::{Deserialize, Deserializer, Serialize};
9
10use super::analysis::validate_symbol;
11use super::domain::validate_trade_id;
12use super::{MAX_INSTRUMENT_BYTES, MAX_TRADE_ID_BYTES};
13
14pub const MAX_JOURNAL_RECORDS: usize = 1_000_000;
15pub const MAX_JOURNAL_PER_CALLBACK: usize = 4096;
16pub const MAX_JOURNAL_REASON_BYTES: usize = 4096;
17pub const MAX_CHART_REF_BYTES: usize = 4096;
18pub const MAX_JOURNAL_VALUES: usize = 256;
19pub const MAX_JOURNAL_VALUE_KEY_BYTES: usize = 64;
20pub const MAX_EXPERIMENT_LABEL_BYTES: usize = 256;
21
22/// Generic category for one non-economic research record.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum JournalKind {
26    DecisionContext,
27    OutcomeReview,
28    NoAction,
29    Hypothetical,
30    PeriodReview,
31}
32
33/// Caller-visible limits for journal retention and research text.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35pub struct StrategyResearchLimits {
36    max_journal_records: usize,
37    max_journal_per_callback: usize,
38    max_reason_bytes: usize,
39    max_chart_ref_bytes: usize,
40    max_values_per_record: usize,
41    max_value_key_bytes: usize,
42    max_experiment_label_bytes: usize,
43}
44
45impl StrategyResearchLimits {
46    #[allow(clippy::too_many_arguments)]
47    pub fn new(
48        max_journal_records: usize,
49        max_journal_per_callback: usize,
50        max_reason_bytes: usize,
51        max_chart_ref_bytes: usize,
52        max_values_per_record: usize,
53        max_value_key_bytes: usize,
54        max_experiment_label_bytes: usize,
55    ) -> Result<Self, StrategyJournalError> {
56        validate_limit(
57            "max_journal_records",
58            max_journal_records,
59            MAX_JOURNAL_RECORDS,
60            true,
61        )?;
62        validate_limit(
63            "max_journal_per_callback",
64            max_journal_per_callback,
65            MAX_JOURNAL_PER_CALLBACK,
66            false,
67        )?;
68        validate_limit(
69            "max_reason_bytes",
70            max_reason_bytes,
71            MAX_JOURNAL_REASON_BYTES,
72            false,
73        )?;
74        validate_limit(
75            "max_chart_ref_bytes",
76            max_chart_ref_bytes,
77            MAX_CHART_REF_BYTES,
78            false,
79        )?;
80        validate_limit(
81            "max_values_per_record",
82            max_values_per_record,
83            MAX_JOURNAL_VALUES,
84            true,
85        )?;
86        validate_limit(
87            "max_value_key_bytes",
88            max_value_key_bytes,
89            MAX_JOURNAL_VALUE_KEY_BYTES,
90            false,
91        )?;
92        validate_limit(
93            "max_experiment_label_bytes",
94            max_experiment_label_bytes,
95            MAX_EXPERIMENT_LABEL_BYTES,
96            false,
97        )?;
98        Ok(Self {
99            max_journal_records,
100            max_journal_per_callback,
101            max_reason_bytes,
102            max_chart_ref_bytes,
103            max_values_per_record,
104            max_value_key_bytes,
105            max_experiment_label_bytes,
106        })
107    }
108
109    pub fn max_journal_records(self) -> usize {
110        self.max_journal_records
111    }
112
113    pub fn max_journal_per_callback(self) -> usize {
114        self.max_journal_per_callback
115    }
116
117    pub fn max_reason_bytes(self) -> usize {
118        self.max_reason_bytes
119    }
120
121    pub fn max_chart_ref_bytes(self) -> usize {
122        self.max_chart_ref_bytes
123    }
124
125    pub fn max_values_per_record(self) -> usize {
126        self.max_values_per_record
127    }
128
129    pub fn max_value_key_bytes(self) -> usize {
130        self.max_value_key_bytes
131    }
132
133    pub fn max_experiment_label_bytes(self) -> usize {
134        self.max_experiment_label_bytes
135    }
136}
137
138impl Default for StrategyResearchLimits {
139    fn default() -> Self {
140        Self {
141            max_journal_records: 10_000,
142            max_journal_per_callback: 256,
143            max_reason_bytes: 1024,
144            max_chart_ref_bytes: 1024,
145            max_values_per_record: 32,
146            max_value_key_bytes: 64,
147            max_experiment_label_bytes: 128,
148        }
149    }
150}
151
152#[derive(Deserialize)]
153#[serde(deny_unknown_fields)]
154struct StrategyResearchLimitsDef {
155    max_journal_records: usize,
156    max_journal_per_callback: usize,
157    max_reason_bytes: usize,
158    max_chart_ref_bytes: usize,
159    max_values_per_record: usize,
160    max_value_key_bytes: usize,
161    max_experiment_label_bytes: usize,
162}
163
164impl<'de> Deserialize<'de> for StrategyResearchLimits {
165    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
166    where
167        D: Deserializer<'de>,
168    {
169        let value = StrategyResearchLimitsDef::deserialize(deserializer)?;
170        Self::new(
171            value.max_journal_records,
172            value.max_journal_per_callback,
173            value.max_reason_bytes,
174            value.max_chart_ref_bytes,
175            value.max_values_per_record,
176            value.max_value_key_bytes,
177            value.max_experiment_label_bytes,
178        )
179        .map_err(serde::de::Error::custom)
180    }
181}
182
183/// Validated journal output returned by one strategy callback.
184#[derive(Debug, Clone, PartialEq, Serialize)]
185pub struct StrategyJournalDraft {
186    kind: JournalKind,
187    symbol: String,
188    related_trade_id: Option<String>,
189    reason: String,
190    chart_ref: Option<String>,
191    values: BTreeMap<String, f64>,
192}
193
194impl StrategyJournalDraft {
195    pub fn new(
196        kind: JournalKind,
197        symbol: impl Into<String>,
198        related_trade_id: Option<String>,
199        reason: impl Into<String>,
200        chart_ref: Option<String>,
201        values: BTreeMap<String, f64>,
202        limits: StrategyResearchLimits,
203    ) -> Result<Self, StrategyJournalError> {
204        let draft = Self {
205            kind,
206            symbol: symbol.into(),
207            related_trade_id,
208            reason: reason.into(),
209            chart_ref,
210            values,
211        };
212        draft.validate(limits)?;
213        Ok(draft)
214    }
215
216    pub fn kind(&self) -> JournalKind {
217        self.kind
218    }
219
220    pub fn symbol(&self) -> &str {
221        &self.symbol
222    }
223
224    pub fn related_trade_id(&self) -> Option<&str> {
225        self.related_trade_id.as_deref()
226    }
227
228    pub fn reason(&self) -> &str {
229        &self.reason
230    }
231
232    pub fn chart_ref(&self) -> Option<&str> {
233        self.chart_ref.as_deref()
234    }
235
236    pub fn values(&self) -> &BTreeMap<String, f64> {
237        &self.values
238    }
239
240    fn validate(&self, limits: StrategyResearchLimits) -> Result<(), StrategyJournalError> {
241        validate_symbol(&self.symbol).map_err(|_| StrategyJournalError::InvalidSymbol {
242            symbol: self.symbol.clone(),
243            maximum: MAX_INSTRUMENT_BYTES,
244        })?;
245        if let Some(trade_id) = self.related_trade_id.as_deref() {
246            validate_trade_id(trade_id).map_err(|_| StrategyJournalError::InvalidTradeId)?;
247        }
248        if !valid_text(&self.reason, limits.max_reason_bytes) {
249            return Err(StrategyJournalError::InvalidReason {
250                maximum: limits.max_reason_bytes,
251            });
252        }
253        if self
254            .chart_ref
255            .as_deref()
256            .is_some_and(|value| !valid_text(value, limits.max_chart_ref_bytes))
257        {
258            return Err(StrategyJournalError::InvalidChartRef {
259                maximum: limits.max_chart_ref_bytes,
260            });
261        }
262        if self.values.len() > limits.max_values_per_record {
263            return Err(StrategyJournalError::TooManyValues {
264                actual: self.values.len(),
265                maximum: limits.max_values_per_record,
266            });
267        }
268        for (key, value) in &self.values {
269            if !valid_identifier(key, limits.max_value_key_bytes) {
270                return Err(StrategyJournalError::InvalidValueKey {
271                    key: key.clone(),
272                    maximum: limits.max_value_key_bytes,
273                });
274            }
275            if !value.is_finite() {
276                return Err(StrategyJournalError::NonFiniteValue { key: key.clone() });
277            }
278        }
279        Ok(())
280    }
281}
282
283#[derive(Deserialize)]
284#[serde(deny_unknown_fields)]
285struct StrategyJournalDraftDef {
286    kind: JournalKind,
287    symbol: String,
288    related_trade_id: Option<String>,
289    reason: String,
290    chart_ref: Option<String>,
291    #[serde(deserialize_with = "deserialize_journal_values")]
292    values: BTreeMap<String, f64>,
293}
294
295impl<'de> Deserialize<'de> for StrategyJournalDraft {
296    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297    where
298        D: Deserializer<'de>,
299    {
300        let value = StrategyJournalDraftDef::deserialize(deserializer)?;
301        Self::new(
302            value.kind,
303            value.symbol,
304            value.related_trade_id,
305            value.reason,
306            value.chart_ref,
307            value.values,
308            maximum_research_limits(),
309        )
310        .map_err(serde::de::Error::custom)
311    }
312}
313
314/// Runtime-stamped non-economic journal record.
315#[derive(Debug, Clone, PartialEq, Serialize)]
316pub struct StrategyJournalRecord {
317    sequence: u64,
318    observed_through: NaiveDateTime,
319    kind: JournalKind,
320    symbol: String,
321    related_trade_id: Option<String>,
322    reason: String,
323    chart_ref: Option<String>,
324    values: BTreeMap<String, f64>,
325}
326
327impl StrategyJournalRecord {
328    pub fn sequence(&self) -> u64 {
329        self.sequence
330    }
331
332    pub fn observed_through(&self) -> NaiveDateTime {
333        self.observed_through
334    }
335
336    pub fn kind(&self) -> JournalKind {
337        self.kind
338    }
339
340    pub fn symbol(&self) -> &str {
341        &self.symbol
342    }
343
344    pub fn related_trade_id(&self) -> Option<&str> {
345        self.related_trade_id.as_deref()
346    }
347
348    pub fn reason(&self) -> &str {
349        &self.reason
350    }
351
352    pub fn chart_ref(&self) -> Option<&str> {
353        self.chart_ref.as_deref()
354    }
355
356    pub fn values(&self) -> &BTreeMap<String, f64> {
357        &self.values
358    }
359
360    fn from_draft(
361        sequence: u64,
362        observed_through: NaiveDateTime,
363        draft: StrategyJournalDraft,
364    ) -> Self {
365        Self {
366            sequence,
367            observed_through,
368            kind: draft.kind,
369            symbol: draft.symbol,
370            related_trade_id: draft.related_trade_id,
371            reason: draft.reason,
372            chart_ref: draft.chart_ref,
373            values: draft.values,
374        }
375    }
376}
377
378#[derive(Deserialize)]
379#[serde(deny_unknown_fields)]
380struct StrategyJournalRecordDef {
381    sequence: u64,
382    observed_through: NaiveDateTime,
383    kind: JournalKind,
384    symbol: String,
385    related_trade_id: Option<String>,
386    reason: String,
387    chart_ref: Option<String>,
388    #[serde(deserialize_with = "deserialize_journal_values")]
389    values: BTreeMap<String, f64>,
390}
391
392impl<'de> Deserialize<'de> for StrategyJournalRecord {
393    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
394    where
395        D: Deserializer<'de>,
396    {
397        let value = StrategyJournalRecordDef::deserialize(deserializer)?;
398        let draft = StrategyJournalDraft::new(
399            value.kind,
400            value.symbol,
401            value.related_trade_id,
402            value.reason,
403            value.chart_ref,
404            value.values,
405            maximum_research_limits(),
406        )
407        .map_err(serde::de::Error::custom)?;
408        Ok(Self::from_draft(
409            value.sequence,
410            value.observed_through,
411            draft,
412        ))
413    }
414}
415
416/// Exact retained and omitted journal counts.
417#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
418#[serde(deny_unknown_fields)]
419pub struct StrategyJournalRetention {
420    pub retained: usize,
421    pub omitted: usize,
422}
423
424/// Ordered retained journal records and exact omission metadata.
425#[derive(Debug, Clone, Default, PartialEq, Serialize)]
426pub struct StrategyJournalOutput {
427    pub records: Vec<StrategyJournalRecord>,
428    pub retention: StrategyJournalRetention,
429}
430
431#[derive(Deserialize)]
432#[serde(deny_unknown_fields)]
433struct StrategyJournalOutputDef {
434    records: Vec<StrategyJournalRecord>,
435    retention: StrategyJournalRetention,
436}
437
438impl<'de> Deserialize<'de> for StrategyJournalOutput {
439    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
440    where
441        D: Deserializer<'de>,
442    {
443        let value = StrategyJournalOutputDef::deserialize(deserializer)?;
444        if value.records.len() > MAX_JOURNAL_RECORDS
445            || value.retention.retained != value.records.len()
446        {
447            return Err(serde::de::Error::custom(
448                StrategyJournalError::InvalidRetention,
449            ));
450        }
451        for (index, record) in value.records.iter().enumerate() {
452            let expected = u64::try_from(index).map_err(serde::de::Error::custom)?;
453            if record.sequence() != expected {
454                return Err(serde::de::Error::custom(
455                    StrategyJournalError::InvalidRecordSequence {
456                        expected,
457                        actual: record.sequence(),
458                    },
459                ));
460            }
461        }
462        Ok(Self {
463            records: value.records,
464            retention: value.retention,
465        })
466    }
467}
468
469/// Bounded recorder for runtime-stamped journal drafts.
470pub struct StrategyJournalRecorder {
471    limits: StrategyResearchLimits,
472    records: Vec<StrategyJournalRecord>,
473    omitted: usize,
474    next_sequence: u64,
475}
476
477impl StrategyJournalRecorder {
478    pub fn new(limits: StrategyResearchLimits) -> Self {
479        Self {
480            limits,
481            records: Vec::new(),
482            omitted: 0,
483            next_sequence: 0,
484        }
485    }
486
487    pub fn push_callback(
488        &mut self,
489        observed_through: NaiveDateTime,
490        drafts: Vec<StrategyJournalDraft>,
491    ) -> Result<(), StrategyJournalError> {
492        if drafts.len() > self.limits.max_journal_per_callback {
493            return Err(StrategyJournalError::TooManyDrafts {
494                actual: drafts.len(),
495                maximum: self.limits.max_journal_per_callback,
496            });
497        }
498        for draft in &drafts {
499            draft.validate(self.limits)?;
500        }
501
502        let draft_count =
503            u64::try_from(drafts.len()).map_err(|_| StrategyJournalError::SequenceOverflow)?;
504        self.next_sequence
505            .checked_add(draft_count)
506            .ok_or(StrategyJournalError::SequenceOverflow)?;
507        let available = self
508            .limits
509            .max_journal_records
510            .saturating_sub(self.records.len());
511        let omitted = drafts.len().saturating_sub(available);
512        self.omitted
513            .checked_add(omitted)
514            .ok_or(StrategyJournalError::OmittedCounterOverflow)?;
515
516        for draft in drafts {
517            let sequence = self.next_sequence;
518            self.next_sequence = self
519                .next_sequence
520                .checked_add(1)
521                .expect("journal sequence capacity was prevalidated");
522            if self.records.len() < self.limits.max_journal_records {
523                self.records.push(StrategyJournalRecord::from_draft(
524                    sequence,
525                    observed_through,
526                    draft,
527                ));
528            } else {
529                self.omitted += 1;
530            }
531        }
532        Ok(())
533    }
534
535    pub fn finish(self) -> StrategyJournalOutput {
536        StrategyJournalOutput {
537            retention: StrategyJournalRetention {
538                retained: self.records.len(),
539                omitted: self.omitted,
540            },
541            records: self.records,
542        }
543    }
544}
545
546/// Validation and retention failures for strategy research output.
547#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
548pub enum StrategyJournalError {
549    #[error("{field} must be greater than zero")]
550    ZeroLimit { field: &'static str },
551    #[error("{field} {actual} exceeds maximum {maximum}")]
552    LimitTooLarge {
553        field: &'static str,
554        actual: usize,
555        maximum: usize,
556    },
557    #[error("invalid journal symbol '{symbol}', expected at most {maximum} identifier bytes")]
558    InvalidSymbol { symbol: String, maximum: usize },
559    #[error("related trade ID must contain 1 to {MAX_TRADE_ID_BYTES} trimmed non-control bytes")]
560    InvalidTradeId,
561    #[error("journal reason must contain 1 to {maximum} trimmed non-control bytes")]
562    InvalidReason { maximum: usize },
563    #[error("chart reference must contain 1 to {maximum} trimmed non-control bytes")]
564    InvalidChartRef { maximum: usize },
565    #[error("journal values count {actual} exceeds maximum {maximum}")]
566    TooManyValues { actual: usize, maximum: usize },
567    #[error("journal value key '{key}' must contain 1 to {maximum} ASCII identifier bytes")]
568    InvalidValueKey { key: String, maximum: usize },
569    #[error("journal value key '{key}' is duplicated")]
570    DuplicateValueKey { key: String },
571    #[error("journal value '{key}' must be finite")]
572    NonFiniteValue { key: String },
573    #[error("callback returned {actual} journal drafts, exceeding maximum {maximum}")]
574    TooManyDrafts { actual: usize, maximum: usize },
575    #[error("journal sequence overflowed")]
576    SequenceOverflow,
577    #[error("journal omitted counter overflowed")]
578    OmittedCounterOverflow,
579    #[error("journal retention does not match retained records")]
580    InvalidRetention,
581    #[error("journal record sequence {actual} does not match expected sequence {expected}")]
582    InvalidRecordSequence { expected: u64, actual: u64 },
583    #[error("experiment label must contain 1 to {maximum} trimmed non-control bytes")]
584    InvalidExperimentLabel { maximum: usize },
585}
586
587pub(crate) fn validate_experiment_label(
588    value: &str,
589    limits: StrategyResearchLimits,
590) -> Result<(), StrategyJournalError> {
591    if valid_text(value, limits.max_experiment_label_bytes) {
592        Ok(())
593    } else {
594        Err(StrategyJournalError::InvalidExperimentLabel {
595            maximum: limits.max_experiment_label_bytes,
596        })
597    }
598}
599
600fn deserialize_journal_values<'de, D>(deserializer: D) -> Result<BTreeMap<String, f64>, D::Error>
601where
602    D: Deserializer<'de>,
603{
604    struct JournalValuesVisitor;
605
606    impl<'de> Visitor<'de> for JournalValuesVisitor {
607        type Value = BTreeMap<String, f64>;
608
609        fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
610            formatter.write_str("a map of unique journal scalar values")
611        }
612
613        fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
614        where
615            A: MapAccess<'de>,
616        {
617            let mut values = BTreeMap::new();
618            while let Some((key, value)) = map.next_entry::<String, f64>()? {
619                if values.insert(key.clone(), value).is_some() {
620                    return Err(serde::de::Error::custom(
621                        StrategyJournalError::DuplicateValueKey { key },
622                    ));
623                }
624            }
625            Ok(values)
626        }
627    }
628
629    deserializer.deserialize_map(JournalValuesVisitor)
630}
631
632fn validate_limit(
633    field: &'static str,
634    actual: usize,
635    maximum: usize,
636    allow_zero: bool,
637) -> Result<(), StrategyJournalError> {
638    if actual == 0 && !allow_zero {
639        return Err(StrategyJournalError::ZeroLimit { field });
640    }
641    if actual > maximum {
642        return Err(StrategyJournalError::LimitTooLarge {
643            field,
644            actual,
645            maximum,
646        });
647    }
648    Ok(())
649}
650
651fn valid_text(value: &str, maximum: usize) -> bool {
652    !value.is_empty()
653        && value.len() <= maximum
654        && value.trim() == value
655        && !value.chars().any(char::is_control)
656}
657
658fn valid_identifier(value: &str, maximum: usize) -> bool {
659    !value.is_empty()
660        && value.len() <= maximum
661        && value
662            .bytes()
663            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-'))
664}
665
666pub(crate) fn maximum_research_limits() -> StrategyResearchLimits {
667    StrategyResearchLimits {
668        max_journal_records: MAX_JOURNAL_RECORDS,
669        max_journal_per_callback: MAX_JOURNAL_PER_CALLBACK,
670        max_reason_bytes: MAX_JOURNAL_REASON_BYTES,
671        max_chart_ref_bytes: MAX_CHART_REF_BYTES,
672        max_values_per_record: MAX_JOURNAL_VALUES,
673        max_value_key_bytes: MAX_JOURNAL_VALUE_KEY_BYTES,
674        max_experiment_label_bytes: MAX_EXPERIMENT_LABEL_BYTES,
675    }
676}