1use tokio::sync::mpsc;
2
3use hyper_playbook::engine::{PlaybookEngine, TickAction, TickResult};
4use hyper_ta::technical_analysis::TechnicalIndicators;
5
6use crate::signal::{Side, SignalAction, SignalSource, TradeSignal};
7
8pub struct SignalAdapter {
14 engine: PlaybookEngine,
15 signal_tx: mpsc::Sender<TradeSignal>,
16 strategy_id: String,
17 symbol: String,
18}
19
20impl SignalAdapter {
21 pub fn new(
22 engine: PlaybookEngine,
23 signal_tx: mpsc::Sender<TradeSignal>,
24 strategy_id: String,
25 symbol: String,
26 ) -> Self {
27 Self {
28 engine,
29 signal_tx,
30 strategy_id,
31 symbol,
32 }
33 }
34
35 pub fn signal_tx_ref(&self) -> &mpsc::Sender<TradeSignal> {
37 &self.signal_tx
38 }
39
40 pub async fn on_tick(&mut self, indicators: &TechnicalIndicators, now: u64) {
44 let tick_result = self.engine.tick(indicators, now).await;
45 if let Some(signal) = tick_to_signal(&tick_result, &self.strategy_id, &self.symbol) {
46 let _ = self.signal_tx.send(signal).await;
47 }
48 }
49}
50
51pub fn tick_to_signal(tick: &TickResult, strategy_id: &str, symbol: &str) -> Option<TradeSignal> {
57 let action = match &tick.action {
58 TickAction::OrderPlaced { side, size, .. } => {
59 let s = if side == "buy" || side == "long" {
60 Side::Buy
61 } else {
62 Side::Sell
63 };
64 SignalAction::Open {
65 side: s,
66 size: *size,
67 price: None,
68 }
69 }
70 TickAction::PositionClosed { .. } => SignalAction::Close {
71 side: Side::Sell,
72 size: 0.0,
73 },
74 TickAction::ForceClose { reason } => SignalAction::CloseAll {
75 reason: reason.clone(),
76 },
77 TickAction::None | TickAction::OrderFilled { .. } | TickAction::OrderCancelled { .. } => {
78 return None;
79 }
80 };
81
82 let reason = if tick.triggered_rules.is_empty() {
83 format!("regime={}", tick.regime)
84 } else {
85 format!(
86 "regime={}, rules=[{}]",
87 tick.regime,
88 tick.triggered_rules.join(", ")
89 )
90 };
91
92 Some(TradeSignal {
93 id: uuid::Uuid::new_v4().to_string(),
94 timestamp: std::time::SystemTime::now()
95 .duration_since(std::time::UNIX_EPOCH)
96 .unwrap_or_default()
97 .as_secs(),
98 source: SignalSource::Playbook {
99 strategy_id: strategy_id.to_string(),
100 regime: tick.regime.clone(),
101 },
102 symbol: symbol.to_string(),
103 action,
104 reason,
105 })
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111 use hyper_playbook::engine::{TickAction, TickResult};
112
113 fn make_tick(action: TickAction) -> TickResult {
114 TickResult {
115 regime: "neutral".into(),
116 regime_changed: false,
117 previous_regime: None,
118 fsm_state: "Idle".into(),
119 action,
120 triggered_rules: vec![],
121 }
122 }
123
124 #[test]
125 fn converts_order_placed_buy_to_open() {
126 let tick = make_tick(TickAction::OrderPlaced {
127 order_id: "o-1".into(),
128 side: "buy".into(),
129 size: 100.0,
130 });
131 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
132 assert!(
133 matches!(sig.action, SignalAction::Open { side: Side::Buy, size, .. } if size == 100.0)
134 );
135 assert_eq!(sig.symbol, "BTC-PERP");
136 assert!(matches!(
137 sig.source,
138 SignalSource::Playbook { ref strategy_id, .. } if strategy_id == "sg-1"
139 ));
140 }
141
142 #[test]
143 fn converts_order_placed_sell_to_open() {
144 let tick = make_tick(TickAction::OrderPlaced {
145 order_id: "o-2".into(),
146 side: "sell".into(),
147 size: 50.0,
148 });
149 let sig = tick_to_signal(&tick, "sg-1", "ETH-PERP").unwrap();
150 assert!(
151 matches!(sig.action, SignalAction::Open { side: Side::Sell, size, .. } if size == 50.0)
152 );
153 }
154
155 #[test]
156 fn converts_order_placed_long_to_buy() {
157 let tick = make_tick(TickAction::OrderPlaced {
158 order_id: "o-3".into(),
159 side: "long".into(),
160 size: 10.0,
161 });
162 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
163 assert!(matches!(
164 sig.action,
165 SignalAction::Open {
166 side: Side::Buy,
167 ..
168 }
169 ));
170 }
171
172 #[test]
173 fn converts_position_closed_to_close() {
174 let tick = make_tick(TickAction::PositionClosed {
175 reason: "exit_rule".into(),
176 });
177 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
178 assert!(matches!(sig.action, SignalAction::Close { .. }));
179 }
180
181 #[test]
182 fn converts_force_close_to_close_all() {
183 let tick = make_tick(TickAction::ForceClose {
184 reason: "regime_change".into(),
185 });
186 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
187 assert!(
188 matches!(sig.action, SignalAction::CloseAll { ref reason } if reason == "regime_change")
189 );
190 }
191
192 #[test]
193 fn returns_none_for_no_action() {
194 let tick = make_tick(TickAction::None);
195 assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
196 }
197
198 #[test]
199 fn returns_none_for_order_filled() {
200 let tick = make_tick(TickAction::OrderFilled {
201 position_id: "p-1".into(),
202 entry_price: 50000.0,
203 });
204 assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
205 }
206
207 #[test]
208 fn returns_none_for_order_cancelled() {
209 let tick = make_tick(TickAction::OrderCancelled {
210 order_id: "o-1".into(),
211 reason: "timeout".into(),
212 });
213 assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
214 }
215
216 #[test]
217 fn reason_includes_triggered_rules() {
218 let tick = TickResult {
219 regime: "bull".into(),
220 regime_changed: false,
221 previous_regime: None,
222 fsm_state: "Idle".into(),
223 action: TickAction::OrderPlaced {
224 order_id: "o-1".into(),
225 side: "buy".into(),
226 size: 100.0,
227 },
228 triggered_rules: vec!["rsi_oversold".into(), "macd_cross".into()],
229 };
230 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
231 assert!(sig.reason.contains("rsi_oversold"));
232 assert!(sig.reason.contains("macd_cross"));
233 assert!(sig.reason.contains("regime=bull"));
234 }
235
236 #[test]
237 fn reason_without_rules_shows_regime_only() {
238 let tick = make_tick(TickAction::PositionClosed {
239 reason: "stop_loss".into(),
240 });
241 let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
242 assert_eq!(sig.reason, "regime=neutral");
243 }
244}