Skip to main content

jflow_core/
position_events.rs

1//! Position event ingress (JFLOW-C).
2//!
3//! Receives live position snapshots from the execution side (Ruby / fks),
4//! defines the wire shape, validates inputs, and computes a guidance hint
5//! (hold / reduce / exit) the producer can act on. The receiving HTTP
6//! handler lives in `janus-api`; persistence and brain-pipeline-driven
7//! guidance refinement are follow-ups.
8
9use crate::market::Side;
10use crate::optimized_params::OptimizedParams;
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13use std::time::{Duration, Instant};
14use tokio::sync::RwLock;
15
16/// A snapshot of an open position pushed by the execution side.
17///
18/// Shape is the minimum needed for downstream guidance. Add fields here when
19/// a consumer (guidance engine / memory store) actually reads them.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PositionEvent {
22    /// Trading pair / instrument (e.g. "BTC-USD").
23    pub symbol: String,
24    /// Direction: Buy = long, Sell = short.
25    pub side: Side,
26    /// Position size in base units (always positive; direction is in `side`).
27    pub qty: f64,
28    /// Average fill price the position was opened at.
29    pub entry_price: f64,
30    /// Mark / last price used to compute `pnl_unrealized`.
31    pub current_price: f64,
32    /// Unrealized P&L in quote currency (signed).
33    pub pnl_unrealized: f64,
34    /// Optional client position id for correlation across repeated pushes.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub position_id: Option<String>,
37    /// Optional JanusAI session id (groups positions under one run).
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub session_id: Option<String>,
40    /// Optional volatility hint: recent ATR expressed as a percentage of
41    /// `current_price` (e.g. `1.5` = ATR is 1.5% of price). When present,
42    /// guidance widens the stop-loss to sit outside normal ATR-sized
43    /// noise. Producers that don't track ATR simply omit it.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub atr_pct: Option<f64>,
46}
47
48impl PositionEvent {
49    /// Reject obviously malformed events at the boundary.
50    pub fn validate(&self) -> Result<(), &'static str> {
51        if self.symbol.is_empty() {
52            return Err("symbol is empty");
53        }
54        if !self.qty.is_finite() || self.qty <= 0.0 {
55            return Err("qty must be positive and finite");
56        }
57        if !self.entry_price.is_finite() || self.entry_price <= 0.0 {
58            return Err("entry_price must be positive and finite");
59        }
60        if !self.current_price.is_finite() || self.current_price <= 0.0 {
61            return Err("current_price must be positive and finite");
62        }
63        if !self.pnl_unrealized.is_finite() {
64            return Err("pnl_unrealized must be finite");
65        }
66        if let Some(atr) = self.atr_pct
67            && (!atr.is_finite() || atr < 0.0)
68        {
69            return Err("atr_pct must be a non-negative finite percentage");
70        }
71        Ok(())
72    }
73
74    /// Unrealized P&L as a ratio of entry notional (`entry_price * qty`).
75    ///
76    /// Returns `None` when notional is non-positive. Sign-agnostic — the
77    /// sign of `pnl_unrealized` already encodes direction for Buy/Sell, so
78    /// `+0.05` is a 5% gain whether long or short.
79    pub fn pnl_ratio(&self) -> Option<f64> {
80        let notional = self.entry_price * self.qty;
81        (notional > 0.0).then(|| self.pnl_unrealized / notional)
82    }
83}
84
85/// A terminal event: the producer closed a position. Carries the realized
86/// outcome so Janus can record what actually happened against the guidance it
87/// advised while the position was open. Sent to `POST /api/v1/positions/close`.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PositionClose {
90    /// Trading pair / instrument (e.g. "BTC-USD").
91    pub symbol: String,
92    /// Direction the (now-closed) position was held in.
93    pub side: Side,
94    /// Position size in base units (always positive).
95    pub qty: f64,
96    /// Average fill price the position was opened at.
97    pub entry_price: f64,
98    /// Average fill price the position was closed at.
99    pub exit_price: f64,
100    /// Realized P&L in quote currency (signed).
101    pub pnl_realized: f64,
102    /// Optional realized risk-reward ratio for this trade, when the producer
103    /// tracks it. Fed straight into affinity learning.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub rr_ratio: Option<f64>,
106    /// Optional strategy name that opened this position. Required for the
107    /// outcome to be fed back into the per-`(strategy, asset)` affinity
108    /// tracker; omitted closes are still persisted, just not recorded live.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub strategy: Option<String>,
111    /// Client position id, used to correlate with the open snapshots so the
112    /// outcome can be joined with this position's guidance history.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub position_id: Option<String>,
115    /// Optional JanusAI session id.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub session_id: Option<String>,
118}
119
120impl PositionClose {
121    /// Reject malformed close events at the boundary.
122    pub fn validate(&self) -> Result<(), &'static str> {
123        if self.symbol.is_empty() {
124            return Err("symbol is empty");
125        }
126        if !self.qty.is_finite() || self.qty <= 0.0 {
127            return Err("qty must be positive and finite");
128        }
129        if !self.entry_price.is_finite() || self.entry_price <= 0.0 {
130            return Err("entry_price must be positive and finite");
131        }
132        if !self.exit_price.is_finite() || self.exit_price <= 0.0 {
133            return Err("exit_price must be positive and finite");
134        }
135        if !self.pnl_realized.is_finite() {
136            return Err("pnl_realized must be finite");
137        }
138        Ok(())
139    }
140
141    /// Realized P&L as a ratio of entry notional. `None` when notional is
142    /// non-positive (impossible after [`validate`](Self::validate)).
143    pub fn realized_ratio(&self) -> Option<f64> {
144        let notional = self.entry_price * self.qty;
145        (notional > 0.0).then(|| self.pnl_realized / notional)
146    }
147}
148
149/// Coarse win/loss classification of a closed trade.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum OutcomeResult {
153    Win,
154    Loss,
155    Breakeven,
156}
157
158impl OutcomeResult {
159    /// Classify from a realized P&L ratio (treats `|ratio| < 1e-9` as flat).
160    pub fn from_ratio(ratio: f64) -> Self {
161        if ratio > 1e-9 {
162            Self::Win
163        } else if ratio < -1e-9 {
164            Self::Loss
165        } else {
166            Self::Breakeven
167        }
168    }
169
170    /// Lowercase wire name (matches the serde representation).
171    pub fn as_str(self) -> &'static str {
172        match self {
173            Self::Win => "win",
174            Self::Loss => "loss",
175            Self::Breakeven => "breakeven",
176        }
177    }
178}
179
180/// A closed-trade record joining a [`PositionClose`] with the guidance
181/// history accumulated in [`PositionState`] while the position was open.
182///
183/// This is the "outcome" Janus captures so the guidance engine can be
184/// evaluated and tuned. Downstream, the JanusAI service compacts these into
185/// `janus_memories` (fks repo). Fields sourced from the tracker are `None`
186/// when the position was never tracked (no `position_id`, or no snapshots
187/// arrived before the close).
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct PositionOutcome {
190    pub symbol: String,
191    pub side: Side,
192    pub qty: f64,
193    pub entry_price: f64,
194    pub exit_price: f64,
195    pub pnl_realized: f64,
196    /// Realized P&L as a ratio of entry notional.
197    pub realized_ratio: f64,
198    /// Win / loss / breakeven from the sign of `realized_ratio`.
199    pub result: OutcomeResult,
200    /// Realized risk-reward ratio, when the producer reported one.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub rr_ratio: Option<f64>,
203    /// Strategy that opened the position, when reported. Affinity recording
204    /// is skipped when this is absent.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub strategy: Option<String>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub position_id: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub session_id: Option<String>,
211    /// Highest unrealized-P&L ratio seen while the position was open.
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub peak_pnl_ratio: Option<f64>,
214    /// Number of open snapshots observed (0 if the position was untracked).
215    pub samples: u64,
216    /// The last guidance action Janus advised for this position.
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub last_guidance: Option<GuidanceAction>,
219    /// Seconds the position was reported open.
220    #[serde(skip_serializing_if = "Option::is_none")]
221    pub time_in_position_secs: Option<f64>,
222}
223
224impl PositionOutcome {
225    /// Build an outcome from a close event joined with the position's
226    /// accumulated [`PositionState`] (when it was tracked).
227    pub fn from_close(close: &PositionClose, state: Option<&PositionState>) -> Self {
228        let realized_ratio = close.realized_ratio().unwrap_or(0.0);
229        Self {
230            symbol: close.symbol.clone(),
231            side: close.side,
232            qty: close.qty,
233            entry_price: close.entry_price,
234            exit_price: close.exit_price,
235            pnl_realized: close.pnl_realized,
236            realized_ratio,
237            result: OutcomeResult::from_ratio(realized_ratio),
238            rr_ratio: close.rr_ratio,
239            strategy: close.strategy.clone(),
240            position_id: close.position_id.clone(),
241            session_id: close.session_id.clone(),
242            peak_pnl_ratio: state.map(|s| s.peak_pnl_ratio),
243            samples: state.map_or(0, |s| s.samples),
244            last_guidance: state.map(|s| s.last_action),
245            time_in_position_secs: state.map(|s| s.time_in_position().as_secs_f64()),
246        }
247    }
248
249    /// Whether this trade closed in profit (a `Win`). `Breakeven` and `Loss`
250    /// both count as not-a-winner for affinity purposes.
251    pub fn is_winner(&self) -> bool {
252        self.result == OutcomeResult::Win
253    }
254}
255
256/// Guidance action returned to the execution side for an open position.
257///
258/// Producers (Ruby / fks) are free to ignore this — it is advisory. The
259/// recommended interpretation:
260///
261/// - [`Hold`](GuidanceAction::Hold): no change suggested.
262/// - [`Reduce`](GuidanceAction::Reduce): trim size (typically take partial
263///   profit), but don't close.
264/// - [`Exit`](GuidanceAction::Exit): close the position immediately.
265#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
266#[serde(rename_all = "lowercase")]
267pub enum GuidanceAction {
268    Hold,
269    Reduce,
270    Exit,
271}
272
273impl GuidanceAction {
274    /// Lowercase wire name (matches the serde representation).
275    pub fn as_str(self) -> &'static str {
276        match self {
277            Self::Hold => "hold",
278            Self::Reduce => "reduce",
279            Self::Exit => "exit",
280        }
281    }
282}
283
284/// Advisory guidance returned alongside the receive acknowledgement.
285#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
286pub struct Guidance {
287    pub action: GuidanceAction,
288    pub reason: String,
289}
290
291impl Guidance {
292    fn hold(reason: impl Into<String>) -> Self {
293        Self {
294            action: GuidanceAction::Hold,
295            reason: reason.into(),
296        }
297    }
298    fn reduce(reason: impl Into<String>) -> Self {
299        Self {
300            action: GuidanceAction::Reduce,
301            reason: reason.into(),
302        }
303    }
304    fn exit(reason: impl Into<String>) -> Self {
305        Self {
306            action: GuidanceAction::Exit,
307            reason: reason.into(),
308        }
309    }
310}
311
312/// Threshold ratios used by [`compute_guidance`]. Decoupled from
313/// [`OptimizedParams`] so callers can supply optimizer-derived values when
314/// available and fall back to conservative defaults otherwise.
315#[derive(Debug, Clone, Copy, PartialEq)]
316pub struct GuidanceThresholds {
317    /// Negative ratio of notional at which to exit on a loss (e.g. `-0.02`
318    /// = exit when unrealized loss reaches 2% of `entry_price * qty`).
319    pub stop_loss_ratio: f64,
320    /// Positive ratio of notional at which to reduce on profit (e.g. `0.05`
321    /// = trim when unrealized gain reaches 5% of `entry_price * qty`).
322    pub take_profit_ratio: f64,
323}
324
325impl Default for GuidanceThresholds {
326    fn default() -> Self {
327        Self {
328            stop_loss_ratio: -0.02,
329            take_profit_ratio: 0.05,
330        }
331    }
332}
333
334impl GuidanceThresholds {
335    /// Derive thresholds from optimizer-tuned params.
336    ///
337    /// Both `stop_loss_ratio` and `take_profit_ratio` are now learnable
338    /// via [`OptimizedParams::stop_loss_pct`] and
339    /// [`OptimizedParams::take_profit_pct`] respectively.
340    /// `stop_loss_pct` is stored as a positive percentage (e.g. `2.0`)
341    /// and converted to a negative ratio here (`-0.02`).
342    pub fn from_optimized_params(params: &OptimizedParams) -> Self {
343        Self {
344            stop_loss_ratio: -(params.stop_loss_pct / 100.0),
345            take_profit_ratio: params.take_profit_pct / 100.0,
346        }
347    }
348
349    /// Loosen the stop-loss so it sits outside normal ATR-sized noise.
350    ///
351    /// `atr_pct` is recent ATR as a percentage of price (the volatility
352    /// hint carried on [`PositionEvent`]); `atr_multiplier` is how many
353    /// ATRs the optimizer places its trailing stop (reused here so the
354    /// band matches the strategy's own notion of "normal" movement). If
355    /// the ATR band (`atr_multiplier * atr_pct`) is wider than the
356    /// configured stop, the stop is widened to match — otherwise the
357    /// configured stop is already conservative and is kept.
358    ///
359    /// Take-profit is intentionally left untouched: it's a target, not
360    /// noise protection. Non-positive inputs are a no-op, so callers can
361    /// pass values straight through without pre-checking.
362    pub fn widen_for_volatility(self, atr_pct: f64, atr_multiplier: f64) -> Self {
363        // Guard non-positive / non-finite inputs (NaN fails `is_finite`),
364        // so callers can pass producer-supplied values straight through.
365        if !atr_pct.is_finite() || atr_pct <= 0.0 || atr_multiplier <= 0.0 {
366            return self;
367        }
368        // ATR band as a positive ratio of notional, then signed negative
369        // to match `stop_loss_ratio`'s convention.
370        let atr_floor = -(atr_multiplier * atr_pct / 100.0);
371        Self {
372            // Both negative; the more-negative (wider) stop wins.
373            stop_loss_ratio: self.stop_loss_ratio.min(atr_floor),
374            ..self
375        }
376    }
377
378    /// Tighten the stop toward break-even in proportion to elevated fear.
379    ///
380    /// The inverse of [`widen_for_volatility`]: under amygdala stress we
381    /// want a losing position on a shorter leash. `fear` is expected in
382    /// the elevated band `[FEAR_ELEVATED_LEVEL, FEAR_EXIT_LEVEL)`; the
383    /// stop magnitude scales linearly from full width (at the bottom of
384    /// the band) down to [`STOP_TIGHTEN_FLOOR`] of its width (at the top).
385    /// It never reaches zero, so a position isn't exited on the first
386    /// tick of an adverse move. Fear below the elevated band is a no-op.
387    pub fn tighten_stop_for_fear(self, fear: f64) -> Self {
388        if !fear.is_finite() || fear < FEAR_ELEVATED_LEVEL {
389            return self;
390        }
391        let span = FEAR_EXIT_LEVEL - FEAR_ELEVATED_LEVEL;
392        let t = ((fear - FEAR_ELEVATED_LEVEL) / span).clamp(0.0, 1.0);
393        let factor = 1.0 - t * (1.0 - STOP_TIGHTEN_FLOOR);
394        Self {
395            stop_loss_ratio: self.stop_loss_ratio * factor,
396            ..self
397        }
398    }
399}
400
401/// Fear level (inclusive) at or above which guidance exits outright,
402/// regardless of P&L — the amygdala equivalent of a crisis regime.
403pub const FEAR_EXIT_LEVEL: f64 = 0.8;
404
405/// Fear level (inclusive) at or above which guidance escalates: banks
406/// open profit (Reduce) or tightens the stop on a losing position.
407pub const FEAR_ELEVATED_LEVEL: f64 = 0.5;
408
409/// Floor for the stop-tightening factor at the top of the elevated band
410/// (just below [`FEAR_EXIT_LEVEL`]). `0.25` ⇒ the stop tightens to a
411/// quarter of its configured width but never to break-even.
412const STOP_TIGHTEN_FLOOR: f64 = 0.25;
413
414/// Compute advisory guidance for an open position.
415///
416/// Rules in priority order:
417/// 1. Crisis-flavoured regime ⇒ [`Exit`](GuidanceAction::Exit).
418/// 2. High amygdala fear (≥ [`FEAR_EXIT_LEVEL`]) ⇒ [`Exit`](GuidanceAction::Exit).
419/// 3. Elevated fear (≥ [`FEAR_ELEVATED_LEVEL`]): if in profit ⇒
420///    [`Reduce`](GuidanceAction::Reduce) (bank it early); if at a loss ⇒
421///    tighten the stop toward break-even before the P&L checks below.
422/// 4. Unrealized loss ≤ `thresholds.stop_loss_ratio` of notional ⇒
423///    [`Exit`](GuidanceAction::Exit).
424/// 5. Unrealized gain ≥ `thresholds.take_profit_ratio` of notional ⇒
425///    [`Reduce`](GuidanceAction::Reduce).
426/// 6. Otherwise ⇒ [`Hold`](GuidanceAction::Hold).
427///
428/// `fear` is the latest amygdala threat level (`0.0..=1.0`), or `None`
429/// when no producer has reported one. `notional = entry_price * qty`
430/// (sign-agnostic; works for both Buy and Sell positions because
431/// `pnl_unrealized` is supplied with sign already).
432pub fn compute_guidance(
433    event: &PositionEvent,
434    regime: Option<&str>,
435    thresholds: GuidanceThresholds,
436    fear: Option<f64>,
437) -> Guidance {
438    if let Some(label) = regime
439        && is_crisis_regime(label)
440    {
441        return Guidance::exit(format!("regime: {label}"));
442    }
443
444    // Amygdala fear escalation (graduated: bank winners, tighten losers).
445    let mut thresholds = thresholds;
446    if let Some(fear) = fear.filter(|f| f.is_finite()) {
447        if fear >= FEAR_EXIT_LEVEL {
448            return Guidance::exit(format!("fear {fear:.2} ≥ {FEAR_EXIT_LEVEL}"));
449        }
450        if fear >= FEAR_ELEVATED_LEVEL {
451            if event.pnl_unrealized > 0.0 {
452                return Guidance::reduce(format!("fear {fear:.2}: banking open profit"));
453            }
454            // At a loss or flat: shorten the leash before the P&L checks.
455            thresholds = thresholds.tighten_stop_for_fear(fear);
456        }
457    }
458
459    if let Some(ratio) = event.pnl_ratio() {
460        if ratio <= thresholds.stop_loss_ratio {
461            let pct = ratio * 100.0;
462            return Guidance::exit(format!("stop loss: {pct:.2}% of notional"));
463        }
464        if ratio >= thresholds.take_profit_ratio {
465            let pct = ratio * 100.0;
466            return Guidance::reduce(format!("take profit: {pct:.2}% of notional"));
467        }
468    }
469
470    Guidance::hold("within bounds")
471}
472
473/// Extract the base asset from a position symbol — `"BTC-USD"` → `"BTC"`,
474/// `"ETH/USDT"` → `"ETH"`, `"BTC"` → `"BTC"`. Used to look up per-asset
475/// [`OptimizedParams`] from a [`ParamManager`](crate::optimized_params::ParamManager).
476pub fn base_asset(symbol: &str) -> &str {
477    symbol
478        .split(['-', '/'])
479        .next()
480        .filter(|s| !s.is_empty())
481        .unwrap_or(symbol)
482}
483
484fn is_crisis_regime(label: &str) -> bool {
485    let lower = label.to_ascii_lowercase();
486    ["crisis", "panic", "flash_crash", "shock"]
487        .iter()
488        .any(|needle| lower.contains(needle))
489}
490
491/// Rolling per-position state, accumulated across the repeated snapshots a
492/// producer pushes for the same `position_id`. Lets guidance depend on a
493/// position's *history* (peak profit, prior advice) rather than scoring each
494/// snapshot in isolation.
495#[derive(Debug, Clone)]
496pub struct PositionState {
497    /// Instant the first snapshot for this position arrived.
498    pub first_seen: Instant,
499    /// Instant the most recent snapshot arrived (drives TTL eviction).
500    pub last_seen: Instant,
501    /// Number of snapshots observed for this position.
502    pub samples: u64,
503    /// Highest unrealized-P&L ratio (`pnl / notional`) seen so far.
504    pub peak_pnl_ratio: f64,
505    /// Most recent guidance action issued for this position.
506    pub last_action: GuidanceAction,
507}
508
509impl PositionState {
510    /// How long this position has been reported.
511    pub fn time_in_position(&self) -> Duration {
512        self.last_seen.duration_since(self.first_seen)
513    }
514}
515
516/// Tunables for the stateful guidance layer in [`PositionTracker`].
517#[derive(Debug, Clone, Copy)]
518pub struct TrailingConfig {
519    /// Peak gain (ratio of notional) a position must reach before the
520    /// trailing give-back rule arms. Peaks below this are too small to act on.
521    pub arm_ratio: f64,
522    /// Fraction of the peak gain that, once surrendered, triggers a `Reduce`
523    /// to lock in what's left. `0.5` ⇒ "gave back half the peak".
524    pub giveback_frac: f64,
525    /// Evict positions not seen within this window (presumed closed/stale).
526    pub ttl: Duration,
527    /// Hard cap on tracked positions, so a misbehaving producer can't grow
528    /// the map without bound.
529    pub max_entries: usize,
530}
531
532impl Default for TrailingConfig {
533    fn default() -> Self {
534        Self {
535            arm_ratio: 0.03,
536            giveback_frac: 0.5,
537            ttl: Duration::from_secs(3600),
538            max_entries: 10_000,
539        }
540    }
541}
542
543/// Stateful refinement layer on top of [`compute_guidance`].
544///
545/// [`compute_guidance`] is pure and scores a single snapshot. The tracker
546/// remembers each position (keyed by `position_id`) and adds two rules that
547/// require history:
548///
549/// 1. **Trailing give-back** — once a position has been a meaningful winner
550///    (peak ≥ [`TrailingConfig::arm_ratio`]) and then surrenders enough of
551///    that peak ([`TrailingConfig::giveback_frac`]), upgrade a `Hold` to
552///    `Reduce` to bank the remaining profit. The stateless rule can't see the
553///    prior peak, so it would just `Hold`.
554/// 2. **Sticky exit** — once we've advised `Exit`, a later snapshot that
555///    merely drifts back inside the bands keeps `Exit` rather than flip-
556///    flopping to `Hold` on a one-tick bounce.
557///
558/// Snapshots without a `position_id` are not tracked; their guidance passes
559/// through unchanged (fully backward compatible).
560pub struct PositionTracker {
561    states: RwLock<HashMap<String, PositionState>>,
562    config: TrailingConfig,
563}
564
565impl Default for PositionTracker {
566    fn default() -> Self {
567        Self::new()
568    }
569}
570
571impl PositionTracker {
572    /// New tracker with default [`TrailingConfig`].
573    pub fn new() -> Self {
574        Self::with_config(TrailingConfig::default())
575    }
576
577    /// New tracker with explicit config.
578    pub fn with_config(config: TrailingConfig) -> Self {
579        Self {
580            states: RwLock::new(HashMap::new()),
581            config,
582        }
583    }
584
585    /// Number of positions currently tracked (after pruning). For metrics/tests.
586    pub async fn tracked(&self) -> usize {
587        self.states.read().await.len()
588    }
589
590    /// Remove and return a position's accumulated state. Call this when the
591    /// position closes so its guidance history can be joined with the realized
592    /// outcome (see [`PositionOutcome::from_close`]). Returns `None` if the
593    /// position was never tracked.
594    pub async fn finalize(&self, position_id: &str) -> Option<PositionState> {
595        self.states.write().await.remove(position_id)
596    }
597
598    /// Refine stateless `base` guidance using this position's history, record
599    /// the updated state, and return the (possibly upgraded) guidance.
600    ///
601    /// Positions without a `position_id` are returned unchanged and not
602    /// tracked. The critical section is pure (no `.await` while the lock is
603    /// held), so this stays cheap under load.
604    pub async fn observe(&self, event: &PositionEvent, base: Guidance) -> Guidance {
605        let Some(key) = event.position_id.clone() else {
606            return base;
607        };
608        let ratio = event.pnl_ratio().unwrap_or(0.0);
609        let now = Instant::now();
610
611        let mut states = self.states.write().await;
612
613        // Best-effort TTL prune, then a hard cap (evict the least-recently
614        // seen) so the map can't grow without bound on a long-lived process.
615        states.retain(|_, s| now.duration_since(s.last_seen) <= self.config.ttl);
616        if !states.contains_key(&key)
617            && states.len() >= self.config.max_entries
618            && let Some(oldest) = states
619                .iter()
620                .min_by_key(|(_, s)| s.last_seen)
621                .map(|(k, _)| k.clone())
622        {
623            states.remove(&oldest);
624        }
625
626        let entry = states.entry(key).or_insert_with(|| PositionState {
627            first_seen: now,
628            last_seen: now,
629            samples: 0,
630            peak_pnl_ratio: ratio,
631            last_action: GuidanceAction::Hold,
632        });
633        let prior_action = entry.last_action;
634        entry.last_seen = now;
635        entry.samples += 1;
636        entry.peak_pnl_ratio = entry.peak_pnl_ratio.max(ratio);
637        let peak = entry.peak_pnl_ratio;
638
639        let mut action = base.action;
640        let mut reason = base.reason;
641
642        // (1) Trailing give-back: only ever *upgrades* a Hold while still in
643        // profit. A loss is the stop-loss's job, and we never downgrade a
644        // Reduce/Exit.
645        if action == GuidanceAction::Hold
646            && ratio > 0.0
647            && peak >= self.config.arm_ratio
648            && ratio <= peak * (1.0 - self.config.giveback_frac)
649        {
650            action = GuidanceAction::Reduce;
651            reason = format!(
652                "trailing: gave back to {:.2}% from {:.2}% peak",
653                ratio * 100.0,
654                peak * 100.0
655            );
656        }
657
658        // (2) Sticky exit: don't rescind a prior Exit on a small bounce.
659        if prior_action == GuidanceAction::Exit && action != GuidanceAction::Exit {
660            action = GuidanceAction::Exit;
661            reason = "prior exit still standing".to_string();
662        }
663
664        entry.last_action = action;
665        Guidance { action, reason }
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    fn sample() -> PositionEvent {
674        PositionEvent {
675            symbol: "BTC-USD".to_string(),
676            side: Side::Buy,
677            qty: 0.5,
678            entry_price: 60_000.0,
679            current_price: 61_000.0,
680            pnl_unrealized: 500.0,
681            position_id: Some("pos-1".to_string()),
682            session_id: Some("sess-1".to_string()),
683            atr_pct: None,
684        }
685    }
686
687    #[test]
688    fn validate_accepts_well_formed_event() {
689        assert!(sample().validate().is_ok());
690    }
691
692    #[test]
693    fn validate_rejects_empty_symbol() {
694        let mut e = sample();
695        e.symbol.clear();
696        assert_eq!(e.validate(), Err("symbol is empty"));
697    }
698
699    #[test]
700    fn validate_rejects_non_positive_qty() {
701        let mut e = sample();
702        e.qty = 0.0;
703        assert!(e.validate().is_err());
704        e.qty = -1.0;
705        assert!(e.validate().is_err());
706    }
707
708    #[test]
709    fn validate_rejects_non_finite_prices() {
710        let mut e = sample();
711        e.entry_price = f64::NAN;
712        assert!(e.validate().is_err());
713
714        let mut e = sample();
715        e.current_price = f64::INFINITY;
716        assert!(e.validate().is_err());
717
718        let mut e = sample();
719        e.pnl_unrealized = f64::NAN;
720        assert!(e.validate().is_err());
721    }
722
723    #[test]
724    fn round_trips_through_json_with_optional_fields_omitted() {
725        let e = PositionEvent {
726            position_id: None,
727            session_id: None,
728            ..sample()
729        };
730        let json = serde_json::to_string(&e).unwrap();
731        assert!(!json.contains("position_id"));
732        assert!(!json.contains("session_id"));
733        let back: PositionEvent = serde_json::from_str(&json).unwrap();
734        assert_eq!(back.symbol, e.symbol);
735        assert!(back.position_id.is_none());
736    }
737
738    #[test]
739    fn deserializes_minimal_payload() {
740        let json = r#"{
741            "symbol": "ETH-USD",
742            "side": "Sell",
743            "qty": 2.0,
744            "entry_price": 3000.0,
745            "current_price": 2950.0,
746            "pnl_unrealized": 100.0
747        }"#;
748        let e: PositionEvent = serde_json::from_str(json).unwrap();
749        assert_eq!(e.symbol, "ETH-USD");
750        assert_eq!(e.side, Side::Sell);
751        assert!(e.position_id.is_none());
752    }
753
754    // ── Guidance ─────────────────────────────────────────────────────
755
756    fn default_thresholds() -> GuidanceThresholds {
757        GuidanceThresholds::default()
758    }
759
760    #[test]
761    fn guidance_holds_when_within_bounds() {
762        // 0.5 BTC at 60_000 = 30_000 notional. +100 pnl = 0.33% — below take-profit.
763        let mut e = sample();
764        e.pnl_unrealized = 100.0;
765        let g = compute_guidance(&e, None, default_thresholds(), None);
766        assert_eq!(g.action, GuidanceAction::Hold);
767    }
768
769    #[test]
770    fn guidance_exits_on_stop_loss_breach() {
771        // Notional 30_000; -2% = -600. -700 trips the stop.
772        let mut e = sample();
773        e.pnl_unrealized = -700.0;
774        let g = compute_guidance(&e, None, default_thresholds(), None);
775        assert_eq!(g.action, GuidanceAction::Exit);
776        assert!(g.reason.contains("stop loss"));
777    }
778
779    #[test]
780    fn guidance_reduces_on_take_profit() {
781        // Notional 30_000; +5% = +1500. +2000 trips the take-profit.
782        let mut e = sample();
783        e.pnl_unrealized = 2_000.0;
784        let g = compute_guidance(&e, None, default_thresholds(), None);
785        assert_eq!(g.action, GuidanceAction::Reduce);
786        assert!(g.reason.contains("take profit"));
787    }
788
789    #[test]
790    fn guidance_exits_on_crisis_regime_regardless_of_pnl() {
791        // Even with healthy pnl, a crisis regime triggers exit.
792        let mut e = sample();
793        e.pnl_unrealized = 100.0;
794        let g = compute_guidance(&e, Some("crisis_volatility_spike"), default_thresholds(), None);
795        assert_eq!(g.action, GuidanceAction::Exit);
796        assert!(g.reason.contains("regime"));
797    }
798
799    #[test]
800    fn guidance_crisis_detection_is_case_insensitive_and_substring() {
801        let e = sample();
802        for label in ["PANIC", "Flash_Crash detected", "shockwave"] {
803            assert_eq!(
804                compute_guidance(&e, Some(label), default_thresholds(), None).action,
805                GuidanceAction::Exit,
806                "label {label:?} should trigger exit"
807            );
808        }
809    }
810
811    #[test]
812    fn guidance_ignores_unknown_regime_labels() {
813        let e = sample();
814        // "bullish_trend" isn't a crisis label, so guidance is pnl-driven.
815        assert_eq!(
816            compute_guidance(&e, Some("bullish_trend"), default_thresholds(), None).action,
817            GuidanceAction::Hold
818        );
819    }
820
821    #[test]
822    fn guidance_action_serializes_lowercase() {
823        let g = Guidance::hold("ok");
824        let json = serde_json::to_string(&g).unwrap();
825        assert!(json.contains("\"action\":\"hold\""));
826    }
827
828    // ── GuidanceThresholds ───────────────────────────────────────────
829
830    #[test]
831    fn thresholds_from_optimized_params_overrides_both_ratios() {
832        let params = OptimizedParams {
833            take_profit_pct: 8.0,
834            stop_loss_pct: 3.0, // optimizer tuned to tighter stop
835            ..OptimizedParams::new("BTC")
836        };
837        let t = GuidanceThresholds::from_optimized_params(&params);
838        assert!((t.take_profit_ratio - 0.08).abs() < 1e-9);
839        // stop_loss_pct is positive; ratio must be negative.
840        assert!((t.stop_loss_ratio - (-0.03)).abs() < 1e-9);
841    }
842
843    #[test]
844    fn thresholds_default_stop_loss_matches_optimized_params_default() {
845        // Ensure serde default (2.0%) and GuidanceThresholds::default (-0.02)
846        // stay in sync — a divergence would silently change live behaviour.
847        let params = OptimizedParams::default();
848        let t = GuidanceThresholds::from_optimized_params(&params);
849        assert_eq!(t.stop_loss_ratio, GuidanceThresholds::default().stop_loss_ratio);
850        assert_eq!(t.take_profit_ratio, GuidanceThresholds::default().take_profit_ratio);
851    }
852
853    // ── widen_for_volatility ─────────────────────────────────────────
854
855    #[test]
856    fn widen_for_volatility_loosens_stop_when_atr_band_is_wider() {
857        // Default stop -2%. ATR 1.5% × multiplier 2.0 = 3% band → wider,
858        // so the stop should loosen to -3%. Take-profit untouched.
859        let t = GuidanceThresholds::default().widen_for_volatility(1.5, 2.0);
860        assert!((t.stop_loss_ratio - (-0.03)).abs() < 1e-9);
861        assert_eq!(t.take_profit_ratio, GuidanceThresholds::default().take_profit_ratio);
862    }
863
864    #[test]
865    fn widen_for_volatility_keeps_stop_when_already_wider() {
866        // Configured stop -5%. ATR band 0.5% × 2.0 = 1% → narrower, so the
867        // already-conservative configured stop is kept.
868        let base = GuidanceThresholds {
869            stop_loss_ratio: -0.05,
870            ..GuidanceThresholds::default()
871        };
872        let t = base.widen_for_volatility(0.5, 2.0);
873        assert!((t.stop_loss_ratio - (-0.05)).abs() < 1e-9);
874    }
875
876    #[test]
877    fn widen_for_volatility_is_noop_for_nonpositive_inputs() {
878        let base = GuidanceThresholds::default();
879        assert_eq!(base.widen_for_volatility(0.0, 2.0), base);
880        assert_eq!(base.widen_for_volatility(1.5, 0.0), base);
881        assert_eq!(base.widen_for_volatility(-1.0, 2.0), base);
882    }
883
884    #[test]
885    fn guidance_volatility_widened_stop_avoids_noise_exit() {
886        // Notional 30_000. -800 pnl ≈ -2.67% → trips the default -2% stop.
887        let mut e = sample();
888        e.pnl_unrealized = -800.0;
889        assert_eq!(
890            compute_guidance(&e, None, GuidanceThresholds::default(), None).action,
891            GuidanceAction::Exit
892        );
893        // But with ATR 2% × multiplier 2.0 = 4% band, the stop widens to -4%
894        // and the same -2.67% move is now treated as noise → hold.
895        let widened = GuidanceThresholds::default().widen_for_volatility(2.0, 2.0);
896        assert_eq!(
897            compute_guidance(&e, None, widened, None).action,
898            GuidanceAction::Hold
899        );
900    }
901
902    #[test]
903    fn validate_rejects_negative_atr_pct() {
904        let mut e = sample();
905        e.atr_pct = Some(-0.5);
906        assert!(e.validate().is_err());
907        e.atr_pct = Some(f64::NAN);
908        assert!(e.validate().is_err());
909        e.atr_pct = Some(0.0); // zero is allowed (flat / unknown)
910        assert!(e.validate().is_ok());
911    }
912
913    #[test]
914    fn guidance_take_profit_uses_supplied_threshold() {
915        // Bump take-profit to 10%. +1500 pnl on 30_000 notional = 5% — below the
916        // tuned threshold, so guidance should NOT reduce.
917        let mut e = sample();
918        e.pnl_unrealized = 1_500.0;
919        let tighter = GuidanceThresholds {
920            take_profit_ratio: 0.10,
921            ..GuidanceThresholds::default()
922        };
923        assert_eq!(
924            compute_guidance(&e, None, tighter, None).action,
925            GuidanceAction::Hold
926        );
927    }
928
929    #[test]
930    fn guidance_stop_loss_uses_supplied_threshold() {
931        // Tighten stop to 1%. -200 pnl on 30_000 notional ≈ -0.67% —
932        // below the default -2% but above the tighter -1%, so it should hold.
933        let mut e = sample();
934        e.pnl_unrealized = -200.0;
935        let tighter = GuidanceThresholds {
936            stop_loss_ratio: -0.01,
937            ..GuidanceThresholds::default()
938        };
939        assert_eq!(
940            compute_guidance(&e, None, tighter, None).action,
941            GuidanceAction::Hold,
942            "-0.67% loss should hold under a 1% stop threshold"
943        );
944        // But -350 pnl ≈ -1.17% should now trip the tighter stop.
945        let mut e2 = sample();
946        e2.pnl_unrealized = -350.0;
947        assert_eq!(
948            compute_guidance(&e2, None, tighter, None).action,
949            GuidanceAction::Exit,
950            "-1.17% loss should exit under a 1% stop threshold"
951        );
952    }
953
954    // ── Amygdala fear escalation ─────────────────────────────────────
955
956    #[test]
957    fn guidance_high_fear_exits_regardless_of_pnl() {
958        // Healthy +100 pnl, but fear ≥ 0.8 forces an exit.
959        let mut e = sample();
960        e.pnl_unrealized = 100.0;
961        let g = compute_guidance(&e, None, default_thresholds(), Some(0.85));
962        assert_eq!(g.action, GuidanceAction::Exit);
963        assert!(g.reason.contains("fear"), "reason was: {}", g.reason);
964    }
965
966    #[test]
967    fn guidance_elevated_fear_banks_open_profit() {
968        // In profit but below the take-profit threshold; elevated fear
969        // (0.6) banks it early via Reduce rather than waiting.
970        let mut e = sample();
971        e.pnl_unrealized = 100.0; // 0.33% of notional — under the 5% TP
972        let g = compute_guidance(&e, None, default_thresholds(), Some(0.6));
973        assert_eq!(g.action, GuidanceAction::Reduce);
974        assert!(g.reason.contains("banking"), "reason was: {}", g.reason);
975    }
976
977    #[test]
978    fn guidance_elevated_fear_tightens_stop_on_a_loser() {
979        // -300 pnl ≈ -1% of notional — inside the default -2% stop, so a
980        // calm market holds. At fear 0.8-ε the stop tightens to ~25% of
981        // its width (≈ -0.5%), so the same -1% loss now exits.
982        let mut e = sample();
983        e.pnl_unrealized = -300.0;
984        assert_eq!(
985            compute_guidance(&e, None, default_thresholds(), None).action,
986            GuidanceAction::Hold,
987            "-1% loss holds with no fear"
988        );
989        let g = compute_guidance(&e, None, default_thresholds(), Some(0.79));
990        assert_eq!(
991            g.action,
992            GuidanceAction::Exit,
993            "tightened stop under high-elevated fear should exit a -1% loser"
994        );
995    }
996
997    #[test]
998    fn guidance_low_fear_is_inert() {
999        // Fear below the elevated band changes nothing.
1000        let mut e = sample();
1001        e.pnl_unrealized = 100.0;
1002        assert_eq!(
1003            compute_guidance(&e, None, default_thresholds(), Some(0.3)).action,
1004            GuidanceAction::Hold
1005        );
1006    }
1007
1008    #[test]
1009    fn guidance_crisis_regime_outranks_fear_reduce() {
1010        // A crisis regime exits even when fear would only Reduce a winner.
1011        let mut e = sample();
1012        e.pnl_unrealized = 100.0;
1013        let g = compute_guidance(&e, Some("crisis"), default_thresholds(), Some(0.6));
1014        assert_eq!(g.action, GuidanceAction::Exit);
1015        assert!(g.reason.contains("regime"), "reason was: {}", g.reason);
1016    }
1017
1018    #[test]
1019    fn tighten_stop_for_fear_scales_within_band() {
1020        let base = GuidanceThresholds::default(); // stop -0.02
1021        // Bottom of band: no change.
1022        assert!(
1023            (base.tighten_stop_for_fear(FEAR_ELEVATED_LEVEL).stop_loss_ratio - (-0.02)).abs()
1024                < 1e-9
1025        );
1026        // Top of band (→ exit level): tightened to STOP_TIGHTEN_FLOOR (0.25) of width.
1027        let top = base.tighten_stop_for_fear(FEAR_EXIT_LEVEL - 1e-9).stop_loss_ratio;
1028        assert!((top - (-0.005)).abs() < 1e-4, "got {top}");
1029        // Below band: inert.
1030        assert_eq!(base.tighten_stop_for_fear(0.2), base);
1031    }
1032
1033    // ── base_asset ───────────────────────────────────────────────────
1034
1035    #[test]
1036    fn base_asset_strips_quote_currency_suffix() {
1037        assert_eq!(base_asset("BTC-USD"), "BTC");
1038        assert_eq!(base_asset("ETH/USDT"), "ETH");
1039        assert_eq!(base_asset("SOL"), "SOL");
1040        assert_eq!(base_asset(""), "");
1041    }
1042
1043    // ── PositionTracker (stateful guidance) ──────────────────────────
1044
1045    /// Event with a fixed 30_000 notional (entry 60_000 × qty 0.5), so
1046    /// `pnl_ratio() == pnl / 30_000`.
1047    fn ev(position_id: Option<&str>, pnl: f64) -> PositionEvent {
1048        PositionEvent {
1049            symbol: "BTC-USD".to_string(),
1050            side: Side::Buy,
1051            qty: 0.5,
1052            entry_price: 60_000.0,
1053            current_price: 60_000.0,
1054            pnl_unrealized: pnl,
1055            position_id: position_id.map(String::from),
1056            session_id: None,
1057            atr_pct: None,
1058        }
1059    }
1060
1061    #[test]
1062    fn pnl_ratio_uses_entry_notional() {
1063        assert!((ev(None, 1500.0).pnl_ratio().unwrap() - 0.05).abs() < 1e-9);
1064        // Non-positive notional → None (guards a divide-by-zero downstream).
1065        let mut e = ev(None, 100.0);
1066        e.entry_price = 0.0;
1067        assert!(e.pnl_ratio().is_none());
1068    }
1069
1070    #[tokio::test]
1071    async fn tracker_passes_through_untracked_when_no_position_id() {
1072        let tracker = PositionTracker::new();
1073        let g = tracker
1074            .observe(&ev(None, 600.0), Guidance::hold("within bounds"))
1075            .await;
1076        assert_eq!(g.action, GuidanceAction::Hold);
1077        assert_eq!(tracker.tracked().await, 0);
1078    }
1079
1080    #[tokio::test]
1081    async fn tracker_trailing_reduces_after_giveback() {
1082        let tracker = PositionTracker::new();
1083        // Peaks at +10% (a legit take-profit Reduce), establishing the peak.
1084        let g1 = tracker
1085            .observe(&ev(Some("p1"), 3000.0), Guidance::reduce("take profit"))
1086            .await;
1087        assert_eq!(g1.action, GuidanceAction::Reduce);
1088        // Fades to +4%: the stateless rule would Hold, but half of the 10%
1089        // peak is gone → trailing upgrades to Reduce.
1090        let g2 = tracker
1091            .observe(&ev(Some("p1"), 1200.0), Guidance::hold("within bounds"))
1092            .await;
1093        assert_eq!(g2.action, GuidanceAction::Reduce);
1094        assert!(g2.reason.contains("trailing"), "reason was: {}", g2.reason);
1095    }
1096
1097    #[tokio::test]
1098    async fn tracker_trailing_inert_when_peak_below_arm() {
1099        let tracker = PositionTracker::new();
1100        // Peak only +2% — below the 3% arm threshold, so give-back is ignored.
1101        tracker
1102            .observe(&ev(Some("p2"), 600.0), Guidance::hold("within bounds"))
1103            .await;
1104        let g = tracker
1105            .observe(&ev(Some("p2"), 150.0), Guidance::hold("within bounds"))
1106            .await;
1107        assert_eq!(g.action, GuidanceAction::Hold);
1108    }
1109
1110    #[tokio::test]
1111    async fn tracker_sticky_exit_survives_a_bounce() {
1112        let tracker = PositionTracker::new();
1113        // First snapshot exits (e.g. crisis regime); small pnl so trailing
1114        // never arms and can't be the cause of a later non-Hold.
1115        let g1 = tracker
1116            .observe(&ev(Some("p3"), 100.0), Guidance::exit("regime: crisis"))
1117            .await;
1118        assert_eq!(g1.action, GuidanceAction::Exit);
1119        // Price drifts back inside the bands → stateless Hold, but the prior
1120        // Exit still stands.
1121        let g2 = tracker
1122            .observe(&ev(Some("p3"), 100.0), Guidance::hold("within bounds"))
1123            .await;
1124        assert_eq!(g2.action, GuidanceAction::Exit);
1125        assert!(g2.reason.contains("prior exit"), "reason was: {}", g2.reason);
1126    }
1127
1128    #[tokio::test]
1129    async fn tracker_caps_tracked_positions() {
1130        let tracker = PositionTracker::with_config(TrailingConfig {
1131            max_entries: 2,
1132            ..TrailingConfig::default()
1133        });
1134        for id in ["a", "b", "c"] {
1135            tracker
1136                .observe(&ev(Some(id), 600.0), Guidance::hold("within bounds"))
1137                .await;
1138        }
1139        assert_eq!(tracker.tracked().await, 2, "oldest entry should be evicted");
1140    }
1141
1142    // ── PositionClose / PositionOutcome (outcome capture) ────────────
1143
1144    fn close_ev(position_id: Option<&str>, pnl_realized: f64) -> PositionClose {
1145        PositionClose {
1146            symbol: "BTC-USD".to_string(),
1147            side: Side::Buy,
1148            qty: 0.5,
1149            entry_price: 60_000.0,
1150            exit_price: 60_500.0,
1151            pnl_realized,
1152            rr_ratio: None,
1153            strategy: None,
1154            position_id: position_id.map(String::from),
1155            session_id: None,
1156        }
1157    }
1158
1159    #[test]
1160    fn position_close_validate_rejects_bad_input() {
1161        assert!(close_ev(None, 100.0).validate().is_ok());
1162
1163        let mut e = close_ev(None, 100.0);
1164        e.symbol.clear();
1165        assert!(e.validate().is_err());
1166
1167        let mut e = close_ev(None, 100.0);
1168        e.qty = -1.0;
1169        assert!(e.validate().is_err());
1170
1171        let mut e = close_ev(None, 100.0);
1172        e.exit_price = 0.0;
1173        assert!(e.validate().is_err());
1174
1175        let e = close_ev(None, f64::NAN);
1176        assert!(e.validate().is_err());
1177    }
1178
1179    #[test]
1180    fn outcome_from_close_without_state_is_untracked() {
1181        // 30_000 notional; +1500 realized = +5% → Win, but no tracker state.
1182        let o = PositionOutcome::from_close(&close_ev(None, 1500.0), None);
1183        assert_eq!(o.result, OutcomeResult::Win);
1184        assert!((o.realized_ratio - 0.05).abs() < 1e-9);
1185        assert_eq!(o.samples, 0);
1186        assert!(o.peak_pnl_ratio.is_none());
1187        assert!(o.last_guidance.is_none());
1188        assert!(o.time_in_position_secs.is_none());
1189
1190        // Sign classification.
1191        assert_eq!(
1192            PositionOutcome::from_close(&close_ev(None, -600.0), None).result,
1193            OutcomeResult::Loss
1194        );
1195        assert_eq!(
1196            PositionOutcome::from_close(&close_ev(None, 0.0), None).result,
1197            OutcomeResult::Breakeven
1198        );
1199    }
1200
1201    #[test]
1202    fn outcome_from_close_joins_tracker_state() {
1203        let now = Instant::now();
1204        let state = PositionState {
1205            first_seen: now,
1206            last_seen: now,
1207            samples: 3,
1208            peak_pnl_ratio: 0.08,
1209            last_action: GuidanceAction::Reduce,
1210        };
1211        let o = PositionOutcome::from_close(&close_ev(Some("p"), 300.0), Some(&state));
1212        assert_eq!(o.samples, 3);
1213        assert_eq!(o.peak_pnl_ratio, Some(0.08));
1214        assert_eq!(o.last_guidance, Some(GuidanceAction::Reduce));
1215        assert!(o.time_in_position_secs.is_some());
1216        assert_eq!(o.result, OutcomeResult::Win); // +300 / 30_000 = +1%
1217    }
1218
1219    #[test]
1220    fn outcome_carries_strategy_and_rr_for_affinity() {
1221        let mut close = close_ev(Some("p"), 900.0);
1222        close.strategy = Some("ema_cross".to_string());
1223        close.rr_ratio = Some(2.5);
1224        let o = PositionOutcome::from_close(&close, None);
1225        assert_eq!(o.strategy.as_deref(), Some("ema_cross"));
1226        assert_eq!(o.rr_ratio, Some(2.5));
1227        assert!(o.is_winner(), "+900 realized is a win");
1228        // Breakeven and losses are not winners.
1229        assert!(!PositionOutcome::from_close(&close_ev(None, 0.0), None).is_winner());
1230        assert!(!PositionOutcome::from_close(&close_ev(None, -50.0), None).is_winner());
1231    }
1232
1233    #[tokio::test]
1234    async fn tracker_finalize_removes_and_returns_state() {
1235        let tracker = PositionTracker::new();
1236        tracker
1237            .observe(&ev(Some("p"), 3000.0), Guidance::reduce("take profit"))
1238            .await;
1239        let state = tracker.finalize("p").await.expect("position was tracked");
1240        assert_eq!(state.samples, 1);
1241        assert!((state.peak_pnl_ratio - 0.10).abs() < 1e-9);
1242        assert_eq!(state.last_action, GuidanceAction::Reduce);
1243        // Finalize consumes the entry.
1244        assert_eq!(tracker.tracked().await, 0);
1245        assert!(tracker.finalize("p").await.is_none());
1246    }
1247}