修复迟到成交和换股批次的FIFO与持有期

This commit is contained in:
boris
2026-09-14 19:15:01 +08:00
parent b2eaaa0d26
commit e9c9ecbd48
5 changed files with 536 additions and 0 deletions
@@ -23,6 +23,200 @@ fn sample() -> ManualExecutionReplay {
fn reseal(input: &mut ManualExecutionReplay) {
input.content_sha256 = input.content_digest().unwrap();
}
fn delayed_buy_replay() -> ManualExecutionReplay {
let mut input = sample();
let template = input.actions[0].clone();
input.actions.clear();
for (index, side, executed, observed, price, fee) in [
(
0,
OrderSide::Buy,
"2026-09-14T01:30:00Z",
"2026-09-14T01:30:01Z",
"20",
"0.25",
),
(
1,
OrderSide::Buy,
"2026-09-11T06:00:00Z",
"2026-09-14T01:30:02Z",
"10",
"0.75",
),
(
2,
OrderSide::Sell,
"2026-09-14T01:31:00Z",
"2026-09-14T01:31:01Z",
"10",
"0.5",
),
(
3,
OrderSide::Sell,
"2026-09-14T01:32:00Z",
"2026-09-14T01:32:01Z",
"10",
"0.5",
),
] {
let executed: DateTime<Utc> = executed.parse().unwrap();
let observed: DateTime<Utc> = observed.parse().unwrap();
let mut action = template.clone();
action.action_id = format!("action-{index}");
action.audit_event_ids = vec![format!("audit-{index}")];
action.confirmed_at = executed - chrono::Duration::seconds(2);
action.confirmation_observed_at = action.confirmed_at;
let order = &mut action.orders[0];
order.order_id = format!("order-{index}");
order.broker_order_id = Some(format!("broker-{index}"));
order.side = side;
order.order_created_at = executed - chrono::Duration::seconds(1);
order.terminal_observed_at = observed;
let fill = &mut order.fills[0];
fill.trade_id = format!("trade-{index}");
fill.observation_event_id = format!("receipt-{index}");
fill.observation_sequence = index + 1;
fill.fee_observation_event_id = fill.observation_event_id.clone();
fill.fee_observation_sequence = fill.observation_sequence;
fill.trade_date = executed
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
fill.executed_at = executed;
fill.observed_at = observed;
fill.fee_observed_at = observed;
fill.price = price.parse().unwrap();
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
fill.total_fee = fee.parse().unwrap();
input.actions.push(action);
}
reseal(&mut input);
input.validate().unwrap();
input
}
#[test]
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:30:02Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(
applications
.iter()
.map(|row| row.trade_id.as_str())
.collect::<Vec<_>>(),
["trade-0", "trade-1"]
);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
assert_eq!(
position.last_buy_date(),
NaiveDate::from_ymd_opt(2026, 9, 14)
);
assert_eq!(position.quantity, 200);
let calendar = crate::TradingCalendar::new(
[11, 14, 15, 16, 17, 18]
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
.into(),
);
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
has_position: true,
opened_date: position.opened_date(),
last_buy_date: position.last_buy_date(),
last_sell_date: None,
};
let mut policy = crate::holding_policy::AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
policy.buy_protection_days = 3;
for day in [14, 15, 16, 17] {
let permission = policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
&evidence,
&calendar,
)
.unwrap();
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
assert!(!permission.max_holding_exit);
}
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
}
#[test]
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:31:01Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(applications.len(), 3);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.quantity, 100);
assert_eq!(position.unrealized_pnl(), -1000.25);
assert_eq!(
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
0
);
assert_eq!(position.realized_pnl(), -0.75);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.external_cash_flow_total(), 0.);
assert!(
cursor
.advance(
"2026-09-14T01:32:01Z".parse().unwrap(),
&mut portfolio,
&data,
false
)
.unwrap_err()
.contains("T+1")
);
assert_eq!(cursor.applied_count(), 3);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
}
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
let mut input = input.clone();
reseal(&mut input);