use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::Result;
use crate::market::{MarketDataEvent, Symbol};
use crate::signal::SignalType;
use crate::types::{Fill, OrderKind, Position, Price, Volume};
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub enum SizeHint {
MarginFraction(f64),
NotionalUsd(f64),
Quantity(Volume),
#[default]
Default,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Decision {
pub signal: SignalType,
pub confidence: f64,
#[serde(default)]
pub size_hint: SizeHint,
pub stop_price: Option<Price>,
pub take_profit_price: Option<Price>,
#[serde(default)]
pub order_kind: OrderKind,
#[serde(default)]
pub limit_price: Option<Price>,
#[serde(default)]
pub metadata: serde_json::Value,
}
impl Decision {
pub fn hold() -> Self {
Self {
signal: SignalType::Hold,
confidence: 0.0,
size_hint: SizeHint::Default,
stop_price: None,
take_profit_price: None,
order_kind: OrderKind::Market,
limit_price: None,
metadata: serde_json::Value::Null,
}
}
pub fn buy(confidence: f64) -> Self {
Self {
signal: SignalType::Buy,
confidence,
..Self::hold()
}
}
pub fn sell(confidence: f64) -> Self {
Self {
signal: SignalType::Sell,
confidence,
..Self::hold()
}
}
pub fn close() -> Self {
Self {
signal: SignalType::Close,
confidence: 1.0,
..Self::hold()
}
}
pub fn with_stop(mut self, price: Price) -> Self {
self.stop_price = Some(price);
self
}
pub fn with_take_profit(mut self, price: Price) -> Self {
self.take_profit_price = Some(price);
self
}
pub fn with_size_hint(mut self, hint: SizeHint) -> Self {
self.size_hint = hint;
self
}
pub fn with_limit_price(mut self, price: Price) -> Self {
self.order_kind = OrderKind::Limit;
self.limit_price = Some(price);
self
}
pub fn with_order_kind(mut self, kind: OrderKind) -> Self {
self.order_kind = kind;
self
}
pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
self.metadata = metadata;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct BrainHealth {
pub healthy: bool,
pub events_processed: u64,
pub non_hold_decisions: u64,
#[serde(default)]
pub details: serde_json::Value,
}
impl BrainHealth {
pub fn ok() -> Self {
Self {
healthy: true,
..Default::default()
}
}
pub fn unhealthy(reason: impl Into<String>) -> Self {
Self {
healthy: false,
details: serde_json::json!({ "reason": reason.into() }),
..Default::default()
}
}
}
#[async_trait]
pub trait Brain: Send + Sync + 'static {
fn name(&self) -> &str;
fn owned_symbols(&self) -> Option<Vec<Symbol>> {
None
}
async fn on_event(&self, event: &MarketDataEvent, position: &Position) -> Result<Decision>;
async fn on_fill(&self, _fill: &Fill) -> Result<()> {
Ok(())
}
async fn on_position_change(&self, _symbol: &Symbol, _position: &Position) -> Result<()> {
Ok(())
}
async fn health(&self) -> BrainHealth {
BrainHealth::ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Price;
#[test]
fn decision_hold() {
let d = Decision::hold();
assert!(matches!(d.signal, SignalType::Hold));
assert_eq!(d.confidence, 0.0);
assert!(d.stop_price.is_none());
assert!(d.take_profit_price.is_none());
assert!(matches!(d.size_hint, SizeHint::Default));
}
#[test]
fn decision_buy_sell_close() {
let b = Decision::buy(0.75);
assert!(matches!(b.signal, SignalType::Buy));
assert_eq!(b.confidence, 0.75);
let s = Decision::sell(0.5);
assert!(matches!(s.signal, SignalType::Sell));
let c = Decision::close();
assert!(matches!(c.signal, SignalType::Close));
assert_eq!(c.confidence, 1.0);
}
#[test]
fn decision_builders_compose() {
let d = Decision::buy(0.9)
.with_stop(Price(95.0))
.with_take_profit(Price(110.0))
.with_size_hint(SizeHint::MarginFraction(0.25))
.with_metadata(serde_json::json!({"reason": "ema-cross"}));
assert_eq!(d.stop_price, Some(Price(95.0)));
assert_eq!(d.take_profit_price, Some(Price(110.0)));
assert!(matches!(d.size_hint, SizeHint::MarginFraction(f) if (f - 0.25).abs() < 1e-9));
assert_eq!(d.metadata["reason"], "ema-cross");
}
#[test]
fn decision_serde_roundtrip() {
let d = Decision::sell(0.6).with_stop(Price(120.0));
let json = serde_json::to_string(&d).unwrap();
let back: Decision = serde_json::from_str(&json).unwrap();
assert!(matches!(back.signal, SignalType::Sell));
assert_eq!(back.confidence, 0.6);
assert_eq!(back.stop_price, Some(Price(120.0)));
}
#[test]
fn decision_defaults_to_market() {
let d = Decision::buy(1.0);
assert!(matches!(d.order_kind, OrderKind::Market));
assert!(d.limit_price.is_none());
}
#[test]
fn with_limit_price_sets_kind_and_price() {
let d = Decision::buy(1.0).with_limit_price(Price(100.0));
assert!(matches!(d.order_kind, OrderKind::Limit));
assert_eq!(d.limit_price, Some(Price(100.0)));
}
#[test]
fn with_order_kind_overrides_kind() {
let d = Decision::sell(1.0)
.with_limit_price(Price(50.0))
.with_order_kind(OrderKind::PostOnly);
assert!(matches!(d.order_kind, OrderKind::PostOnly));
assert_eq!(d.limit_price, Some(Price(50.0)));
}
#[test]
fn decision_serde_roundtrip_with_order_kind() {
let d = Decision::buy(0.5).with_order_kind(OrderKind::Fok);
let json = serde_json::to_string(&d).unwrap();
let back: Decision = serde_json::from_str(&json).unwrap();
assert!(matches!(back.order_kind, OrderKind::Fok));
}
#[test]
fn decision_deserializes_legacy_json_without_new_fields() {
let legacy =
r#"{"signal":"buy","confidence":0.7,"stop_price":null,"take_profit_price":null}"#;
let back: Decision = serde_json::from_str(legacy).unwrap();
assert!(matches!(back.signal, SignalType::Buy));
assert!(matches!(back.order_kind, OrderKind::Market));
assert!(back.limit_price.is_none());
}
#[test]
fn brain_health_ok_is_healthy() {
let h = BrainHealth::ok();
assert!(h.healthy);
assert_eq!(h.events_processed, 0);
assert_eq!(h.non_hold_decisions, 0);
}
#[test]
fn brain_health_unhealthy_captures_reason() {
let h = BrainHealth::unhealthy("warm-up incomplete");
assert!(!h.healthy);
assert_eq!(h.details["reason"], "warm-up incomplete");
}
}