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 /// Symbols this brain exclusively owns, or `None` to see every symbol.
303 ///
304 /// This is the multi-brain arbitration contract:
305 ///
306 /// - **`None` (default):** the brain receives events for *all* configured
307 /// symbols and is responsible for its own filtering. Multiple `None`
308 /// brains may run together — the framework does not guard against them
309 /// acting on the same symbol, so they must coordinate themselves.
310 /// - **`Some(symbols)`:** the framework routes *only* those symbols'
311 /// events to this brain (others are skipped before `on_event`), and
312 /// **rejects at startup** any configuration where two brains claim the
313 /// same symbol — preventing two strategies from fighting over one
314 /// position. A brain that owns its symbols needn't filter internally.
315 ///
316 /// Must be cheap and deterministic — it's read once at startup for the
317 /// overlap check and cached per execution loop.
318 fn owned_symbols(&self) -> Option<Vec<Symbol>> {
319 None
320 }
321
322 /// Core decision point — called on every market event for any symbol
323 /// this brain cares about.
324 ///
325 /// `position` is the exchange-reported position for the event's symbol
326 /// at the time this call is made. May be [`Position::FLAT`].
327 ///
328 /// Return [`Decision::hold`] for "do nothing" — this is always safe.
329 /// For any recoverable problem (stale data, transient compute error),
330 /// return `Err` rather than panicking: the framework logs the error
331 /// and keeps the service running.
332 ///
333 /// # Panics
334 ///
335 /// Treat a panic here as a hard bug, never as control flow. Each brain
336 /// runs in its own supervised task, so under `panic = "unwind"` a panic
337 /// is contained to that task and sibling brains keep running. But a
338 /// release build compiled with `panic = "abort"` (as this workspace
339 /// does) will abort the **entire process** on any panic — there is no
340 /// isolation in that configuration. Return `Err` for anything you want
341 /// to survive.
342 async fn on_event(&self, event: &MarketDataEvent, position: &Position) -> Result<Decision>;
343
344 /// Called after the exchange confirms a fill. Informational only —
345 /// returning an error does not unwind the fill.
346 ///
347 /// Default implementation is a no-op.
348 async fn on_fill(&self, _fill: &Fill) -> Result<()> {
349 Ok(())
350 }
351
352 /// Called whenever the exchange reports a position change from any
353 /// source (our fills, external actions, liquidations, funding).
354 /// Informational only.
355 ///
356 /// Default implementation is a no-op.
357 async fn on_position_change(&self, _symbol: &Symbol, _position: &Position) -> Result<()> {
358 Ok(())
359 }
360
361 /// Report current brain health for the supervisor's `/health` endpoint.
362 ///
363 /// Default implementation returns "healthy" — override to surface
364 /// indicator warm-up state, model staleness, memory pressure, etc.
365 async fn health(&self) -> BrainHealth {
366 BrainHealth::ok()
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373 use crate::types::Price;
374
375 #[test]
376 fn decision_hold() {
377 let d = Decision::hold();
378 assert!(matches!(d.signal, SignalType::Hold));
379 assert_eq!(d.confidence, 0.0);
380 assert!(d.stop_price.is_none());
381 assert!(d.take_profit_price.is_none());
382 assert!(matches!(d.size_hint, SizeHint::Default));
383 }
384
385 #[test]
386 fn decision_buy_sell_close() {
387 let b = Decision::buy(0.75);
388 assert!(matches!(b.signal, SignalType::Buy));
389 assert_eq!(b.confidence, 0.75);
390
391 let s = Decision::sell(0.5);
392 assert!(matches!(s.signal, SignalType::Sell));
393
394 let c = Decision::close();
395 assert!(matches!(c.signal, SignalType::Close));
396 assert_eq!(c.confidence, 1.0);
397 }
398
399 #[test]
400 fn decision_builders_compose() {
401 let d = Decision::buy(0.9)
402 .with_stop(Price(95.0))
403 .with_take_profit(Price(110.0))
404 .with_size_hint(SizeHint::MarginFraction(0.25))
405 .with_metadata(serde_json::json!({"reason": "ema-cross"}));
406
407 assert_eq!(d.stop_price, Some(Price(95.0)));
408 assert_eq!(d.take_profit_price, Some(Price(110.0)));
409 assert!(matches!(d.size_hint, SizeHint::MarginFraction(f) if (f - 0.25).abs() < 1e-9));
410 assert_eq!(d.metadata["reason"], "ema-cross");
411 }
412
413 #[test]
414 fn decision_serde_roundtrip() {
415 let d = Decision::sell(0.6).with_stop(Price(120.0));
416 let json = serde_json::to_string(&d).unwrap();
417 let back: Decision = serde_json::from_str(&json).unwrap();
418 assert!(matches!(back.signal, SignalType::Sell));
419 assert_eq!(back.confidence, 0.6);
420 assert_eq!(back.stop_price, Some(Price(120.0)));
421 }
422
423 #[test]
424 fn decision_defaults_to_market() {
425 let d = Decision::buy(1.0);
426 assert!(matches!(d.order_kind, OrderKind::Market));
427 assert!(d.limit_price.is_none());
428 }
429
430 #[test]
431 fn with_limit_price_sets_kind_and_price() {
432 let d = Decision::buy(1.0).with_limit_price(Price(100.0));
433 assert!(matches!(d.order_kind, OrderKind::Limit));
434 assert_eq!(d.limit_price, Some(Price(100.0)));
435 }
436
437 #[test]
438 fn with_order_kind_overrides_kind() {
439 let d = Decision::sell(1.0)
440 .with_limit_price(Price(50.0))
441 .with_order_kind(OrderKind::PostOnly);
442 assert!(matches!(d.order_kind, OrderKind::PostOnly));
443 assert_eq!(d.limit_price, Some(Price(50.0)));
444 }
445
446 #[test]
447 fn decision_serde_roundtrip_with_order_kind() {
448 let d = Decision::buy(0.5).with_order_kind(OrderKind::Fok);
449 let json = serde_json::to_string(&d).unwrap();
450 let back: Decision = serde_json::from_str(&json).unwrap();
451 assert!(matches!(back.order_kind, OrderKind::Fok));
452 }
453
454 #[test]
455 fn decision_deserializes_legacy_json_without_new_fields() {
456 // A Decision serialized before 0.2b (no order_kind / limit_price)
457 // must still deserialize, defaulting to a market entry.
458 let legacy =
459 r#"{"signal":"buy","confidence":0.7,"stop_price":null,"take_profit_price":null}"#;
460 let back: Decision = serde_json::from_str(legacy).unwrap();
461 assert!(matches!(back.signal, SignalType::Buy));
462 assert!(matches!(back.order_kind, OrderKind::Market));
463 assert!(back.limit_price.is_none());
464 }
465
466 #[test]
467 fn brain_health_ok_is_healthy() {
468 let h = BrainHealth::ok();
469 assert!(h.healthy);
470 assert_eq!(h.events_processed, 0);
471 assert_eq!(h.non_hold_decisions, 0);
472 }
473
474 #[test]
475 fn brain_health_unhealthy_captures_reason() {
476 let h = BrainHealth::unhealthy("warm-up incomplete");
477 assert!(!h.healthy);
478 assert_eq!(h.details["reason"], "warm-up incomplete");
479 }
480}