Skip to main content

sharpebench_protocol/
lib.rs

1//! The language-agnostic agent ⇄ harness protocol.
2//!
3//! Agents are **external** — a container or HTTP endpoint, in any language — not
4//! Rust code. Each decision step the harness sends a [`MarketObservation`] (JSON)
5//! and the agent replies with a [`Decision`] (JSON). Keeping this surface tiny and
6//! stable is what lets any vendor compete (and is the whole adoption story).
7//!
8//! All observations are **point-in-time**: `close_history`, `fundamentals` and
9//! `news` only ever contain information available at or before `date`.
10#![forbid(unsafe_code)]
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16/// What the agent sees at one decision point.
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct MarketObservation {
19    /// ISO-8601 date of the decision point.
20    pub date: String,
21    pub cash: f64,
22    pub symbols: Vec<SymbolSnapshot>,
23    pub portfolio: Vec<PositionState>,
24}
25
26/// Point-in-time data for one instrument.
27#[derive(Clone, Debug, Serialize, Deserialize)]
28pub struct SymbolSnapshot {
29    pub symbol: String,
30    /// Trailing closes up to and including `date` (oldest first).
31    pub close_history: Vec<f64>,
32    /// Named fundamental fields (e.g. `pe`, `revenue_yoy`). Empty if unavailable.
33    #[serde(default)]
34    pub fundamentals: BTreeMap<String, f64>,
35    /// Headlines published on or before `date`.
36    #[serde(default)]
37    pub news: Vec<String>,
38}
39
40/// The agent's current holding in one instrument.
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct PositionState {
43    pub symbol: String,
44    pub shares: f64,
45    pub avg_price: f64,
46}
47
48/// What the agent returns.
49#[derive(Clone, Debug, Serialize, Deserialize)]
50pub struct Decision {
51    pub orders: Vec<Order>,
52    /// Free-text rationale, captured into the trajectory for auditability.
53    #[serde(default)]
54    pub reasoning: String,
55    /// Optional self-reported compute/token spend for producing *this* decision.
56    /// The engine accumulates it into the run's `cost`, which drives the
57    /// cost-normalized leaderboard columns (`return_per_cost` / `dsr_per_cost` =
58    /// skill-per-dollar-of-compute). `None` = not reported, so existing agents
59    /// need no change and the cost columns stay `None` (back-compat).
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub cost: Option<DecisionCost>,
62}
63
64/// An agent's self-reported spend to produce one decision. Every field defaults to
65/// zero so a partial report (e.g. tokens only, no dollar figure) still deserializes.
66/// The engine reduces this to a single scalar via [`DecisionCost::billable_units`].
67#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
68pub struct DecisionCost {
69    /// Dollar cost of the compute/tokens spent on this decision. The preferred
70    /// unit for skill-per-dollar reporting.
71    #[serde(default)]
72    pub cost_usd: f64,
73    /// Prompt/input tokens consumed.
74    #[serde(default)]
75    pub tokens_in: u64,
76    /// Completion/output tokens produced.
77    #[serde(default)]
78    pub tokens_out: u64,
79    /// Reasoning/thinking tokens, reported as a legibility breakdown. Providers
80    /// typically already bill these inside `tokens_out`, so they are *not* re-added
81    /// into the token total; they are surfaced separately, not double-counted.
82    #[serde(default)]
83    pub reasoning_tokens: u64,
84}
85
86impl DecisionCost {
87    /// The single scalar the engine folds into `Run.cost` (any consistent unit,
88    /// matching the leaderboard's cost column). Prefers the reported dollar figure;
89    /// with no dollars reported it falls back to total billable tokens
90    /// (`tokens_in + tokens_out`). Reasoning tokens are a sub-breakdown of the
91    /// output and are not added again.
92    pub fn billable_units(&self) -> f64 {
93        if self.cost_usd > 0.0 {
94            self.cost_usd
95        } else {
96            (self.tokens_in + self.tokens_out) as f64
97        }
98    }
99}
100
101/// A single per-instrument instruction.
102#[derive(Clone, Debug, Serialize, Deserialize)]
103pub struct Order {
104    pub symbol: String,
105    pub action: Action,
106    /// Target portfolio weight for this symbol in [0, 1] (signed for shorts).
107    pub target_weight: f64,
108    /// Stated conviction in [0, 1]; scored for calibration.
109    #[serde(default = "default_confidence")]
110    pub confidence: f64,
111    /// Optional one-line rationale for *this* order, captured into the run trace
112    /// (audit trail). Defaults to empty so existing agents need no change.
113    #[serde(default)]
114    pub rationale: String,
115}
116
117/// Discrete action label (sizing is carried by `target_weight`).
118#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120pub enum Action {
121    Buy,
122    Sell,
123    Hold,
124    Close,
125}
126
127fn default_confidence() -> f64 {
128    0.5
129}
130
131/// One captured decision step of a single backtest run: the agent's *raw* output
132/// at one point-in-time observation. This is the persisted artifact — it holds the
133/// agent's [`Decision`] (orders, sizing, conviction, reasoning) tagged with the
134/// observation it was made against, and deliberately stores **no** returns, NAV, or
135/// any self-reported metric. The score is recomputed by replaying these decisions
136/// through the engine, never read from the agent's word.
137#[derive(Clone, Debug, Serialize, Deserialize)]
138pub struct DecisionStep {
139    /// 0-based step index within the run's window (`window.start + step` is the
140    /// dataset index the observation was drawn from).
141    pub step: usize,
142    /// Stable id of the point-in-time observation this decision answered — the
143    /// observation's ISO date. Lets a verifier confirm the decision lines up with
144    /// the frozen dataset's bar at the replayed step.
145    pub observation_id: String,
146    /// The agent's raw decision at this step (orders + reasoning).
147    pub decision: Decision,
148}
149
150/// One captured backtest run (a single window × seed): the ordered sequence of the
151/// agent's raw decision steps, plus the (window, seed) coordinates needed to replay
152/// it through the identical point-in-time engine path.
153#[derive(Clone, Debug, Serialize, Deserialize)]
154pub struct RunTrajectory {
155    /// Inclusive window start (dataset index of the first decision step).
156    pub window_start: usize,
157    /// Exclusive window end.
158    pub window_end: usize,
159    /// Execution seed the run was driven with (governs slippage noise on replay).
160    pub seed: u64,
161    /// The raw decisions, in step order.
162    pub steps: Vec<DecisionStep>,
163}
164
165/// An agent's full captured trajectory: every (window × seed) run's raw decisions.
166/// Serde-(de)serializable to JSON; this is the on-disk artifact a separate verifier
167/// ingests to recompute the score from raw decisions alone.
168#[derive(Clone, Debug, Serialize, Deserialize)]
169pub struct AgentTrajectory {
170    pub agent_id: String,
171    /// In-sample search budget the agent declared (mirrors `AgentSubmission`), so a
172    /// recomputed submission carries the same deflation footprint.
173    #[serde(default)]
174    pub in_sample_trials: u32,
175    /// One captured run per (window, seed), in the same order the harness produced
176    /// them (window-major: all seeds of window 0, then window 1, …).
177    pub runs: Vec<RunTrajectory>,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn observation_and_decision_roundtrip() {
186        let obs = MarketObservation {
187            date: "2025-01-01".to_string(),
188            cash: 1.0,
189            symbols: vec![SymbolSnapshot {
190                symbol: "A".to_string(),
191                close_history: vec![1.0, 2.0],
192                fundamentals: Default::default(),
193                news: vec!["headline".to_string()],
194            }],
195            portfolio: vec![PositionState {
196                symbol: "A".to_string(),
197                shares: 1.0,
198                avg_price: 2.0,
199            }],
200        };
201        let back: MarketObservation =
202            serde_json::from_str(&serde_json::to_string(&obs).unwrap()).unwrap();
203        assert_eq!(back.symbols[0].symbol, "A");
204
205        let d = Decision {
206            orders: vec![Order {
207                symbol: "A".to_string(),
208                action: Action::Buy,
209                target_weight: 0.5,
210                confidence: 0.9,
211                rationale: "trailing breakout".to_string(),
212            }],
213            reasoning: "r".to_string(),
214            cost: None,
215        };
216        let db: Decision = serde_json::from_str(&serde_json::to_string(&d).unwrap()).unwrap();
217        assert_eq!(db.orders[0].action, Action::Buy);
218        // The per-order rationale survives the JSON round-trip into the trajectory.
219        assert_eq!(db.orders[0].rationale, "trailing breakout");
220
221        // Older agents that omit `rationale` still deserialize (default empty).
222        let legacy = r#"{"orders":[{"symbol":"A","action":"buy","target_weight":0.5}]}"#;
223        let parsed: Decision = serde_json::from_str(legacy).unwrap();
224        assert_eq!(parsed.orders[0].rationale, "");
225        assert!((parsed.orders[0].confidence - 0.5).abs() < 1e-12);
226        // A legacy decision omits `cost` entirely (back-compat → None).
227        assert!(parsed.cost.is_none());
228    }
229
230    #[test]
231    fn decision_cost_channel_parses_and_reduces() {
232        // An agent self-reporting spend: dollars present → billable = dollars.
233        let with_cost = r#"{"orders":[],"reasoning":"","cost":{"cost_usd":0.42,
234            "tokens_in":1200,"tokens_out":300,"reasoning_tokens":180}}"#;
235        let d: Decision = serde_json::from_str(with_cost).unwrap();
236        let c = d.cost.expect("cost channel present");
237        assert!((c.cost_usd - 0.42).abs() < 1e-12);
238        assert_eq!(c.tokens_in, 1200);
239        assert!((c.billable_units() - 0.42).abs() < 1e-12);
240
241        // Tokens-only report (no dollars) → billable = tokens_in + tokens_out;
242        // reasoning tokens are a sub-breakdown of the output, not re-added.
243        let tokens_only = DecisionCost {
244            cost_usd: 0.0,
245            tokens_in: 1000,
246            tokens_out: 250,
247            reasoning_tokens: 200,
248        };
249        assert!((tokens_only.billable_units() - 1250.0).abs() < 1e-12);
250
251        // `cost` round-trips through JSON.
252        let d2 = Decision {
253            orders: Vec::new(),
254            reasoning: String::new(),
255            cost: Some(tokens_only),
256        };
257        let back: Decision = serde_json::from_str(&serde_json::to_string(&d2).unwrap()).unwrap();
258        assert_eq!(back.cost, Some(tokens_only));
259    }
260
261    #[test]
262    fn trajectory_roundtrips_through_json() {
263        let traj = AgentTrajectory {
264            agent_id: "a".to_string(),
265            in_sample_trials: 7,
266            runs: vec![RunTrajectory {
267                window_start: 20,
268                window_end: 30,
269                seed: 3,
270                steps: vec![DecisionStep {
271                    step: 0,
272                    observation_id: "2025-001".to_string(),
273                    decision: Decision {
274                        orders: vec![Order {
275                            symbol: "A".to_string(),
276                            action: Action::Buy,
277                            target_weight: 0.25,
278                            confidence: 0.8,
279                            rationale: String::new(),
280                        }],
281                        reasoning: "r".to_string(),
282                        cost: None,
283                    },
284                }],
285            }],
286        };
287        let back: AgentTrajectory =
288            serde_json::from_str(&serde_json::to_string(&traj).unwrap()).unwrap();
289        assert_eq!(back.agent_id, "a");
290        assert_eq!(back.in_sample_trials, 7);
291        assert_eq!(back.runs[0].seed, 3);
292        assert_eq!(back.runs[0].steps[0].observation_id, "2025-001");
293        assert_eq!(back.runs[0].steps[0].decision.orders[0].target_weight, 0.25);
294    }
295}