429 lines
15 KiB
Rust
429 lines
15 KiB
Rust
//! 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<Utc>,
|
|
#[serde(flatten)]
|
|
pub action: PositionExposureAction,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct PositionExposureTimeline {
|
|
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
|
|
}
|
|
|
|
impl PositionExposureTimeline {
|
|
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
|
|
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<Utc>,
|
|
execution_date: NaiveDate,
|
|
legacy: &BTreeMap<NaiveDate, f64>,
|
|
strategy_exposure: f64,
|
|
) -> Option<f64> {
|
|
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<Utc>) -> Option<i32> {
|
|
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<Option<crate::OrderIntent>, 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<f64, String> {
|
|
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<PositionExposureEvent> = 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::<PositionExposureEvent>(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());
|
|
}
|
|
}
|