hyper-agent-core 0.1.0

Core domain logic for hyper-agent: pipeline, executor, signals, positions
Documentation
use tokio::sync::mpsc;

use hyper_playbook::engine::{PlaybookEngine, TickAction, TickResult};
use hyper_ta::technical_analysis::TechnicalIndicators;

use crate::signal::{Side, SignalAction, SignalSource, TradeSignal};

/// Bridges PlaybookEngine tick results to TradeSignal messages.
///
/// On each tick, the adapter feeds indicators into the engine, converts
/// actionable [`TickResult`]s into [`TradeSignal`]s, and sends them through
/// an mpsc channel for downstream processing by the Order Pipeline.
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,
        }
    }

    /// Get a reference to the signal sender (for reconstructing adapter on strategy reload).
    pub fn signal_tx_ref(&self) -> &mpsc::Sender<TradeSignal> {
        &self.signal_tx
    }

    /// Feed a new set of indicators and timestamp to the engine.
    /// If the tick produces an actionable result, a [`TradeSignal`] is sent
    /// through the channel.
    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;
        }
    }
}

/// Convert a [`TickResult`] into a [`TradeSignal`].
///
/// Returns `None` for no-action ticks (`None`, `OrderFilled`, `OrderCancelled`).
/// This is a standalone public function so it can be tested without
/// constructing a full `SignalAdapter`.
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");
    }
}