1use 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#[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#[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#[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#[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#[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#[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
469pub 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#[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}