1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PositionEvent {
22 pub symbol: String,
24 pub side: Side,
26 pub qty: f64,
28 pub entry_price: f64,
30 pub current_price: f64,
32 pub pnl_unrealized: f64,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub position_id: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub session_id: Option<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub atr_pct: Option<f64>,
46}
47
48impl PositionEvent {
49 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct PositionClose {
90 pub symbol: String,
92 pub side: Side,
94 pub qty: f64,
96 pub entry_price: f64,
98 pub exit_price: f64,
100 pub pnl_realized: f64,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub rr_ratio: Option<f64>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub strategy: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub position_id: Option<String>,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub session_id: Option<String>,
118}
119
120impl PositionClose {
121 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 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#[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 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 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#[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 pub realized_ratio: f64,
198 pub result: OutcomeResult,
200 #[serde(skip_serializing_if = "Option::is_none")]
202 pub rr_ratio: Option<f64>,
203 #[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 #[serde(skip_serializing_if = "Option::is_none")]
213 pub peak_pnl_ratio: Option<f64>,
214 pub samples: u64,
216 #[serde(skip_serializing_if = "Option::is_none")]
218 pub last_guidance: Option<GuidanceAction>,
219 #[serde(skip_serializing_if = "Option::is_none")]
221 pub time_in_position_secs: Option<f64>,
222}
223
224impl PositionOutcome {
225 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 pub fn is_winner(&self) -> bool {
252 self.result == OutcomeResult::Win
253 }
254}
255
256#[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 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#[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#[derive(Debug, Clone, Copy, PartialEq)]
316pub struct GuidanceThresholds {
317 pub stop_loss_ratio: f64,
320 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 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 pub fn widen_for_volatility(self, atr_pct: f64, atr_multiplier: f64) -> Self {
363 if !atr_pct.is_finite() || atr_pct <= 0.0 || atr_multiplier <= 0.0 {
366 return self;
367 }
368 let atr_floor = -(atr_multiplier * atr_pct / 100.0);
371 Self {
372 stop_loss_ratio: self.stop_loss_ratio.min(atr_floor),
374 ..self
375 }
376 }
377
378 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
401pub const FEAR_EXIT_LEVEL: f64 = 0.8;
404
405pub const FEAR_ELEVATED_LEVEL: f64 = 0.5;
408
409const STOP_TIGHTEN_FLOOR: f64 = 0.25;
413
414pub 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 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 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
473pub 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#[derive(Debug, Clone)]
496pub struct PositionState {
497 pub first_seen: Instant,
499 pub last_seen: Instant,
501 pub samples: u64,
503 pub peak_pnl_ratio: f64,
505 pub last_action: GuidanceAction,
507}
508
509impl PositionState {
510 pub fn time_in_position(&self) -> Duration {
512 self.last_seen.duration_since(self.first_seen)
513 }
514}
515
516#[derive(Debug, Clone, Copy)]
518pub struct TrailingConfig {
519 pub arm_ratio: f64,
522 pub giveback_frac: f64,
525 pub ttl: Duration,
527 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
543pub 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 pub fn new() -> Self {
574 Self::with_config(TrailingConfig::default())
575 }
576
577 pub fn with_config(config: TrailingConfig) -> Self {
579 Self {
580 states: RwLock::new(HashMap::new()),
581 config,
582 }
583 }
584
585 pub async fn tracked(&self) -> usize {
587 self.states.read().await.len()
588 }
589
590 pub async fn finalize(&self, position_id: &str) -> Option<PositionState> {
595 self.states.write().await.remove(position_id)
596 }
597
598 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 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 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 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 fn default_thresholds() -> GuidanceThresholds {
757 GuidanceThresholds::default()
758 }
759
760 #[test]
761 fn guidance_holds_when_within_bounds() {
762 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 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 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 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 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 #[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, ..OptimizedParams::new("BTC")
836 };
837 let t = GuidanceThresholds::from_optimized_params(¶ms);
838 assert!((t.take_profit_ratio - 0.08).abs() < 1e-9);
839 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 let params = OptimizedParams::default();
848 let t = GuidanceThresholds::from_optimized_params(¶ms);
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 #[test]
856 fn widen_for_volatility_loosens_stop_when_atr_band_is_wider() {
857 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 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 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 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); assert!(e.validate().is_ok());
911 }
912
913 #[test]
914 fn guidance_take_profit_uses_supplied_threshold() {
915 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 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 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 #[test]
957 fn guidance_high_fear_exits_regardless_of_pnl() {
958 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 let mut e = sample();
971 e.pnl_unrealized = 100.0; 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 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 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 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(); assert!(
1023 (base.tighten_stop_for_fear(FEAR_ELEVATED_LEVEL).stop_loss_ratio - (-0.02)).abs()
1024 < 1e-9
1025 );
1026 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 assert_eq!(base.tighten_stop_for_fear(0.2), base);
1031 }
1032
1033 #[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 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 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 let g1 = tracker
1085 .observe(&ev(Some("p1"), 3000.0), Guidance::reduce("take profit"))
1086 .await;
1087 assert_eq!(g1.action, GuidanceAction::Reduce);
1088 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 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 let g1 = tracker
1116 .observe(&ev(Some("p3"), 100.0), Guidance::exit("regime: crisis"))
1117 .await;
1118 assert_eq!(g1.action, GuidanceAction::Exit);
1119 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 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 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 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); }
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 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 assert_eq!(tracker.tracked().await, 0);
1245 assert!(tracker.finalize("p").await.is_none());
1246 }
1247}