use crate::market::Side;
use crate::optimized_params::OptimizedParams;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionEvent {
pub symbol: String,
pub side: Side,
pub qty: f64,
pub entry_price: f64,
pub current_price: f64,
pub pnl_unrealized: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub atr_pct: Option<f64>,
}
impl PositionEvent {
pub fn validate(&self) -> Result<(), &'static str> {
if self.symbol.is_empty() {
return Err("symbol is empty");
}
if !self.qty.is_finite() || self.qty <= 0.0 {
return Err("qty must be positive and finite");
}
if !self.entry_price.is_finite() || self.entry_price <= 0.0 {
return Err("entry_price must be positive and finite");
}
if !self.current_price.is_finite() || self.current_price <= 0.0 {
return Err("current_price must be positive and finite");
}
if !self.pnl_unrealized.is_finite() {
return Err("pnl_unrealized must be finite");
}
if let Some(atr) = self.atr_pct
&& (!atr.is_finite() || atr < 0.0)
{
return Err("atr_pct must be a non-negative finite percentage");
}
Ok(())
}
pub fn pnl_ratio(&self) -> Option<f64> {
let notional = self.entry_price * self.qty;
(notional > 0.0).then(|| self.pnl_unrealized / notional)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionClose {
pub symbol: String,
pub side: Side,
pub qty: f64,
pub entry_price: f64,
pub exit_price: f64,
pub pnl_realized: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rr_ratio: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl PositionClose {
pub fn validate(&self) -> Result<(), &'static str> {
if self.symbol.is_empty() {
return Err("symbol is empty");
}
if !self.qty.is_finite() || self.qty <= 0.0 {
return Err("qty must be positive and finite");
}
if !self.entry_price.is_finite() || self.entry_price <= 0.0 {
return Err("entry_price must be positive and finite");
}
if !self.exit_price.is_finite() || self.exit_price <= 0.0 {
return Err("exit_price must be positive and finite");
}
if !self.pnl_realized.is_finite() {
return Err("pnl_realized must be finite");
}
Ok(())
}
pub fn realized_ratio(&self) -> Option<f64> {
let notional = self.entry_price * self.qty;
(notional > 0.0).then(|| self.pnl_realized / notional)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OutcomeResult {
Win,
Loss,
Breakeven,
}
impl OutcomeResult {
pub fn from_ratio(ratio: f64) -> Self {
if ratio > 1e-9 {
Self::Win
} else if ratio < -1e-9 {
Self::Loss
} else {
Self::Breakeven
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Win => "win",
Self::Loss => "loss",
Self::Breakeven => "breakeven",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PositionOutcome {
pub symbol: String,
pub side: Side,
pub qty: f64,
pub entry_price: f64,
pub exit_price: f64,
pub pnl_realized: f64,
pub realized_ratio: f64,
pub result: OutcomeResult,
#[serde(skip_serializing_if = "Option::is_none")]
pub rr_ratio: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub strategy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub position_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub peak_pnl_ratio: Option<f64>,
pub samples: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_guidance: Option<GuidanceAction>,
#[serde(skip_serializing_if = "Option::is_none")]
pub time_in_position_secs: Option<f64>,
}
impl PositionOutcome {
pub fn from_close(close: &PositionClose, state: Option<&PositionState>) -> Self {
let realized_ratio = close.realized_ratio().unwrap_or(0.0);
Self {
symbol: close.symbol.clone(),
side: close.side,
qty: close.qty,
entry_price: close.entry_price,
exit_price: close.exit_price,
pnl_realized: close.pnl_realized,
realized_ratio,
result: OutcomeResult::from_ratio(realized_ratio),
rr_ratio: close.rr_ratio,
strategy: close.strategy.clone(),
position_id: close.position_id.clone(),
session_id: close.session_id.clone(),
peak_pnl_ratio: state.map(|s| s.peak_pnl_ratio),
samples: state.map_or(0, |s| s.samples),
last_guidance: state.map(|s| s.last_action),
time_in_position_secs: state.map(|s| s.time_in_position().as_secs_f64()),
}
}
pub fn is_winner(&self) -> bool {
self.result == OutcomeResult::Win
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum GuidanceAction {
Hold,
Reduce,
Exit,
}
impl GuidanceAction {
pub fn as_str(self) -> &'static str {
match self {
Self::Hold => "hold",
Self::Reduce => "reduce",
Self::Exit => "exit",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Guidance {
pub action: GuidanceAction,
pub reason: String,
}
impl Guidance {
fn hold(reason: impl Into<String>) -> Self {
Self {
action: GuidanceAction::Hold,
reason: reason.into(),
}
}
fn reduce(reason: impl Into<String>) -> Self {
Self {
action: GuidanceAction::Reduce,
reason: reason.into(),
}
}
fn exit(reason: impl Into<String>) -> Self {
Self {
action: GuidanceAction::Exit,
reason: reason.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GuidanceThresholds {
pub stop_loss_ratio: f64,
pub take_profit_ratio: f64,
}
impl Default for GuidanceThresholds {
fn default() -> Self {
Self {
stop_loss_ratio: -0.02,
take_profit_ratio: 0.05,
}
}
}
impl GuidanceThresholds {
pub fn from_optimized_params(params: &OptimizedParams) -> Self {
Self {
stop_loss_ratio: -(params.stop_loss_pct / 100.0),
take_profit_ratio: params.take_profit_pct / 100.0,
}
}
pub fn widen_for_volatility(self, atr_pct: f64, atr_multiplier: f64) -> Self {
if !atr_pct.is_finite() || atr_pct <= 0.0 || atr_multiplier <= 0.0 {
return self;
}
let atr_floor = -(atr_multiplier * atr_pct / 100.0);
Self {
stop_loss_ratio: self.stop_loss_ratio.min(atr_floor),
..self
}
}
pub fn tighten_stop_for_fear(self, fear: f64) -> Self {
if !fear.is_finite() || fear < FEAR_ELEVATED_LEVEL {
return self;
}
let span = FEAR_EXIT_LEVEL - FEAR_ELEVATED_LEVEL;
let t = ((fear - FEAR_ELEVATED_LEVEL) / span).clamp(0.0, 1.0);
let factor = 1.0 - t * (1.0 - STOP_TIGHTEN_FLOOR);
Self {
stop_loss_ratio: self.stop_loss_ratio * factor,
..self
}
}
}
pub const FEAR_EXIT_LEVEL: f64 = 0.8;
pub const FEAR_ELEVATED_LEVEL: f64 = 0.5;
const STOP_TIGHTEN_FLOOR: f64 = 0.25;
pub fn compute_guidance(
event: &PositionEvent,
regime: Option<&str>,
thresholds: GuidanceThresholds,
fear: Option<f64>,
) -> Guidance {
if let Some(label) = regime
&& is_crisis_regime(label)
{
return Guidance::exit(format!("regime: {label}"));
}
let mut thresholds = thresholds;
if let Some(fear) = fear.filter(|f| f.is_finite()) {
if fear >= FEAR_EXIT_LEVEL {
return Guidance::exit(format!("fear {fear:.2} ≥ {FEAR_EXIT_LEVEL}"));
}
if fear >= FEAR_ELEVATED_LEVEL {
if event.pnl_unrealized > 0.0 {
return Guidance::reduce(format!("fear {fear:.2}: banking open profit"));
}
thresholds = thresholds.tighten_stop_for_fear(fear);
}
}
if let Some(ratio) = event.pnl_ratio() {
if ratio <= thresholds.stop_loss_ratio {
let pct = ratio * 100.0;
return Guidance::exit(format!("stop loss: {pct:.2}% of notional"));
}
if ratio >= thresholds.take_profit_ratio {
let pct = ratio * 100.0;
return Guidance::reduce(format!("take profit: {pct:.2}% of notional"));
}
}
Guidance::hold("within bounds")
}
pub fn base_asset(symbol: &str) -> &str {
symbol
.split(['-', '/'])
.next()
.filter(|s| !s.is_empty())
.unwrap_or(symbol)
}
fn is_crisis_regime(label: &str) -> bool {
let lower = label.to_ascii_lowercase();
["crisis", "panic", "flash_crash", "shock"]
.iter()
.any(|needle| lower.contains(needle))
}
#[derive(Debug, Clone)]
pub struct PositionState {
pub first_seen: Instant,
pub last_seen: Instant,
pub samples: u64,
pub peak_pnl_ratio: f64,
pub last_action: GuidanceAction,
}
impl PositionState {
pub fn time_in_position(&self) -> Duration {
self.last_seen.duration_since(self.first_seen)
}
}
#[derive(Debug, Clone, Copy)]
pub struct TrailingConfig {
pub arm_ratio: f64,
pub giveback_frac: f64,
pub ttl: Duration,
pub max_entries: usize,
}
impl Default for TrailingConfig {
fn default() -> Self {
Self {
arm_ratio: 0.03,
giveback_frac: 0.5,
ttl: Duration::from_secs(3600),
max_entries: 10_000,
}
}
}
pub struct PositionTracker {
states: RwLock<HashMap<String, PositionState>>,
config: TrailingConfig,
}
impl Default for PositionTracker {
fn default() -> Self {
Self::new()
}
}
impl PositionTracker {
pub fn new() -> Self {
Self::with_config(TrailingConfig::default())
}
pub fn with_config(config: TrailingConfig) -> Self {
Self {
states: RwLock::new(HashMap::new()),
config,
}
}
pub async fn tracked(&self) -> usize {
self.states.read().await.len()
}
pub async fn finalize(&self, position_id: &str) -> Option<PositionState> {
self.states.write().await.remove(position_id)
}
pub async fn observe(&self, event: &PositionEvent, base: Guidance) -> Guidance {
let Some(key) = event.position_id.clone() else {
return base;
};
let ratio = event.pnl_ratio().unwrap_or(0.0);
let now = Instant::now();
let mut states = self.states.write().await;
states.retain(|_, s| now.duration_since(s.last_seen) <= self.config.ttl);
if !states.contains_key(&key)
&& states.len() >= self.config.max_entries
&& let Some(oldest) = states
.iter()
.min_by_key(|(_, s)| s.last_seen)
.map(|(k, _)| k.clone())
{
states.remove(&oldest);
}
let entry = states.entry(key).or_insert_with(|| PositionState {
first_seen: now,
last_seen: now,
samples: 0,
peak_pnl_ratio: ratio,
last_action: GuidanceAction::Hold,
});
let prior_action = entry.last_action;
entry.last_seen = now;
entry.samples += 1;
entry.peak_pnl_ratio = entry.peak_pnl_ratio.max(ratio);
let peak = entry.peak_pnl_ratio;
let mut action = base.action;
let mut reason = base.reason;
if action == GuidanceAction::Hold
&& ratio > 0.0
&& peak >= self.config.arm_ratio
&& ratio <= peak * (1.0 - self.config.giveback_frac)
{
action = GuidanceAction::Reduce;
reason = format!(
"trailing: gave back to {:.2}% from {:.2}% peak",
ratio * 100.0,
peak * 100.0
);
}
if prior_action == GuidanceAction::Exit && action != GuidanceAction::Exit {
action = GuidanceAction::Exit;
reason = "prior exit still standing".to_string();
}
entry.last_action = action;
Guidance { action, reason }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> PositionEvent {
PositionEvent {
symbol: "BTC-USD".to_string(),
side: Side::Buy,
qty: 0.5,
entry_price: 60_000.0,
current_price: 61_000.0,
pnl_unrealized: 500.0,
position_id: Some("pos-1".to_string()),
session_id: Some("sess-1".to_string()),
atr_pct: None,
}
}
#[test]
fn validate_accepts_well_formed_event() {
assert!(sample().validate().is_ok());
}
#[test]
fn validate_rejects_empty_symbol() {
let mut e = sample();
e.symbol.clear();
assert_eq!(e.validate(), Err("symbol is empty"));
}
#[test]
fn validate_rejects_non_positive_qty() {
let mut e = sample();
e.qty = 0.0;
assert!(e.validate().is_err());
e.qty = -1.0;
assert!(e.validate().is_err());
}
#[test]
fn validate_rejects_non_finite_prices() {
let mut e = sample();
e.entry_price = f64::NAN;
assert!(e.validate().is_err());
let mut e = sample();
e.current_price = f64::INFINITY;
assert!(e.validate().is_err());
let mut e = sample();
e.pnl_unrealized = f64::NAN;
assert!(e.validate().is_err());
}
#[test]
fn round_trips_through_json_with_optional_fields_omitted() {
let e = PositionEvent {
position_id: None,
session_id: None,
..sample()
};
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("position_id"));
assert!(!json.contains("session_id"));
let back: PositionEvent = serde_json::from_str(&json).unwrap();
assert_eq!(back.symbol, e.symbol);
assert!(back.position_id.is_none());
}
#[test]
fn deserializes_minimal_payload() {
let json = r#"{
"symbol": "ETH-USD",
"side": "Sell",
"qty": 2.0,
"entry_price": 3000.0,
"current_price": 2950.0,
"pnl_unrealized": 100.0
}"#;
let e: PositionEvent = serde_json::from_str(json).unwrap();
assert_eq!(e.symbol, "ETH-USD");
assert_eq!(e.side, Side::Sell);
assert!(e.position_id.is_none());
}
fn default_thresholds() -> GuidanceThresholds {
GuidanceThresholds::default()
}
#[test]
fn guidance_holds_when_within_bounds() {
let mut e = sample();
e.pnl_unrealized = 100.0;
let g = compute_guidance(&e, None, default_thresholds(), None);
assert_eq!(g.action, GuidanceAction::Hold);
}
#[test]
fn guidance_exits_on_stop_loss_breach() {
let mut e = sample();
e.pnl_unrealized = -700.0;
let g = compute_guidance(&e, None, default_thresholds(), None);
assert_eq!(g.action, GuidanceAction::Exit);
assert!(g.reason.contains("stop loss"));
}
#[test]
fn guidance_reduces_on_take_profit() {
let mut e = sample();
e.pnl_unrealized = 2_000.0;
let g = compute_guidance(&e, None, default_thresholds(), None);
assert_eq!(g.action, GuidanceAction::Reduce);
assert!(g.reason.contains("take profit"));
}
#[test]
fn guidance_exits_on_crisis_regime_regardless_of_pnl() {
let mut e = sample();
e.pnl_unrealized = 100.0;
let g = compute_guidance(&e, Some("crisis_volatility_spike"), default_thresholds(), None);
assert_eq!(g.action, GuidanceAction::Exit);
assert!(g.reason.contains("regime"));
}
#[test]
fn guidance_crisis_detection_is_case_insensitive_and_substring() {
let e = sample();
for label in ["PANIC", "Flash_Crash detected", "shockwave"] {
assert_eq!(
compute_guidance(&e, Some(label), default_thresholds(), None).action,
GuidanceAction::Exit,
"label {label:?} should trigger exit"
);
}
}
#[test]
fn guidance_ignores_unknown_regime_labels() {
let e = sample();
assert_eq!(
compute_guidance(&e, Some("bullish_trend"), default_thresholds(), None).action,
GuidanceAction::Hold
);
}
#[test]
fn guidance_action_serializes_lowercase() {
let g = Guidance::hold("ok");
let json = serde_json::to_string(&g).unwrap();
assert!(json.contains("\"action\":\"hold\""));
}
#[test]
fn thresholds_from_optimized_params_overrides_both_ratios() {
let params = OptimizedParams {
take_profit_pct: 8.0,
stop_loss_pct: 3.0, ..OptimizedParams::new("BTC")
};
let t = GuidanceThresholds::from_optimized_params(¶ms);
assert!((t.take_profit_ratio - 0.08).abs() < 1e-9);
assert!((t.stop_loss_ratio - (-0.03)).abs() < 1e-9);
}
#[test]
fn thresholds_default_stop_loss_matches_optimized_params_default() {
let params = OptimizedParams::default();
let t = GuidanceThresholds::from_optimized_params(¶ms);
assert_eq!(t.stop_loss_ratio, GuidanceThresholds::default().stop_loss_ratio);
assert_eq!(t.take_profit_ratio, GuidanceThresholds::default().take_profit_ratio);
}
#[test]
fn widen_for_volatility_loosens_stop_when_atr_band_is_wider() {
let t = GuidanceThresholds::default().widen_for_volatility(1.5, 2.0);
assert!((t.stop_loss_ratio - (-0.03)).abs() < 1e-9);
assert_eq!(t.take_profit_ratio, GuidanceThresholds::default().take_profit_ratio);
}
#[test]
fn widen_for_volatility_keeps_stop_when_already_wider() {
let base = GuidanceThresholds {
stop_loss_ratio: -0.05,
..GuidanceThresholds::default()
};
let t = base.widen_for_volatility(0.5, 2.0);
assert!((t.stop_loss_ratio - (-0.05)).abs() < 1e-9);
}
#[test]
fn widen_for_volatility_is_noop_for_nonpositive_inputs() {
let base = GuidanceThresholds::default();
assert_eq!(base.widen_for_volatility(0.0, 2.0), base);
assert_eq!(base.widen_for_volatility(1.5, 0.0), base);
assert_eq!(base.widen_for_volatility(-1.0, 2.0), base);
}
#[test]
fn guidance_volatility_widened_stop_avoids_noise_exit() {
let mut e = sample();
e.pnl_unrealized = -800.0;
assert_eq!(
compute_guidance(&e, None, GuidanceThresholds::default(), None).action,
GuidanceAction::Exit
);
let widened = GuidanceThresholds::default().widen_for_volatility(2.0, 2.0);
assert_eq!(
compute_guidance(&e, None, widened, None).action,
GuidanceAction::Hold
);
}
#[test]
fn validate_rejects_negative_atr_pct() {
let mut e = sample();
e.atr_pct = Some(-0.5);
assert!(e.validate().is_err());
e.atr_pct = Some(f64::NAN);
assert!(e.validate().is_err());
e.atr_pct = Some(0.0); assert!(e.validate().is_ok());
}
#[test]
fn guidance_take_profit_uses_supplied_threshold() {
let mut e = sample();
e.pnl_unrealized = 1_500.0;
let tighter = GuidanceThresholds {
take_profit_ratio: 0.10,
..GuidanceThresholds::default()
};
assert_eq!(
compute_guidance(&e, None, tighter, None).action,
GuidanceAction::Hold
);
}
#[test]
fn guidance_stop_loss_uses_supplied_threshold() {
let mut e = sample();
e.pnl_unrealized = -200.0;
let tighter = GuidanceThresholds {
stop_loss_ratio: -0.01,
..GuidanceThresholds::default()
};
assert_eq!(
compute_guidance(&e, None, tighter, None).action,
GuidanceAction::Hold,
"-0.67% loss should hold under a 1% stop threshold"
);
let mut e2 = sample();
e2.pnl_unrealized = -350.0;
assert_eq!(
compute_guidance(&e2, None, tighter, None).action,
GuidanceAction::Exit,
"-1.17% loss should exit under a 1% stop threshold"
);
}
#[test]
fn guidance_high_fear_exits_regardless_of_pnl() {
let mut e = sample();
e.pnl_unrealized = 100.0;
let g = compute_guidance(&e, None, default_thresholds(), Some(0.85));
assert_eq!(g.action, GuidanceAction::Exit);
assert!(g.reason.contains("fear"), "reason was: {}", g.reason);
}
#[test]
fn guidance_elevated_fear_banks_open_profit() {
let mut e = sample();
e.pnl_unrealized = 100.0; let g = compute_guidance(&e, None, default_thresholds(), Some(0.6));
assert_eq!(g.action, GuidanceAction::Reduce);
assert!(g.reason.contains("banking"), "reason was: {}", g.reason);
}
#[test]
fn guidance_elevated_fear_tightens_stop_on_a_loser() {
let mut e = sample();
e.pnl_unrealized = -300.0;
assert_eq!(
compute_guidance(&e, None, default_thresholds(), None).action,
GuidanceAction::Hold,
"-1% loss holds with no fear"
);
let g = compute_guidance(&e, None, default_thresholds(), Some(0.79));
assert_eq!(
g.action,
GuidanceAction::Exit,
"tightened stop under high-elevated fear should exit a -1% loser"
);
}
#[test]
fn guidance_low_fear_is_inert() {
let mut e = sample();
e.pnl_unrealized = 100.0;
assert_eq!(
compute_guidance(&e, None, default_thresholds(), Some(0.3)).action,
GuidanceAction::Hold
);
}
#[test]
fn guidance_crisis_regime_outranks_fear_reduce() {
let mut e = sample();
e.pnl_unrealized = 100.0;
let g = compute_guidance(&e, Some("crisis"), default_thresholds(), Some(0.6));
assert_eq!(g.action, GuidanceAction::Exit);
assert!(g.reason.contains("regime"), "reason was: {}", g.reason);
}
#[test]
fn tighten_stop_for_fear_scales_within_band() {
let base = GuidanceThresholds::default(); assert!(
(base.tighten_stop_for_fear(FEAR_ELEVATED_LEVEL).stop_loss_ratio - (-0.02)).abs()
< 1e-9
);
let top = base.tighten_stop_for_fear(FEAR_EXIT_LEVEL - 1e-9).stop_loss_ratio;
assert!((top - (-0.005)).abs() < 1e-4, "got {top}");
assert_eq!(base.tighten_stop_for_fear(0.2), base);
}
#[test]
fn base_asset_strips_quote_currency_suffix() {
assert_eq!(base_asset("BTC-USD"), "BTC");
assert_eq!(base_asset("ETH/USDT"), "ETH");
assert_eq!(base_asset("SOL"), "SOL");
assert_eq!(base_asset(""), "");
}
fn ev(position_id: Option<&str>, pnl: f64) -> PositionEvent {
PositionEvent {
symbol: "BTC-USD".to_string(),
side: Side::Buy,
qty: 0.5,
entry_price: 60_000.0,
current_price: 60_000.0,
pnl_unrealized: pnl,
position_id: position_id.map(String::from),
session_id: None,
atr_pct: None,
}
}
#[test]
fn pnl_ratio_uses_entry_notional() {
assert!((ev(None, 1500.0).pnl_ratio().unwrap() - 0.05).abs() < 1e-9);
let mut e = ev(None, 100.0);
e.entry_price = 0.0;
assert!(e.pnl_ratio().is_none());
}
#[tokio::test]
async fn tracker_passes_through_untracked_when_no_position_id() {
let tracker = PositionTracker::new();
let g = tracker
.observe(&ev(None, 600.0), Guidance::hold("within bounds"))
.await;
assert_eq!(g.action, GuidanceAction::Hold);
assert_eq!(tracker.tracked().await, 0);
}
#[tokio::test]
async fn tracker_trailing_reduces_after_giveback() {
let tracker = PositionTracker::new();
let g1 = tracker
.observe(&ev(Some("p1"), 3000.0), Guidance::reduce("take profit"))
.await;
assert_eq!(g1.action, GuidanceAction::Reduce);
let g2 = tracker
.observe(&ev(Some("p1"), 1200.0), Guidance::hold("within bounds"))
.await;
assert_eq!(g2.action, GuidanceAction::Reduce);
assert!(g2.reason.contains("trailing"), "reason was: {}", g2.reason);
}
#[tokio::test]
async fn tracker_trailing_inert_when_peak_below_arm() {
let tracker = PositionTracker::new();
tracker
.observe(&ev(Some("p2"), 600.0), Guidance::hold("within bounds"))
.await;
let g = tracker
.observe(&ev(Some("p2"), 150.0), Guidance::hold("within bounds"))
.await;
assert_eq!(g.action, GuidanceAction::Hold);
}
#[tokio::test]
async fn tracker_sticky_exit_survives_a_bounce() {
let tracker = PositionTracker::new();
let g1 = tracker
.observe(&ev(Some("p3"), 100.0), Guidance::exit("regime: crisis"))
.await;
assert_eq!(g1.action, GuidanceAction::Exit);
let g2 = tracker
.observe(&ev(Some("p3"), 100.0), Guidance::hold("within bounds"))
.await;
assert_eq!(g2.action, GuidanceAction::Exit);
assert!(g2.reason.contains("prior exit"), "reason was: {}", g2.reason);
}
#[tokio::test]
async fn tracker_caps_tracked_positions() {
let tracker = PositionTracker::with_config(TrailingConfig {
max_entries: 2,
..TrailingConfig::default()
});
for id in ["a", "b", "c"] {
tracker
.observe(&ev(Some(id), 600.0), Guidance::hold("within bounds"))
.await;
}
assert_eq!(tracker.tracked().await, 2, "oldest entry should be evicted");
}
fn close_ev(position_id: Option<&str>, pnl_realized: f64) -> PositionClose {
PositionClose {
symbol: "BTC-USD".to_string(),
side: Side::Buy,
qty: 0.5,
entry_price: 60_000.0,
exit_price: 60_500.0,
pnl_realized,
rr_ratio: None,
strategy: None,
position_id: position_id.map(String::from),
session_id: None,
}
}
#[test]
fn position_close_validate_rejects_bad_input() {
assert!(close_ev(None, 100.0).validate().is_ok());
let mut e = close_ev(None, 100.0);
e.symbol.clear();
assert!(e.validate().is_err());
let mut e = close_ev(None, 100.0);
e.qty = -1.0;
assert!(e.validate().is_err());
let mut e = close_ev(None, 100.0);
e.exit_price = 0.0;
assert!(e.validate().is_err());
let e = close_ev(None, f64::NAN);
assert!(e.validate().is_err());
}
#[test]
fn outcome_from_close_without_state_is_untracked() {
let o = PositionOutcome::from_close(&close_ev(None, 1500.0), None);
assert_eq!(o.result, OutcomeResult::Win);
assert!((o.realized_ratio - 0.05).abs() < 1e-9);
assert_eq!(o.samples, 0);
assert!(o.peak_pnl_ratio.is_none());
assert!(o.last_guidance.is_none());
assert!(o.time_in_position_secs.is_none());
assert_eq!(
PositionOutcome::from_close(&close_ev(None, -600.0), None).result,
OutcomeResult::Loss
);
assert_eq!(
PositionOutcome::from_close(&close_ev(None, 0.0), None).result,
OutcomeResult::Breakeven
);
}
#[test]
fn outcome_from_close_joins_tracker_state() {
let now = Instant::now();
let state = PositionState {
first_seen: now,
last_seen: now,
samples: 3,
peak_pnl_ratio: 0.08,
last_action: GuidanceAction::Reduce,
};
let o = PositionOutcome::from_close(&close_ev(Some("p"), 300.0), Some(&state));
assert_eq!(o.samples, 3);
assert_eq!(o.peak_pnl_ratio, Some(0.08));
assert_eq!(o.last_guidance, Some(GuidanceAction::Reduce));
assert!(o.time_in_position_secs.is_some());
assert_eq!(o.result, OutcomeResult::Win); }
#[test]
fn outcome_carries_strategy_and_rr_for_affinity() {
let mut close = close_ev(Some("p"), 900.0);
close.strategy = Some("ema_cross".to_string());
close.rr_ratio = Some(2.5);
let o = PositionOutcome::from_close(&close, None);
assert_eq!(o.strategy.as_deref(), Some("ema_cross"));
assert_eq!(o.rr_ratio, Some(2.5));
assert!(o.is_winner(), "+900 realized is a win");
assert!(!PositionOutcome::from_close(&close_ev(None, 0.0), None).is_winner());
assert!(!PositionOutcome::from_close(&close_ev(None, -50.0), None).is_winner());
}
#[tokio::test]
async fn tracker_finalize_removes_and_returns_state() {
let tracker = PositionTracker::new();
tracker
.observe(&ev(Some("p"), 3000.0), Guidance::reduce("take profit"))
.await;
let state = tracker.finalize("p").await.expect("position was tracked");
assert_eq!(state.samples, 1);
assert!((state.peak_pnl_ratio - 0.10).abs() < 1e-9);
assert_eq!(state.last_action, GuidanceAction::Reduce);
assert_eq!(tracker.tracked().await, 0);
assert!(tracker.finalize("p").await.is_none());
}
}