//! Dated manual adjustments are ordered facts; restoring is not a 100% target. use chrono::{DateTime, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] pub enum PositionExposureAction { Scale { #[serde(rename = "requestedBps", alias = "requested_bps")] requested_bps: i32, }, Set { #[serde(rename = "targetExposureBps", alias = "target_exposure_bps")] target_exposure_bps: i32, }, Restore, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct PositionExposureEvent { #[serde(alias = "event_id")] pub event_id: String, pub sequence: u64, #[serde(alias = "effective_at")] pub effective_at: DateTime, #[serde(flatten)] pub action: PositionExposureAction, } #[derive(Debug, Clone, Default)] pub struct PositionExposureTimeline { events: BTreeMap<(DateTime, u64), PositionExposureAction>, } impl PositionExposureTimeline { pub fn from_events(events: &[PositionExposureEvent]) -> Result { let mut result = Self::default(); let mut ids = BTreeSet::new(); let mut sequences = BTreeSet::new(); for event in events { if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) { return Err("position exposure event id is missing or duplicated".into()); } if event.sequence == 0 || !sequences.insert(event.sequence) { return Err("position exposure event sequence must be positive and unique".into()); } if let PositionExposureAction::Scale { requested_bps } = event.action && !(0..=10000).contains(&requested_bps) { return Err("position exposure scale must be between 0 and 10000 bps".into()); } if let PositionExposureAction::Set { target_exposure_bps, } = event.action && !(0..=10_000).contains(&target_exposure_bps) { return Err("position exposure target must be between 0 and 10000 bps".into()); } result .events .insert((event.effective_at, event.sequence), event.action.clone()); } Ok(result) } /// Legacy day-level contracts remain day-level; never invent intraday times. pub fn exposure_at( &self, at: DateTime, execution_date: NaiveDate, legacy: &BTreeMap, strategy_exposure: f64, ) -> Option { match self .events .range(..=(at, u64::MAX)) .next_back() .map(|(_, action)| action) { Some(PositionExposureAction::Scale { requested_bps }) => { Some(strategy_exposure * f64::from(*requested_bps) / 10000.) } Some(PositionExposureAction::Set { target_exposure_bps, }) => Some(f64::from(*target_exposure_bps) / 10_000.), Some(PositionExposureAction::Restore) => None, None => legacy .range(..=execution_date) .next_back() .map(|(_, value)| *value), } } pub fn scale_at(&self, at: DateTime) -> Option { match self .events .range(..=(at, u64::MAX)) .next_back() .map(|(_, action)| action) { Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps), _ => None, } } } /// Scale new buys and desired targets without weakening sell/reduction or /// cancellation instructions. Prices, subscriptions and cash flows are intact. pub fn scale_explicit_intent( mut intent: crate::OrderIntent, bps: i32, open_orders: &[crate::OpenOrderView], ) -> Result, String> { use crate::OrderIntent as I; if !(0..=10000).contains(&bps) { return Err("position scale out of range".into()); } if bps == 10000 { return Ok(Some(intent)); } if let I::WithTimeInForce { intent: inner, time_in_force, } = intent { return Ok( scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce { intent: Box::new(intent), time_in_force, }), ); } let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32; let amount = |value: f64, target: bool| -> Result { if !value.is_finite() || (target && value < 0.) { return Err("position override received an invalid original amount".into()); } Ok(if value > 0. { value * f64::from(bps) / 10000. } else { value }) }; match &mut intent { I::Shares { quantity, .. } | I::LimitShares { quantity, .. } | I::Lots { lots: quantity, .. } | I::LimitLots { lots: quantity, .. } => { if *quantity > 0 { *quantity = integer(*quantity); if *quantity == 0 { return Ok(None); } } } I::TargetShares { target_quantity, .. } | I::LimitTargetShares { target_quantity, .. } => { if *target_quantity < 0 { return Err("position override received a negative target quantity".into()); } *target_quantity = integer(*target_quantity); } I::Value { value, .. } | I::LimitValue { value, .. } | I::AlgoValue { value, .. } | I::Percent { percent: value, .. } | I::LimitPercent { percent: value, .. } | I::AlgoPercent { percent: value, .. } => { *value = amount(*value, false)?; if *value == 0. { return Ok(None); } } I::TargetValue { target_value, .. } | I::LimitTargetValue { target_value, .. } | I::TimedTargetValue { target_value, .. } | I::TargetPercent { target_percent: target_value, .. } | I::LimitTargetPercent { target_percent: target_value, .. } => { *target_value = amount(*target_value, true)?; } I::TargetPortfolioSmart { target_weights, .. } => { for value in target_weights.values_mut() { *value = amount(*value, true)?; } } I::ModifyOrder { order_id, new_total_quantity: Some(quantity), .. } => { let order = open_orders .iter() .find(|order| order.order_id == *order_id) .ok_or("position override cannot resolve the order being modified")?; if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity { let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000; *quantity = order.requested_quantity + extra as u32; } } I::Futures { .. } | I::StockPool { .. } => { return Err("manual equity scaling cannot transform this intent kind".into()); } I::ModifyOrder { .. } | I::CancelOrder { .. } | I::CancelSymbol { .. } | I::CancelAll { .. } | I::UpdateUniverse { .. } | I::Subscribe { .. } | I::Unsubscribe { .. } | I::DepositWithdraw { .. } | I::FinanceRepay { .. } | I::SetManagementFeeRate { .. } => {} I::WithTimeInForce { .. } => unreachable!("wrapper handled first"), } Ok(Some(intent)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() { let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00") .unwrap() .with_timezone(&Utc); let event = PositionExposureEvent { event_id: "scale".into(), sequence: 1, effective_at: at, action: PositionExposureAction::Scale { requested_bps: 5000, }, }; let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap(); assert_eq!( timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.), Some(0.) ); assert_eq!( timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2), Some(0.1) ); let restored = PositionExposureEvent { event_id: "restore".into(), sequence: 2, effective_at: at, action: PositionExposureAction::Restore, }; let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap(); assert_eq!( timeline .exposure_at( at, at.date_naive(), &BTreeMap::from([(at.date_naive(), 1.)]), 0.2 ) .unwrap_or(0.2), 0.2 ); } #[test] fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() { use crate::OrderIntent as I; let symbol = "000001.SZ".to_string(); let reason = "fixture".to_string(); for bps in [0, 3000, 5000, 10000] { let ratio = f64::from(bps) / 10000.; let buy = I::LimitShares { symbol: symbol.clone(), quantity: 1000, limit_price: 12.345, reason: reason.clone(), }; let scaled = scale_explicit_intent(buy, bps, &[]).unwrap(); if bps == 0 { assert!(scaled.is_none()) } else if let Some(I::LimitShares { quantity, limit_price, .. }) = scaled { assert_eq!(quantity, (1000. * ratio) as i32); assert_eq!(limit_price, 12.345); } else { panic!("wrong intent") } let sell = I::Shares { symbol: symbol.clone(), quantity: -1000, reason: reason.clone(), }; assert!(matches!( scale_explicit_intent(sell, bps, &[]).unwrap(), Some(I::Shares { quantity: -1000, .. }) )); let clear = I::TargetShares { symbol: symbol.clone(), target_quantity: 0, reason: reason.clone(), }; assert!(matches!( scale_explicit_intent(clear, bps, &[]).unwrap(), Some(I::TargetShares { target_quantity: 0, .. }) )); let target = I::TargetPercent { symbol: symbol.clone(), target_percent: 0.2, reason: reason.clone(), }; if let Some(I::TargetPercent { target_percent, .. }) = scale_explicit_intent(target, bps, &[]).unwrap() { assert!((target_percent - 0.2 * ratio).abs() < 1e-12) } else { panic!("wrong target") } let deposit = I::DepositWithdraw { amount: 123.456, receiving_days: 2, reason: reason.clone(), }; assert!(matches!( scale_explicit_intent(deposit, bps, &[]).unwrap(), Some(I::DepositWithdraw { amount: 123.456, receiving_days: 2, .. }) )); } assert!( scale_explicit_intent( I::TargetValue { symbol, target_value: f64::NAN, reason }, 0, &[] ) .is_err() ); } #[test] fn same_day_adjustments_restore_and_future_events_keep_their_own_times() { let events: Vec = serde_json::from_value(json!([ {"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0}, {"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000}, {"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"}, {"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000} ])).unwrap(); let timeline = PositionExposureTimeline::from_events(&events).unwrap(); let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap(); let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]); for (time, expected) in [ ("09:30:00", Some(0.8)), ("10:00:00", Some(0.)), ("12:59:59", Some(0.)), ("13:00:00", Some(0.5)), ("14:00:00", None), ("15:00:00", None), ] { let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00")) .unwrap() .with_timezone(&Utc); assert_eq!( timeline.exposure_at(at, date, &legacy, 0.2), expected, "{time}" ); } let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00") .unwrap() .with_timezone(&Utc); assert_eq!( timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2), None ); } #[test] fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() { let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000}); for (key, value) in [ ("action", json!("other")), ("effectiveAt", json!("2026-09-10 09:30:00")), ("targetExposureBps", json!(null)), ] { let mut invalid = valid.clone(); invalid[key] = value; assert!(serde_json::from_value::(invalid).is_err()); } let event: PositionExposureEvent = serde_json::from_value(valid).unwrap(); assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err()); let mut invalid = event.clone(); invalid.action = PositionExposureAction::Set { target_exposure_bps: 10001, }; assert!(PositionExposureTimeline::from_events(&[invalid]).is_err()); let mut duplicate = event.clone(); duplicate.event_id = "two".into(); assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err()); } }