Skip to main content

rustrade_core/
brain.rs

1//! The [`Brain`] trait — rustrade's central abstraction.
2//!
3//! A `Brain` is the strategic layer of a trading bot: it consumes market
4//! events and outputs [`Decision`]s. Everything else in rustrade (supervisor,
5//! exchange client, risk layer, execution) is plumbing around this one trait.
6//!
7//! # Why a single trait?
8//!
9//! Trading bots come in many flavours — indicator-based, ML-based,
10//! neuromorphic, hybrid. The common contract is: "given market state, tell
11//! me what to do." Encoding that contract as one narrow trait means:
12//!
13//! - A rule-based `SarBrain` and a 10-million-parameter `NeuromorphicBrain`
14//!   are interchangeable to the rest of the framework.
15//! - Backtesting and live trading share the same brain implementation.
16//! - You can run multiple brains in parallel (e.g. A/B or ensemble) by
17//!   composing them in an outer `Brain` impl.
18//!
19//! # What `Brain` does NOT do
20//!
21//! A `Brain` does **not**:
22//! - Place orders directly — it returns a [`Decision`]; the execution
23//!   layer decides whether to act.
24//! - Manage positions — `on_position_change` is informational only.
25//! - Do risk sizing — it may suggest size via [`SizeHint`], but the risk
26//!   layer has the final say.
27//! - Own the indicator state externally — that's a brain-internal concern.
28//!   Two different brains can maintain entirely different indicator stacks.
29
30use async_trait::async_trait;
31use serde::{Deserialize, Serialize};
32
33use crate::error::Result;
34use crate::market::{MarketDataEvent, Symbol};
35use crate::signal::SignalType;
36use crate::types::{Fill, OrderKind, Position, Price, Volume};
37
38/// How large the brain wants the next order to be. The risk layer can honour,
39/// scale down, or reject this hint.
40#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
41pub enum SizeHint {
42    /// Use a fraction of available margin (0.0..=1.0).
43    MarginFraction(f64),
44    /// Target a specific notional in quote currency.
45    NotionalUsd(f64),
46    /// Target a specific number of contracts or base units.
47    Quantity(Volume),
48    /// Defer to the risk layer's default sizing entirely.
49    #[default]
50    Default,
51}
52
53/// A brain's decision on a single market event.
54///
55/// `signal` is always present; the other fields are hints and metadata that
56/// the execution and risk layers may or may not use.
57///
58/// # Example
59///
60/// ```
61/// use rustrade_core::{Decision, Price, SizeHint};
62///
63/// // The four constructor shapes the framework uses internally.
64/// let _hold = Decision::hold();
65/// let _close = Decision::close();
66/// let _buy = Decision::buy(0.8);
67/// let _sell = Decision::sell(0.6);
68///
69/// // Chain stop / take-profit / size hints / metadata.
70/// let decision = Decision::buy(0.9)
71///     .with_stop(Price(95.0))
72///     .with_take_profit(Price(110.0))
73///     .with_size_hint(SizeHint::MarginFraction(0.5))
74///     .with_metadata(serde_json::json!({"reason": "ema-cross"}));
75///
76/// assert_eq!(decision.stop_price, Some(Price(95.0)));
77/// assert_eq!(decision.take_profit_price, Some(Price(110.0)));
78/// ```
79///
80/// # Entry order kind
81///
82/// By default an entry executes as a [`OrderKind::Market`] order. A brain
83/// can request a resting or time-in-force variant via
84/// [`Decision::with_limit_price`] (limit) or [`Decision::with_order_kind`]
85/// (post-only / IOC / FOK). The execution layer gates non-trivial kinds on
86/// the adapter's [`Capability`](crate::Capability) — an unsupported kind
87/// blocks the order rather than silently downgrading it (which would change
88/// fill / fee semantics). `Close` decisions always execute as reduce-only
89/// market orders regardless of these fields.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct Decision {
92    /// What the brain decided to do (Buy / Sell / Hold / Close).
93    pub signal: SignalType,
94    /// Confidence in [0.0, 1.0].
95    pub confidence: f64,
96    /// Optional suggested size.
97    #[serde(default)]
98    pub size_hint: SizeHint,
99    /// Optional suggested stop-loss price.
100    pub stop_price: Option<Price>,
101    /// Optional suggested take-profit price.
102    pub take_profit_price: Option<Price>,
103    /// How the entry order should execute. Defaults to
104    /// [`OrderKind::Market`]. Ignored for `Close` decisions.
105    #[serde(default)]
106    pub order_kind: OrderKind,
107    /// Limit price for non-market entries. Used by
108    /// [`OrderKind::Limit`] / [`OrderKind::PostOnly`] / [`OrderKind::Ioc`]
109    /// / [`OrderKind::Fok`]; if absent for those kinds the execution layer
110    /// falls back to the triggering event's price and logs a warning.
111    #[serde(default)]
112    pub limit_price: Option<Price>,
113    /// Free-form brain metadata, used for logging and post-trade analysis.
114    #[serde(default)]
115    pub metadata: serde_json::Value,
116}
117
118impl Decision {
119    /// Convenience: "no action".
120    pub fn hold() -> Self {
121        Self {
122            signal: SignalType::Hold,
123            confidence: 0.0,
124            size_hint: SizeHint::Default,
125            stop_price: None,
126            take_profit_price: None,
127            order_kind: OrderKind::Market,
128            limit_price: None,
129            metadata: serde_json::Value::Null,
130        }
131    }
132
133    /// Convenience: open or flip a long with the given confidence.
134    pub fn buy(confidence: f64) -> Self {
135        Self {
136            signal: SignalType::Buy,
137            confidence,
138            ..Self::hold()
139        }
140    }
141
142    /// Convenience: open or flip a short with the given confidence.
143    pub fn sell(confidence: f64) -> Self {
144        Self {
145            signal: SignalType::Sell,
146            confidence,
147            ..Self::hold()
148        }
149    }
150
151    /// Convenience: close the current position without reversing.
152    pub fn close() -> Self {
153        Self {
154            signal: SignalType::Close,
155            confidence: 1.0,
156            ..Self::hold()
157        }
158    }
159
160    /// Suggest a stop-loss price.
161    pub fn with_stop(mut self, price: Price) -> Self {
162        self.stop_price = Some(price);
163        self
164    }
165
166    /// Suggest a take-profit price.
167    pub fn with_take_profit(mut self, price: Price) -> Self {
168        self.take_profit_price = Some(price);
169        self
170    }
171
172    /// Override the default size hint.
173    pub fn with_size_hint(mut self, hint: SizeHint) -> Self {
174        self.size_hint = hint;
175        self
176    }
177
178    /// Request a resting limit entry at `price` (sets
179    /// [`Decision::order_kind`] to [`OrderKind::Limit`]).
180    pub fn with_limit_price(mut self, price: Price) -> Self {
181        self.order_kind = OrderKind::Limit;
182        self.limit_price = Some(price);
183        self
184    }
185
186    /// Set the entry order kind explicitly — e.g. [`OrderKind::PostOnly`],
187    /// [`OrderKind::Ioc`], [`OrderKind::Fok`]. For any non-market kind also
188    /// set a limit via [`Self::with_limit_price`] (or the execution layer
189    /// falls back to the event price).
190    pub fn with_order_kind(mut self, kind: OrderKind) -> Self {
191        self.order_kind = kind;
192        self
193    }
194
195    /// Attach free-form metadata for logging / post-hoc analysis.
196    pub fn with_metadata(mut self, metadata: serde_json::Value) -> Self {
197        self.metadata = metadata;
198        self
199    }
200}
201
202/// Reported health of a [`Brain`]. Surfaces to the supervisor's health endpoint.
203#[derive(Debug, Clone, Serialize, Deserialize, Default)]
204pub struct BrainHealth {
205    /// Is the brain healthy enough to continue trading?
206    pub healthy: bool,
207    /// Number of events processed since startup.
208    pub events_processed: u64,
209    /// Number of decisions emitted that were not `Hold`.
210    pub non_hold_decisions: u64,
211    /// Free-form status fields for the `/health` JSON response.
212    #[serde(default)]
213    pub details: serde_json::Value,
214}
215
216impl BrainHealth {
217    /// A healthy default with zero counters.
218    pub fn ok() -> Self {
219        Self {
220            healthy: true,
221            ..Default::default()
222        }
223    }
224
225    /// An unhealthy state with a single `reason` field in `details`.
226    pub fn unhealthy(reason: impl Into<String>) -> Self {
227        Self {
228            healthy: false,
229            details: serde_json::json!({ "reason": reason.into() }),
230            ..Default::default()
231        }
232    }
233}
234
235/// The strategic layer of a trading bot.
236///
237/// Implementors receive market events and the current position state, and
238/// return a decision on each event. See the module-level docs for the
239/// design rationale.
240///
241/// # Threading & mutability
242///
243/// Methods take `&self` so implementors can be shared across tasks via `Arc`.
244/// Use interior mutability (`Mutex`, `RwLock`, atomics) for any state that
245/// needs to be updated across calls. This mirrors the pattern in
246/// `rustrade-supervisor::TradingService`.
247///
248/// # Object safety
249///
250/// `Brain` is object-safe. You can store brains as `Box<dyn Brain>` or
251/// `Arc<dyn Brain>` and swap between implementations at runtime.
252///
253/// # Example
254///
255/// A minimal brain that goes long when the close is above a fixed
256/// threshold and flat otherwise. Note the `Mutex<State>` pattern for
257/// any cross-call state.
258///
259/// ```
260/// use std::sync::Mutex;
261/// use async_trait::async_trait;
262/// use rustrade_core::{
263///     Brain, BrainHealth, Decision, MarketDataEvent, Position, Result,
264/// };
265///
266/// struct ThresholdBrain {
267///     threshold: f64,
268///     state: Mutex<usize>, // events seen
269/// }
270///
271/// #[async_trait]
272/// impl Brain for ThresholdBrain {
273///     fn name(&self) -> &str { "threshold" }
274///
275///     async fn on_event(
276///         &self,
277///         event: &MarketDataEvent,
278///         position: &Position,
279///     ) -> Result<Decision> {
280///         *self.state.lock().unwrap() += 1;
281///         let close = match event {
282///             MarketDataEvent::Candle { candle, .. } => candle.close,
283///             _ => return Ok(Decision::hold()),
284///         };
285///         if close > self.threshold && position.qty <= 0.0 {
286///             Ok(Decision::buy(1.0))
287///         } else if close <= self.threshold && position.qty > 0.0 {
288///             Ok(Decision::close())
289///         } else {
290///             Ok(Decision::hold())
291///         }
292///     }
293///
294///     async fn health(&self) -> BrainHealth { BrainHealth::ok() }
295/// }
296/// ```
297#[async_trait]
298pub trait Brain: Send + Sync + 'static {
299    /// Human-readable identifier used in logs and metrics.
300    fn name(&self) -> &str;
301
302    /// Core decision point — called on every market event for any symbol
303    /// this brain cares about.
304    ///
305    /// `position` is the exchange-reported position for the event's symbol
306    /// at the time this call is made. May be [`Position::FLAT`].
307    ///
308    /// Return [`Decision::hold`] for "do nothing" — this is always safe.
309    /// For any recoverable problem (stale data, transient compute error),
310    /// return `Err` rather than panicking: the framework logs the error
311    /// and keeps the service running.
312    ///
313    /// # Panics
314    ///
315    /// Treat a panic here as a hard bug, never as control flow. Each brain
316    /// runs in its own supervised task, so under `panic = "unwind"` a panic
317    /// is contained to that task and sibling brains keep running. But a
318    /// release build compiled with `panic = "abort"` (as this workspace
319    /// does) will abort the **entire process** on any panic — there is no
320    /// isolation in that configuration. Return `Err` for anything you want
321    /// to survive.
322    async fn on_event(&self, event: &MarketDataEvent, position: &Position) -> Result<Decision>;
323
324    /// Called after the exchange confirms a fill. Informational only —
325    /// returning an error does not unwind the fill.
326    ///
327    /// Default implementation is a no-op.
328    async fn on_fill(&self, _fill: &Fill) -> Result<()> {
329        Ok(())
330    }
331
332    /// Called whenever the exchange reports a position change from any
333    /// source (our fills, external actions, liquidations, funding).
334    /// Informational only.
335    ///
336    /// Default implementation is a no-op.
337    async fn on_position_change(&self, _symbol: &Symbol, _position: &Position) -> Result<()> {
338        Ok(())
339    }
340
341    /// Report current brain health for the supervisor's `/health` endpoint.
342    ///
343    /// Default implementation returns "healthy" — override to surface
344    /// indicator warm-up state, model staleness, memory pressure, etc.
345    async fn health(&self) -> BrainHealth {
346        BrainHealth::ok()
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::types::Price;
354
355    #[test]
356    fn decision_hold() {
357        let d = Decision::hold();
358        assert!(matches!(d.signal, SignalType::Hold));
359        assert_eq!(d.confidence, 0.0);
360        assert!(d.stop_price.is_none());
361        assert!(d.take_profit_price.is_none());
362        assert!(matches!(d.size_hint, SizeHint::Default));
363    }
364
365    #[test]
366    fn decision_buy_sell_close() {
367        let b = Decision::buy(0.75);
368        assert!(matches!(b.signal, SignalType::Buy));
369        assert_eq!(b.confidence, 0.75);
370
371        let s = Decision::sell(0.5);
372        assert!(matches!(s.signal, SignalType::Sell));
373
374        let c = Decision::close();
375        assert!(matches!(c.signal, SignalType::Close));
376        assert_eq!(c.confidence, 1.0);
377    }
378
379    #[test]
380    fn decision_builders_compose() {
381        let d = Decision::buy(0.9)
382            .with_stop(Price(95.0))
383            .with_take_profit(Price(110.0))
384            .with_size_hint(SizeHint::MarginFraction(0.25))
385            .with_metadata(serde_json::json!({"reason": "ema-cross"}));
386
387        assert_eq!(d.stop_price, Some(Price(95.0)));
388        assert_eq!(d.take_profit_price, Some(Price(110.0)));
389        assert!(matches!(d.size_hint, SizeHint::MarginFraction(f) if (f - 0.25).abs() < 1e-9));
390        assert_eq!(d.metadata["reason"], "ema-cross");
391    }
392
393    #[test]
394    fn decision_serde_roundtrip() {
395        let d = Decision::sell(0.6).with_stop(Price(120.0));
396        let json = serde_json::to_string(&d).unwrap();
397        let back: Decision = serde_json::from_str(&json).unwrap();
398        assert!(matches!(back.signal, SignalType::Sell));
399        assert_eq!(back.confidence, 0.6);
400        assert_eq!(back.stop_price, Some(Price(120.0)));
401    }
402
403    #[test]
404    fn decision_defaults_to_market() {
405        let d = Decision::buy(1.0);
406        assert!(matches!(d.order_kind, OrderKind::Market));
407        assert!(d.limit_price.is_none());
408    }
409
410    #[test]
411    fn with_limit_price_sets_kind_and_price() {
412        let d = Decision::buy(1.0).with_limit_price(Price(100.0));
413        assert!(matches!(d.order_kind, OrderKind::Limit));
414        assert_eq!(d.limit_price, Some(Price(100.0)));
415    }
416
417    #[test]
418    fn with_order_kind_overrides_kind() {
419        let d = Decision::sell(1.0)
420            .with_limit_price(Price(50.0))
421            .with_order_kind(OrderKind::PostOnly);
422        assert!(matches!(d.order_kind, OrderKind::PostOnly));
423        assert_eq!(d.limit_price, Some(Price(50.0)));
424    }
425
426    #[test]
427    fn decision_serde_roundtrip_with_order_kind() {
428        let d = Decision::buy(0.5).with_order_kind(OrderKind::Fok);
429        let json = serde_json::to_string(&d).unwrap();
430        let back: Decision = serde_json::from_str(&json).unwrap();
431        assert!(matches!(back.order_kind, OrderKind::Fok));
432    }
433
434    #[test]
435    fn decision_deserializes_legacy_json_without_new_fields() {
436        // A Decision serialized before 0.2b (no order_kind / limit_price)
437        // must still deserialize, defaulting to a market entry.
438        let legacy =
439            r#"{"signal":"buy","confidence":0.7,"stop_price":null,"take_profit_price":null}"#;
440        let back: Decision = serde_json::from_str(legacy).unwrap();
441        assert!(matches!(back.signal, SignalType::Buy));
442        assert!(matches!(back.order_kind, OrderKind::Market));
443        assert!(back.limit_price.is_none());
444    }
445
446    #[test]
447    fn brain_health_ok_is_healthy() {
448        let h = BrainHealth::ok();
449        assert!(h.healthy);
450        assert_eq!(h.events_processed, 0);
451        assert_eq!(h.non_hold_decisions, 0);
452    }
453
454    #[test]
455    fn brain_health_unhealthy_captures_reason() {
456        let h = BrainHealth::unhealthy("warm-up incomplete");
457        assert!(!h.healthy);
458        assert_eq!(h.details["reason"], "warm-up incomplete");
459    }
460}