hyper-agent-core 0.1.0

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

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::position_manager::{Position, PositionManager};

/// Error type for order execution.
#[derive(Debug, thiserror::Error)]
pub enum ExecutorError {
    #[error("Position error: {0}")]
    Position(#[from] crate::position_manager::PositionError),
    #[error("Execution error: {0}")]
    Execution(String),
}

/// Parameters for submitting an order.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderParams {
    /// Market identifier, e.g. "BTC-PERP".
    pub market: String,
    /// Order side: "buy" or "sell".
    pub side: String,
    /// Order size in base units.
    pub size: f64,
    /// Limit price. `None` means market order.
    pub price: Option<f64>,
}

/// Result of a submitted order.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderResult {
    pub order_id: String,
    pub filled_price: f64,
    pub filled_size: f64,
    /// Original requested size — compare with `filled_size` to detect partial fills.
    pub requested_size: f64,
    /// Order status: "filled", "partial_fill", "resting", "simulated", "ok", "trigger_sl", "trigger_tp".
    pub status: String,
}

/// Trait for submitting and cancelling orders.
///
/// Implementations include the paper executor (simulated fills persisted
/// to SQLite) and the dry-run executor (log-only, no state).
#[async_trait]
pub trait OrderSubmitter: Send + Sync {
    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError>;
    async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutorError>;
}

// ---------------------------------------------------------------------------
// Paper executor
// ---------------------------------------------------------------------------

/// Simulates order fills and records positions in the SQLite database.
///
/// Uses the supplied price (or a default mock mid-price) as the fill price,
/// creates a position record, and returns a synthetic order result.
pub struct PaperExecutor {
    position_manager: Arc<PositionManager>,
}

impl PaperExecutor {
    pub fn new(position_manager: Arc<PositionManager>) -> Self {
        Self { position_manager }
    }
}

#[async_trait]
impl OrderSubmitter for PaperExecutor {
    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError> {
        let fill_price = params.price.unwrap_or(0.0);
        let order_id = uuid::Uuid::new_v4().to_string();

        let side = match params.side.as_str() {
            "buy" => "long",
            "sell" => "short",
            other => other,
        };

        let pos = Position {
            id: order_id.clone(),
            market: params.market.clone(),
            side: side.to_string(),
            size: params.size,
            entry_price: fill_price,
            current_price: Some(fill_price),
            status: "open".to_string(),
            pnl: Some(0.0),
            mode: "paper".to_string(),
            strategy: None,
            opened_at: chrono::Utc::now().to_rfc3339(),
            closed_at: None,
            close_reason: None,
        };

        self.position_manager.open_position(&pos).await?;

        Ok(OrderResult {
            order_id,
            filled_price: fill_price,
            filled_size: params.size,
            requested_size: params.size,
            status: "filled".to_string(),
        })
    }

    async fn cancel_order(&self, _order_id: &str) -> Result<(), ExecutorError> {
        // Paper executor: cancel is a no-op since fills are instant.
        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Dry-run executor
// ---------------------------------------------------------------------------

/// Logs order actions without persisting any state.
///
/// Useful for testing agent decision logic without side effects.
pub struct DryRunExecutor;

#[async_trait]
impl OrderSubmitter for DryRunExecutor {
    async fn place_order(&self, params: &OrderParams) -> Result<OrderResult, ExecutorError> {
        let order_id = uuid::Uuid::new_v4().to_string();
        let fill_price = params.price.unwrap_or(0.0);

        tracing::info!(
            order_id = %order_id,
            market = %params.market,
            side = %params.side,
            size = %params.size,
            price = %fill_price,
            "[dry-run] simulated order"
        );

        Ok(OrderResult {
            order_id,
            filled_price: fill_price,
            filled_size: params.size,
            requested_size: params.size,
            status: "simulated".to_string(),
        })
    }

    async fn cancel_order(&self, order_id: &str) -> Result<(), ExecutorError> {
        tracing::info!(order_id = %order_id, "[dry-run] simulated cancel");
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_paper_executor() -> (Arc<PositionManager>, PaperExecutor) {
        let pm = Arc::new(PositionManager::in_memory().unwrap());
        let exec = PaperExecutor::new(Arc::clone(&pm));
        (pm, exec)
    }

    #[tokio::test]
    async fn test_paper_executor_place_buy() {
        let (pm, exec) = make_paper_executor();

        let result = exec
            .place_order(&OrderParams {
                market: "BTC-PERP".to_string(),
                side: "buy".to_string(),
                size: 0.5,
                price: Some(60000.0),
            })
            .await
            .unwrap();

        assert_eq!(result.filled_price, 60000.0);
        assert_eq!(result.filled_size, 0.5);
        assert_eq!(result.requested_size, 0.5);
        assert_eq!(result.status, "filled");

        // Position should exist in DB
        let pos = pm.get_position(&result.order_id).await.unwrap().unwrap();
        assert_eq!(pos.market, "BTC-PERP");
        assert_eq!(pos.side, "long");
        assert_eq!(pos.size, 0.5);
        assert_eq!(pos.entry_price, 60000.0);
        assert_eq!(pos.mode, "paper");
        assert_eq!(pos.status, "open");
    }

    #[tokio::test]
    async fn test_paper_executor_place_sell() {
        let (pm, exec) = make_paper_executor();

        let result = exec
            .place_order(&OrderParams {
                market: "ETH-PERP".to_string(),
                side: "sell".to_string(),
                size: 10.0,
                price: Some(3000.0),
            })
            .await
            .unwrap();

        let pos = pm.get_position(&result.order_id).await.unwrap().unwrap();
        assert_eq!(pos.side, "short");
        assert_eq!(pos.entry_price, 3000.0);
    }

    #[tokio::test]
    async fn test_paper_executor_market_order_zero_price() {
        let (_pm, exec) = make_paper_executor();

        let result = exec
            .place_order(&OrderParams {
                market: "SOL-PERP".to_string(),
                side: "buy".to_string(),
                size: 100.0,
                price: None,
            })
            .await
            .unwrap();

        assert_eq!(result.filled_price, 0.0);
    }

    #[tokio::test]
    async fn test_paper_executor_cancel_is_noop() {
        let (_pm, exec) = make_paper_executor();
        exec.cancel_order("any-id").await.unwrap();
    }

    #[tokio::test]
    async fn test_paper_executor_positions_accumulate() {
        let (pm, exec) = make_paper_executor();

        for _ in 0..3 {
            exec.place_order(&OrderParams {
                market: "BTC-PERP".to_string(),
                side: "buy".to_string(),
                size: 1.0,
                price: Some(60000.0),
            })
            .await
            .unwrap();
        }

        assert_eq!(pm.list_open().await.unwrap().len(), 3);
    }

    #[tokio::test]
    async fn test_dry_run_executor_place_order() {
        let exec = DryRunExecutor;

        let result = exec
            .place_order(&OrderParams {
                market: "BTC-PERP".to_string(),
                side: "buy".to_string(),
                size: 1.0,
                price: Some(60000.0),
            })
            .await
            .unwrap();

        assert_eq!(result.filled_price, 60000.0);
        assert_eq!(result.filled_size, 1.0);
        assert_eq!(result.requested_size, 1.0);
        assert_eq!(result.status, "simulated");
        assert!(!result.order_id.is_empty());
    }

    #[tokio::test]
    async fn test_dry_run_executor_cancel() {
        let exec = DryRunExecutor;
        exec.cancel_order("anything").await.unwrap();
    }

    #[tokio::test]
    async fn test_dry_run_executor_market_order() {
        let exec = DryRunExecutor;

        let result = exec
            .place_order(&OrderParams {
                market: "ETH-PERP".to_string(),
                side: "sell".to_string(),
                size: 5.0,
                price: None,
            })
            .await
            .unwrap();

        assert_eq!(result.filled_price, 0.0);
        assert_eq!(result.status, "simulated");
    }

    #[test]
    fn order_result_requested_size_tracks_original() {
        let result = OrderResult {
            order_id: "test".into(),
            filled_price: 65000.0,
            filled_size: 0.005,
            requested_size: 0.01,
            status: "partial_fill".into(),
        };
        assert_eq!(result.requested_size, 0.01);
        assert_eq!(result.filled_size, 0.005);
        assert_eq!(result.status, "partial_fill");
        // Fill percentage
        let pct = (result.filled_size / result.requested_size) * 100.0;
        assert!((pct - 50.0).abs() < 0.01);
    }

    #[tokio::test]
    async fn paper_executor_always_full_fill() {
        let (_pm, exec) = make_paper_executor();
        let result = exec
            .place_order(&OrderParams {
                market: "BTC-PERP".to_string(),
                side: "buy".to_string(),
                size: 0.01,
                price: Some(65000.0),
            })
            .await
            .unwrap();
        // Paper executor always fills the full requested size
        assert_eq!(result.filled_size, result.requested_size);
        assert_eq!(result.status, "filled");
    }
}