zero_operator_state/events.rs
1//! The event stream fed into the classifier.
2//!
3//! Events are recorded in `~/.zero/state/events.log` and replayed on
4//! start. The enum is exhaustive on purpose: adding a new event type
5//! fails every match arm in the classifier until the author decides
6//! how it maps to the state vector.
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10
11/// Where a trading decision originated.
12///
13/// Used to compute the override rate in §2.1 and to separate operator
14/// initiative from automated flow.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Source {
18 /// Operator accepted a Plan-mode verdict without modification.
19 Plan,
20 /// Engine executed under Auto mode with no operator touch.
21 Auto,
22 /// Operator executed under Headless policy.
23 Headless,
24 /// Operator override — rejected Plan's recommendation and acted
25 /// anyway. The strongest signal for deviation-rate.
26 Override,
27 /// Operator-initiated trade not tied to any engine proposal.
28 Manual,
29}
30
31/// Outcome of a completed trade. Used for loss-reaction timing and
32/// the conviction-calibration report.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "snake_case")]
35pub enum Outcome {
36 Win,
37 Loss,
38 Scratch,
39}
40
41/// The event kinds the classifier understands.
42///
43/// New variants added here force every match arm in `classifier.rs`
44/// to decide how the event affects the vector — the compiler becomes
45/// the reviewer.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum EventKind {
49 /// Operator took an action that could open or modify a position.
50 DecisionMade { symbol: String, source: Source },
51 /// A position closed. `loss_reaction_ms` is filled when the prior
52 /// event was also a close on the same symbol.
53 TradeClosed {
54 symbol: String,
55 outcome: Outcome,
56 pnl_r: f64,
57 conviction: Option<u8>,
58 },
59 /// Operator hit `/break` (or a similar risk-reducing rest).
60 BreakStarted { planned_ms: Option<u64> },
61 /// Break ended (timer, keypress, or session resume).
62 BreakEnded,
63 /// Operator has been idle for more than the sleep-proxy threshold.
64 Idle { since_ms: u64 },
65 /// Operator returned from idle.
66 Resumed,
67 /// Plan-mode verdict shown to the operator (count of how many
68 /// they've seen drives the override-rate denominator).
69 VerdictShown,
70 /// Plan-mode verdict was explicitly rejected by the operator.
71 VerdictOverridden,
72 /// Session began (launch or resume).
73 SessionStarted,
74 /// Session ended.
75 SessionEnded,
76 /// Operator-supplied conviction rating for a past trade
77 /// (`/rate <trade_id> <1..=10>`). The classifier does not
78 /// attribute the rating back onto the original `TradeClosed`
79 /// variant because the two events are separated by human
80 /// latency — merging them would force the classifier to
81 /// carry a mutable trade index. Keeping `Conviction` as its
82 /// own event lets the downstream consumer (operator-state
83 /// engine POST, future calibration overlay) join on
84 /// `trade_id` without the classifier needing to know how.
85 ///
86 /// `rating` is a `u8` in `1..=10` — the parser enforces the
87 /// range before pushing. `trade_id` is the engine's opaque
88 /// trade identifier, never parsed CLI-side.
89 Conviction { trade_id: String, rating: u8 },
90}
91
92/// Wall-clock-timestamped event. Instances are the only thing that
93/// [`crate::Classifier`] consumes.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct Event {
96 pub ts: DateTime<Utc>,
97 #[serde(flatten)]
98 pub kind: EventKind,
99}
100
101impl Event {
102 #[must_use]
103 pub fn new(ts: DateTime<Utc>, kind: EventKind) -> Self {
104 Self { ts, kind }
105 }
106}