修复迟到成交和换股批次的FIFO与持有期
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user