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