Skip to main content

hyper_agent_core/
signal_adapter.rs

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
8/// Bridges PlaybookEngine tick results to TradeSignal messages.
9///
10/// On each tick, the adapter feeds indicators into the engine, converts
11/// actionable [`TickResult`]s into [`TradeSignal`]s, and sends them through
12/// an mpsc channel for downstream processing by the Order Pipeline.
13pub 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    /// Get a reference to the signal sender (for reconstructing adapter on strategy reload).
36    pub fn signal_tx_ref(&self) -> &mpsc::Sender<TradeSignal> {
37        &self.signal_tx
38    }
39
40    /// Feed a new set of indicators and timestamp to the engine.
41    /// If the tick produces an actionable result, a [`TradeSignal`] is sent
42    /// through the channel.
43    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
51/// Convert a [`TickResult`] into a [`TradeSignal`].
52///
53/// Returns `None` for no-action ticks (`None`, `OrderFilled`, `OrderCancelled`).
54/// This is a standalone public function so it can be tested without
55/// constructing a full `SignalAdapter`.
56pub 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}