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}