use tokio::sync::mpsc;
use hyper_playbook::engine::{PlaybookEngine, TickAction, TickResult};
use hyper_ta::technical_analysis::TechnicalIndicators;
use crate::signal::{Side, SignalAction, SignalSource, TradeSignal};
pub struct SignalAdapter {
engine: PlaybookEngine,
signal_tx: mpsc::Sender<TradeSignal>,
strategy_id: String,
symbol: String,
}
impl SignalAdapter {
pub fn new(
engine: PlaybookEngine,
signal_tx: mpsc::Sender<TradeSignal>,
strategy_id: String,
symbol: String,
) -> Self {
Self {
engine,
signal_tx,
strategy_id,
symbol,
}
}
pub fn signal_tx_ref(&self) -> &mpsc::Sender<TradeSignal> {
&self.signal_tx
}
pub async fn on_tick(&mut self, indicators: &TechnicalIndicators, now: u64) {
let tick_result = self.engine.tick(indicators, now).await;
if let Some(signal) = tick_to_signal(&tick_result, &self.strategy_id, &self.symbol) {
let _ = self.signal_tx.send(signal).await;
}
}
}
pub fn tick_to_signal(tick: &TickResult, strategy_id: &str, symbol: &str) -> Option<TradeSignal> {
let action = match &tick.action {
TickAction::OrderPlaced { side, size, .. } => {
let s = if side == "buy" || side == "long" {
Side::Buy
} else {
Side::Sell
};
SignalAction::Open {
side: s,
size: *size,
price: None,
}
}
TickAction::PositionClosed { .. } => SignalAction::Close {
side: Side::Sell,
size: 0.0,
},
TickAction::ForceClose { reason } => SignalAction::CloseAll {
reason: reason.clone(),
},
TickAction::None | TickAction::OrderFilled { .. } | TickAction::OrderCancelled { .. } => {
return None;
}
};
let reason = if tick.triggered_rules.is_empty() {
format!("regime={}", tick.regime)
} else {
format!(
"regime={}, rules=[{}]",
tick.regime,
tick.triggered_rules.join(", ")
)
};
Some(TradeSignal {
id: uuid::Uuid::new_v4().to_string(),
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
source: SignalSource::Playbook {
strategy_id: strategy_id.to_string(),
regime: tick.regime.clone(),
},
symbol: symbol.to_string(),
action,
reason,
})
}
#[cfg(test)]
mod tests {
use super::*;
use hyper_playbook::engine::{TickAction, TickResult};
fn make_tick(action: TickAction) -> TickResult {
TickResult {
regime: "neutral".into(),
regime_changed: false,
previous_regime: None,
fsm_state: "Idle".into(),
action,
triggered_rules: vec![],
}
}
#[test]
fn converts_order_placed_buy_to_open() {
let tick = make_tick(TickAction::OrderPlaced {
order_id: "o-1".into(),
side: "buy".into(),
size: 100.0,
});
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert!(
matches!(sig.action, SignalAction::Open { side: Side::Buy, size, .. } if size == 100.0)
);
assert_eq!(sig.symbol, "BTC-PERP");
assert!(matches!(
sig.source,
SignalSource::Playbook { ref strategy_id, .. } if strategy_id == "sg-1"
));
}
#[test]
fn converts_order_placed_sell_to_open() {
let tick = make_tick(TickAction::OrderPlaced {
order_id: "o-2".into(),
side: "sell".into(),
size: 50.0,
});
let sig = tick_to_signal(&tick, "sg-1", "ETH-PERP").unwrap();
assert!(
matches!(sig.action, SignalAction::Open { side: Side::Sell, size, .. } if size == 50.0)
);
}
#[test]
fn converts_order_placed_long_to_buy() {
let tick = make_tick(TickAction::OrderPlaced {
order_id: "o-3".into(),
side: "long".into(),
size: 10.0,
});
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert!(matches!(
sig.action,
SignalAction::Open {
side: Side::Buy,
..
}
));
}
#[test]
fn converts_position_closed_to_close() {
let tick = make_tick(TickAction::PositionClosed {
reason: "exit_rule".into(),
});
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert!(matches!(sig.action, SignalAction::Close { .. }));
}
#[test]
fn converts_force_close_to_close_all() {
let tick = make_tick(TickAction::ForceClose {
reason: "regime_change".into(),
});
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert!(
matches!(sig.action, SignalAction::CloseAll { ref reason } if reason == "regime_change")
);
}
#[test]
fn returns_none_for_no_action() {
let tick = make_tick(TickAction::None);
assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
}
#[test]
fn returns_none_for_order_filled() {
let tick = make_tick(TickAction::OrderFilled {
position_id: "p-1".into(),
entry_price: 50000.0,
});
assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
}
#[test]
fn returns_none_for_order_cancelled() {
let tick = make_tick(TickAction::OrderCancelled {
order_id: "o-1".into(),
reason: "timeout".into(),
});
assert!(tick_to_signal(&tick, "sg-1", "BTC-PERP").is_none());
}
#[test]
fn reason_includes_triggered_rules() {
let tick = TickResult {
regime: "bull".into(),
regime_changed: false,
previous_regime: None,
fsm_state: "Idle".into(),
action: TickAction::OrderPlaced {
order_id: "o-1".into(),
side: "buy".into(),
size: 100.0,
},
triggered_rules: vec!["rsi_oversold".into(), "macd_cross".into()],
};
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert!(sig.reason.contains("rsi_oversold"));
assert!(sig.reason.contains("macd_cross"));
assert!(sig.reason.contains("regime=bull"));
}
#[test]
fn reason_without_rules_shows_regime_only() {
let tick = make_tick(TickAction::PositionClosed {
reason: "stop_loss".into(),
});
let sig = tick_to_signal(&tick, "sg-1", "BTC-PERP").unwrap();
assert_eq!(sig.reason, "regime=neutral");
}
}