Skip to main content

sharpebench_sim/
agent.rs

1//! In-process trading agents.
2//!
3//! External agents speak the JSON [`sharpebench_protocol`] over a container/HTTP boundary;
4//! this trait is the in-process equivalent used for reference agents and tests.
5
6use std::collections::BTreeMap;
7
8use sharpebench_protocol::{Action, Decision, MarketObservation, Order};
9
10/// Something that turns a point-in-time observation into trading orders.
11pub trait Agent {
12    fn decide(&mut self, obs: &MarketObservation) -> Decision;
13}
14
15/// A trading *team*: several member agents whose target weights are averaged into
16/// one consensus decision (a symbol only one member likes is down-weighted by the
17/// whole team's size). Modelled on the TradingAgents multi-agent firm — the team
18/// is scored as a unit while [`sharpebench_core::attribute_roles`] estimates each member's
19/// load on the team outcome.
20pub struct TeamAgent {
21    pub members: Vec<Box<dyn Agent>>,
22}
23
24impl Agent for TeamAgent {
25    fn decide(&mut self, obs: &MarketObservation) -> Decision {
26        let n = self.members.len().max(1) as f64;
27        let mut weight: BTreeMap<String, f64> = BTreeMap::new();
28        let mut conf: BTreeMap<String, f64> = BTreeMap::new();
29        let mut votes: BTreeMap<String, f64> = BTreeMap::new();
30        for m in self.members.iter_mut() {
31            for o in m.decide(obs).orders {
32                *weight.entry(o.symbol.clone()).or_default() += o.target_weight;
33                *conf.entry(o.symbol.clone()).or_default() += o.confidence;
34                *votes.entry(o.symbol).or_default() += 1.0;
35            }
36        }
37        let orders = weight
38            .iter()
39            .map(|(sym, &w)| {
40                let avg_w = (w / n).max(0.0);
41                Order {
42                    symbol: sym.clone(),
43                    action: if avg_w > 0.0 {
44                        Action::Buy
45                    } else {
46                        Action::Close
47                    },
48                    target_weight: avg_w,
49                    confidence: conf[sym] / votes[sym].max(1.0),
50                    rationale: format!("team consensus weight {avg_w:.3}"),
51                }
52            })
53            .collect();
54        Decision {
55            orders,
56            reasoning: "team consensus (mean target weight)".to_string(),
57        }
58    }
59}
60
61/// Equal-weight buy-and-hold across all symbols — the baseline every agent must beat.
62pub struct BuyAndHold;
63
64impl Agent for BuyAndHold {
65    fn decide(&mut self, obs: &MarketObservation) -> Decision {
66        let n = obs.symbols.len().max(1) as f64;
67        let w = 1.0 / n;
68        let orders = obs
69            .symbols
70            .iter()
71            .map(|s| Order {
72                symbol: s.symbol.clone(),
73                action: Action::Buy,
74                target_weight: w,
75                confidence: 0.5,
76                rationale: "equal-weight hold".to_string(),
77            })
78            .collect();
79        Decision {
80            orders,
81            reasoning: "equal-weight buy-and-hold".to_string(),
82        }
83    }
84}
85
86/// The do-nothing agent: always holds (empty orders). A trivial baseline, and the
87/// graceful fallback when an external agent process can't be spawned mid-run —
88/// consistent with how the external transports already degrade to a hold on error.
89pub struct HoldAgent;
90
91impl Agent for HoldAgent {
92    fn decide(&mut self, _obs: &MarketObservation) -> Decision {
93        Decision {
94            orders: Vec::new(),
95            reasoning: "hold".to_string(),
96        }
97    }
98}
99
100/// A coin-flip "monkey": a fully-invested, long-only portfolio with random
101/// weights each step. Seeded so it is reproducible. Run many of these to draw the
102/// **luck floor** — the distribution of outcomes from zero skill that a genuine
103/// agent must clear to be rank-eligible.
104pub struct RandomAgent {
105    rng: crate::costs::Rng,
106}
107
108impl RandomAgent {
109    pub fn new(seed: u64) -> Self {
110        Self {
111            rng: crate::costs::Rng::new(seed ^ 0x1AC4_0000_2026_0000),
112        }
113    }
114}
115
116impl Agent for RandomAgent {
117    fn decide(&mut self, obs: &MarketObservation) -> Decision {
118        let raws: Vec<f64> = obs.symbols.iter().map(|_| self.rng.unit()).collect();
119        let total: f64 = raws.iter().sum();
120        let orders = obs
121            .symbols
122            .iter()
123            .zip(&raws)
124            .map(|(s, &r)| {
125                let w = if total > 0.0 { r / total } else { 0.0 };
126                Order {
127                    symbol: s.symbol.clone(),
128                    action: if w > 0.0 { Action::Buy } else { Action::Close },
129                    target_weight: w,
130                    confidence: 0.5,
131                    rationale: "random allocation".to_string(),
132                }
133            })
134            .collect();
135        Decision {
136            orders,
137            reasoning: "random allocation (luck floor)".to_string(),
138        }
139    }
140}
141
142/// Cross-sectional momentum: equal-weight the symbols with positive trailing return.
143pub struct Momentum {
144    pub lookback: usize,
145}
146
147impl Default for Momentum {
148    fn default() -> Self {
149        Self { lookback: 10 }
150    }
151}
152
153impl Agent for Momentum {
154    fn decide(&mut self, obs: &MarketObservation) -> Decision {
155        let scores: Vec<(String, f64)> = obs
156            .symbols
157            .iter()
158            .map(|s| {
159                let h = &s.close_history;
160                let score = if h.len() >= 2 && h[0] > 0.0 {
161                    h[h.len() - 1] / h[0] - 1.0
162                } else {
163                    0.0
164                };
165                (s.symbol.clone(), score)
166            })
167            .collect();
168
169        let n_winners = scores.iter().filter(|(_, sc)| *sc > 0.0).count();
170        let w = if n_winners > 0 {
171            1.0 / n_winners as f64
172        } else {
173            0.0
174        };
175
176        let orders = scores
177            .iter()
178            .map(|(sym, sc)| {
179                let positive = *sc > 0.0;
180                Order {
181                    symbol: sym.clone(),
182                    action: if positive { Action::Buy } else { Action::Close },
183                    target_weight: if positive { w } else { 0.0 },
184                    confidence: (0.5 + sc.abs()).min(1.0),
185                    rationale: if positive {
186                        format!("positive trailing return {sc:.3}")
187                    } else {
188                        "non-positive trailing return".to_string()
189                    },
190                }
191            })
192            .collect();
193
194        Decision {
195            orders,
196            reasoning: "cross-sectional momentum".to_string(),
197        }
198    }
199}