1use std::collections::BTreeMap;
7
8use sharpebench_protocol::{Action, Decision, MarketObservation, Order};
9
10pub trait Agent {
12 fn decide(&mut self, obs: &MarketObservation) -> Decision;
13}
14
15pub 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
61pub 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
86pub 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
100pub 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
142pub 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}