Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c70a9273b | |||
| 695fdee4b8 | |||
| 4c6147e2ee | |||
| 818552bc96 | |||
| 534ab42906 | |||
| ba4b77fd74 | |||
| ad76bdb6ae | |||
| 59a0c95aae | |||
| d4e7cdd5b5 | |||
| 984f9d308d | |||
| fbc4233dcd | |||
| 05f1cbbe00 | |||
| ef9cc39882 | |||
| b4c68be29b | |||
| e9c9ecbd48 | |||
| b2eaaa0d26 | |||
| 13c89e8d59 | |||
| 232e9ae154 | |||
| f8955bfb18 | |||
| fb8192a286 | |||
| 7f0c6a008a | |||
| 93de28d369 | |||
| 665653c3fe | |||
| a29c434be9 | |||
| 4c96d0c31f | |||
| 5e11f3da22 | |||
| 8e7ae69b0b | |||
| 81acc54228 | |||
| 600808b171 | |||
| d2f1b64af1 | |||
| 9a54156df9 | |||
| 237ee15a51 | |||
| 996b909608 | |||
| c62ae1206f | |||
| 3a3091a2cf | |||
| d2aa16a2f0 | |||
| 0576cf9b6d | |||
| 636e0dfd05 | |||
| c98bcc3eb2 | |||
| 53af3a6a85 | |||
| 70c6f7e90b | |||
| 0ed6752a73 | |||
| 3e8cc63b9a | |||
| be171683c9 | |||
| 0a6fab9038 | |||
| e8abf43cd4 | |||
| 2286bfa757 | |||
| 93809eea1b | |||
| f7f0ff2951 | |||
| effa0c6456 | |||
| b1ca2dfada | |||
| d15abc18ae | |||
| 9370dfe6e9 | |||
| b19108558f | |||
| fe05384f80 | |||
| 0ff90c4329 | |||
| c85daae608 | |||
| f73513e2d4 | |||
| 4e953b6e98 | |||
| b232847e40 | |||
| e3b3929578 | |||
| 20e73d567b | |||
| cf4498668b | |||
| 3f39943ee4 | |||
| 5c65e65c6f | |||
| f3c70ea566 | |||
| 07b7b181b6 | |||
| fe7243bbc3 | |||
| 875e31f71f | |||
| 61bd14d001 |
+771
-121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,778 @@
|
||||
use super::*;
|
||||
|
||||
fn time(minute: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
|
||||
}
|
||||
|
||||
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
|
||||
data_with_snapshot(quotes, limit_test_snapshot())
|
||||
}
|
||||
|
||||
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()],
|
||||
vec![snapshot],
|
||||
vec![],
|
||||
vec![limit_test_candidate(true, true)],
|
||||
vec![limit_test_benchmark()],
|
||||
vec![],
|
||||
quotes
|
||||
.iter()
|
||||
.map(|&(minute, price, volume)| {
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.timestamp = quote.date.and_time(time(minute));
|
||||
quote.volume_delta = u64::from(volume);
|
||||
quote.amount_delta = price * f64::from(volume);
|
||||
quote.bid1_volume = u64::from(volume / 100);
|
||||
quote.ask1_volume = u64::from(volume / 100);
|
||||
quote
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0003)
|
||||
.with_minimum_commission(5.),
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time(0))
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_strict_value_budget(true)
|
||||
}
|
||||
|
||||
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::AlgoValue {
|
||||
symbol: "000001.SZ".into(),
|
||||
value,
|
||||
style,
|
||||
start_time: Some(time(0)),
|
||||
end_time: Some(time(10)),
|
||||
reason: "clock-algorithm".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
Some(time(minute)),
|
||||
Some(time(minute)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10.1, 4_000),
|
||||
(5, 10.2, 4_000),
|
||||
(10, 10.3, 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 5, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
let canonical = |rows: &[crate::events::FillEvent]| {
|
||||
rows.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.commission.to_bits(),
|
||||
fill.stamp_tax.to_bits(),
|
||||
fill.transfer_fee.to_bits(),
|
||||
fill.execution_timestamp,
|
||||
fill.order_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
|
||||
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
|
||||
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(5, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
|
||||
let partial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
partial
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
let working = broker.open_order_views();
|
||||
assert_eq!(working[0].order_id, 1);
|
||||
assert_eq!(working[0].filled_quantity, 100);
|
||||
assert_eq!(
|
||||
working[0].reserved_cash,
|
||||
Some(10_000. + partial.fill_events[0].net_cash_flow)
|
||||
);
|
||||
let cancel = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
3,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "explicit-user-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
broker.next_day_order_expiry(limit_test_snapshot().date),
|
||||
Some(time(10))
|
||||
);
|
||||
let terminal = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(terminal.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
terminal.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(
|
||||
terminal
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.detail.contains("Expired")),
|
||||
"{:?}",
|
||||
terminal.process_events
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(1, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(11_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let other = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
1,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 1_000,
|
||||
reason: "separate-buy".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
other.fill_events.is_empty(),
|
||||
"cash reserved for order 1 was spent: {:?}",
|
||||
other.fill_events
|
||||
);
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.order_id == Some(1))
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
|
||||
assert!(account.cash() >= 1_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
|
||||
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
|
||||
let mut changed = limit_test_snapshot();
|
||||
changed.close = 100.;
|
||||
changed.last_price = 100.;
|
||||
let run = |data: DataSet| {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let initial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert!(initial.fill_events.is_empty());
|
||||
let quantity = broker.open_order_views()[0].requested_quantity;
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
(
|
||||
quantity,
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.net_cash_flow.to_bits(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
run(data("es)),
|
||||
run(data_with_snapshot("es, changed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
|
||||
let data = data(&[
|
||||
(0, 10., 400),
|
||||
(2, 10., 800),
|
||||
(5, 10., 1_200),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let empty = StrategyDecision::default();
|
||||
let mut filled = 0;
|
||||
let mut commission = 0.;
|
||||
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
filled += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>();
|
||||
commission += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>();
|
||||
assert_eq!(filled, expected);
|
||||
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
|
||||
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
|
||||
}
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(
|
||||
commission,
|
||||
reference
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
|
||||
let broker = broker().with_matching_type(MatchingType::Vwap);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 900,
|
||||
reason: "configured-vwap".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"{:?}",
|
||||
first.order_events
|
||||
);
|
||||
let second = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(second.fill_events[0].quantity, 100);
|
||||
assert_eq!(second.fill_events[0].order_id, Some(1));
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(final_batch.fill_events[0].quantity, 700);
|
||||
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
|
||||
let date = limit_test_snapshot().date;
|
||||
for acquired_today in [false, true] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
if acquired_today {
|
||||
date
|
||||
} else {
|
||||
date.pred_opt().unwrap()
|
||||
},
|
||||
1_000,
|
||||
10.,
|
||||
);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
assert_eq!(
|
||||
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
|
||||
if acquired_today { 0 } else { 1_000 }
|
||||
);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
if !acquired_today {
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
|
||||
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
|
||||
}
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
|
||||
for tif in [
|
||||
OrderTimeInForce::Ioc,
|
||||
OrderTimeInForce::Fok,
|
||||
OrderTimeInForce::Day,
|
||||
OrderTimeInForce::Gtc,
|
||||
] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
if !decision.order_intents[0].supports_time_in_force(tif) {
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let error = broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&decision,
|
||||
Some(time(0)),
|
||||
Some(time(0)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not supported for this order intent")
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
continue;
|
||||
}
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let first = step(&broker, &mut account, &data, 0, &decision);
|
||||
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
|
||||
assert_eq!(
|
||||
!broker.open_order_views().is_empty(),
|
||||
persists,
|
||||
"{tif:?}: {:?}",
|
||||
first.order_events
|
||||
);
|
||||
if !persists {
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
|
||||
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(15_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
decision
|
||||
.order_intents
|
||||
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
|
||||
step(&broker, &mut account, &data, 0, &decision);
|
||||
assert_eq!(
|
||||
broker
|
||||
.open_order_views()
|
||||
.iter()
|
||||
.map(|order| order.reserved_cash.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10_000., 5_000.]
|
||||
);
|
||||
let report = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| (fill.order_id, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(1), 900), (Some(2), 500)]
|
||||
);
|
||||
assert!(account.cash() >= 0.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
|
||||
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
|
||||
let broker = broker()
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
limit_price: 9.5,
|
||||
reason: "resting".into(),
|
||||
}
|
||||
.with_time_in_force(OrderTimeInForce::Gtc),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
let modify = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::ModifyOrder {
|
||||
order_id: 1,
|
||||
new_total_quantity: Some(200),
|
||||
new_limit_price: Some(9.3),
|
||||
reason: "pre-open-amend".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(modify.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views()[0].limit_price, 9.3);
|
||||
assert_eq!(broker.open_order_views()[0].requested_quantity, 200);
|
||||
let cancel = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "pre-open-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() {
|
||||
let data = data(&[(0, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let submit = StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "normal-order".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&submit,
|
||||
Some(time(0))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert_eq!(
|
||||
step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity,
|
||||
100
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
// Kept inside broker::tests to inspect internal accepted-order identity as
|
||||
// well as the public report. These are simulator states, never GT requests.
|
||||
fn recovery_test_data(missing_previous: Option<usize>, intraday: bool) -> DataSet {
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let mut instruments = Vec::new();
|
||||
let mut rows = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut quotes = Vec::new();
|
||||
for index in 1..=2 {
|
||||
let symbol = format!("{index:06}.SZ");
|
||||
let mut instrument = limit_test_instrument();
|
||||
instrument.symbol = symbol.clone();
|
||||
instruments.push(instrument);
|
||||
for day in [previous, date] {
|
||||
if day == previous && missing_previous == Some(index) {
|
||||
continue;
|
||||
}
|
||||
let mut row = dated_limit_test_snapshot(day);
|
||||
row.symbol = symbol.clone().into();
|
||||
rows.push(row);
|
||||
let mut candidate = dated_limit_test_candidate(day, false, false, true, true);
|
||||
candidate.symbol = symbol.clone().into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
if intraday {
|
||||
let mut quote = limit_test_quote(10., 10., 10.);
|
||||
quote.symbol = symbol;
|
||||
quote.date = date;
|
||||
quote.timestamp = date.and_hms_opt(9, 33, 0).unwrap();
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
instruments,
|
||||
rows,
|
||||
vec![],
|
||||
candidates,
|
||||
vec![
|
||||
dated_limit_test_benchmark(previous),
|
||||
dated_limit_test_benchmark(date),
|
||||
],
|
||||
vec![],
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn recovery_test_broker(
|
||||
intraday: bool,
|
||||
first_side: OrderSide,
|
||||
) -> (
|
||||
BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
PortfolioState,
|
||||
) {
|
||||
let mut broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(if intraday {
|
||||
MatchingType::MinuteLast
|
||||
} else {
|
||||
MatchingType::CurrentBarClose
|
||||
})
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false)
|
||||
.with_slippage_model(SlippageModel::HistoricalVolumeVolatility(
|
||||
super::DynamicSlippageConfig::new(0., 0., 0.1),
|
||||
));
|
||||
if intraday {
|
||||
broker =
|
||||
broker.with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 33, 0).unwrap());
|
||||
}
|
||||
let mut first = test_open_order(1);
|
||||
first.filled_quantity = 100;
|
||||
first.remaining_quantity = 100;
|
||||
first.commission_remaining = Some(0.);
|
||||
first.side = first_side;
|
||||
let mut second = test_open_order(2);
|
||||
second.symbol = "000002.SZ".into();
|
||||
broker.upsert_open_order(first);
|
||||
broker.upsert_open_order(second);
|
||||
broker.next_order_id.set(3);
|
||||
let mut account = PortfolioState::new(9000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
if first_side == OrderSide::Buy {
|
||||
100
|
||||
} else {
|
||||
200
|
||||
},
|
||||
10.,
|
||||
);
|
||||
account.begin_trading_day();
|
||||
(broker, account)
|
||||
}
|
||||
|
||||
fn recovery_report_value(report: &BrokerExecutionReport) -> serde_json::Value {
|
||||
serde_json::json!({"orders":report.order_events,"fills":report.fill_events,
|
||||
"positions":report.position_events,"accounts":report.account_events,
|
||||
"events":report.process_events,"diagnostics":report.diagnostics})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_resting_order_batch_keeps_accepted_orders_and_unpublished_financial_state() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
for intraday in [false, true] {
|
||||
for first_side in [OrderSide::Buy, OrderSide::Sell] {
|
||||
for missing in [1, 2] {
|
||||
let (broker, mut account) = recovery_test_broker(intraday, first_side);
|
||||
let orders = format!("{:?}", broker.open_orders.borrow());
|
||||
let ledger = account.financial_replay_identity();
|
||||
let error = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(Some(missing), intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("historical_slippage_calibration_missing")
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{:?}", broker.open_orders.borrow()),
|
||||
orders,
|
||||
"intraday={intraday} first={first_side:?} missing={missing}"
|
||||
);
|
||||
assert_eq!(account.financial_replay_identity(), ledger);
|
||||
assert!(broker.same_day_sold_symbols.borrow().is_empty());
|
||||
let recovered = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(None, intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let (clean, mut clean_account) = recovery_test_broker(intraday, first_side);
|
||||
let reference = clean
|
||||
.execute(
|
||||
date,
|
||||
&mut clean_account,
|
||||
&recovery_test_data(None, intraday),
|
||||
&StrategyDecision::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
recovery_report_value(&recovered),
|
||||
recovery_report_value(&reference)
|
||||
);
|
||||
assert_eq!(
|
||||
account.financial_replay_identity(),
|
||||
clean_account.financial_replay_identity()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert_eq!(recovered.fill_events.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_new_batch_does_not_erase_prior_success_or_double_charge_on_retry() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let (broker, mut account) = recovery_test_broker(true, OrderSide::Buy);
|
||||
let good = recovery_test_data(None, true);
|
||||
let prior = broker
|
||||
.execute(date, &mut account, &good, &StrategyDecision::default())
|
||||
.unwrap();
|
||||
assert_eq!(prior.fill_events.len(), 2);
|
||||
let initial = account.financial_replay_identity();
|
||||
let id = broker.next_order_id.get();
|
||||
let decision = StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "next-batch-a".into(),
|
||||
},
|
||||
OrderIntent::Shares {
|
||||
symbol: "000002.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "next-batch-b".into(),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
// A later quote lets this batch execute independently of the prior fills.
|
||||
let mut parts = good.snapshot_components();
|
||||
for quote in &mut parts.execution_quotes {
|
||||
quote.timestamp += chrono::Duration::minutes(1);
|
||||
}
|
||||
broker
|
||||
.runtime_execution_clock
|
||||
.set(Some(NaiveTime::from_hms_opt(9, 34, 0).unwrap()));
|
||||
let restored = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments.clone(),
|
||||
parts.market.clone(),
|
||||
parts.factors.clone(),
|
||||
parts.candidates.clone(),
|
||||
parts.benchmarks.clone(),
|
||||
vec![],
|
||||
parts.execution_quotes.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
parts
|
||||
.market
|
||||
.retain(|row| !(row.symbol.as_str() == "000002.SZ" && row.date < date));
|
||||
let broken = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
vec![],
|
||||
parts.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
broker
|
||||
.execute(date, &mut account, &broken, &decision)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.financial_replay_identity(), initial);
|
||||
assert_eq!(broker.next_order_id.get(), id);
|
||||
assert!(broker.open_orders.borrow().is_empty());
|
||||
let result = broker
|
||||
.execute(date, &mut account, &restored, &decision)
|
||||
.unwrap();
|
||||
assert_eq!(result.fill_events.len(), 2);
|
||||
assert_eq!(result.fill_events[0].order_id, Some(id));
|
||||
assert_eq!(result.fill_events[1].order_id, Some(id + 1));
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 300);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 300);
|
||||
assert_eq!(
|
||||
prior.fill_events.len(),
|
||||
2,
|
||||
"previously returned report remains intact"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwinding_an_unpublished_simulator_transaction_restores_its_state() {
|
||||
let (broker, mut account) = recovery_test_broker(false, OrderSide::Sell);
|
||||
let initial = account.financial_replay_identity();
|
||||
let orders = format!("{:?}", broker.open_orders.borrow());
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = broker.execution_transaction(&mut account, true, None, |account| {
|
||||
account.apply_cash_delta(500.).unwrap();
|
||||
broker.open_orders.borrow_mut().clear();
|
||||
panic!("isolated simulator callback unwind");
|
||||
});
|
||||
}));
|
||||
assert!(result.is_err());
|
||||
assert_eq!(account.financial_replay_identity(), initial);
|
||||
assert_eq!(format!("{:?}", broker.open_orders.borrow()), orders);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_batch_failure_keeps_both_targets_and_prior_generation_progress() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut parts = recovery_test_data(None, false).snapshot_components();
|
||||
for instrument in &mut parts.instruments {
|
||||
instrument.board = "ETF".into();
|
||||
}
|
||||
let good = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments.clone(),
|
||||
parts.market.clone(),
|
||||
parts.factors.clone(),
|
||||
parts.candidates.clone(),
|
||||
parts.benchmarks.clone(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
parts
|
||||
.market
|
||||
.retain(|row| !(row.date == date && row.symbol.as_str() == "000002.SZ"));
|
||||
let bad = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
.unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let members = std::sync::Arc::new(
|
||||
(1..=2)
|
||||
.map(|index| crate::stock_pool_execution::StockPoolMemberSpec {
|
||||
symbol: format!("{index:06}.SZ"),
|
||||
requested_order: index,
|
||||
recommendation_reason: String::new(),
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
broker
|
||||
.deferred_etf_targets
|
||||
.borrow_mut()
|
||||
.replace_generation("pool", "latest");
|
||||
for index in 1..=2 {
|
||||
broker
|
||||
.deferred_etf_targets
|
||||
.borrow_mut()
|
||||
.upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(),
|
||||
generation: "latest".into(),
|
||||
symbol: format!("{index:06}.SZ"),
|
||||
signal_date: previous,
|
||||
signal_at: previous.and_hms_opt(13, 0, 0).unwrap(),
|
||||
execute_on: Some(date),
|
||||
target_value: 1000.into(),
|
||||
target_weight_bps: 5000,
|
||||
side: crate::stock_pool_execution::OrderSide::Buy,
|
||||
max_positions: 2,
|
||||
rule: Default::default(),
|
||||
members: std::sync::Arc::clone(&members),
|
||||
reason: "deferred recovery fixture".into(),
|
||||
});
|
||||
}
|
||||
let queue = format!("{:?}", broker.deferred_etf_targets.borrow());
|
||||
let mut account = PortfolioState::new(10000.);
|
||||
let state = account.stock_pool_execution_state("pool");
|
||||
assert!(
|
||||
broker
|
||||
.execute_deferred_etf_targets(date, &mut account, &bad)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10000.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(account.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(format!("{:?}", broker.deferred_etf_targets.borrow()), queue);
|
||||
assert_eq!(broker.next_order_id.get(), 1);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
let result = broker
|
||||
.execute_deferred_etf_targets(date, &mut account, &good)
|
||||
.unwrap();
|
||||
assert_eq!(result.fill_events.len(), 2, "{result:?}");
|
||||
assert_eq!(broker.pending_etf_target_count(), 0);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_callback_unwind_does_not_leak_order_context_or_authoritative_prior_state() {
|
||||
struct PanicRules;
|
||||
impl crate::rules::EquityRuleHooks for PanicRules {
|
||||
fn can_buy(
|
||||
&self,
|
||||
_: chrono::NaiveDate,
|
||||
_: &DailyMarketSnapshot,
|
||||
_: &CandidateEligibility,
|
||||
_: PriceField,
|
||||
) -> crate::rules::RuleCheck {
|
||||
panic!("isolated rule callback panic")
|
||||
}
|
||||
fn can_sell(
|
||||
&self,
|
||||
_: chrono::NaiveDate,
|
||||
_: &DailyMarketSnapshot,
|
||||
_: &CandidateEligibility,
|
||||
_: &crate::portfolio::Position,
|
||||
_: PriceField,
|
||||
) -> crate::rules::RuleCheck {
|
||||
unreachable!()
|
||||
}
|
||||
}
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let prior = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), PanicRules)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
broker.runtime_decision_date.set(Some(prior));
|
||||
broker
|
||||
.runtime_buy_denials
|
||||
.borrow_mut()
|
||||
.insert("unrelated".into(), "prior".into());
|
||||
let mut account = PortfolioState::new(10000.);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: BTreeMap::from([("another".into(), "temporary".into())]),
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
limit_price: 10.,
|
||||
reason: "panic fixture".into(),
|
||||
}
|
||||
.with_time_in_force(OrderTimeInForce::Gtc),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _ = broker.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&recovery_test_data(None, false),
|
||||
&decision,
|
||||
);
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10000.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert_eq!(broker.runtime_decision_date.get(), Some(prior));
|
||||
assert_eq!(
|
||||
*broker.runtime_buy_denials.borrow(),
|
||||
BTreeMap::from([("unrelated".into(), "prior".into())])
|
||||
);
|
||||
assert_eq!(broker.runtime_time_in_force.get(), None);
|
||||
assert_eq!(broker.runtime_target_position_limit.get(), None);
|
||||
assert_eq!(broker.execution_transaction_depth.get(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simulator_transaction_profile_preserves_successful_output() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let previous = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut instruments = Vec::new();
|
||||
let mut market = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for index in 1..=30 {
|
||||
let symbol = format!("{index:06}.SZ");
|
||||
let mut instrument = limit_test_instrument();
|
||||
instrument.symbol = symbol.clone();
|
||||
instruments.push(instrument);
|
||||
for day in [previous, date] {
|
||||
let mut row = dated_limit_test_snapshot(day);
|
||||
row.symbol = symbol.clone().into();
|
||||
market.push(row);
|
||||
let mut row = dated_limit_test_candidate(day, false, false, true, true);
|
||||
row.symbol = symbol.clone().into();
|
||||
candidates.push(row);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components(
|
||||
instruments,
|
||||
market,
|
||||
vec![],
|
||||
candidates,
|
||||
vec![
|
||||
dated_limit_test_benchmark(previous),
|
||||
dated_limit_test_benchmark(date),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let mut reference = None;
|
||||
let mut samples = Vec::new();
|
||||
for protected in [false, true, true, false] {
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
// Private comparison only: no runtime option can disable protection.
|
||||
if !protected {
|
||||
broker.execution_transaction_depth.set(1);
|
||||
}
|
||||
let mut account = PortfolioState::new(10_000_000.);
|
||||
for index in 1..=30 {
|
||||
for _ in 0..20 {
|
||||
account
|
||||
.position_mut(&format!("{index:06}.SZ"))
|
||||
.buy(previous, 100, 10.);
|
||||
}
|
||||
}
|
||||
account.begin_trading_day();
|
||||
let mut orders = Vec::new();
|
||||
let mut fills = Vec::new();
|
||||
let start = std::time::Instant::now();
|
||||
for index in 0..500 {
|
||||
let report = broker
|
||||
.execute(
|
||||
date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: format!("{:06}.SZ", index % 30 + 1),
|
||||
quantity: 100,
|
||||
reason: "transaction profile".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
orders.extend(report.order_events);
|
||||
fills.extend(report.fill_events);
|
||||
}
|
||||
samples.push(
|
||||
serde_json::json!({"protected":protected,"microseconds":start.elapsed().as_micros()}),
|
||||
);
|
||||
assert_eq!(fills.len(), 500);
|
||||
let outcome = serde_json::json!({"orders":orders,"fills":fills,"ledger":account.financial_replay_identity()});
|
||||
if let Some(reference) = &reference {
|
||||
assert_eq!(&outcome, reference);
|
||||
} else {
|
||||
reference = Some(outcome);
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"simulator_transaction_profile={}",
|
||||
serde_json::json!({"securities":30,"initial_lots_per_security":20,"calls":500,"samples":samples,
|
||||
"scope":"isolated broker only; not Source or full backtest throughput"})
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,35 @@ use super::*;
|
||||
use crate::holding_policy::HoldingLifecycleEvidence;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
use chrono::Timelike;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct DeferredStockPoolExecution {
|
||||
date: NaiveDate,
|
||||
contract: Box<pool::FrozenStockPoolIntent>,
|
||||
buy_only: bool,
|
||||
symbols: BTreeSet<String>,
|
||||
initial_holdings: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R> {
|
||||
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
|
||||
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
|
||||
!self.deferred_stock_pools.borrow().is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
|
||||
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
|
||||
if pending.date <= date {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
||||
if !value.is_finite() {
|
||||
@@ -40,15 +69,141 @@ fn pool_positions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod successor_protection_tests {
|
||||
use super::*;
|
||||
use crate::holding_policy::{AutomaticTradeLock, AutomaticTradeProtection};
|
||||
fn day(n: u32) -> NaiveDate { NaiveDate::from_ymd_opt(2026, 9, n).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_rechecks_inherited_locks_and_cooldown_before_any_order() {
|
||||
let old = "159915.SZ";
|
||||
let new = "159999.SZ";
|
||||
let data = DataSet::from_components(
|
||||
[old, new].into_iter().map(|symbol| crate::Instrument {
|
||||
symbol: symbol.into(), name: "isolated ETF fixture".into(), board: "ETF".into(), round_lot: 100,
|
||||
listed_at: Some(day(1)), delisted_at: None, status: "active".into(),
|
||||
}).collect(), vec![crate::DailyMarketSnapshot {
|
||||
date: day(15), symbol: new.into(), timestamp: None, day_open: 5., open: 5., high: 5., low: 5.,
|
||||
close: 5., last_price: 5., bid1: 5., ask1: 5., prev_close: 5., volume: 100000,
|
||||
minute_volume: 0, bid1_volume: 100000, ask1_volume: 100000, trading_phase: None,
|
||||
paused: false, upper_limit: 5.5, lower_limit: 4.5, price_tick: 0.001,
|
||||
}], vec![], vec![crate::CandidateEligibility {
|
||||
date: day(15), symbol: new.into(), is_st: false, is_star_st: false, is_new_listing: false,
|
||||
is_paused: false, allow_buy: true, allow_sell: true, is_kcb: false, is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}], [11,14,15].into_iter().map(|n| crate::BenchmarkSnapshot {
|
||||
date: day(n), benchmark: "000300.SH".into(), open: 100., close: 100., prev_close: 100., volume: 10000,
|
||||
}).collect()).unwrap();
|
||||
for mode in ["lock", "cooldown", "expired"] {
|
||||
let broker = BrokerSimulator::new(crate::ChinaAShareCostModel::default(), crate::ChinaEquityRuleHooks)
|
||||
.with_volume_limit(false).with_liquidity_limit(false);
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
portfolio.position_mut(old).buy(day(11), 200, 10.);
|
||||
portfolio.position_mut(old).sell(100, 10.).unwrap();
|
||||
broker.mark_same_day_sold(day(11), old);
|
||||
portfolio.apply_successor_conversion(old, new, 2., 0.).unwrap();
|
||||
let policy = AutomaticTradeProtection {
|
||||
sell_cooldown_days: if mode == "cooldown" { 3 } else { 0 },
|
||||
locks: if mode != "cooldown" { vec![AutomaticTradeLock {
|
||||
symbol: old.into(), start_date: day(11), end_date: Some(day(if mode == "expired" {14} else {15})),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let rule = pool::StockPoolExecutionRule { automatic_trade_protection: policy, ..Default::default() };
|
||||
broker.deferred_etf_targets.borrow_mut().replace_generation("pool", "latest");
|
||||
broker.deferred_etf_targets.borrow_mut().upsert(crate::etf_execution::DeferredEtfTarget {
|
||||
pool_id: "pool".into(), generation: "latest".into(), symbol: new.into(),
|
||||
signal_date: day(14), signal_at: day(14).and_hms_opt(13,0,0).unwrap(), execute_on: Some(day(15)),
|
||||
target_value: 5000.into(), target_weight_bps: 10000, side: pool::OrderSide::Buy, max_positions: 1,
|
||||
rule: std::sync::Arc::new(rule), members: std::sync::Arc::new(vec![pool::StockPoolMemberSpec {
|
||||
symbol: new.into(), requested_order: 0, recommendation_reason: String::new(),
|
||||
target_weight_bps: None, stop_loss: None, take_profit: None,
|
||||
}]), reason: "isolated deferred ETF target".into(),
|
||||
});
|
||||
let report = broker.execute_deferred_etf_targets(day(15), &mut portfolio, &data).unwrap();
|
||||
if mode == "expired" {
|
||||
assert_eq!(report.fill_events.len(), 1, "{report:?}");
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 1000);
|
||||
} else {
|
||||
assert!(report.order_events.is_empty(), "{mode}: {report:?}");
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert_eq!(portfolio.position(new).unwrap().quantity, 200);
|
||||
assert!(report.diagnostics.iter().any(|text| text.contains(if mode == "lock" {"automatic_trade_locked"} else {"sell_fill_cooldown"})));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fn pool_automatic_permission(&self, symbol: &str, date: NaiveDate,
|
||||
policy: &crate::holding_policy::AutomaticTradeProtection,
|
||||
portfolio: &PortfolioState, data: &DataSet)
|
||||
-> Result<crate::holding_policy::AutomaticTradePermission, BacktestError> {
|
||||
let position = portfolio.position(symbol).filter(|position| position.quantity > 0);
|
||||
let sold = self.same_day_sold_symbols.borrow().iter().rev()
|
||||
.find(|(day, symbols)| **day <= date && (symbols.contains(symbol)
|
||||
|| portfolio.corporate_predecessors(symbol).any(|previous| symbols.contains(previous))))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: position.and_then(|position| position.last_buy_date()), last_sell_date: sold,
|
||||
};
|
||||
policy.evaluate_with_predecessors(symbol, date, &evidence, data.calendar(),
|
||||
portfolio.corporate_predecessors(symbol)).map_err(BacktestError::Execution)
|
||||
}
|
||||
|
||||
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
|
||||
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
|
||||
let mut expired = Vec::new();
|
||||
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
|
||||
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
|
||||
}
|
||||
for id in expired {
|
||||
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
}
|
||||
}
|
||||
if self.has_open_orders() || clock.is_none() { return Ok(()); }
|
||||
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
|
||||
for (id, pending) in pending {
|
||||
let now = clock.expect("clock checked above");
|
||||
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
|
||||
self.deferred_stock_pools.borrow_mut().insert(id, pending);
|
||||
continue;
|
||||
}
|
||||
let prior_followup = self.runtime_stock_pool_followup.replace(true);
|
||||
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
|
||||
let prior_created = self.runtime_order_created_date.replace(Some(date));
|
||||
let order_start = report.order_events.len();
|
||||
let fill_start = report.fill_events.len();
|
||||
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
|
||||
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
|
||||
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
|
||||
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
|
||||
self.runtime_stock_pool_followup.set(prior_followup);
|
||||
self.runtime_decision_date.set(prior_decision);
|
||||
self.runtime_order_created_date.set(prior_created);
|
||||
result?;
|
||||
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pool_quote_inputs(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
symbols: &BTreeSet<String>,
|
||||
execution_clock: Option<NaiveDateTime>,
|
||||
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
|
||||
symbols
|
||||
cumulative_conditions: bool,
|
||||
) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
|
||||
let mut unavailable = Vec::new();
|
||||
let quotes = symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let snapshot = data.market(date, symbol).ok_or_else(|| {
|
||||
@@ -100,7 +255,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -112,7 +267,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Sell,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -134,11 +289,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let totals = if cumulative_conditions {
|
||||
match data.execution_session_totals(symbol, clock) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
(
|
||||
quote.last_price,
|
||||
snapshot.prev_close,
|
||||
Some(quote.volume_delta as f64),
|
||||
Some(quote.amount_delta),
|
||||
totals.map(|total| total.0),
|
||||
totals.map(|total| total.1),
|
||||
Some(quote.bid1),
|
||||
Some(quote.ask1),
|
||||
buy,
|
||||
@@ -153,13 +314,24 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
// A daily open does not reveal the session's volume/turnover.
|
||||
let completed = self.effective_execution_price_field(date) == PriceField::Close;
|
||||
let totals = if cumulative_conditions && !completed {
|
||||
let at = execution_clock.unwrap_or_else(|| date.and_hms_opt(9,30,0).unwrap());
|
||||
match data.execution_session_totals(symbol, at) {
|
||||
Ok(totals) => Some(totals),
|
||||
Err(reason) => { unavailable.push(reason); None }
|
||||
}
|
||||
} else { None };
|
||||
let amount = if completed && cumulative_conditions {
|
||||
data.factor(date, symbol).and_then(|row| row.extra_factors.get("amount")).copied()
|
||||
.map(|value| decimal(value, "amount")).transpose()?
|
||||
} else { totals.map(|total| total.1) };
|
||||
(
|
||||
price,
|
||||
snapshot.prev_close,
|
||||
completed.then_some(snapshot.volume as f64),
|
||||
if completed { Some(Decimal::from(snapshot.volume)) } else { totals.map(|total| total.0) },
|
||||
amount,
|
||||
None,
|
||||
None,
|
||||
Some(price),
|
||||
Some(price),
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
|
||||
)
|
||||
@@ -168,8 +340,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
symbol: symbol.clone(),
|
||||
last_price: decimal(price, "price")?,
|
||||
prev_close: Some(decimal(prev, "prev_close")?),
|
||||
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
|
||||
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
|
||||
volume,
|
||||
turnover: amount,
|
||||
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
|
||||
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
|
||||
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
|
||||
@@ -182,7 +354,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect::<Result<Vec<_>, BacktestError>>()?;
|
||||
Ok((quotes, unavailable))
|
||||
}
|
||||
|
||||
fn pool_etf_fallback_reference(&self, date: NaiveDate, data: &DataSet, symbol: &str, clock: Option<NaiveDateTime>) -> Result<Option<crate::etf_execution::EtfFallbackReference>, BacktestError> {
|
||||
@@ -206,6 +379,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
|
||||
execution_cursors, global_execution_cursor, commission_state, report, false, None)
|
||||
}
|
||||
|
||||
fn process_stock_pool_contract_phase(
|
||||
&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>, report: &mut BrokerExecutionReport, buy_only: bool,
|
||||
initial_holdings: Option<&BTreeSet<String>>,
|
||||
) -> Result<(), BacktestError> {
|
||||
if contract.signal_date > date
|
||||
|| contract.frozen_equity < Decimal::ZERO
|
||||
@@ -246,6 +430,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
scope.extend(portfolio.positions().keys().cloned());
|
||||
let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
|
||||
let official_dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
let initial_positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio
|
||||
@@ -264,6 +449,9 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||
if self.has_open_orders() {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
|
||||
});
|
||||
report
|
||||
.diagnostics
|
||||
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
|
||||
@@ -309,15 +497,19 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.push("paused".into());
|
||||
}
|
||||
}
|
||||
let before_positions = portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
// All delayed symbols in a generation share immutable configuration.
|
||||
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||
let mut deferred_configuration = None;
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
if buy_only && side == pool::OrderSide::Sell { continue; }
|
||||
if side == pool::OrderSide::Buy && self.has_open_orders()
|
||||
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
|
||||
});
|
||||
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
|
||||
break;
|
||||
}
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_scope {
|
||||
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||
@@ -328,8 +520,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fallback_references.insert(symbol.clone(), reference);
|
||||
}
|
||||
}
|
||||
let quotes =
|
||||
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
||||
let (quotes, unavailable) = self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor,
|
||||
crate::stock_pool_quote_facts::requires_session_totals(&contract.rule))?;
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let execution_state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
@@ -353,25 +545,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
|
||||
let sold = self
|
||||
.same_day_sold_symbols
|
||||
.borrow()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(),
|
||||
opened_date: position.and_then(|p| p.opened_date()),
|
||||
last_buy_date: position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date: sold,
|
||||
};
|
||||
let permission = contract
|
||||
.rule
|
||||
.automatic_trade_protection
|
||||
.evaluate(symbol, date, &evidence, data.calendar())
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(symbol, date,
|
||||
&contract.rule.automatic_trade_protection, portfolio, data)?;
|
||||
constraints
|
||||
.automatic_permissions
|
||||
.insert(symbol.clone(), permission);
|
||||
@@ -448,7 +623,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
Decimal::ZERO,
|
||||
Some(&fee),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
.map_err(|error| BacktestError::Execution(if !unavailable.is_empty()
|
||||
&& (error.contains("requires volume") || error.contains("requires amount")) {
|
||||
format!("{error}; {}", unavailable.join("; "))
|
||||
} else { error }))?;
|
||||
report.diagnostics.extend(unavailable.into_iter().map(|reason| format!("stock_pool_quote_fact_unavailable {reason}")));
|
||||
let mut updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
@@ -572,6 +751,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
/// Called at the opening clock, after settlement/corporate actions and
|
||||
/// auction callbacks. It never sends a stock order or replays a strategy.
|
||||
pub(crate) fn execute_deferred_etf_targets(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
self.execution_transaction(portfolio, !self.has_open_orders() && self.pending_etf_target_count() > 0, None,
|
||||
|portfolio| self.execute_deferred_etf_targets_inner(date, portfolio, data))
|
||||
}
|
||||
|
||||
fn execute_deferred_etf_targets_inner(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
if self.has_open_orders() {
|
||||
if self.pending_etf_target_count() > 0 {
|
||||
@@ -594,10 +778,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
}
|
||||
let position = portfolio.position(&target.symbol).filter(|p| p.quantity > 0);
|
||||
let before_quantity = position.map_or(0, |p| p.quantity);
|
||||
let permission = target.rule.automatic_trade_protection.evaluate(&target.symbol, date, &HoldingLifecycleEvidence {
|
||||
has_position:position.is_some(), opened_date:position.and_then(|p| p.opened_date()), last_buy_date:position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date:self.same_day_sold_symbols.borrow().iter().rev().find(|(day, symbols)| **day <= date && symbols.contains(&target.symbol)).map(|(day, _)| *day),
|
||||
}, data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = self.pool_automatic_permission(&target.symbol, date,
|
||||
&target.rule.automatic_trade_protection, portfolio, data)?;
|
||||
let denial = if target.side == pool::OrderSide::Buy {
|
||||
permission.buy_denial.or(permission.max_holding_exit.then_some("max_holding_exit_pending"))
|
||||
} else { permission.sell_denial };
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
fn pool_batch_data() -> DataSet {
|
||||
pool_batch_data_with(|_| true)
|
||||
}
|
||||
|
||||
fn pool_batch_data_with(change: impl Fn(&mut IntradayExecutionQuote) -> bool) -> DataSet {
|
||||
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
|
||||
let instruments = symbols
|
||||
.iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_instrument()
|
||||
})
|
||||
.collect();
|
||||
let snapshots = symbols
|
||||
.iter()
|
||||
.map(|symbol| DailyMarketSnapshot {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_snapshot()
|
||||
})
|
||||
.collect();
|
||||
let candidates = symbols
|
||||
.iter()
|
||||
.map(|symbol| CandidateEligibility {
|
||||
symbol: (*symbol).into(),
|
||||
..limit_test_candidate(true, true)
|
||||
})
|
||||
.collect();
|
||||
let mut quotes = Vec::new();
|
||||
for minute in [30, 31, 32, 33, 34, 36] {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == "000001.SZ" && minute > 30 {
|
||||
10.5
|
||||
} else {
|
||||
10.0
|
||||
};
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.symbol = symbol.into();
|
||||
quote.timestamp = quote.date.and_hms_opt(9, minute, 0).unwrap();
|
||||
quote.volume_delta = 200;
|
||||
quote.bid1_volume = 200;
|
||||
quote.ask1_volume = 200;
|
||||
quote.amount_delta = price * 200.0;
|
||||
if change(&mut quote) {
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
instruments,
|
||||
snapshots,
|
||||
Vec::new(),
|
||||
candidates,
|
||||
vec![limit_test_benchmark()],
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()])
|
||||
}
|
||||
|
||||
fn pool_batch_decision(symbol: &str, generation: &str, end: &str) -> StrategyDecision {
|
||||
use crate::stock_pool_execution as pool;
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let symbols = vec![symbol.to_owned()];
|
||||
let rule = pool::StockPoolExecutionRule {
|
||||
pricing_mode: pool::POOL_PRICE_FIXED_LIMIT.into(),
|
||||
fixed_prices: [
|
||||
("000001.SZ".into(), rust_decimal::Decimal::new(104, 1)),
|
||||
("000002.SZ".into(), 10.into()),
|
||||
("000003.SZ".into(), 10.into()),
|
||||
]
|
||||
.into(),
|
||||
window_end: end.into(),
|
||||
..Default::default()
|
||||
};
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::StockPool {
|
||||
contract: Box::new(pool::FrozenStockPoolIntent {
|
||||
pool_id: "batch-test".into(),
|
||||
signal_date: signal,
|
||||
frozen_equity: 2000.into(),
|
||||
selection: pool::StockPoolSelection {
|
||||
trade_date: signal,
|
||||
requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(),
|
||||
risk_eligible_symbols: symbols.clone(),
|
||||
final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(),
|
||||
inherited_from_generation: None,
|
||||
explicit_empty: false,
|
||||
generation: Some(generation.into()),
|
||||
},
|
||||
members: vec![pool::StockPoolMemberSpec {
|
||||
symbol: symbol.into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: None,
|
||||
}],
|
||||
rule,
|
||||
constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1),
|
||||
..Default::default()
|
||||
},
|
||||
invest_ratio_bps: 10000,
|
||||
reserve_cash: 0.into(),
|
||||
out_of_pool_policy: "reduce_to_zero_when_sellable".into(),
|
||||
generation: generation.into(),
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_broker(partial: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
let cost = ChinaAShareCostModel::from_trading_constraints(
|
||||
crate::risk_control::TradingConstraintConfig {
|
||||
commission_rate: 0.0,
|
||||
minimum_commission: 0.0,
|
||||
stamp_tax_rate_before_change: 0.0,
|
||||
stamp_tax_rate_after_change: 0.0,
|
||||
transfer_fee_rate: 0.0,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let broker =
|
||||
BrokerSimulator::new_with_execution_price(cost, ChinaEquityRuleHooks, PriceField::Open)
|
||||
.with_matching_type(if partial {
|
||||
MatchingType::MinuteLast
|
||||
} else {
|
||||
MatchingType::NextBarOpen
|
||||
})
|
||||
.with_volume_limit(partial)
|
||||
.with_volume_percent(0.5)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false);
|
||||
if partial {
|
||||
broker
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(9, 30, 0).unwrap())
|
||||
} else {
|
||||
broker
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_batch_account() -> PortfolioState {
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
10.0,
|
||||
);
|
||||
account
|
||||
}
|
||||
|
||||
fn pool_batch_tick(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
account: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, minute, 0).unwrap()));
|
||||
broker.execute(date, account, data, decision).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_sell_continues_buy_after_actual_fill_without_strategy_rerun() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let initial = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "first", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(initial.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
assert_eq!(broker.open_order_views()[0].side, OrderSide::Sell);
|
||||
let done = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
done.fill_events
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000001.SZ" && fill.side == OrderSide::Sell)
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").map(|p| p.quantity),
|
||||
Some(200),
|
||||
"sell proceeds must trigger the retained buy phase: {:?}",
|
||||
done.diagnostics
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
let repeated = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(repeated.order_events.is_empty() && repeated.fill_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_partial_sell_waits_for_the_whole_batch_and_never_reissues_buys() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "partial", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
let first = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
first
|
||||
.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
let second = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let third = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
let ids = second
|
||||
.order_events
|
||||
.iter()
|
||||
.chain(&third.order_events)
|
||||
.filter(|event| event.side == OrderSide::Buy)
|
||||
.filter_map(|event| event.order_id)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert_eq!(
|
||||
ids.len(),
|
||||
1,
|
||||
"one buy intention; partial reports must keep its ID"
|
||||
);
|
||||
assert!(
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
34,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.order_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_sell_does_not_start_buys_after_the_configured_window() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "expired", "09:32"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert!(
|
||||
last.order_events
|
||||
.iter()
|
||||
.all(|event| event.side == OrderSide::Sell)
|
||||
);
|
||||
assert!(
|
||||
last.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_new_signal_supersedes_the_unsubmitted_buy_phase() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(true);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "old", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&pool_batch_decision("000003.SZ", "new", "09:35"),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
32,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
33,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
assert_eq!(account.position("000003.SZ").unwrap().quantity, 200);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_uses_fresh_quotes_and_actual_submission_clock() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| {
|
||||
if quote.symbol == "000002.SZ" {
|
||||
quote.last_price = 10.2;
|
||||
quote.bid1 = 10.2;
|
||||
quote.ask1 = 10.2;
|
||||
quote.amount_delta = 2040.0;
|
||||
}
|
||||
true
|
||||
});
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut decision = pool_batch_decision("000002.SZ", "fresh", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
contract.rule.pricing_mode = crate::stock_pool_execution::POOL_PRICE_FORMULA_LIMIT.into();
|
||||
contract.rule.sell_offset_bps = 400;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
account.position("000002.SZ").unwrap().quantity,
|
||||
100,
|
||||
"2000/10.2 rounds to one 100-share lot, not 200 at stale open"
|
||||
);
|
||||
let fill = result
|
||||
.fill_events
|
||||
.iter()
|
||||
.find(|fill| fill.symbol == "000002.SZ")
|
||||
.unwrap();
|
||||
assert_eq!(fill.price, 10.2);
|
||||
assert_eq!(
|
||||
fill.execution_start_timestamp,
|
||||
Some(date.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
let event = result
|
||||
.order_events
|
||||
.iter()
|
||||
.find(|event| event.side == OrderSide::Buy)
|
||||
.unwrap();
|
||||
assert_eq!(event.decision_date, Some(signal));
|
||||
assert_eq!(event.order_created_date, Some(date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_after_sell_rejects_missing_quote_instead_of_reusing_daily_open() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data_with(|quote| quote.symbol != "000002.SZ");
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "missing", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
broker
|
||||
.runtime_intraday_start_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
broker
|
||||
.runtime_intraday_end_time
|
||||
.set(Some(chrono::NaiveTime::from_hms_opt(9, 31, 0).unwrap()));
|
||||
let error = broker
|
||||
.execute(date, &mut account, &data, &StrategyDecision::default())
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("stock_pool_execution_quote_missing:000002.SZ"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(account.position("000002.SZ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_delayed_take_profit_does_not_rebuy_the_same_generation_exit() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = PortfolioState::new(0.0);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
chrono::NaiveDate::from_ymd_opt(2024, 12, 30).unwrap(),
|
||||
200,
|
||||
9.0,
|
||||
);
|
||||
let mut decision = pool_batch_decision("000002.SZ", "take-profit", "09:35");
|
||||
if let OrderIntent::StockPool { contract } = &mut decision.order_intents[0] {
|
||||
let symbols = vec!["000001.SZ".to_owned(), "000002.SZ".to_owned()];
|
||||
contract.selection.requested_symbols = symbols.clone();
|
||||
contract.selection.normal_trading_symbols = symbols.clone();
|
||||
contract.selection.risk_eligible_symbols = symbols.clone();
|
||||
contract.selection.final_symbols = symbols;
|
||||
contract.constraints.target_holding_count = Some(2);
|
||||
contract.members.insert(
|
||||
0,
|
||||
crate::stock_pool_execution::StockPoolMemberSpec {
|
||||
symbol: "000001.SZ".into(),
|
||||
recommendation_reason: String::new(),
|
||||
requested_order: 0,
|
||||
target_weight_bps: None,
|
||||
stop_loss: None,
|
||||
take_profit: Some(rust_decimal::Decimal::new(5, 2)),
|
||||
},
|
||||
);
|
||||
contract.members[1].requested_order = 1;
|
||||
}
|
||||
broker
|
||||
.execute_with_event_dates(date, signal, signal, &mut account, &data, &decision)
|
||||
.unwrap();
|
||||
let result = pool_batch_tick(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
31,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
);
|
||||
assert_eq!(account.position("000002.SZ").unwrap().quantity, 200);
|
||||
assert!(
|
||||
!result
|
||||
.order_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == "000001.SZ" && event.side == OrderSide::Buy)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_pending_phase_cannot_cross_the_execution_session() {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let signal = chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap();
|
||||
let data = pool_batch_data();
|
||||
let broker = pool_batch_broker(false);
|
||||
let mut account = pool_batch_account();
|
||||
let mut report = broker
|
||||
.execute_with_event_dates(
|
||||
date,
|
||||
signal,
|
||||
signal,
|
||||
&mut account,
|
||||
&data,
|
||||
&pool_batch_decision("000002.SZ", "end", "09:35"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(broker.has_pending_stock_pool_execution());
|
||||
broker.finish_stock_pool_session(date, &mut report);
|
||||
assert!(!broker.has_pending_stock_pool_execution());
|
||||
assert!(
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|event| event.contains("unsubmitted_phase_expired"))
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"session cleanup preserves broker order history and remainder"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stock_pool_engine_drives_the_pending_buy_without_a_minute_strategy_callback() {
|
||||
use crate::{BacktestConfig, BacktestEngine, BacktestError, Strategy, StrategyContext};
|
||||
struct DailyPool;
|
||||
impl Strategy for DailyPool {
|
||||
fn name(&self) -> &str {
|
||||
"daily-pool-batch"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<crate::ScheduleRule> {
|
||||
vec![
|
||||
crate::ScheduleRule::daily("open", crate::ScheduleStage::OnDay)
|
||||
.with_time_rule(crate::ScheduleTimeRule::physical_time(9, 30)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &crate::ScheduleRule,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
if ctx.execution_date.day() == 2 {
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::LimitTargetShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_quantity: 200,
|
||||
limit_price: 10.0,
|
||||
reason: "initial-entry".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
Ok(pool_batch_decision("000002.SZ", "rotation", "09:35"))
|
||||
}
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
_: &StrategyContext<'_>,
|
||||
_: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, BacktestError> {
|
||||
panic!("this daily strategy must not be rerun to continue a pending batch")
|
||||
}
|
||||
}
|
||||
use chrono::Datelike;
|
||||
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let last = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let base = pool_batch_data();
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
let mut benchmarks = Vec::new();
|
||||
let mut quotes = Vec::new();
|
||||
for date in [first, last] {
|
||||
for symbol in ["000001.SZ", "000002.SZ", "000003.SZ"] {
|
||||
let mut row = base.market(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
market.push(row);
|
||||
let mut row = base.candidate(first, symbol).unwrap().clone();
|
||||
row.date = date;
|
||||
candidates.push(row);
|
||||
factors.push(crate::data::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
for original in base.execution_quotes_on(first, symbol) {
|
||||
let mut quote = original.clone();
|
||||
quote.date = date;
|
||||
quote.timestamp = date.and_time(original.timestamp.time());
|
||||
quotes.push(quote);
|
||||
}
|
||||
}
|
||||
let mut row = limit_test_benchmark();
|
||||
row.date = date;
|
||||
benchmarks.push(row);
|
||||
}
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
base.instruments().values().cloned().collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
benchmarks,
|
||||
Vec::new(),
|
||||
quotes,
|
||||
)
|
||||
.unwrap()
|
||||
.with_additional_trading_dates([chrono::NaiveDate::from_ymd_opt(2024, 12, 31).unwrap()]);
|
||||
let config = BacktestConfig {
|
||||
initial_cash: 2000.0,
|
||||
benchmark_code: "000852.SH".into(),
|
||||
start_date: Some(first),
|
||||
end_date: Some(last),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
};
|
||||
let result = BacktestEngine::new(data, DailyPool, pool_batch_broker(false), config)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
result.fills.len(),
|
||||
3,
|
||||
"initial buy, delayed sell, resumed buy: orders={:?} equity={:?}",
|
||||
result.order_events,
|
||||
result.equity_curve
|
||||
);
|
||||
assert_eq!(result.fills[2].symbol, "000002.SZ");
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(
|
||||
result.fills[2].execution_timestamp,
|
||||
Some(last.and_hms_opt(9, 31, 0).unwrap())
|
||||
);
|
||||
assert_eq!(result.holdings_summary.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
use crate::{
|
||||
AccountEvent, BacktestError, BrokerExecutionReport, CashReceivable, DataSet, FillEvent,
|
||||
OrderSide, PortfolioState, PositionEvent, PriceField, ProcessEvent, ProcessEventKind,
|
||||
};
|
||||
use chrono::{NaiveDate, TimeZone};
|
||||
|
||||
pub(crate) fn validate_action<'a>(
|
||||
action: &'a crate::CorporateAction,
|
||||
data: &DataSet,
|
||||
) -> Result<Option<(&'a str, f64, f64)>, String> {
|
||||
let terms = action.validated_successor_terms()?;
|
||||
crate::finite_serialization::validate(action).map_err(|error| {
|
||||
format!(
|
||||
"corporate_action_invalid_number: symbol={} action_date={} detail={error}",
|
||||
action.symbol, action.date
|
||||
)
|
||||
})?;
|
||||
if let Some((successor, _, _)) = terms {
|
||||
for (symbol, role) in [(&*action.symbol, "source"), (successor, "successor")] {
|
||||
if data.instrument(symbol).is_none() {
|
||||
return Err(format!(
|
||||
"corporate_action_{role}_instrument_missing: symbol={symbol} action_date={} source_symbol={}",
|
||||
action.date, action.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// One corporate-action calculation for normal processing and audited replay.
|
||||
pub(crate) fn apply(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let actions = data.corporate_actions_on(date);
|
||||
for action in actions {
|
||||
validate_action(action, data).map_err(BacktestError::Execution)?;
|
||||
}
|
||||
if !actions.iter().any(|action| {
|
||||
action.has_effect()
|
||||
&& portfolio
|
||||
.position(&action.symbol)
|
||||
.is_some_and(|position| position.quantity > 0)
|
||||
}) {
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
// An entire settlement batch is a single ledger update. A later invalid
|
||||
// cash leg must not leave an earlier split, receivable, target unit, or
|
||||
// note applied to the observed account.
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let report = apply_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
cash_dividends_enabled,
|
||||
cash_dividend_adjusts_cost_basis,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn apply_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
cash_dividends_enabled: bool,
|
||||
cash_dividend_adjusts_cost_basis: bool,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for action in data.corporate_actions_on(date) {
|
||||
if !action.has_effect() {
|
||||
continue;
|
||||
}
|
||||
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if existing_position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cash_dividends_enabled && action.share_cash.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
let (cash_delta, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for dividend action");
|
||||
let cash_delta = if cash_dividend_adjusts_cost_basis {
|
||||
position.apply_cash_dividend(action.share_cash)
|
||||
} else {
|
||||
position.apply_cash_dividend_preserve_cost_basis(action.share_cash)
|
||||
};
|
||||
(cash_delta, position.quantity, position.average_cost)
|
||||
};
|
||||
if cash_delta.abs() > f64::EPSILON {
|
||||
let payable_date = action.payable_date.unwrap_or(date);
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: action.symbol.clone(),
|
||||
ex_date: date,
|
||||
payable_date,
|
||||
amount: cash_delta,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
let note = format!(
|
||||
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
||||
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
||||
);
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity: 0,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for split")
|
||||
.quantity,
|
||||
split_ratio,
|
||||
0,
|
||||
)?;
|
||||
portfolio
|
||||
.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let (delta_quantity, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for split action");
|
||||
let delta_quantity = position.apply_split_ratio(split_ratio);
|
||||
(delta_quantity, position.quantity, position.average_cost)
|
||||
};
|
||||
if delta_quantity != 0 {
|
||||
let note = format!(
|
||||
"stock_split {} ratio={:.6} delta_qty={}",
|
||||
action.symbol, split_ratio, delta_quantity
|
||||
);
|
||||
notes.push(note);
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("stock_split {:.6}", split_ratio),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((successor_symbol, ratio, cash_per_share)) = action
|
||||
.validated_successor_terms()
|
||||
.map_err(BacktestError::Execution)?
|
||||
{
|
||||
checked_quantity(
|
||||
&action.symbol,
|
||||
date,
|
||||
portfolio
|
||||
.position(&action.symbol)
|
||||
.expect("position exists for conversion")
|
||||
.quantity,
|
||||
ratio,
|
||||
portfolio
|
||||
.position(successor_symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
)?;
|
||||
let Some(outcome) = portfolio.apply_successor_conversion(
|
||||
&action.symbol,
|
||||
successor_symbol,
|
||||
ratio,
|
||||
cash_per_share,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let reason = format!(
|
||||
"successor_conversion {}->{} ratio={:.6} cash_per_share={:.6}",
|
||||
outcome.old_symbol, outcome.new_symbol, ratio, cash_per_share
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.old_symbol.clone(),
|
||||
delta_quantity: -(outcome.old_quantity as i32),
|
||||
quantity_after: 0,
|
||||
average_cost: 0.0,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: outcome.new_symbol.clone(),
|
||||
delta_quantity: outcome.new_quantity_delta,
|
||||
quantity_after: outcome.new_quantity_after,
|
||||
average_cost: outcome.new_average_cost_after,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
if outcome.cash_delta.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.apply_cash_delta(outcome.cash_delta)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note: format!("{} cash={:.2}", reason, outcome.cash_delta),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn checked_quantity(
|
||||
symbol: &str,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
ratio: f64,
|
||||
merged: u32,
|
||||
) -> Result<(), BacktestError> {
|
||||
let scaled = (f64::from(quantity) * ratio).round();
|
||||
if !scaled.is_finite()
|
||||
|| scaled < 0.
|
||||
|| scaled > f64::from(i32::MAX)
|
||||
|| scaled + f64::from(merged) > f64::from(u32::MAX)
|
||||
{
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"corporate_action_quantity_overflow: symbol={symbol} action_date={date}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Preserve the declared fee-free accounting allocation model; this does not
|
||||
/// submit a market order or use a later opening quote as an earlier fact.
|
||||
pub(crate) fn settle_receivables(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
if !portfolio
|
||||
.cash_receivables()
|
||||
.iter()
|
||||
.any(|row| row.payable_date <= date)
|
||||
{
|
||||
return Ok(BrokerExecutionReport::default());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut recorded = Vec::new();
|
||||
let control = if reinvest_enabled {
|
||||
manual_reinvestment_control(date, runtime_input)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = settle_receivables_inner(
|
||||
date,
|
||||
data,
|
||||
&mut next,
|
||||
&mut recorded,
|
||||
reinvest_enabled,
|
||||
control,
|
||||
)?;
|
||||
*portfolio = next;
|
||||
notes.extend(recorded);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// The accounting stage precedes the market session. A later same-day setting
|
||||
/// must not retroactively change an allocation already observed at settlement.
|
||||
#[derive(Clone, Copy)]
|
||||
enum ManualReinvestmentControl<'a> {
|
||||
Event(&'a crate::position_exposure::PositionExposureEvent),
|
||||
LegacyZero(NaiveDate),
|
||||
}
|
||||
|
||||
impl ManualReinvestmentControl<'_> {
|
||||
fn suppression(self, symbol: &str) -> Option<String> {
|
||||
use crate::position_exposure::PositionExposureAction as Action;
|
||||
match self {
|
||||
Self::LegacyZero(date) => Some(format!(
|
||||
"runtime_zero_exposure legacy_effective_date={date}"
|
||||
)),
|
||||
Self::Event(event) => {
|
||||
if matches!(event.action, Action::Restore) {
|
||||
return None;
|
||||
}
|
||||
let reason = if matches!(
|
||||
event.action,
|
||||
Action::Scale { requested_bps: 0 }
|
||||
| Action::Set {
|
||||
target_exposure_bps: 0
|
||||
}
|
||||
) {
|
||||
"runtime_zero_exposure"
|
||||
} else if event
|
||||
.allocation_weights_bps
|
||||
.as_ref()
|
||||
.is_some_and(|weights| weights.get(symbol).copied().unwrap_or(0) == 0)
|
||||
{
|
||||
"runtime_zero_allocation"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(format!(
|
||||
"{reason} event_sequence={} effective_at={}",
|
||||
event.sequence, event.effective_at
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_reinvestment_control(
|
||||
date: NaiveDate,
|
||||
runtime_input: Option<&crate::manual_execution::ManualExecutionReplay>,
|
||||
) -> Result<Option<ManualReinvestmentControl<'_>>, BacktestError> {
|
||||
let Some(input) = runtime_input else {
|
||||
return Ok(None);
|
||||
};
|
||||
let at = chrono::FixedOffset::east_opt(8 * 3600)
|
||||
.unwrap()
|
||||
.from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap())
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: accounting stage clock is out of range".into(),
|
||||
)
|
||||
})?
|
||||
.with_timezone(&chrono::Utc);
|
||||
// The input has already been validated and bound to the runtime audit.
|
||||
// Keep the same (time, sequence) precedence as PositionExposureTimeline.
|
||||
if let Some(event) = input
|
||||
.position_exposure_events
|
||||
.iter()
|
||||
.filter(|event| event.effective_at <= at)
|
||||
.max_by_key(|event| (event.effective_at, event.sequence))
|
||||
{
|
||||
return Ok(Some(ManualReinvestmentControl::Event(event)));
|
||||
}
|
||||
Ok(input
|
||||
.legacy_position_exposure_bps
|
||||
.range(..=date)
|
||||
.next_back()
|
||||
.filter(|(_, bps)| **bps == 0)
|
||||
.map(|(day, _)| ManualReinvestmentControl::LegacyZero(*day)))
|
||||
}
|
||||
|
||||
fn settle_receivables_inner(
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
reinvest_enabled: bool,
|
||||
control: Option<ManualReinvestmentControl<'_>>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let due = portfolio.take_due_cash_receivables(date);
|
||||
for receivable in due {
|
||||
let cash_before = portfolio.cash();
|
||||
portfolio
|
||||
.settle_cash_receivable(&receivable)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let mut note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
if let Some(suppression) =
|
||||
control.and_then(|control| control.suppression(&receivable.symbol))
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.
|
||||
{
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={suppression} cash_retained=true"
|
||||
));
|
||||
} else if reinvest_enabled
|
||||
&& receivable.reason.starts_with("cash_dividend")
|
||||
&& receivable.amount > 0.0
|
||||
{
|
||||
let instrument = data.instrument(&receivable.symbol).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: instrument metadata missing symbol={} payable_date={date}", receivable.symbol)))?;
|
||||
if let Some(reason) = instrument.dated_market_absence_reason(date) {
|
||||
note.push_str(&format!(
|
||||
" dividend_reinvestment_not_applied reason={reason} cash_retained=true"
|
||||
));
|
||||
} else {
|
||||
let (reinvest_price, reference_basis) = if let Some(position) = portfolio
|
||||
.position(&receivable.symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
(Some(position.last_price), "adjusted_carried_mark")
|
||||
} else {
|
||||
(
|
||||
data.calendar().previous_day(date).and_then(|prev_date| {
|
||||
data.price_on_or_before(
|
||||
prev_date,
|
||||
&receivable.symbol,
|
||||
PriceField::Close,
|
||||
)
|
||||
}),
|
||||
"previous_completed_close",
|
||||
)
|
||||
};
|
||||
let price = reinvest_price.filter(|price| price.is_finite() && *price > 0.).ok_or_else(|| BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: accounting reference missing or invalid symbol={} payable_date={date} basis={reference_basis}", receivable.symbol)))?;
|
||||
let round_lot = instrument.round_lot;
|
||||
if round_lot == 0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"dividend_reinvestment: invalid quantity unit symbol={}",
|
||||
receivable.symbol
|
||||
)));
|
||||
}
|
||||
{
|
||||
let raw = (receivable.amount / price).floor();
|
||||
if !raw.is_finite() || raw > i32::MAX as f64 {
|
||||
return Err(BacktestError::Execution("dividend_reinvestment: accounting allocation quantity exceeds the ledger contract".into()));
|
||||
}
|
||||
let raw_quantity = raw as u32;
|
||||
let reinvest_quantity = (raw_quantity / round_lot) * round_lot;
|
||||
if reinvest_quantity > 0 {
|
||||
// Report the same micro-unit amount actually posted to
|
||||
// the ledger, not a floating multiplication residue.
|
||||
let reinvest_money =
|
||||
crate::FixedMoney::from_f64(reinvest_quantity as f64 * price)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: allocation amount out of range"
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let cash_delta = reinvest_money.checked_neg().ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: cash amount out of range".into(),
|
||||
)
|
||||
})?;
|
||||
let residual_cash = crate::FixedMoney::from_f64(receivable.amount)
|
||||
.and_then(|cash| cash.checked_sub(reinvest_money))
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"dividend_reinvestment: residual amount out of range".into(),
|
||||
)
|
||||
})?
|
||||
.to_f64();
|
||||
let reinvest_cash = reinvest_money.to_f64();
|
||||
portfolio
|
||||
.apply_cash_delta_fixed(cash_delta)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.position_mut(&receivable.symbol).buy(
|
||||
date,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
);
|
||||
|
||||
note = format!(
|
||||
"cash_receivable_reinvested {} ex_date={} payable_date={} cash={:.2} reinvest_qty={} reinvest_price={:.4} residual_cash={:.2}",
|
||||
receivable.symbol,
|
||||
receivable.ex_date,
|
||||
receivable.payable_date,
|
||||
receivable.amount,
|
||||
reinvest_quantity,
|
||||
price,
|
||||
residual_cash
|
||||
);
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::DividendReinvestment,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
execution_date: None,
|
||||
execution_start_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
execution_timestamp: date.and_hms_opt(0, 0, 0),
|
||||
order_id: None,
|
||||
symbol: receivable.symbol.clone(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: reinvest_quantity,
|
||||
price,
|
||||
gross_amount: reinvest_cash,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
transfer_fee: 0.0,
|
||||
net_cash_flow: cash_delta.to_f64(),
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: receivable.symbol.clone(),
|
||||
delta_quantity: reinvest_quantity as i32,
|
||||
quantity_after: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(&receivable.symbol)
|
||||
.map(|position| position.average_cost)
|
||||
.unwrap_or(0.0),
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: "dividend_reinvestment".to_string(),
|
||||
});
|
||||
report.process_events.push(ProcessEvent {
|
||||
date,
|
||||
kind: ProcessEventKind::Trade,
|
||||
order_id: None,
|
||||
symbol: Some(receivable.symbol.clone()),
|
||||
side: Some(OrderSide::Buy),
|
||||
detail: format!("dividend_reinvestment model=fee_free_accounting booked_at={} quantity={} price={} reference_basis={} ex_date={} payable_date={} residual_cash={}",
|
||||
date.and_hms_opt(0,0,0).unwrap(), reinvest_quantity, price, reference_basis,
|
||||
receivable.ex_date, receivable.payable_date, residual_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn date() -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()
|
||||
}
|
||||
fn data(delisted: bool) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
|
||||
delisted_at: delisted.then_some(date()),
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(10.);
|
||||
book.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date().pred_opt().unwrap(),
|
||||
payable_date: date(),
|
||||
amount: 100.,
|
||||
reason: "cash_dividend 1".into(),
|
||||
});
|
||||
book
|
||||
}
|
||||
#[test]
|
||||
fn missing_accounting_reference_is_atomic_not_a_silent_cash_only_success() {
|
||||
let mut book = book();
|
||||
let before = book.financial_replay_identity();
|
||||
let error =
|
||||
settle_receivables(date(), &data(false), &mut book, &mut Vec::new(), true, None)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("accounting reference missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
}
|
||||
#[test]
|
||||
fn terminated_security_keeps_paid_cash_and_is_not_recreated_by_reinvestment() {
|
||||
let mut book = book();
|
||||
let mut notes = Vec::new();
|
||||
let report =
|
||||
settle_receivables(date(), &data(true), &mut book, &mut notes, true, None).unwrap();
|
||||
assert_eq!(book.cash(), 110.);
|
||||
assert!(book.positions().is_empty());
|
||||
assert!(book.cash_receivables().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("dividend_reinvestment_not_applied"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_zero_skips_only_unused_allocation_facts_not_invalid_cash_evidence() {
|
||||
let mut input = crate::manual_execution::ManualExecutionReplay {
|
||||
schema: crate::manual_execution::MANUAL_REPLAY_SCHEMA.into(),
|
||||
runtime_id: "runtime".into(),
|
||||
account_id: "account".into(),
|
||||
source_contract_sha256: "a".repeat(64),
|
||||
content_sha256: String::new(),
|
||||
observation_cutoff: "2026-09-14T08:00:00Z".parse().unwrap(),
|
||||
actions: vec![],
|
||||
position_exposure_events: vec![],
|
||||
legacy_position_exposure_bps: std::collections::BTreeMap::from([(date(), 0)]),
|
||||
};
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
input.validate().unwrap();
|
||||
let mut account = book();
|
||||
let mut notes = Vec::new();
|
||||
// No historical price is present, but no optional purchase is wanted.
|
||||
let report = settle_receivables(
|
||||
date(),
|
||||
&data(false),
|
||||
&mut account,
|
||||
&mut notes,
|
||||
true,
|
||||
Some(&input),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(account.cash(), 110.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(notes[0].contains("runtime_zero_exposure"));
|
||||
let mut account = book();
|
||||
account.add_cash_receivable(CashReceivable {
|
||||
symbol: "000001.SZ".into(),
|
||||
ex_date: date(),
|
||||
payable_date: date(),
|
||||
amount: f64::NAN,
|
||||
reason: "cash_dividend invalid fixture".into(),
|
||||
});
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(
|
||||
settle_receivables(
|
||||
date(),
|
||||
&data(false),
|
||||
&mut account,
|
||||
&mut notes,
|
||||
true,
|
||||
Some(&input)
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 10.);
|
||||
assert_eq!(account.cash_receivables().len(), 2);
|
||||
assert!(account.cash_receivables()[1].amount.is_nan());
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
fn conversion() -> crate::CorporateAction {
|
||||
crate::CorporateAction {
|
||||
date: date(),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some("000002.SZ".into()),
|
||||
successor_ratio: Some(1.5),
|
||||
successor_cash: Some(0.5),
|
||||
}
|
||||
}
|
||||
|
||||
fn conversion_data(actions: Vec<crate::CorporateAction>, include_successor: bool) -> DataSet {
|
||||
let mut instruments = data(false)
|
||||
.instruments()
|
||||
.values()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if include_successor {
|
||||
let mut successor = instruments[0].clone();
|
||||
successor.symbol = "000002.SZ".into();
|
||||
instruments.push(successor);
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
instruments,
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: date(),
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
actions,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn conversion_book() -> PortfolioState {
|
||||
let mut book = PortfolioState::new(1000.);
|
||||
book.position_mut("000001.SZ")
|
||||
.buy(date().pred_opt().unwrap(), 100, 10.);
|
||||
book
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security() {
|
||||
let mut action = conversion();
|
||||
action.share_cash = 1.;
|
||||
action.share_bonus = 1.;
|
||||
let data = conversion_data(vec![action], false);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"));
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash() {
|
||||
let base = conversion();
|
||||
let mut cases = Vec::new();
|
||||
for ratio in [
|
||||
None,
|
||||
Some(0.),
|
||||
Some(-1.),
|
||||
Some(f64::NAN),
|
||||
Some(f64::INFINITY),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_ratio = ratio;
|
||||
cases.push(row);
|
||||
}
|
||||
for symbol in [
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
Some("000001.SZ"),
|
||||
Some(" 000002.SZ"),
|
||||
] {
|
||||
let mut row = base.clone();
|
||||
row.successor_symbol = symbol.map(str::to_owned);
|
||||
cases.push(row);
|
||||
}
|
||||
for cash in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let mut row = base.clone();
|
||||
row.successor_cash = Some(cash);
|
||||
cases.push(row);
|
||||
}
|
||||
for action in cases {
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let data = conversion_data(vec![action.clone()], true);
|
||||
let mut notes = Vec::new();
|
||||
assert!(
|
||||
apply(date(), &data, &mut book, &mut notes, true, true).is_err(),
|
||||
"accepted {action:?}"
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes() {
|
||||
let mut dividend = conversion();
|
||||
dividend.successor_symbol = None;
|
||||
dividend.successor_ratio = None;
|
||||
dividend.successor_cash = None;
|
||||
dividend.share_cash = 1.;
|
||||
dividend.share_bonus = 1.;
|
||||
let mut failure = conversion();
|
||||
failure.successor_cash = Some(1e100);
|
||||
let data = conversion_data(vec![dividend, failure], true);
|
||||
let mut book = conversion_book();
|
||||
let mut state = crate::stock_pool_state::StockPoolExecutionState {
|
||||
last_execution_date: date().pred_opt(),
|
||||
..Default::default()
|
||||
};
|
||||
state.position_action_bases.insert(
|
||||
"000001.SZ".into(),
|
||||
crate::stock_pool_state::StockPoolPositionActionBasis {
|
||||
generation: "original".into(),
|
||||
first_execution_date: date().pred_opt().unwrap(),
|
||||
quantity: rust_decimal::Decimal::from(100),
|
||||
},
|
||||
);
|
||||
state.last_target_weights.insert("000001.SZ".into(), 10000);
|
||||
book.set_stock_pool_execution_state("pool", state.clone())
|
||||
.unwrap();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = vec!["prior".into()];
|
||||
assert!(apply(date(), &data, &mut book, &mut notes, true, true).is_err());
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert_eq!(book.stock_pool_execution_state("pool"), state);
|
||||
assert_eq!(notes, ["prior"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_quantity_overflow_fails_without_saturation_or_a_negative_delta() {
|
||||
for split in [false, true] {
|
||||
let mut action = conversion();
|
||||
if split {
|
||||
action.share_bonus = 1e100;
|
||||
} else {
|
||||
action.successor_ratio = Some(1e100);
|
||||
}
|
||||
let data = conversion_data(vec![action], true);
|
||||
let mut book = conversion_book();
|
||||
let before = book.financial_replay_identity();
|
||||
let mut notes = Vec::new();
|
||||
let error = apply(date(), &data, &mut book, &mut notes, true, true).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("corporate_action_quantity_overflow")
|
||||
);
|
||||
assert_eq!(book.financial_replay_identity(), before);
|
||||
assert!(notes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_conversion_preserves_lots_without_creating_orders_or_fills() {
|
||||
let data = conversion_data(vec![conversion()], true);
|
||||
let mut book = conversion_book();
|
||||
book.position_mut("000002.SZ").buy(date(), 50, 20.);
|
||||
let mut notes = Vec::new();
|
||||
let report = apply(date(), &data, &mut book, &mut notes, true, true).unwrap();
|
||||
assert!(book.position("000001.SZ").is_none());
|
||||
let successor = book.position("000002.SZ").unwrap();
|
||||
assert_eq!(successor.quantity, 200);
|
||||
assert_eq!(successor.opened_date(), date().pred_opt());
|
||||
assert_eq!(successor.last_buy_date(), Some(date()));
|
||||
assert_eq!(book.cash(), 1050.);
|
||||
assert!(report.fill_events.is_empty());
|
||||
assert!(report.order_events.is_empty());
|
||||
assert_eq!(report.position_events.len(), 2);
|
||||
assert!(notes[0].contains("ratio=1.500000"));
|
||||
}
|
||||
}
|
||||
+406
-132
@@ -3,7 +3,7 @@ use std::cmp::Reverse;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap, HashMap, HashSet};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use ahash::AHashMap;
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
use compact_str::CompactString;
|
||||
use rayon::prelude::*;
|
||||
@@ -284,6 +284,8 @@ pub struct CorporateAction {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IntradayExecutionQuote {
|
||||
#[serde(default)]
|
||||
pub observation_kind: QuoteObservationKind,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
@@ -301,10 +303,18 @@ pub struct IntradayExecutionQuote {
|
||||
pub trading_phase: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum QuoteObservationKind {
|
||||
#[default]
|
||||
Unspecified,
|
||||
MinuteBar,
|
||||
}
|
||||
|
||||
/// Sparse same-day fields layered onto an already-built immutable daily panel.
|
||||
///
|
||||
/// These fields do not participate in daily price series, adjustment series,
|
||||
/// symbol indexes, or rolling windows. Applying them in place lets the runner
|
||||
/// These fields leave daily OHLC, adjustment series and symbol indexes intact,
|
||||
/// but update quote history and Last-price rolling windows. Applying them lets the runner
|
||||
/// reuse the candidate-planning `DataSet` as the final execution `DataSet`
|
||||
/// without rebuilding the full market panel.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -449,19 +459,29 @@ impl CorporateAction {
|
||||
self.successor_symbol
|
||||
.as_ref()
|
||||
.is_some_and(|symbol| !symbol.trim().is_empty())
|
||||
&& self.successor_ratio_value() > 0.0
|
||||
}
|
||||
|
||||
pub fn successor_ratio_value(&self) -> f64 {
|
||||
self.successor_ratio
|
||||
.filter(|ratio| ratio.is_finite() && *ratio > 0.0)
|
||||
.unwrap_or(1.0)
|
||||
}
|
||||
|
||||
pub fn successor_cash_value(&self) -> f64 {
|
||||
self.successor_cash
|
||||
.filter(|cash| cash.is_finite())
|
||||
.unwrap_or(0.0)
|
||||
/// A code mapping alone is not evidence for a 1:1 financial conversion.
|
||||
/// An absent cash component means no declared cash leg; an invalid one
|
||||
/// must never be replaced with zero.
|
||||
pub(crate) fn validated_successor_terms(&self) -> Result<Option<(&str, f64, f64)>, String> {
|
||||
let fail = |reason: &str| format!(
|
||||
"corporate_action_{reason}: symbol={} action_date={}", self.symbol, self.date);
|
||||
let Some(symbol) = self.successor_symbol.as_deref() else {
|
||||
if self.successor_ratio.is_some() || self.successor_cash.is_some() {
|
||||
return Err(fail("successor_symbol_missing"));
|
||||
}
|
||||
return Ok(None);
|
||||
};
|
||||
if symbol.is_empty() || symbol.trim() != symbol || symbol == self.symbol
|
||||
|| self.symbol.is_empty() || self.symbol.trim() != self.symbol {
|
||||
return Err(fail("successor_symbol_invalid"));
|
||||
}
|
||||
let ratio = self.successor_ratio.filter(|ratio| ratio.is_finite() && *ratio > 0.)
|
||||
.ok_or_else(|| fail("successor_ratio_missing_or_invalid"))?;
|
||||
let cash = self.successor_cash.unwrap_or(0.);
|
||||
if !cash.is_finite() { return Err(fail("successor_cash_invalid")); }
|
||||
Ok(Some((symbol, ratio, cash)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,17 +607,21 @@ pub fn decision_free_float_cap_bn(factor: &DailyFactorSnapshot) -> f64 {
|
||||
#[derive(Debug, Clone)]
|
||||
struct SymbolPriceSeries {
|
||||
base: Arc<SymbolDailySeriesBase>,
|
||||
timestamps: Vec<Option<String>>,
|
||||
last_prices: Vec<f64>,
|
||||
bid1s: Vec<f64>,
|
||||
ask1s: Vec<f64>,
|
||||
minute_volumes: Vec<u64>,
|
||||
bid1_volumes: Vec<u64>,
|
||||
ask1_volumes: Vec<u64>,
|
||||
trading_phases: Vec<Option<String>>,
|
||||
last_prefix: Vec<f64>,
|
||||
timestamps: RepeatedValues<Option<String>>,
|
||||
last_prices: ReferenceMatchedValues,
|
||||
bid1s: ReferenceMatchedValues,
|
||||
ask1s: ReferenceMatchedValues,
|
||||
minute_volumes: RepeatedValues<u64>,
|
||||
bid1_volumes: RepeatedValues<u64>,
|
||||
ask1_volumes: RepeatedValues<u64>,
|
||||
trading_phases: RepeatedValues<Option<String>>,
|
||||
last_prefix: ReferenceMatchedValues,
|
||||
}
|
||||
|
||||
#[path = "series_columns.rs"]
|
||||
mod series_columns;
|
||||
use series_columns::{ReferenceMatchedValues, RepeatedValues};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SymbolDailySeriesBase {
|
||||
symbol: String,
|
||||
@@ -613,6 +637,7 @@ struct SymbolDailySeriesBase {
|
||||
upper_limits: Vec<f64>,
|
||||
lower_limits: Vec<f64>,
|
||||
price_ticks: Vec<f64>,
|
||||
day_open_prefix: Vec<f64>,
|
||||
open_prefix: Vec<f64>,
|
||||
close_prefix: Vec<f64>,
|
||||
prev_close_prefix: Vec<f64>,
|
||||
@@ -829,51 +854,52 @@ impl SymbolPriceSeries {
|
||||
);
|
||||
let row_count = rows.len();
|
||||
let mut dates = Vec::with_capacity(row_count);
|
||||
let mut timestamps = Vec::with_capacity(row_count);
|
||||
let mut timestamps = RepeatedValues::new();
|
||||
let mut day_opens = Vec::with_capacity(row_count);
|
||||
let mut opens = Vec::with_capacity(row_count);
|
||||
let mut highs = Vec::with_capacity(row_count);
|
||||
let mut lows = Vec::with_capacity(row_count);
|
||||
let mut closes = Vec::with_capacity(row_count);
|
||||
let mut prev_closes = Vec::with_capacity(row_count);
|
||||
let mut last_prices = Vec::with_capacity(row_count);
|
||||
let mut bid1s = Vec::with_capacity(row_count);
|
||||
let mut ask1s = Vec::with_capacity(row_count);
|
||||
let mut last_prices = ReferenceMatchedValues::Identical;
|
||||
let mut bid1s = ReferenceMatchedValues::Identical;
|
||||
let mut ask1s = ReferenceMatchedValues::Identical;
|
||||
let mut volumes = Vec::with_capacity(row_count);
|
||||
let mut minute_volumes = Vec::with_capacity(row_count);
|
||||
let mut bid1_volumes = Vec::with_capacity(row_count);
|
||||
let mut ask1_volumes = Vec::with_capacity(row_count);
|
||||
let mut trading_phases = Vec::with_capacity(row_count);
|
||||
let mut minute_volumes = RepeatedValues::new();
|
||||
let mut bid1_volumes = RepeatedValues::new();
|
||||
let mut ask1_volumes = RepeatedValues::new();
|
||||
let mut trading_phases = RepeatedValues::new();
|
||||
let mut paused = Vec::with_capacity(row_count);
|
||||
let mut upper_limits = Vec::with_capacity(row_count);
|
||||
let mut lower_limits = Vec::with_capacity(row_count);
|
||||
let mut price_ticks = Vec::with_capacity(row_count);
|
||||
for row in rows {
|
||||
dates.push(row.date);
|
||||
timestamps.push(row.timestamp.clone());
|
||||
timestamps.push(&row.timestamp, row_count);
|
||||
day_opens.push(row.day_open);
|
||||
opens.push(row.open);
|
||||
highs.push(row.high);
|
||||
lows.push(row.low);
|
||||
closes.push(row.close);
|
||||
prev_closes.push(row.prev_close);
|
||||
last_prices.push(row.last_price);
|
||||
bid1s.push(row.bid1);
|
||||
ask1s.push(row.ask1);
|
||||
last_prices.push(row.last_price, &closes, row_count);
|
||||
bid1s.push(row.bid1, &closes, row_count);
|
||||
ask1s.push(row.ask1, &closes, row_count);
|
||||
volumes.push(row.volume);
|
||||
minute_volumes.push(row.minute_volume);
|
||||
bid1_volumes.push(row.bid1_volume);
|
||||
ask1_volumes.push(row.ask1_volume);
|
||||
trading_phases.push(row.trading_phase.clone());
|
||||
minute_volumes.push(&row.minute_volume, row_count);
|
||||
bid1_volumes.push(&row.bid1_volume, row_count);
|
||||
ask1_volumes.push(&row.ask1_volume, row_count);
|
||||
trading_phases.push(&row.trading_phase, row_count);
|
||||
paused.push(row.paused);
|
||||
upper_limits.push(row.upper_limit);
|
||||
lower_limits.push(row.lower_limit);
|
||||
price_ticks.push(row.price_tick);
|
||||
}
|
||||
let day_open_prefix = prefix_sums(&day_opens);
|
||||
let open_prefix = prefix_sums(&opens);
|
||||
let close_prefix = prefix_sums(&closes);
|
||||
let prev_close_prefix = prefix_sums(&prev_closes);
|
||||
let last_prefix = prefix_sums(&last_prices);
|
||||
let last_prefix = last_prices.prefix();
|
||||
let mut valid_volume_sum_prefix = Vec::with_capacity(volumes.len() + 1);
|
||||
let mut valid_volume_count_prefix = Vec::with_capacity(volumes.len() + 1);
|
||||
valid_volume_sum_prefix.push(0.0);
|
||||
@@ -916,6 +942,7 @@ impl SymbolPriceSeries {
|
||||
upper_limits,
|
||||
lower_limits,
|
||||
price_ticks,
|
||||
day_open_prefix,
|
||||
open_prefix,
|
||||
close_prefix,
|
||||
prev_close_prefix,
|
||||
@@ -945,23 +972,23 @@ impl SymbolPriceSeries {
|
||||
.dates
|
||||
.binary_search(&overlay.date)
|
||||
.map_err(|_| overlay.date)?;
|
||||
self.timestamps[index] = overlay.timestamp.clone();
|
||||
self.timestamps.set(index, overlay.timestamp.clone());
|
||||
if let Some(last_price) = overlay
|
||||
.last_price
|
||||
.filter(|value| value.is_finite() && *value > 0.0)
|
||||
{
|
||||
self.last_prices[index] = last_price;
|
||||
self.last_prices.set(index, last_price, &self.base.closes);
|
||||
last_price_changed = true;
|
||||
}
|
||||
self.bid1s[index] = overlay.bid1;
|
||||
self.ask1s[index] = overlay.ask1;
|
||||
self.minute_volumes[index] = overlay.minute_volume;
|
||||
self.bid1_volumes[index] = overlay.bid1_volume;
|
||||
self.ask1_volumes[index] = overlay.ask1_volume;
|
||||
self.trading_phases[index] = overlay.trading_phase.clone();
|
||||
self.bid1s.set(index, overlay.bid1, &self.base.closes);
|
||||
self.ask1s.set(index, overlay.ask1, &self.base.closes);
|
||||
self.minute_volumes.set(index, overlay.minute_volume);
|
||||
self.bid1_volumes.set(index, overlay.bid1_volume);
|
||||
self.ask1_volumes.set(index, overlay.ask1_volume);
|
||||
self.trading_phases.set(index, overlay.trading_phase.clone());
|
||||
}
|
||||
if last_price_changed {
|
||||
self.last_prefix = prefix_sums(&self.last_prices);
|
||||
self.last_prefix = self.last_prices.prefix();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1181,7 +1208,7 @@ impl SymbolPriceSeries {
|
||||
PriceField::DayOpen => &self.day_opens,
|
||||
PriceField::Open => &self.opens,
|
||||
PriceField::Close => &self.closes,
|
||||
PriceField::Last => &self.last_prices,
|
||||
PriceField::Last => self.last_prices.values(&self.closes),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1195,10 +1222,10 @@ impl SymbolPriceSeries {
|
||||
|
||||
fn prefix_for(&self, field: PriceField) -> &[f64] {
|
||||
match field {
|
||||
PriceField::DayOpen => &self.open_prefix,
|
||||
PriceField::DayOpen => &self.day_open_prefix,
|
||||
PriceField::Open => &self.open_prefix,
|
||||
PriceField::Close => &self.close_prefix,
|
||||
PriceField::Last => &self.last_prefix,
|
||||
PriceField::Last => self.last_prefix.values(&self.close_prefix),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1212,9 +1239,9 @@ impl SymbolPriceSeries {
|
||||
high: self.highs[index],
|
||||
low: self.lows[index],
|
||||
close: self.closes[index],
|
||||
last_price: self.last_prices[index],
|
||||
bid1: self.bid1s[index],
|
||||
ask1: self.ask1s[index],
|
||||
last_price: self.last_prices.values(&self.closes)[index],
|
||||
bid1: self.bid1s.values(&self.closes)[index],
|
||||
ask1: self.ask1s.values(&self.closes)[index],
|
||||
prev_close: self.prev_closes[index],
|
||||
volume: self.volumes[index],
|
||||
minute_volume: self.minute_volumes[index],
|
||||
@@ -1235,12 +1262,12 @@ impl SymbolPriceSeries {
|
||||
"high" => Some(self.highs[index]),
|
||||
"low" => Some(self.lows[index]),
|
||||
"close" | "price" => Some(self.closes[index]),
|
||||
"last" | "last_price" => Some(self.last_prices[index]),
|
||||
"last" | "last_price" => Some(self.last_prices.values(&self.closes)[index]),
|
||||
"prev_close" | "pre_close" => Some(self.prev_closes[index]),
|
||||
"volume" => Some(self.volumes[index] as f64),
|
||||
"minute_volume" => Some(self.minute_volumes[index] as f64),
|
||||
"bid1" => Some(self.bid1s[index]),
|
||||
"ask1" => Some(self.ask1s[index]),
|
||||
"bid1" => Some(self.bid1s.values(&self.closes)[index]),
|
||||
"ask1" => Some(self.ask1s.values(&self.closes)[index]),
|
||||
"bid1_volume" => Some(self.bid1_volumes[index] as f64),
|
||||
"ask1_volume" => Some(self.ask1_volumes[index] as f64),
|
||||
"upper_limit" => Some(self.upper_limits[index]),
|
||||
@@ -1407,6 +1434,7 @@ pub struct DataSet {
|
||||
corporate_actions_by_date: Arc<BTreeMap<NaiveDate, Vec<CorporateAction>>>,
|
||||
execution_quotes_by_date: Arc<HashMap<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>>,
|
||||
execution_quote_dates: Arc<Vec<NaiveDate>>,
|
||||
condition_totals: Arc<std::sync::Mutex<crate::stock_pool_quote_facts::SessionTotalsCache>>,
|
||||
order_book_depth_index: Arc<HashMap<(NaiveDate, String), Vec<IntradayOrderBookDepthLevel>>>,
|
||||
benchmark_by_date: Arc<BTreeMap<NaiveDate, BenchmarkSnapshot>>,
|
||||
market_series_by_symbol: Arc<AHashMap<String, Arc<SymbolPriceSeries>>>,
|
||||
@@ -1575,48 +1603,15 @@ impl DataSet {
|
||||
benchmark_by_date: BTreeMap::new(),
|
||||
corporate_actions_by_date: BTreeMap::new(),
|
||||
};
|
||||
for mut bundle in bundles {
|
||||
// Indexed collection retains chronological error precedence while each
|
||||
// worker validates and normalizes only its owned day buffers.
|
||||
let prepared = bundles
|
||||
.into_par_iter()
|
||||
.map(normalize_daily_snapshot_bundle)
|
||||
.collect::<Vec<_>>();
|
||||
for bundle in prepared {
|
||||
let bundle = bundle?;
|
||||
let date = bundle.date;
|
||||
if bundle.benchmark.date != date {
|
||||
return Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "benchmark",
|
||||
bundle_date: date,
|
||||
row_date: bundle.benchmark.date,
|
||||
symbol: bundle.benchmark.benchmark.clone(),
|
||||
});
|
||||
}
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.market,
|
||||
date,
|
||||
"market",
|
||||
|row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.factors,
|
||||
date,
|
||||
"factor",
|
||||
|row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.candidates,
|
||||
date,
|
||||
"candidate",
|
||||
|row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.corporate_actions,
|
||||
date,
|
||||
"corporate_action",
|
||||
|row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
|
||||
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
|
||||
if !bundle.market.is_empty() {
|
||||
grouped.market_by_date.insert(date, bundle.market);
|
||||
}
|
||||
@@ -1941,6 +1936,7 @@ impl DataSet {
|
||||
candidate_row_positions_by_date: Arc::new(candidate_row_positions_by_date),
|
||||
corporate_actions_by_date: Arc::new(corporate_actions_by_date),
|
||||
execution_quotes_by_date: Arc::new(execution_quotes_by_date),
|
||||
condition_totals: Arc::new(std::sync::Mutex::new(Default::default())),
|
||||
execution_quote_dates: Arc::new(execution_quote_dates),
|
||||
order_book_depth_index: Arc::new(order_book_depth_index),
|
||||
benchmark_by_date: Arc::new(benchmark_by_date),
|
||||
@@ -2271,6 +2267,17 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Counts market, factor, candidate, benchmark and corporate-action rows without cloning them.
|
||||
pub fn snapshot_row_counts(&self) -> (usize, usize, usize, usize, usize) {
|
||||
(
|
||||
self.market_by_date.values().map(Vec::len).sum(),
|
||||
self.factor_by_date.values().map(Vec::len).sum(),
|
||||
self.candidate_by_date.values().map(Vec::len).sum(),
|
||||
self.benchmark_by_date.len(),
|
||||
self.corporate_actions_by_date.values().map(Vec::len).sum(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -2279,6 +2286,15 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
pub fn execution_session_totals(&self, symbol: &str, at: NaiveDateTime) -> Result<(rust_decimal::Decimal, rust_decimal::Decimal), String> {
|
||||
let mut cache = self.condition_totals.lock().map_err(|_| "stock_pool_session_prefix_cache_poisoned")?;
|
||||
if cache.date != Some(at.date()) {
|
||||
cache.date = Some(at.date());
|
||||
cache.symbols.clear();
|
||||
}
|
||||
cache.symbols.entry(symbol.into()).or_insert_with(|| crate::stock_pool_quote_facts::MinutePrefix::build(at.date(), symbol, self.execution_quotes_on(at.date(), symbol))).at(at)
|
||||
}
|
||||
|
||||
pub fn has_execution_quotes_on_date(&self, date: NaiveDate) -> bool {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -2451,6 +2467,7 @@ impl DataSet {
|
||||
/// Replaces the run-local execution quote layer without touching the
|
||||
/// immutable daily panel.
|
||||
pub fn replace_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let execution_quotes_by_date = build_execution_quote_index(quotes);
|
||||
let quote_count = execution_quotes_by_date
|
||||
.values()
|
||||
@@ -2466,6 +2483,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn add_execution_quotes(&mut self, quotes: Vec<IntradayExecutionQuote>) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let mut grouped = HashMap::<NaiveDate, HashMap<String, Vec<IntradayExecutionQuote>>>::new();
|
||||
for quote in quotes {
|
||||
grouped
|
||||
@@ -2566,6 +2584,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn remove_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let removed = Arc::make_mut(&mut self.execution_quotes_by_date).remove(&date);
|
||||
let Some(rows_by_symbol) = removed else {
|
||||
return 0;
|
||||
@@ -2578,6 +2597,7 @@ impl DataSet {
|
||||
}
|
||||
|
||||
pub fn release_execution_quotes_on_date(&mut self, date: NaiveDate) -> usize {
|
||||
self.condition_totals = Arc::new(std::sync::Mutex::new(Default::default()));
|
||||
let row_count = self
|
||||
.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -4398,9 +4418,9 @@ fn normalize_factor_snapshots(
|
||||
});
|
||||
}
|
||||
let already_normalized = snapshot.extra_factors.iter().all(|(field, value)| {
|
||||
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\'');
|
||||
let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
|
||||
!trimmed.is_empty()
|
||||
&& trimmed == field.as_ref()
|
||||
&& trimmed == field.as_str()
|
||||
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
|
||||
&& value.is_finite()
|
||||
});
|
||||
@@ -4411,15 +4431,15 @@ fn normalize_factor_snapshots(
|
||||
.extra_factors
|
||||
.into_iter()
|
||||
.filter_map(|(field, value)| {
|
||||
let trimmed = field.as_ref().trim().trim_matches('"').trim_matches('\'');
|
||||
let trimmed = field.as_str().trim().trim_matches('"').trim_matches('\'');
|
||||
if trimmed.is_empty() || !value.is_finite() {
|
||||
None
|
||||
} else if trimmed == field.as_ref()
|
||||
} else if trimmed == field.as_str()
|
||||
&& trimmed.bytes().all(|byte| !byte.is_ascii_uppercase())
|
||||
{
|
||||
Some((field, value))
|
||||
} else {
|
||||
Some((Cow::Owned(trimmed.to_ascii_lowercase()), value))
|
||||
Some((CompactString::from(trimmed.to_ascii_lowercase()), value))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -4447,6 +4467,38 @@ fn normalize_history_frequency(frequency: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_daily_snapshot_bundle(
|
||||
mut bundle: DailySnapshotBundle,
|
||||
) -> Result<DailySnapshotBundle, DataSetError> {
|
||||
let date = bundle.date;
|
||||
if bundle.benchmark.date != date {
|
||||
return Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "benchmark",
|
||||
bundle_date: date,
|
||||
row_date: bundle.benchmark.date,
|
||||
symbol: bundle.benchmark.benchmark.clone(),
|
||||
});
|
||||
}
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.market, date, "market", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.factors, date, "factor", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.candidates, date, "candidate", |row| row.date, |row| row.symbol.as_str(),
|
||||
)?;
|
||||
validate_daily_bundle_component_dates(
|
||||
&bundle.corporate_actions, date, "corporate_action", |row| row.date,
|
||||
|row| row.symbol.as_str(),
|
||||
)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.market, |row| row.symbol.as_str());
|
||||
bundle.factors = normalize_factor_snapshots(bundle.factors)?;
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.factors, |row| row.symbol.as_str());
|
||||
sort_rows_by_symbol_if_needed(&mut bundle.candidates, |row| row.symbol.as_str());
|
||||
Ok(bundle)
|
||||
}
|
||||
|
||||
fn validate_daily_bundle_component_dates<T, D, S>(
|
||||
rows: &[T],
|
||||
bundle_date: NaiveDate,
|
||||
@@ -4507,7 +4559,7 @@ fn build_symbol_id_index(
|
||||
factor_by_date: &BTreeMap<NaiveDate, Vec<DailyFactorSnapshot>>,
|
||||
candidate_by_date: &BTreeMap<NaiveDate, Vec<CandidateEligibility>>,
|
||||
) -> AHashMap<String, u32> {
|
||||
let mut symbols = instruments.keys().cloned().collect::<HashSet<_>>();
|
||||
let mut symbols = instruments.keys().cloned().collect::<AHashSet<_>>();
|
||||
for rows in market_by_date.values() {
|
||||
for row in rows {
|
||||
if !symbols.contains(row.symbol.as_str()) {
|
||||
@@ -4549,10 +4601,11 @@ fn build_group_symbol_ids<T, F>(
|
||||
symbol_of: F,
|
||||
) -> BTreeMap<NaiveDate, Vec<u32>>
|
||||
where
|
||||
F: Fn(&T) -> &str + Copy,
|
||||
T: Sync,
|
||||
F: Fn(&T) -> &str + Copy + Send + Sync,
|
||||
{
|
||||
groups
|
||||
.iter()
|
||||
.par_iter()
|
||||
.map(|(date, rows)| {
|
||||
let symbol_ids = rows
|
||||
.iter()
|
||||
@@ -4565,6 +4618,8 @@ where
|
||||
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
|
||||
(*date, symbol_ids)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -4644,7 +4699,7 @@ fn build_factor_market_cap_order(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_dense_row_positions<T>(
|
||||
fn build_dense_row_positions<T: Sync>(
|
||||
groups: &BTreeMap<NaiveDate, Vec<T>>,
|
||||
symbol_ids_by_date: &BTreeMap<NaiveDate, Vec<u32>>,
|
||||
symbol_count: usize,
|
||||
@@ -4655,23 +4710,27 @@ fn build_dense_row_positions<T>(
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut positions_by_date = BTreeMap::new();
|
||||
for (date, rows) in groups {
|
||||
let symbol_ids = symbol_ids_by_date.get(date)?;
|
||||
if rows.len() != symbol_ids.len() {
|
||||
return None;
|
||||
}
|
||||
let mut positions = vec![MISSING_ROW_POSITION; symbol_count];
|
||||
for (row_index, symbol_id) in symbol_ids.iter().copied().enumerate() {
|
||||
let position = positions.get_mut(usize::try_from(symbol_id).ok()?)?;
|
||||
if *position != MISSING_ROW_POSITION {
|
||||
// Each task owns one bounded day index. No partial index is published if
|
||||
// any day has a missing, duplicate, or misaligned symbol identifier.
|
||||
groups
|
||||
.par_iter()
|
||||
.map(|(date, rows)| {
|
||||
let symbol_ids = symbol_ids_by_date.get(date)?;
|
||||
if rows.len() != symbol_ids.len() {
|
||||
return None;
|
||||
}
|
||||
*position = u32::try_from(row_index).ok()?;
|
||||
}
|
||||
positions_by_date.insert(*date, positions);
|
||||
}
|
||||
Some(positions_by_date)
|
||||
let mut positions = vec![MISSING_ROW_POSITION; symbol_count];
|
||||
for (row_index, symbol_id) in symbol_ids.iter().copied().enumerate() {
|
||||
let position = positions.get_mut(usize::try_from(symbol_id).ok()?)?;
|
||||
if *position != MISSING_ROW_POSITION {
|
||||
return None;
|
||||
}
|
||||
*position = u32::try_from(row_index).ok()?;
|
||||
}
|
||||
Some((*date, positions))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.map(|days| days.into_iter().collect())
|
||||
}
|
||||
|
||||
fn build_calendar_series_end_positions(
|
||||
@@ -5158,7 +5217,7 @@ mod tests {
|
||||
&run_data.execution_quote_dates
|
||||
));
|
||||
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote {
|
||||
run_data.add_execution_quotes(vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -5301,7 +5360,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp,
|
||||
@@ -5403,7 +5462,7 @@ mod tests {
|
||||
successor_cash: None,
|
||||
};
|
||||
corporate_actions.push(corporate_action.clone());
|
||||
execution_quotes.push(IntradayExecutionQuote {
|
||||
execution_quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbols[0].to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -5443,6 +5502,10 @@ mod tests {
|
||||
)
|
||||
.expect("daily bundle dataset");
|
||||
|
||||
let row_count = dates.len() * symbols.len();
|
||||
let expected_counts = (row_count, row_count, row_count, dates.len(), dates.len());
|
||||
assert_eq!(flat.snapshot_row_counts(), expected_counts);
|
||||
assert_eq!(grouped.snapshot_row_counts(), expected_counts);
|
||||
assert_eq!(flat.calendar().days(), grouped.calendar().days());
|
||||
assert_eq!(flat.benchmark_code(), grouped.benchmark_code());
|
||||
for date in dates {
|
||||
@@ -5517,6 +5580,122 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_daily_bundle_validation_keeps_earliest_error_and_component_order() {
|
||||
let bundles = || (2..30).rev().map(|day| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
|
||||
let mut benchmark = benchmark_row("2025-01-01", 20.0);
|
||||
benchmark.date = date;
|
||||
DailySnapshotBundle {
|
||||
date, benchmark,
|
||||
market: vec![market_row("2025-01-01", 10.0, 100)],
|
||||
factors: Vec::new(), candidates: Vec::new(), corporate_actions: Vec::new(),
|
||||
}
|
||||
}).collect::<Vec<_>>();
|
||||
for threads in [1, 2, 8] {
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap();
|
||||
for _ in 0..4 {
|
||||
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
|
||||
Vec::new(), bundles(), Vec::new(),
|
||||
));
|
||||
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "market", bundle_date, ..
|
||||
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
|
||||
}
|
||||
let mut values = bundles();
|
||||
values.last_mut().unwrap().benchmark.date = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
|
||||
let result = pool.install(|| DataSet::from_daily_bundles_with_execution_quotes(
|
||||
Vec::new(), values, Vec::new(),
|
||||
));
|
||||
assert!(matches!(result, Err(DataSetError::InvalidDailyBundleComponentDate {
|
||||
kind: "benchmark", bundle_date, ..
|
||||
}) if bundle_date == NaiveDate::from_ymd_opt(2025, 1, 2).unwrap()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_daily_symbol_indices_match_scalar_for_sparse_and_empty_days() {
|
||||
let symbols = ["000001.SZ", "159915.SZ", "600000.SH", "932000.CSI", "custom-long-instrument"];
|
||||
let index = symbols.iter().enumerate()
|
||||
.map(|(id, symbol)| (symbol.to_string(), id as u32))
|
||||
.collect::<AHashMap<_, _>>();
|
||||
let groups = (1..29).map(|day| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, day).unwrap();
|
||||
let rows = symbols.iter().enumerate()
|
||||
.filter(|(id, _)| day % 7 != 0 && (*id + day as usize) % 3 != 0)
|
||||
.map(|(_, symbol)| symbol.to_string()).collect::<Vec<_>>();
|
||||
(date, rows)
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
let expected_ids = groups.iter().map(|(date, rows)| {
|
||||
(*date, rows.iter().map(|symbol| index[symbol]).collect::<Vec<_>>())
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
let expected_positions = expected_ids.iter().map(|(date, ids)| {
|
||||
let mut positions = vec![super::MISSING_ROW_POSITION; symbols.len()];
|
||||
for (row, id) in ids.iter().enumerate() { positions[*id as usize] = row as u32; }
|
||||
(*date, positions)
|
||||
}).collect::<BTreeMap<_, _>>();
|
||||
for threads in [1, 2, 8] {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
|
||||
let ids = super::build_group_symbol_ids(&groups, &index, String::as_str);
|
||||
assert_eq!(ids, expected_ids);
|
||||
assert_eq!(super::build_dense_row_positions(&groups, &ids, symbols.len()), Some(expected_positions.clone()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_dense_index_rejects_invalid_days_without_publishing_partial_index() {
|
||||
let day1 = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let day2 = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let groups = BTreeMap::from([(day1, vec![0, 1]), (day2, vec![0, 1])]);
|
||||
let valid = BTreeMap::from([(day1, vec![0, 2]), (day2, vec![1, 2])]);
|
||||
for threads in [1, 2, 8] {
|
||||
rayon::ThreadPoolBuilder::new().num_threads(threads).build().unwrap().install(|| {
|
||||
for invalid in [vec![], vec![1], vec![1, 1], vec![1, 3], vec![1, u32::MAX]] {
|
||||
let mut ids = valid.clone();
|
||||
ids.insert(day2, invalid);
|
||||
assert!(super::build_dense_row_positions(&groups, &ids, 3).is_none());
|
||||
}
|
||||
let mut missing = valid.clone();
|
||||
missing.remove(&day2);
|
||||
assert!(super::build_dense_row_positions(&groups, &missing, 3).is_none());
|
||||
assert!(super::build_dense_row_positions(&groups, &valid, usize::MAX).is_none());
|
||||
assert!(super::build_dense_row_positions(&groups, &valid, super::MAX_DENSE_ROW_INDEX_BYTES).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_id_union_preserves_lexical_order_and_all_component_sources() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let instrument = Instrument {
|
||||
symbol: "932000.CSI".into(), name: "index".into(), board: "CSI".into(),
|
||||
round_lot: 100, listed_at: None, delisted_at: None, status: "active".into(),
|
||||
};
|
||||
let mut market = market_row("2025-01-02", -0.0, 0);
|
||||
market.symbol = "custom-long-instrument".into();
|
||||
let factor = DailyFactorSnapshot {
|
||||
date, symbol: "159915.SZ".into(), market_cap_bn: 0.0, free_float_cap_bn: 0.0,
|
||||
pe_ttm: 0.0, turnover_ratio: None, effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: None, extra_factors: NumericFactorMap::new(),
|
||||
};
|
||||
let candidate = CandidateEligibility {
|
||||
date, symbol: "000001.SZ".into(), is_st: true, is_star_st: true,
|
||||
is_new_listing: false, is_paused: true, allow_buy: false, allow_sell: false,
|
||||
is_kcb: false, is_one_yuan: false, risk_level_code: Some("test".into()),
|
||||
};
|
||||
let ids = super::build_symbol_id_index(
|
||||
&HashMap::from([(instrument.symbol.clone(), instrument)]),
|
||||
&BTreeMap::from([(date, vec![market.clone(), market])]),
|
||||
&BTreeMap::from([(date, vec![factor])]),
|
||||
&BTreeMap::from([(date, vec![candidate])]),
|
||||
);
|
||||
assert_eq!(ids, AHashMap::from_iter([
|
||||
("000001.SZ".to_string(), 0), ("159915.SZ".to_string(), 1),
|
||||
("932000.CSI".to_string(), 2), ("custom-long-instrument".to_string(), 3),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_symbol_id_snapshot_lookups_preserve_alignment_for_sparse_rows() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
@@ -6114,7 +6293,7 @@ mod tests {
|
||||
vec![benchmark_row("2025-01-02", 12.0)],
|
||||
)
|
||||
.unwrap();
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote {
|
||||
let quote = |symbol: &str, time: &str| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str(
|
||||
&format!("2025-01-02 {time}"),
|
||||
@@ -6193,7 +6372,7 @@ mod tests {
|
||||
#[test]
|
||||
fn shared_execution_quote_release_does_not_clone_the_base_map() {
|
||||
let date = NaiveDate::parse_from_str("2025-01-02", "%Y-%m-%d").unwrap();
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
timestamp: NaiveDateTime::parse_from_str("2025-01-02 10:18:00", "%Y-%m-%d %H:%M:%S")
|
||||
.unwrap(),
|
||||
@@ -6323,10 +6502,8 @@ mod tests {
|
||||
extra_factors: From::from([(Cow::Borrowed("amount"), 10.0)]),
|
||||
}])
|
||||
.expect("normalize clean factor snapshot");
|
||||
assert!(matches!(
|
||||
clean[0].extra_factors.keys().next(),
|
||||
Some(Cow::Borrowed("amount"))
|
||||
));
|
||||
assert_eq!(clean[0].extra_factors.keys().next().map(CompactString::as_str), Some("amount"));
|
||||
assert!(!clean[0].extra_factors.keys().next().unwrap().is_heap_allocated());
|
||||
|
||||
let dirty = normalize_factor_snapshots(vec![DailyFactorSnapshot {
|
||||
date,
|
||||
@@ -6393,6 +6570,103 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_columns_preserve_full_snapshots_and_distinct_price_bits() {
|
||||
for mixed in [false, true] {
|
||||
let mut rows = (0..6).map(|index| {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2 + index).unwrap();
|
||||
let mut row = market_row(&date.to_string(), 10. + index as f64, 1_000);
|
||||
row.minute_volume = 7;
|
||||
row.trading_phase = Some("continuous".to_string());
|
||||
row
|
||||
}).collect::<Vec<_>>();
|
||||
if mixed {
|
||||
rows[2].last_price = 0.;
|
||||
rows[3].bid1 = -0.;
|
||||
rows[4].ask1 = f64::from_bits(0x7ff8_0000_0000_0042);
|
||||
rows[4].timestamp = Some("2025-01-06 10:21:00".to_string());
|
||||
rows[4].trading_phase = None;
|
||||
rows[4].minute_volume = 10_000;
|
||||
}
|
||||
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
for (index, expected) in rows.iter().enumerate() {
|
||||
let actual = series.snapshot_at(index);
|
||||
assert_eq!(serde_json::to_value(&actual).unwrap(), serde_json::to_value(expected).unwrap());
|
||||
assert_eq!(actual.last_price.to_bits(), expected.last_price.to_bits());
|
||||
assert_eq!(actual.bid1.to_bits(), expected.bid1.to_bits());
|
||||
assert_eq!(actual.ask1.to_bits(), expected.ask1.to_bits());
|
||||
}
|
||||
let expected_prefix = prefix_sums(&rows.iter().map(|row| row.last_price).collect::<Vec<_>>());
|
||||
let bits = |values: &[f64]| values.iter().map(|value| value.to_bits()).collect::<Vec<_>>();
|
||||
assert_eq!(bits(series.prefix_for(PriceField::Last)), bits(&expected_prefix));
|
||||
if !mixed {
|
||||
assert!(matches!(series.last_prices, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(series.bid1s, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(series.ask1s, ReferenceMatchedValues::Identical));
|
||||
assert_eq!(series.price_values_for(PriceField::Last).as_ptr(), series.closes.as_ptr());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_overlay_materializes_only_changed_values_and_preserves_history_cutoff() {
|
||||
let rows = [
|
||||
market_row("2025-01-02", 10., 1_000),
|
||||
market_row("2025-01-03", 12., 2_000),
|
||||
market_row("2025-01-06", 14., 3_000),
|
||||
];
|
||||
let original = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
let mut changed = original.clone();
|
||||
let overlay = IntradayMarketSnapshotOverlay {
|
||||
date: rows[2].date, symbol: "000001.SZ".to_string(),
|
||||
timestamp: Some("2025-01-06 13:20:00".to_string()), last_price: Some(15.),
|
||||
bid1: 14., ask1: 15.01, minute_volume: 30, bid1_volume: 20, ask1_volume: 10,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
};
|
||||
changed.apply_intraday_market_overlays(&[&overlay]).unwrap();
|
||||
assert!(Arc::ptr_eq(&original.base, &changed.base));
|
||||
assert!(matches!(original.last_prices, ReferenceMatchedValues::Identical));
|
||||
assert!(matches!(changed.last_prices, ReferenceMatchedValues::Owned(_)));
|
||||
assert!(matches!(changed.bid1s, ReferenceMatchedValues::Identical));
|
||||
let mut expected = rows[2].clone();
|
||||
expected.timestamp = overlay.timestamp.clone();
|
||||
expected.last_price = 15.;
|
||||
expected.bid1 = overlay.bid1;
|
||||
expected.ask1 = overlay.ask1;
|
||||
expected.minute_volume = overlay.minute_volume;
|
||||
expected.bid1_volume = overlay.bid1_volume;
|
||||
expected.ask1_volume = overlay.ask1_volume;
|
||||
expected.trading_phase = overlay.trading_phase.clone();
|
||||
assert_eq!(serde_json::to_value(changed.snapshot_at(2)).unwrap(), serde_json::to_value(expected).unwrap());
|
||||
assert_eq!(original.snapshot_at(2).last_price, 14.);
|
||||
assert_eq!(changed.moving_average(rows[1].date, 2, PriceField::Last), Some(11.));
|
||||
assert_eq!(changed.trailing_values(rows[1].date, 2, PriceField::Last), vec![10., 12.]);
|
||||
assert_eq!(changed.trailing_snapshots(rows[2].date, 2, false).len(), 2);
|
||||
assert_eq!(changed.trailing_numeric_values(rows[2].date, 2, "last", false), vec![10., 12.]);
|
||||
assert_eq!(changed.moving_average(rows[2].date, 2, PriceField::Last), Some(13.5));
|
||||
let mut unknown = overlay;
|
||||
unknown.date = NaiveDate::from_ymd_opt(2025, 2, 1).unwrap();
|
||||
assert_eq!(changed.apply_intraday_market_overlays(&[&unknown]), Err(unknown.date));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn day_open_moving_average_uses_its_own_historical_column() {
|
||||
let mut first = market_row("2025-01-02", 10.0, 100);
|
||||
first.day_open = 10.0;
|
||||
first.open = 20.0;
|
||||
let mut second = market_row("2025-01-03", 12.0, 200);
|
||||
second.day_open = 12.0;
|
||||
second.open = 24.0;
|
||||
let rows = [first, second];
|
||||
let series = SymbolPriceSeries::new("000001.SZ".to_string(), &rows);
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
assert_eq!(series.trailing_values(date, 2, PriceField::DayOpen), vec![10.0, 12.0]);
|
||||
assert_eq!(series.moving_average(date, 2, PriceField::DayOpen), Some(11.0));
|
||||
assert_eq!(series.moving_average(date, 2, PriceField::Open), Some(22.0));
|
||||
assert_eq!(series.moving_average(date, 0, PriceField::DayOpen), None);
|
||||
assert_eq!(series.moving_average(date, 3, PriceField::DayOpen), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbol_price_series_test_constructor_sorts_unsorted_rows() {
|
||||
let series = SymbolPriceSeries::new(
|
||||
|
||||
+3756
-995
File diff suppressed because it is too large
Load Diff
@@ -61,7 +61,7 @@ pub(crate) struct DeferredEtfTarget {
|
||||
|
||||
/// Owned by one broker/run. Replacing a full pool generation supersedes older
|
||||
/// queued targets; order of the latest candidate list is retained.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub(crate) struct DeferredEtfTargets {
|
||||
generations: std::collections::BTreeMap<String, String>,
|
||||
rows: Vec<DeferredEtfTarget>,
|
||||
|
||||
@@ -181,8 +181,22 @@ impl OrderEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillOrigin {
|
||||
#[default]
|
||||
MarketExecution,
|
||||
DividendReinvestment,
|
||||
}
|
||||
|
||||
impl FillOrigin {
|
||||
pub fn is_market_execution(&self) -> bool { *self == Self::MarketExecution }
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FillEvent {
|
||||
#[serde(default, skip_serializing_if = "FillOrigin::is_market_execution")]
|
||||
pub origin: FillOrigin,
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
@@ -219,6 +233,14 @@ pub struct FillEvent {
|
||||
|
||||
impl FillEvent {
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.origin == FillOrigin::DividendReinvestment && (
|
||||
self.order_id.is_some() || self.side != OrderSide::Buy
|
||||
|| self.commission != 0. || self.stamp_tax != 0. || self.transfer_fee != 0.
|
||||
|| self.execution_timestamp != self.date.and_hms_opt(0, 0, 0)
|
||||
|| self.execution_start_timestamp != self.execution_timestamp
|
||||
) {
|
||||
return Err("dividend accounting allocation cannot carry an exchange order, fees, or a market clock".into());
|
||||
}
|
||||
if self.symbol.trim().is_empty()
|
||||
|| self.quantity == 0
|
||||
|| !self.price.is_finite()
|
||||
@@ -311,6 +333,7 @@ pub enum ProcessEventKind {
|
||||
OrderUpdateReject,
|
||||
OrderUnsolicitedUpdate,
|
||||
Trade,
|
||||
ManualExecutionObserved,
|
||||
UniverseUpdated,
|
||||
UniverseSubscribed,
|
||||
UniverseUnsubscribed,
|
||||
@@ -358,6 +381,7 @@ impl ProcessEventKind {
|
||||
Self::OrderUpdateReject => "order_update_reject",
|
||||
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
|
||||
Self::Trade => "trade",
|
||||
Self::ManualExecutionObserved => "manual_execution_observed",
|
||||
Self::UniverseUpdated => "universe_updated",
|
||||
Self::UniverseSubscribed => "universe_subscribed",
|
||||
Self::UniverseUnsubscribed => "universe_unsubscribed",
|
||||
@@ -391,6 +415,7 @@ impl ProcessEventKind {
|
||||
| Self::OrderUpdateReject
|
||||
| Self::OrderUnsolicitedUpdate
|
||||
| Self::Trade
|
||||
| Self::ManualExecutionObserved
|
||||
| Self::UniverseUpdated
|
||||
| Self::UniverseSubscribed
|
||||
| Self::UniverseUnsubscribed
|
||||
@@ -422,7 +447,7 @@ pub struct ProcessEvent {
|
||||
mod tests {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
use super::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
use super::{FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, ProcessEventKind};
|
||||
|
||||
fn order_event(status: OrderStatus, filled_quantity: u32) -> OrderEvent {
|
||||
OrderEvent {
|
||||
@@ -466,6 +491,7 @@ mod tests {
|
||||
|
||||
fn fill_event(start: Option<NaiveDateTime>, end: Option<NaiveDateTime>) -> FillEvent {
|
||||
FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date: NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
@@ -486,6 +512,27 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_origin_cannot_disguise_an_exchange_order_or_fee() {
|
||||
let at = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||
let mut fill = fill_event(Some(at), Some(at));
|
||||
fill.origin = FillOrigin::DividendReinvestment;
|
||||
fill.order_id = None;
|
||||
fill.commission = 0.;
|
||||
fill.net_cash_flow = -1000.;
|
||||
assert!(fill.validate().is_ok());
|
||||
for kind in 0..3 {
|
||||
let mut invalid = fill.clone();
|
||||
match kind {
|
||||
0 => invalid.order_id = Some(1),
|
||||
1 => invalid.commission = 1.,
|
||||
_ => { invalid.execution_timestamp = Some(at + chrono::Duration::hours(9)); invalid.execution_start_timestamp = invalid.execution_timestamp; }
|
||||
}
|
||||
assert!(invalid.validate().is_err());
|
||||
}
|
||||
assert!(serde_json::to_value(fill_event(None, None)).unwrap().get("origin").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_execution_timestamp_range_is_explicit_and_backward_compatible() {
|
||||
let start = NaiveDate::from_ymd_opt(2025, 1, 2)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
|
||||
//! This traverses the original Serialize representation without materializing it.
|
||||
use serde::{Serialize, Serializer, ser};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Finite;
|
||||
|
||||
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
|
||||
value.serialize(Finite)
|
||||
}
|
||||
|
||||
macro_rules! scalar {
|
||||
($($method:ident: $ty:ty),* $(,)?) => {$(
|
||||
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
|
||||
)*};
|
||||
}
|
||||
|
||||
impl Serializer for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
type SerializeSeq = Self;
|
||||
type SerializeTuple = Self;
|
||||
type SerializeTupleStruct = Self;
|
||||
type SerializeTupleVariant = Self;
|
||||
type SerializeMap = Self;
|
||||
type SerializeStruct = Self;
|
||||
type SerializeStructVariant = Self;
|
||||
|
||||
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
|
||||
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
|
||||
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
|
||||
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
|
||||
serialize_str: &str, serialize_bytes: &[u8]);
|
||||
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
|
||||
self.serialize_f64(f64::from(value))
|
||||
}
|
||||
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
|
||||
if value.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ser::Error::custom(
|
||||
"pending strategy intent contains a non-finite number",
|
||||
))
|
||||
}
|
||||
}
|
||||
fn serialize_none(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_unit(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_unit_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
fn serialize_newtype_struct<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_newtype_variant<T: ?Sized + Serialize>(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(self)
|
||||
}
|
||||
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_tuple_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
fn serialize_struct_variant(
|
||||
self,
|
||||
_: &'static str,
|
||||
_: u32,
|
||||
_: &'static str,
|
||||
_: usize,
|
||||
) -> Result<Self, Self::Error> {
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! sequence {
|
||||
($trait:ident, $method:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
sequence!(SerializeSeq, serialize_element);
|
||||
sequence!(SerializeTuple, serialize_element);
|
||||
sequence!(SerializeTupleStruct, serialize_field);
|
||||
sequence!(SerializeTupleVariant, serialize_field);
|
||||
|
||||
impl ser::SerializeMap for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! structure {
|
||||
($trait:ident) => {
|
||||
impl ser::$trait for Finite {
|
||||
type Ok = ();
|
||||
type Error = serde_json::Error;
|
||||
fn serialize_field<T: ?Sized + Serialize>(
|
||||
&mut self,
|
||||
_: &'static str,
|
||||
value: &T,
|
||||
) -> Result<(), Self::Error> {
|
||||
value.serialize(*self)
|
||||
}
|
||||
fn end(self) -> Result<(), Self::Error> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
structure!(SerializeStruct);
|
||||
structure!(SerializeStructVariant);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||
|
||||
#[test]
|
||||
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let decision = StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitTargetPercent {
|
||||
symbol: "000001.SZ".into(),
|
||||
target_percent: 0.5,
|
||||
limit_price: value,
|
||||
reason: "test".into(),
|
||||
}
|
||||
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate(&decision).is_err());
|
||||
assert!(validate(&vec![Some(value)]).is_err());
|
||||
}
|
||||
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,17 @@ impl FixedMoney {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn to_decimal_string(self) -> String {
|
||||
let magnitude = self.0.unsigned_abs();
|
||||
let scale = MONEY_SCALE as u128;
|
||||
let sign = if self.0 < 0 { "-" } else { "" };
|
||||
let width = MONEY_SCALE.ilog10() as usize;
|
||||
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
|
||||
.trim_end_matches('0')
|
||||
.trim_end_matches('.')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
|
||||
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
|
||||
pub enum FuturesDirection {
|
||||
Long,
|
||||
Short,
|
||||
@@ -62,7 +62,7 @@ impl FuturesDirection {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum FuturesPositionEffect {
|
||||
Open,
|
||||
Close,
|
||||
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[derive(Debug, Clone, Copy, Serialize)]
|
||||
pub struct FuturesContractSpec {
|
||||
pub contract_multiplier: f64,
|
||||
pub long_margin_rate: f64,
|
||||
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FuturesOrderIntent {
|
||||
pub symbol: String,
|
||||
pub direction: FuturesDirection,
|
||||
@@ -1048,6 +1048,7 @@ impl FuturesAccountState {
|
||||
)
|
||||
.to_f64();
|
||||
report.fill_events.push(FillEvent {
|
||||
origin: crate::events::FillOrigin::MarketExecution,
|
||||
date,
|
||||
decision_date: None,
|
||||
order_created_date: None,
|
||||
|
||||
@@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence {
|
||||
pub last_sell_date: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct AutomaticTradePermission {
|
||||
pub buy_denial: Option<&'static str>,
|
||||
pub sell_denial: Option<&'static str>,
|
||||
@@ -120,10 +120,25 @@ impl AutomaticTradeProtection {
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.evaluate_with_predecessors(symbol, execution_date, evidence, calendar, std::iter::empty())
|
||||
}
|
||||
|
||||
/// Only accept predecessors from validated, actually applied holding
|
||||
/// conversions. Catalog aliases or requested strategy symbols are not
|
||||
/// evidence that a configured lock covers another security.
|
||||
pub fn evaluate_with_predecessors<'a>(
|
||||
&self,
|
||||
symbol: &str,
|
||||
execution_date: NaiveDate,
|
||||
evidence: &HoldingLifecycleEvidence,
|
||||
calendar: &TradingCalendar,
|
||||
verified_predecessors: impl IntoIterator<Item = &'a str>,
|
||||
) -> Result<AutomaticTradePermission, String> {
|
||||
self.validate()?;
|
||||
let predecessors = verified_predecessors.into_iter().collect::<std::collections::BTreeSet<_>>();
|
||||
if self.locks.iter().any(|lock| {
|
||||
lock.symbol == symbol
|
||||
(lock.symbol == symbol || predecessors.contains(lock.symbol.as_str()))
|
||||
&& lock.start_date <= execution_date
|
||||
&& lock.end_date.is_none_or(|end| execution_date <= end)
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod broker;
|
||||
pub mod calendar;
|
||||
pub mod cost;
|
||||
mod corporate_book;
|
||||
pub mod data;
|
||||
mod numeric_factors;
|
||||
pub mod daily_patterns;
|
||||
@@ -17,15 +18,19 @@ pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
pub mod fixed_point;
|
||||
mod finite_serialization;
|
||||
pub mod futures;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod manual_execution;
|
||||
mod manual_corporate_replay;
|
||||
mod numeric_expr_vm;
|
||||
pub mod platform_expr_strategy;
|
||||
pub mod platform_runtime_schema;
|
||||
pub mod platform_strategy_spec;
|
||||
pub mod portfolio;
|
||||
pub mod portfolio_loss;
|
||||
pub mod position_exposure;
|
||||
pub mod risk_control;
|
||||
pub mod rules;
|
||||
pub mod scheduler;
|
||||
@@ -37,6 +42,7 @@ pub mod stock_pool_execution;
|
||||
pub mod stock_pool_index_policy;
|
||||
pub mod stock_pool_market_cap;
|
||||
pub mod stock_pool_state;
|
||||
pub mod stock_pool_quote_facts;
|
||||
pub mod signal_contract;
|
||||
pub mod strategy_ai;
|
||||
pub mod universe;
|
||||
@@ -59,11 +65,11 @@ pub use engine::{
|
||||
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
|
||||
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
|
||||
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
|
||||
ProcessEventRetention, backtest_execution_dates,
|
||||
ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
|
||||
};
|
||||
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
|
||||
pub use events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
AccountEvent, FillEvent, FillOrigin, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
pub use fixed_point::{
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, NaiveDateTime, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::manual_execution::{
|
||||
AppliedManualFill, ManualCorporateActionReference, ManualCorporateAdjustment,
|
||||
ManualCorporatePositionChange, ManualExecutionReplay, ManualFillObservation,
|
||||
};
|
||||
use crate::{DataSet, FillEvent, FixedMoney, MatchingType, OrderSide, PortfolioState, PriceField};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CashEffect {
|
||||
at: NaiveDateTime,
|
||||
amount: FixedMoney,
|
||||
}
|
||||
|
||||
/// Replays issued financial facts, never strategy callbacks or hypothetical orders.
|
||||
/// The ordinary book remains observable until an actual receipt is delivered.
|
||||
pub(crate) struct ManualCorporateReplay {
|
||||
first_date: NaiveDate,
|
||||
sessions: RefCell<BTreeSet<NaiveDate>>,
|
||||
closed: RefCell<BTreeSet<NaiveDate>>,
|
||||
cash: RefCell<Vec<CashEffect>>,
|
||||
reconciled_count: Cell<usize>,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
}
|
||||
|
||||
impl ManualCorporateReplay {
|
||||
pub(crate) fn new(
|
||||
first_date: NaiveDate,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
reinvest: bool,
|
||||
matching: MatchingType,
|
||||
daily_price: PriceField,
|
||||
same_day_mark_at_fill: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
first_date,
|
||||
sessions: RefCell::new(BTreeSet::new()),
|
||||
closed: RefCell::new(BTreeSet::new()),
|
||||
cash: RefCell::new(Vec::new()),
|
||||
reconciled_count: Cell::new(0),
|
||||
cash_dividends,
|
||||
adjust_cost,
|
||||
reinvest,
|
||||
matching,
|
||||
daily_price,
|
||||
same_day_mark_at_fill,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_session(&self, date: NaiveDate) {
|
||||
self.sessions.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn record_close(&self, date: NaiveDate) {
|
||||
self.closed.borrow_mut().insert(date);
|
||||
}
|
||||
pub(crate) fn committed(&self, count: usize) {
|
||||
self.reconciled_count.set(count);
|
||||
}
|
||||
|
||||
pub(crate) fn record_cash(
|
||||
&self,
|
||||
at: Option<NaiveDateTime>,
|
||||
before: FixedMoney,
|
||||
after: FixedMoney,
|
||||
) -> Result<(), String> {
|
||||
let amount = after
|
||||
.checked_sub(before)
|
||||
.ok_or("manual corporate cash observation overflow")?;
|
||||
if amount != FixedMoney::ZERO {
|
||||
self.cash.borrow_mut().push(CashEffect {
|
||||
at: at.ok_or("manual corporate cash observation has no execution clock")?,
|
||||
amount,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action_references(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<Vec<ManualCorporateActionReference>, String> {
|
||||
let mut symbols = BTreeSet::from([observation.order.symbol.clone()]);
|
||||
let observed_date = local(observation.fill.observed_at).date();
|
||||
let mut actions = Vec::new();
|
||||
for date in self.sessions.borrow().range((
|
||||
std::ops::Bound::Excluded(observation.fill.trade_date),
|
||||
std::ops::Bound::Included(observed_date),
|
||||
)) {
|
||||
for action in data.corporate_actions_on(*date) {
|
||||
if !symbols.contains(&action.symbol) {
|
||||
continue;
|
||||
}
|
||||
let successor_terms = crate::corporate_book::validate_action(action, data)?;
|
||||
let effective = (action.split_ratio() - 1.).abs() > f64::EPSILON
|
||||
|| action.has_successor_conversion()
|
||||
|| (self.cash_dividends && action.share_cash.abs() > f64::EPSILON);
|
||||
if !effective {
|
||||
continue;
|
||||
}
|
||||
if let Some((successor, _, _)) = successor_terms {
|
||||
symbols.insert(successor.to_owned());
|
||||
}
|
||||
actions.push(ManualCorporateActionReference {
|
||||
date: *date,
|
||||
symbol: action.symbol.clone(),
|
||||
successor_symbol: action.successor_symbol.clone(),
|
||||
share_cash: action.share_cash.to_string(),
|
||||
split_ratio: action.split_ratio().to_string(),
|
||||
successor_ratio: action.successor_ratio.map(|value| value.to_string()),
|
||||
successor_cash: action.successor_cash.map(|value| value.to_string()),
|
||||
sha256: digest(
|
||||
&serde_json::to_value(action).map_err(|error| error.to_string())?,
|
||||
)?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(actions)
|
||||
}
|
||||
|
||||
pub(crate) fn required(
|
||||
&self,
|
||||
observation: ManualFillObservation<'_>,
|
||||
data: &DataSet,
|
||||
) -> Result<bool, String> {
|
||||
Ok(!self.action_references(observation, data)?.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn project(
|
||||
&self,
|
||||
source: &ManualExecutionReplay,
|
||||
applied_count: usize,
|
||||
observation: ManualFillObservation<'_>,
|
||||
current: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
has_pending: bool,
|
||||
) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
if has_pending {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let all = source.observations()?;
|
||||
if all.get(applied_count).is_none_or(|next| {
|
||||
next.fill.observation_event_id != observation.fill.observation_event_id
|
||||
}) {
|
||||
return Err(
|
||||
"manual corporate observation prefix differs from the immutable trace".into(),
|
||||
);
|
||||
}
|
||||
let actions = self.action_references(observation, data)?;
|
||||
if actions.is_empty() {
|
||||
return Err("manual corporate projection has no processed corporate action".into());
|
||||
}
|
||||
let at = local(observation.fill.observed_at);
|
||||
let reference = self.replay(
|
||||
source,
|
||||
current.initial_cash_fixed(),
|
||||
&all[..applied_count],
|
||||
self.reconciled_count.get(),
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let expected = current.financial_replay_identity();
|
||||
let reference_identity = reference.financial_replay_identity();
|
||||
if reference_identity != expected {
|
||||
return Err(format!(
|
||||
"manual corporate ledger coverage mismatch: symbol={} observed_at={} expected={} replayed={}",
|
||||
observation.order.symbol,
|
||||
observation.fill.observed_at,
|
||||
digest(&expected)?,
|
||||
digest(&reference_identity)?
|
||||
));
|
||||
}
|
||||
let replayed = self.replay(
|
||||
source,
|
||||
current.initial_cash_fixed(),
|
||||
&all[..=applied_count],
|
||||
applied_count + 1,
|
||||
data,
|
||||
fills,
|
||||
at,
|
||||
)?;
|
||||
let replayed_identity = replayed.financial_replay_identity();
|
||||
let gross = FixedMoney::from_decimal_str(&observation.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&observation.fill.total_fees()?.to_string())?;
|
||||
let cash_delta = match observation.order.side {
|
||||
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
|
||||
OrderSide::Sell => gross.checked_sub(fees),
|
||||
}
|
||||
.ok_or("manual corporate trade cash overflow")?;
|
||||
let before = current.cash_fixed();
|
||||
let after = replayed.cash_fixed();
|
||||
let corporate_cash = after
|
||||
.checked_sub(before)
|
||||
.and_then(|delta| delta.checked_sub(cash_delta))
|
||||
.ok_or("manual corporate adjustment overflow")?;
|
||||
let symbols = current
|
||||
.positions()
|
||||
.keys()
|
||||
.chain(replayed.positions().keys())
|
||||
.cloned()
|
||||
.chain(std::iter::once(observation.order.symbol.clone()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut positions = BTreeMap::new();
|
||||
for symbol in symbols {
|
||||
let change = ManualCorporatePositionChange {
|
||||
quantity_before: current
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
quantity_after: replayed
|
||||
.position(&symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
cost_basis_before: current
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
cost_basis_after: replayed
|
||||
.financial_position_basis(&symbol)
|
||||
.to_decimal_string(),
|
||||
};
|
||||
if change.quantity_before != change.quantity_after
|
||||
|| change.cost_basis_before != change.cost_basis_after
|
||||
{
|
||||
positions.insert(symbol, change);
|
||||
}
|
||||
}
|
||||
let quantity_after = replayed
|
||||
.position(&observation.order.symbol)
|
||||
.map_or(0, |position| position.quantity);
|
||||
let adjustment = ManualCorporateAdjustment {
|
||||
schema: "fidc.manual-corporate-adjustment/v1".into(),
|
||||
observed_at: observation.fill.observed_at,
|
||||
cash_dividends_enabled: self.cash_dividends,
|
||||
dividend_cost_basis_adjustment: self.adjust_cost,
|
||||
dividend_reinvestment: self.reinvest,
|
||||
actions,
|
||||
cash_before: before.to_decimal_string(),
|
||||
cash_after: after.to_decimal_string(),
|
||||
corporate_cash_delta: corporate_cash.to_decimal_string(),
|
||||
positions,
|
||||
reference_sha256: digest(&reference_identity)?,
|
||||
replayed_sha256: digest(&replayed_identity)?,
|
||||
};
|
||||
current.replace_replayed_financial_book(replayed)?;
|
||||
Ok((
|
||||
AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after,
|
||||
},
|
||||
adjustment,
|
||||
))
|
||||
}
|
||||
|
||||
fn replay(
|
||||
&self,
|
||||
runtime_input: &ManualExecutionReplay,
|
||||
initial_cash: FixedMoney,
|
||||
manual: &[ManualFillObservation<'_>],
|
||||
economic_count: usize,
|
||||
data: &DataSet,
|
||||
fills: &[FillEvent],
|
||||
at: NaiveDateTime,
|
||||
) -> Result<PortfolioState, String> {
|
||||
enum Event<'a> {
|
||||
Session,
|
||||
Cash(&'a CashEffect),
|
||||
Corporate(NaiveDate),
|
||||
Settle(NaiveDate),
|
||||
Manual(ManualFillObservation<'a>),
|
||||
Simulated(&'a FillEvent),
|
||||
Close(NaiveDate),
|
||||
}
|
||||
let mut events = Vec::new();
|
||||
let sessions = self.sessions.borrow();
|
||||
let closed = self.closed.borrow();
|
||||
let cash = self.cash.borrow();
|
||||
for date in &*sessions {
|
||||
let clock = date.and_hms_opt(0, 0, 0).unwrap();
|
||||
events.push((clock, 0, 0, Event::Session));
|
||||
events.push((clock, 2, 0, Event::Corporate(*date)));
|
||||
events.push((clock, 3, 0, Event::Settle(*date)));
|
||||
}
|
||||
for (index, effect) in cash.iter().enumerate() {
|
||||
events.push((effect.at, 1, index, Event::Cash(effect)));
|
||||
}
|
||||
for (index, observation) in manual.iter().enumerate() {
|
||||
let clock = if index < economic_count {
|
||||
local(observation.fill.executed_at)
|
||||
} else {
|
||||
local(observation.fill.observed_at)
|
||||
};
|
||||
if clock.date() < self.first_date {
|
||||
return Err("manual corporate execution precedes the represented initial ledger; opening facts are required".into());
|
||||
}
|
||||
events.push((clock, 4, fills.len() + index, Event::Manual(*observation)));
|
||||
}
|
||||
for (index, fill) in fills.iter().enumerate() {
|
||||
fill.validate()?;
|
||||
if fill.origin == crate::events::FillOrigin::DividendReinvestment {
|
||||
// The declared accounting model is recalculated from the
|
||||
// corrected entitlements; this was never a submitted order.
|
||||
continue;
|
||||
}
|
||||
let date = fill.execution_date.unwrap_or(fill.date);
|
||||
// This is the frozen daily matching model, not a broker timestamp.
|
||||
// Intraday contracts must supply their actual execution clock.
|
||||
let clock = match fill.execution_timestamp {
|
||||
Some(clock) => clock,
|
||||
None if matches!(
|
||||
self.matching,
|
||||
MatchingType::OpenAuction | MatchingType::NextBarOpen
|
||||
) =>
|
||||
{
|
||||
date.and_hms_opt(9, 30, 0).unwrap()
|
||||
}
|
||||
None if self.matching == MatchingType::CurrentBarClose
|
||||
&& self.daily_price == PriceField::Close =>
|
||||
{
|
||||
date.and_hms_opt(15, 0, 0).unwrap()
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"manual corporate replay lacks an intraday fill execution clock".into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
events.push((clock, 4, index, Event::Simulated(fill)));
|
||||
}
|
||||
for date in &*closed {
|
||||
events.push((
|
||||
date.and_hms_nano_opt(23, 59, 59, 999_999_999)
|
||||
.unwrap()
|
||||
.min(at),
|
||||
5,
|
||||
0,
|
||||
Event::Close(*date),
|
||||
));
|
||||
}
|
||||
events.sort_by_key(|(clock, priority, sequence, _)| (*clock, *priority, *sequence));
|
||||
let mut book = PortfolioState::from_fixed_initial_cash(initial_cash);
|
||||
for (clock, _, _, event) in events {
|
||||
if clock > at {
|
||||
return Err("manual corporate replay contains a future financial fact".into());
|
||||
}
|
||||
match event {
|
||||
Event::Session => book.begin_trading_day(),
|
||||
Event::Cash(effect) => {
|
||||
book.apply_cash_delta_fixed(effect.amount)?;
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err(
|
||||
"manual corporate replay conflicts with prior cash facts".into()
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::Corporate(date) => {
|
||||
crate::corporate_book::apply(
|
||||
date,
|
||||
data,
|
||||
&mut book,
|
||||
&mut Vec::new(),
|
||||
self.cash_dividends,
|
||||
self.adjust_cost,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Settle(date) => {
|
||||
crate::corporate_book::settle_receivables(date, data, &mut book, &mut Vec::new(), self.reinvest, Some(runtime_input))
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Event::Manual(observation) => {
|
||||
observation.apply(&mut book, data, false)?;
|
||||
}
|
||||
Event::Simulated(fill) => {
|
||||
let gross = FixedMoney::from_f64(fill.gross_amount)
|
||||
.ok_or("invalid simulated gross amount")?;
|
||||
let fees = FixedMoney::checked_sum_f64([
|
||||
fill.commission,
|
||||
fill.stamp_tax,
|
||||
fill.transfer_fee,
|
||||
])
|
||||
.ok_or("invalid simulated fee amount")?;
|
||||
book.apply_observed_manual_fill(
|
||||
fill.execution_date.unwrap_or(fill.date),
|
||||
&fill.symbol,
|
||||
fill.side,
|
||||
fill.quantity,
|
||||
fill.price,
|
||||
fill.price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
book.prune_flat_positions();
|
||||
}
|
||||
Event::Close(date) => {
|
||||
book.update_prices_with_options(
|
||||
date,
|
||||
data,
|
||||
PriceField::Close,
|
||||
self.same_day_mark_at_fill,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
if book.cash_fixed() < FixedMoney::ZERO {
|
||||
return Err("manual corporate replay would borrow unobserved cash".into());
|
||||
}
|
||||
}
|
||||
Ok(book)
|
||||
}
|
||||
}
|
||||
|
||||
fn local(value: DateTime<Utc>) -> NaiveDateTime {
|
||||
value
|
||||
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.naive_local()
|
||||
}
|
||||
fn digest(value: &serde_json::Value) -> Result<String, String> {
|
||||
Ok(format!(
|
||||
"{:x}",
|
||||
Sha256::digest(serde_json::to_vec(value).map_err(|error| error.to_string())?)
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
//! Confirmed manual fills are external observations, not simulated broker fills.
|
||||
//! The producer must bind these records to the runtime's durable order/audit facts.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::events::OrderSide;
|
||||
use crate::{DataSet, FixedMoney, PortfolioState};
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
|
||||
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionReplay {
|
||||
pub schema: String,
|
||||
pub runtime_id: String,
|
||||
pub account_id: String,
|
||||
pub source_contract_sha256: String,
|
||||
pub content_sha256: String,
|
||||
pub observation_cutoff: DateTime<Utc>,
|
||||
pub actions: Vec<ManualExecutionAction>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionAction {
|
||||
pub action_id: String,
|
||||
pub source: ManualExecutionSource,
|
||||
pub audit_event_ids: Vec<String>,
|
||||
pub confirmed_at: DateTime<Utc>,
|
||||
pub confirmation_observed_at: DateTime<Utc>,
|
||||
pub outcome: ManualActionOutcome,
|
||||
pub orders: Vec<ManualExecutionOrder>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualActionOutcome {
|
||||
NoOrdersNeeded,
|
||||
NotExecuted,
|
||||
OrdersTerminal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualExecutionSource {
|
||||
ManualSecurityTrade,
|
||||
ManualPositionAction,
|
||||
ManualRebalance,
|
||||
StockPoolAllocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionOrder {
|
||||
pub order_id: String,
|
||||
pub broker_order_id: Option<String>,
|
||||
pub source_adapter: Option<String>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub order_created_at: DateTime<Utc>,
|
||||
pub terminal_observed_at: DateTime<Utc>,
|
||||
pub terminal_status: ManualOrderTerminalStatus,
|
||||
pub fills: Vec<ManualExecutionFill>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualOrderTerminalStatus {
|
||||
Filled,
|
||||
Cancelled,
|
||||
Rejected,
|
||||
Expired,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualExecutionFill {
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observation_sequence: u64,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub trade_date: NaiveDate,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub timestamp_precision: ManualTimestampPrecision,
|
||||
pub quantity: u32,
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub price: Decimal,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub commission: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub stamp_tax: Option<Decimal>,
|
||||
#[serde(default, with = "rust_decimal::serde::str_option")]
|
||||
pub transfer_fee: Option<Decimal>,
|
||||
/// Full observed charge, including any venue fees not itemized above.
|
||||
#[serde(with = "rust_decimal::serde::str")]
|
||||
pub total_fee: Decimal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ManualTimestampPrecision {
|
||||
Second,
|
||||
Millisecond,
|
||||
Microsecond,
|
||||
Nanosecond,
|
||||
}
|
||||
|
||||
impl ManualTimestampPrecision {
|
||||
fn nanoseconds(self) -> i64 {
|
||||
match self {
|
||||
Self::Second => 1_000_000_000,
|
||||
Self::Millisecond => 1_000_000,
|
||||
Self::Microsecond => 1_000,
|
||||
Self::Nanosecond => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualExecutionFill {
|
||||
pub fn gross_amount(&self) -> Result<Decimal, String> {
|
||||
self.price
|
||||
.checked_mul(Decimal::from(self.quantity))
|
||||
.ok_or_else(|| "manual fill gross amount overflow".into())
|
||||
}
|
||||
|
||||
pub fn total_fees(&self) -> Result<Decimal, String> {
|
||||
let known = [self.commission, self.stamp_tax, self.transfer_fee]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.try_fold(Decimal::ZERO, |sum, fee| {
|
||||
if fee < Decimal::ZERO {
|
||||
return Err("manual fill fee component is negative");
|
||||
}
|
||||
sum.checked_add(fee).ok_or("manual fill fees overflow")
|
||||
})?;
|
||||
if self.total_fee < known {
|
||||
return Err("manual total fee is below its known components".into());
|
||||
}
|
||||
Ok(self.total_fee)
|
||||
}
|
||||
}
|
||||
|
||||
fn identifier(value: &str) -> Result<(), String> {
|
||||
if value.is_empty()
|
||||
|| value.trim() != value
|
||||
|| value.len() > 256
|
||||
|| value.chars().any(char::is_control)
|
||||
{
|
||||
return Err("manual execution identity is empty, untrimmed or invalid".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl ManualExecutionReplay {
|
||||
/// Market/indicator data is needed for securities whose observed fills
|
||||
/// change the portfolio. A rejected, never-filled order is not data demand.
|
||||
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
|
||||
self.validate()?;
|
||||
Ok(self
|
||||
.actions
|
||||
.iter()
|
||||
.flat_map(|action| &action.orders)
|
||||
.filter(|order| !order.fills.is_empty())
|
||||
.map(|order| order.symbol.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
|
||||
self.validate()?;
|
||||
let mut observations = Vec::new();
|
||||
for action in &self.actions {
|
||||
for order in &action.orders {
|
||||
for fill in &order.fills {
|
||||
observations.push(ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
|
||||
Ok(observations)
|
||||
}
|
||||
pub fn content_digest(&self) -> Result<String, String> {
|
||||
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
|
||||
value
|
||||
.as_object_mut()
|
||||
.ok_or("manual replay is not an object")?
|
||||
.remove("contentSha256");
|
||||
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
|
||||
Ok(format!("{:x}", Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.schema != MANUAL_REPLAY_SCHEMA
|
||||
&& self.schema != "fidc.observed-manual-executions/v2"
|
||||
{
|
||||
return Err("unsupported manual replay schema".into());
|
||||
}
|
||||
if self.schema == "fidc.observed-manual-executions/v2"
|
||||
&& (!self.position_exposure_events.is_empty()
|
||||
|| !self.legacy_position_exposure_bps.is_empty())
|
||||
{
|
||||
return Err("runtime configuration requires manual replay v3".into());
|
||||
}
|
||||
crate::position_exposure::PositionExposureTimeline::from_events(
|
||||
&self.position_exposure_events,
|
||||
)?;
|
||||
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
|
||||
return Err("observed runtime position event is after the evidence cutoff".into());
|
||||
}
|
||||
if self
|
||||
.legacy_position_exposure_bps
|
||||
.values()
|
||||
.any(|value| !(0..=10000).contains(value))
|
||||
{
|
||||
return Err("legacy manual exposure is outside 0..10000 bps".into());
|
||||
}
|
||||
identifier(&self.runtime_id)?;
|
||||
identifier(&self.account_id)?;
|
||||
if self.source_contract_sha256.len() != 64
|
||||
|| !self
|
||||
.source_contract_sha256
|
||||
.bytes()
|
||||
.all(|v| v.is_ascii_hexdigit())
|
||||
{
|
||||
return Err("manual replay source contract hash is invalid".into());
|
||||
}
|
||||
if self.content_digest()? != self.content_sha256 {
|
||||
return Err("manual replay content digest mismatch".into());
|
||||
}
|
||||
if self.actions.len() > 100_000 {
|
||||
return Err("manual replay action limit exceeded; trace was not truncated".into());
|
||||
}
|
||||
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
|
||||
let mut actions = BTreeSet::new();
|
||||
let mut audits = BTreeSet::new();
|
||||
let mut orders = BTreeSet::new();
|
||||
let mut broker_orders = BTreeSet::new();
|
||||
let mut trades = BTreeSet::new();
|
||||
let mut observation_events = BTreeSet::new();
|
||||
let mut observation_sequences = BTreeSet::new();
|
||||
let mut fee_observations = BTreeSet::new();
|
||||
let mut receipt_ids = BTreeMap::new();
|
||||
let mut receipt_sequences = BTreeMap::new();
|
||||
for action in &self.actions {
|
||||
identifier(&action.action_id)?;
|
||||
if !actions.insert(action.action_id.as_str())
|
||||
|| action.confirmed_at > self.observation_cutoff
|
||||
|| action.confirmation_observed_at < action.confirmed_at
|
||||
|| action.confirmation_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err("duplicate manual action or confirmation after cutoff".into());
|
||||
}
|
||||
if action.audit_event_ids.is_empty() {
|
||||
return Err("manual action has no immutable audit binding".into());
|
||||
}
|
||||
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
|
||||
return Err("manual action outcome does not prove its order coverage".into());
|
||||
}
|
||||
for id in &action.audit_event_ids {
|
||||
identifier(id)?;
|
||||
if !audits.insert(id.as_str()) {
|
||||
return Err("manual audit event is bound more than once".into());
|
||||
}
|
||||
}
|
||||
for order in &action.orders {
|
||||
identifier(&order.order_id)?;
|
||||
if let Some(adapter) = &order.source_adapter {
|
||||
identifier(adapter)?;
|
||||
}
|
||||
identifier(&order.symbol)?;
|
||||
if let Some(id) = &order.broker_order_id {
|
||||
identifier(id)?;
|
||||
if !broker_orders.insert((
|
||||
order
|
||||
.source_adapter
|
||||
.as_deref()
|
||||
.ok_or("broker identity requires its source adapter")?,
|
||||
order.order_created_at.with_timezone(&shanghai).date_naive(),
|
||||
id.as_str(),
|
||||
)) {
|
||||
return Err("manual local orders share one broker order identity".into());
|
||||
}
|
||||
}
|
||||
if !order.fills.is_empty() && order.source_adapter.is_none() {
|
||||
return Err("manual fills require a known source adapter".into());
|
||||
}
|
||||
if !order.fills.is_empty()
|
||||
&& order.source_adapter.as_deref() != Some("paper")
|
||||
&& order.broker_order_id.is_none()
|
||||
{
|
||||
return Err(
|
||||
"manual broker fills require their original broker order identity".into(),
|
||||
);
|
||||
}
|
||||
if !orders.insert(order.order_id.as_str())
|
||||
|| order.quantity == 0
|
||||
|| order.quantity > i32::MAX as u32
|
||||
{
|
||||
return Err("duplicate manual order or invalid quantity".into());
|
||||
}
|
||||
if order.order_created_at < action.confirmed_at
|
||||
|| order.terminal_observed_at < order.order_created_at
|
||||
|| order.terminal_observed_at > self.observation_cutoff
|
||||
{
|
||||
return Err(
|
||||
"manual order confirmation/submission/terminal time is inconsistent".into(),
|
||||
);
|
||||
}
|
||||
let mut filled = 0_u32;
|
||||
for fill in &order.fills {
|
||||
identifier(&fill.trade_id)?;
|
||||
identifier(&fill.observation_event_id)?;
|
||||
identifier(&fill.fee_observation_event_id)?;
|
||||
if fill.observation_sequence == 0
|
||||
|| fill.observation_sequence > i64::MAX as u64
|
||||
|| !observation_events.insert(fill.observation_event_id.as_str())
|
||||
|| !observation_sequences.insert(fill.observation_sequence)
|
||||
{
|
||||
return Err(
|
||||
"manual fill requires a unique durable observation event and sequence"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if fill.fee_observation_sequence == 0
|
||||
|| fill.fee_observation_sequence > i64::MAX as u64
|
||||
|| fill.fee_observed_at < fill.observed_at
|
||||
|| fill.fee_observed_at > self.observation_cutoff
|
||||
|| !fee_observations.insert((
|
||||
fill.fee_observation_event_id.as_str(),
|
||||
fill.fee_observation_sequence,
|
||||
))
|
||||
{
|
||||
return Err("manual finalized fees require their own unique observation within the cutoff".into());
|
||||
}
|
||||
if (fill.fee_observation_event_id == fill.observation_event_id)
|
||||
!= (fill.fee_observation_sequence == fill.observation_sequence)
|
||||
|| (fill.fee_observation_event_id == fill.observation_event_id
|
||||
&& fill.fee_observed_at != fill.observed_at)
|
||||
{
|
||||
return Err("manual fill and fee observation identities disagree".into());
|
||||
}
|
||||
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|
||||
|| fill.quantity == 0
|
||||
{
|
||||
return Err("duplicate manual trade or zero fill quantity".into());
|
||||
}
|
||||
for (id, sequence) in [
|
||||
(&fill.observation_event_id, fill.observation_sequence),
|
||||
(
|
||||
&fill.fee_observation_event_id,
|
||||
fill.fee_observation_sequence,
|
||||
),
|
||||
] {
|
||||
if receipt_ids
|
||||
.insert(id, (&fill.trade_id, sequence))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|
||||
|| receipt_sequences
|
||||
.insert(sequence, (&fill.trade_id, id))
|
||||
.is_some_and(|owner| owner != (&fill.trade_id, id))
|
||||
{
|
||||
return Err("manual observation identity is reused by a different trade or sequence".into());
|
||||
}
|
||||
}
|
||||
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|
||||
|| fill.observed_at > self.observation_cutoff
|
||||
|| fill.observed_at < order.order_created_at
|
||||
|| fill.observed_at < action.confirmation_observed_at
|
||||
|| fill.observed_at < fill.executed_at
|
||||
|| fill.executed_at > order.terminal_observed_at
|
||||
{
|
||||
return Err("manual fill execution/observation time is inconsistent".into());
|
||||
}
|
||||
if i64::from(fill.executed_at.nanosecond())
|
||||
% fill.timestamp_precision.nanoseconds()
|
||||
!= 0
|
||||
{
|
||||
return Err(
|
||||
"broker timestamp contains digits finer than its declared precision"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let upper = fill
|
||||
.executed_at
|
||||
.checked_add_signed(chrono::Duration::nanoseconds(
|
||||
fill.timestamp_precision.nanoseconds(),
|
||||
))
|
||||
.ok_or("manual execution timestamp overflow")?;
|
||||
let earliest = order.order_created_at.max(action.confirmation_observed_at);
|
||||
if fill.executed_at < earliest && earliest >= upper {
|
||||
return Err("manual fill predates its order or durable confirmation".into());
|
||||
}
|
||||
if fill.price <= Decimal::ZERO {
|
||||
return Err("manual fill requires a positive price".into());
|
||||
}
|
||||
fill.gross_amount()?
|
||||
.checked_add(fill.total_fees()?)
|
||||
.ok_or("manual fill cash amount overflow")?;
|
||||
filled = filled
|
||||
.checked_add(fill.quantity)
|
||||
.ok_or("manual cumulative fill quantity overflow")?;
|
||||
}
|
||||
if filled > order.quantity
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
|
||||
&& filled != order.quantity)
|
||||
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|
||||
|| (matches!(
|
||||
order.terminal_status,
|
||||
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
|
||||
) && filled == order.quantity)
|
||||
{
|
||||
return Err("manual terminal status disagrees with cumulative fills".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ManualFillObservation<'a> {
|
||||
pub action: &'a ManualExecutionAction,
|
||||
pub order: &'a ManualExecutionOrder,
|
||||
pub fill: &'a ManualExecutionFill,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AppliedManualFill {
|
||||
pub gross: FixedMoney,
|
||||
pub fees: FixedMoney,
|
||||
pub cash_delta: FixedMoney,
|
||||
pub quantity_after: u32,
|
||||
}
|
||||
|
||||
/// One replay owns its immutable trace and progress. Advancing is atomic even
|
||||
/// if a later receipt in the same step disagrees with the shadow account.
|
||||
pub struct ManualReplayCursor {
|
||||
replay: std::sync::Arc<ManualExecutionReplay>,
|
||||
indices: Vec<(usize, usize, usize)>,
|
||||
cursor: usize,
|
||||
clock: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ManualReplayApplication {
|
||||
pub action_id: String,
|
||||
pub order_id: String,
|
||||
pub trade_id: String,
|
||||
pub observation_event_id: String,
|
||||
pub observation_sequence: u64,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub fee_observation_event_id: String,
|
||||
pub fee_observed_at: DateTime<Utc>,
|
||||
pub executed_at: DateTime<Utc>,
|
||||
pub symbol: String,
|
||||
pub side: OrderSide,
|
||||
pub quantity: u32,
|
||||
pub quantity_after: u32,
|
||||
pub price: String,
|
||||
pub commission: Option<String>,
|
||||
pub stamp_tax: Option<String>,
|
||||
pub transfer_fee: Option<String>,
|
||||
pub source_total_fee: String,
|
||||
pub source_gross_amount: String,
|
||||
pub ledger_gross_amount: String,
|
||||
pub ledger_fees: String,
|
||||
pub cash_delta: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub corporate_adjustment: Option<ManualCorporateAdjustment>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateAdjustment {
|
||||
pub schema: String,
|
||||
pub observed_at: DateTime<Utc>,
|
||||
pub cash_dividends_enabled: bool,
|
||||
pub dividend_cost_basis_adjustment: bool,
|
||||
#[serde(default, skip_serializing_if = "disabled_flag")]
|
||||
pub dividend_reinvestment: bool,
|
||||
pub actions: Vec<ManualCorporateActionReference>,
|
||||
pub cash_before: String,
|
||||
pub cash_after: String,
|
||||
pub corporate_cash_delta: String,
|
||||
pub positions: BTreeMap<String, ManualCorporatePositionChange>,
|
||||
pub reference_sha256: String,
|
||||
pub replayed_sha256: String,
|
||||
}
|
||||
|
||||
fn disabled_flag(value: &bool) -> bool { !value }
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporateActionReference {
|
||||
pub date: NaiveDate,
|
||||
pub symbol: String,
|
||||
pub successor_symbol: Option<String>,
|
||||
pub share_cash: String,
|
||||
pub split_ratio: String,
|
||||
pub successor_ratio: Option<String>,
|
||||
pub successor_cash: Option<String>,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct ManualCorporatePositionChange {
|
||||
pub quantity_before: u32,
|
||||
pub quantity_after: u32,
|
||||
pub cost_basis_before: String,
|
||||
pub cost_basis_after: String,
|
||||
}
|
||||
|
||||
impl ManualReplayCursor {
|
||||
pub(crate) fn frozen_source(&self) -> std::sync::Arc<ManualExecutionReplay> {
|
||||
self.replay.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn next_observation(&self) -> Option<ManualFillObservation<'_>> {
|
||||
self.indices.get(self.cursor).map(|&(a, o, f)| ManualFillObservation {
|
||||
action: &self.replay.actions[a], order: &self.replay.actions[a].orders[o],
|
||||
fill: &self.replay.actions[a].orders[o].fills[f],
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn advance_next_projected<F>(
|
||||
&mut self, portfolio: &mut PortfolioState, project: F,
|
||||
) -> Result<Option<ManualReplayApplication>, String>
|
||||
where F: FnOnce(ManualFillObservation<'_>, &mut PortfolioState) -> Result<(AppliedManualFill, ManualCorporateAdjustment), String> {
|
||||
let Some(observation) = self.next_observation() else { return Ok(None); };
|
||||
let at = observation.fill.observed_at;
|
||||
if at > self.replay.observation_cutoff || self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual projected observation clock violates the frozen trace".into());
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let (applied, adjustment) = project(observation, &mut next)?;
|
||||
let mut application = observation.application(applied)?;
|
||||
application.corporate_adjustment = Some(adjustment);
|
||||
crate::finite_serialization::validate(&application).map_err(|error| error.to_string())?;
|
||||
*portfolio = next;
|
||||
self.cursor += 1;
|
||||
self.clock = Some(at);
|
||||
Ok(Some(application))
|
||||
}
|
||||
|
||||
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
|
||||
Self::from_shared(std::sync::Arc::new(replay))
|
||||
}
|
||||
|
||||
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
|
||||
replay.validate()?;
|
||||
let mut indices = Vec::new();
|
||||
for (a, action) in replay.actions.iter().enumerate() {
|
||||
for (o, order) in action.orders.iter().enumerate() {
|
||||
for f in 0..order.fills.len() {
|
||||
indices.push((a, o, f));
|
||||
}
|
||||
}
|
||||
}
|
||||
indices.sort_by_key(|&(a, o, f)| {
|
||||
let fill = &replay.actions[a].orders[o].fills[f];
|
||||
(fill.observed_at, fill.observation_sequence)
|
||||
});
|
||||
Ok(Self {
|
||||
replay,
|
||||
indices,
|
||||
cursor: 0,
|
||||
clock: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
|
||||
self.indices
|
||||
.get(self.cursor)
|
||||
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
|
||||
}
|
||||
|
||||
pub fn applied_count(&self) -> usize {
|
||||
self.cursor
|
||||
}
|
||||
|
||||
pub fn advance(
|
||||
&mut self,
|
||||
at: DateTime<Utc>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
let end = self.cursor
|
||||
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
|
||||
self.replay.actions[a].orders[o].fills[f].observed_at <= at
|
||||
}).count();
|
||||
self.advance_through(at, end, portfolio, data, has_pending_orders)
|
||||
}
|
||||
|
||||
/// One receipt at a time lets callbacks observe the intermediate state
|
||||
/// when multiple fills share a timestamp but have distinct durable sequences.
|
||||
pub fn advance_next(
|
||||
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Option<ManualReplayApplication>, String> {
|
||||
let Some(at) = self.next_observation_at() else { return Ok(None); };
|
||||
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
|
||||
Ok(applications.pop())
|
||||
}
|
||||
|
||||
fn advance_through(
|
||||
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
|
||||
data: &DataSet, has_pending_orders: bool,
|
||||
) -> Result<Vec<ManualReplayApplication>, String> {
|
||||
if at > self.replay.observation_cutoff {
|
||||
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
|
||||
}
|
||||
if self.clock.is_some_and(|clock| at < clock) {
|
||||
return Err("manual observation clock moved backwards".into());
|
||||
}
|
||||
if end == self.cursor {
|
||||
self.clock = Some(at);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let mut next = portfolio.clone();
|
||||
let mut applications = Vec::with_capacity(end - self.cursor);
|
||||
for &(a, o, f) in &self.indices[self.cursor..end] {
|
||||
let action = &self.replay.actions[a];
|
||||
let order = &action.orders[o];
|
||||
let fill = &order.fills[f];
|
||||
let applied = ManualFillObservation {
|
||||
action,
|
||||
order,
|
||||
fill,
|
||||
}
|
||||
.apply(&mut next, data, has_pending_orders)?;
|
||||
applications.push(ManualReplayApplication {
|
||||
action_id: action.action_id.clone(),
|
||||
order_id: order.order_id.clone(),
|
||||
trade_id: fill.trade_id.clone(),
|
||||
observation_event_id: fill.observation_event_id.clone(),
|
||||
observation_sequence: fill.observation_sequence,
|
||||
observed_at: fill.observed_at,
|
||||
fee_observation_event_id: fill.fee_observation_event_id.clone(),
|
||||
fee_observed_at: fill.fee_observed_at,
|
||||
executed_at: fill.executed_at,
|
||||
symbol: order.symbol.clone(),
|
||||
side: order.side,
|
||||
quantity: fill.quantity,
|
||||
quantity_after: applied.quantity_after,
|
||||
price: fill.price.to_string(),
|
||||
commission: fill.commission.map(|fee| fee.to_string()),
|
||||
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
|
||||
source_total_fee: fill.total_fee.to_string(),
|
||||
source_gross_amount: fill.gross_amount()?.to_string(),
|
||||
ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(),
|
||||
cash_delta: applied.cash_delta.to_decimal_string(),
|
||||
corporate_adjustment: None,
|
||||
});
|
||||
}
|
||||
*portfolio = next;
|
||||
self.cursor = end;
|
||||
self.clock = Some(at);
|
||||
Ok(applications)
|
||||
}
|
||||
}
|
||||
|
||||
impl ManualFillObservation<'_> {
|
||||
fn application(&self, applied: AppliedManualFill) -> Result<ManualReplayApplication, String> {
|
||||
Ok(ManualReplayApplication {
|
||||
action_id: self.action.action_id.clone(), order_id: self.order.order_id.clone(),
|
||||
trade_id: self.fill.trade_id.clone(), observation_event_id: self.fill.observation_event_id.clone(),
|
||||
observation_sequence: self.fill.observation_sequence, observed_at: self.fill.observed_at,
|
||||
fee_observation_event_id: self.fill.fee_observation_event_id.clone(), fee_observed_at: self.fill.fee_observed_at,
|
||||
executed_at: self.fill.executed_at, symbol: self.order.symbol.clone(), side: self.order.side,
|
||||
quantity: self.fill.quantity, quantity_after: applied.quantity_after, price: self.fill.price.to_string(),
|
||||
commission: self.fill.commission.map(|fee| fee.to_string()), stamp_tax: self.fill.stamp_tax.map(|fee| fee.to_string()),
|
||||
transfer_fee: self.fill.transfer_fee.map(|fee| fee.to_string()), source_total_fee: self.fill.total_fee.to_string(),
|
||||
source_gross_amount: self.fill.gross_amount()?.to_string(), ledger_gross_amount: applied.gross.to_decimal_string(),
|
||||
ledger_fees: applied.fees.to_decimal_string(), cash_delta: applied.cash_delta.to_decimal_string(), corporate_adjustment: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn apply(
|
||||
&self,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
has_pending_orders: bool,
|
||||
) -> Result<AppliedManualFill, String> {
|
||||
if has_pending_orders {
|
||||
return Err("manual observation conflicts with pending shadow orders".into());
|
||||
}
|
||||
let instrument = data
|
||||
.instrument(&self.order.symbol)
|
||||
.ok_or("manual observation instrument is absent from frozen source data")?;
|
||||
if instrument
|
||||
.dated_market_absence_reason(self.fill.trade_date)
|
||||
.is_some()
|
||||
{
|
||||
return Err("manual execution contradicts the frozen instrument lifecycle".into());
|
||||
}
|
||||
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
|
||||
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
|
||||
let price = self
|
||||
.fill
|
||||
.price
|
||||
.to_f64()
|
||||
.filter(|price| price.is_finite() && *price > 0.)
|
||||
.ok_or("manual execution price cannot be represented for valuation")?;
|
||||
// This is the real observed trade price, not a fabricated quote. The
|
||||
// normal market clock remains responsible for subsequent marks.
|
||||
let cash_delta = portfolio.apply_observed_manual_fill(
|
||||
self.fill.trade_date,
|
||||
&self.order.symbol,
|
||||
self.order.side,
|
||||
self.fill.quantity,
|
||||
price,
|
||||
price,
|
||||
gross,
|
||||
fees,
|
||||
)?;
|
||||
Ok(AppliedManualFill {
|
||||
gross,
|
||||
fees,
|
||||
cash_delta,
|
||||
quantity_after: portfolio
|
||||
.position(&self.order.symbol)
|
||||
.map_or(0, |position| position.quantity),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,761 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn sample() -> ManualExecutionReplay {
|
||||
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
|
||||
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
|
||||
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
|
||||
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
|
||||
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
|
||||
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
|
||||
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
|
||||
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
|
||||
"fills":[fill]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
reseal(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn reseal(input: &mut ManualExecutionReplay) {
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_corporate_projection_does_not_change_the_book_or_receipt_cursor() {
|
||||
let mut cursor = ManualReplayCursor::new(sample()).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
let before = portfolio.financial_replay_identity();
|
||||
let error = cursor.advance_next_projected(&mut portfolio, |_, next| {
|
||||
next.apply_cash_delta(-50.)?;
|
||||
next.position_mut("000001.SZ").buy(NaiveDate::from_ymd_opt(2026, 9, 11).unwrap(), 100, 10.);
|
||||
Err("financial coverage mismatch".into())
|
||||
}).unwrap_err();
|
||||
assert_eq!(error, "financial coverage mismatch");
|
||||
assert_eq!(portfolio.financial_replay_identity(), before);
|
||||
assert_eq!(cursor.applied_count(), 0);
|
||||
}
|
||||
|
||||
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);
|
||||
input.validate()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
|
||||
let input = sample();
|
||||
input.validate().unwrap();
|
||||
let fill = &input.actions[0].orders[0].fills[0];
|
||||
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
|
||||
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
|
||||
"10.1234567891"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
|
||||
let mut input = sample();
|
||||
let mut rejected = input.actions[0].orders[0].clone();
|
||||
rejected.order_id = "rejected-order".into();
|
||||
rejected.broker_order_id = None;
|
||||
rejected.source_adapter = None;
|
||||
rejected.symbol = "510300.SH".into();
|
||||
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
rejected.fills.clear();
|
||||
input.actions[0].orders.push(rejected);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input.required_data_symbols().unwrap(),
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
);
|
||||
input.actions[0].orders[0].symbol = "600000.SH".into();
|
||||
assert!(input.required_data_symbols().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
|
||||
let mut input = sample();
|
||||
input.schema = "fidc.observed-manual-executions/v2".into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let old = serde_json::to_value(&input).unwrap();
|
||||
assert!(old.get("positionExposureEvents").is_none());
|
||||
assert!(old.get("legacyPositionExposureBps").is_none());
|
||||
input
|
||||
.legacy_position_exposure_bps
|
||||
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
|
||||
reseal(&mut input);
|
||||
assert!(input.validate().is_err());
|
||||
input.schema = MANUAL_REPLAY_SCHEMA.into();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
|
||||
let mut input = sample();
|
||||
input.position_exposure_events.push(serde_json::from_value(json!({
|
||||
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
|
||||
"action": "scale", "requestedBps": 5000
|
||||
})).unwrap());
|
||||
semantic_result(&input).unwrap();
|
||||
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
|
||||
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
||||
let original = serde_json::to_value(sample()).unwrap();
|
||||
for field in ["price", "totalFee"] {
|
||||
let mut missing = original.clone();
|
||||
missing["actions"][0]["orders"][0]["fills"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
let mut numeric = original.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
|
||||
"numeric {field}"
|
||||
);
|
||||
}
|
||||
for mutate in [
|
||||
("schema", json!("unknown")),
|
||||
("sourceContractSha256", json!("broken")),
|
||||
("accountId", json!(" ")),
|
||||
] {
|
||||
let mut value = original.clone();
|
||||
value[mutate.0] = mutate.1;
|
||||
assert!(
|
||||
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
|
||||
let original = sample();
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].quantity = 200;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].audit_event_ids.clear();
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions.push(invalid.actions[0].clone());
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
|
||||
invalid.actions[0].orders[0].fills.push(duplicate);
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].broker_order_id = None;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
|
||||
reseal(&mut invalid);
|
||||
invalid.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
|
||||
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].fee_observed_at =
|
||||
input.actions[0].orders[0].fills[0].observed_at;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].fills[0].executed_at =
|
||||
"2026-09-14T01:30:00.800001Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders.clear();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let mut value = serde_json::to_value(input).unwrap();
|
||||
value["actions"][0]["outcome"] = json!("result_unknown");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_timezone_and_cutoff_are_required() {
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
let mut input = sample();
|
||||
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
fill.commission = None;
|
||||
fill.stamp_tax = None;
|
||||
fill.transfer_fee = None;
|
||||
assert_eq!(
|
||||
fill.total_fees().unwrap(),
|
||||
"0.1200001".parse::<Decimal>().unwrap()
|
||||
);
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
let value = serde_json::to_value(&input).unwrap();
|
||||
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
|
||||
assert_eq!(
|
||||
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
|
||||
"0.1200001"
|
||||
);
|
||||
for field in ["commission", "stampTax", "transferFee"] {
|
||||
let mut numeric = value.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_ok());
|
||||
assert_eq!(
|
||||
input.actions[0].orders[0].fills[0]
|
||||
.total_fees()
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
"0.15"
|
||||
);
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut input = sample();
|
||||
let fill = &mut input.actions[0].orders[0].fills[0];
|
||||
let original = fill.observed_at;
|
||||
fill.fee_observation_event_id = "fee-receipt-1".into();
|
||||
fill.fee_observation_sequence = 2;
|
||||
fill.fee_observed_at = original + chrono::Duration::hours(1);
|
||||
let fee_time = fill.fee_observed_at;
|
||||
reseal(&mut input);
|
||||
let mut cursor = ManualReplayCursor::new(input).unwrap();
|
||||
assert_eq!(cursor.next_observation_at(), Some(original));
|
||||
let mut portfolio = PortfolioState::new(10_000.);
|
||||
let result = cursor
|
||||
.advance(original, &mut portfolio, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].observed_at, original);
|
||||
assert_eq!(result[0].fee_observed_at, fee_time);
|
||||
assert_eq!(result[0].source_total_fee, "0.1200001");
|
||||
assert!(
|
||||
cursor
|
||||
.advance(fee_time, &mut portfolio, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
|
||||
let input = sample();
|
||||
let original = input.content_sha256.clone();
|
||||
let mut changed = input.clone();
|
||||
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert_eq!(
|
||||
changed.validate().unwrap_err(),
|
||||
"manual replay content digest mismatch"
|
||||
);
|
||||
let mut changed = input;
|
||||
changed.account_id = "another-account".into();
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
|
||||
fn identity_data(listed: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "test".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(listed),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: listed,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
let applied = observations[0].apply(&mut account, &data, false).unwrap();
|
||||
assert_eq!(
|
||||
applied.gross,
|
||||
FixedMoney::from_decimal_str("1012.345679").unwrap()
|
||||
);
|
||||
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
|
||||
assert_eq!(account.cash(), 8987.534321);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.unwrap()
|
||||
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
|
||||
0
|
||||
);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
assert_eq!(account.starting_cash(), 10_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let mut poor = PortfolioState::new(10.);
|
||||
assert!(observations[0].apply(&mut poor, &data, false).is_err());
|
||||
assert_eq!(poor.cash(), 10.);
|
||||
assert!(poor.positions().is_empty());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert!(observations[0].apply(&mut account, &data, true).is_err());
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
assert!(account.positions().is_empty());
|
||||
observations[0].apply(&mut account, &data, false).unwrap();
|
||||
let before = account.cash();
|
||||
let mut sell = input.clone();
|
||||
sell.actions[0].orders[0].side = OrderSide::Sell;
|
||||
reseal(&mut sell);
|
||||
assert!(
|
||||
sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
|
||||
assert!(
|
||||
observations[0]
|
||||
.apply(&mut account, &unlisted, false)
|
||||
.unwrap_err()
|
||||
.contains("lifecycle")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
input.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
let mut sell = input.clone();
|
||||
let order = &mut sell.actions[0].orders[0];
|
||||
order.side = OrderSide::Sell;
|
||||
order.order_created_at += chrono::Duration::days(1);
|
||||
order.terminal_observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
|
||||
order.fills[0].executed_at += chrono::Duration::days(1);
|
||||
order.fills[0].observed_at += chrono::Duration::days(1);
|
||||
order.fills[0].fee_observed_at += chrono::Duration::days(1);
|
||||
sell.observation_cutoff += chrono::Duration::days(1);
|
||||
reseal(&mut sell);
|
||||
let applied = sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(applied.quantity_after, 0);
|
||||
assert_eq!(account.cash(), 9999.76);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
|
||||
let mut input = sample();
|
||||
let mut second = input.actions[0].orders[0].fills[0].clone();
|
||||
second.trade_id = "trade-2".into();
|
||||
second.observation_event_id = "received-2".into();
|
||||
second.observation_sequence = 2;
|
||||
second.fee_observation_event_id = "received-2".into();
|
||||
second.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.insert(0, second);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input
|
||||
.observations()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.fill.observation_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let mut invalid = input.clone();
|
||||
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
let mut invalid = input;
|
||||
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
|
||||
semantic_result(&input).unwrap();
|
||||
input.actions[0].orders[0].quantity = 100;
|
||||
assert!(
|
||||
semantic_result(&input)
|
||||
.unwrap_err()
|
||||
.contains("terminal status")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
|
||||
let input = sample();
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::milliseconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
let records = replay.advance(at, &mut account, &data, false).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].cash_delta, "-1012.465679");
|
||||
assert_eq!(replay.applied_count(), 1);
|
||||
assert_eq!(replay.next_observation_at(), None);
|
||||
let cash = account.cash();
|
||||
assert!(
|
||||
replay
|
||||
.advance(at, &mut account, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), cash);
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::seconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("backwards")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
|
||||
let mut input = sample();
|
||||
let mut next = input.actions[0].orders[0].fills[0].clone();
|
||||
next.trade_id = "trade-2".into();
|
||||
next.observation_event_id = "received-2".into();
|
||||
next.observation_sequence = 2;
|
||||
next.fee_observation_event_id = "received-2".into();
|
||||
next.fee_observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.push(next);
|
||||
reseal(&mut input);
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(1_500.);
|
||||
assert!(replay.advance(at, &mut account, &data, false).is_err());
|
||||
assert_eq!(account.cash(), 1_500.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(replay.applied_count(), 0);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
|
||||
for text in [
|
||||
"0",
|
||||
"100",
|
||||
"-100",
|
||||
"0.000001",
|
||||
"-0.000001",
|
||||
"12345678901234567890123456.123456",
|
||||
] {
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str(text)
|
||||
.unwrap()
|
||||
.to_decimal_string(),
|
||||
text
|
||||
);
|
||||
}
|
||||
let min = FixedMoney::from_raw(i128::MIN);
|
||||
assert!(min.to_decimal_string().starts_with('-'));
|
||||
}
|
||||
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
|
||||
account_events: &[AccountEvent],
|
||||
initial_cash: f64,
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics_with_manual(
|
||||
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
|
||||
manual_executions: &[crate::manual_execution::ManualReplayApplication],
|
||||
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
|
||||
risk_free_contract: Option<&RiskFreeRateContract>,
|
||||
) -> Result<BacktestMetrics, String> {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return Ok(BacktestMetrics {
|
||||
@@ -229,12 +238,20 @@ pub fn compute_backtest_metrics(
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
|
||||
let turnover_by_date = fills
|
||||
let mut turnover_by_date = fills
|
||||
.iter()
|
||||
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||
acc
|
||||
});
|
||||
for execution in manual_executions {
|
||||
use rust_decimal::prelude::ToPrimitive;
|
||||
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
|
||||
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
|
||||
.ok_or("manual turnover requires its validated ledger gross amount")?;
|
||||
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
|
||||
*turnover_by_date.entry(date).or_default() += gross;
|
||||
}
|
||||
let equity_by_date = equity_curve
|
||||
.iter()
|
||||
.map(|point| (point.date, point.total_equity))
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
use std::ops::Index;
|
||||
|
||||
use compact_str::CompactString;
|
||||
use serde::de::{MapAccess, Visitor};
|
||||
use serde::ser::SerializeMap;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
@@ -10,7 +11,14 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
|
||||
#[derive(Clone, Default, PartialEq)]
|
||||
pub struct NumericFactorMap {
|
||||
entries: Vec<(Cow<'static, str>, f64)>,
|
||||
entries: Vec<(CompactString, f64)>,
|
||||
}
|
||||
|
||||
fn compact_key(key: Cow<'static, str>) -> CompactString {
|
||||
match key {
|
||||
Cow::Borrowed(value) => CompactString::const_new(value),
|
||||
Cow::Owned(value) => CompactString::from(value),
|
||||
}
|
||||
}
|
||||
|
||||
impl NumericFactorMap {
|
||||
@@ -30,16 +38,21 @@ impl NumericFactorMap {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Reserve known new fields without geometric spare capacity per snapshot.
|
||||
pub fn reserve_exact(&mut self, additional: usize) {
|
||||
self.entries.reserve_exact(additional);
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &self.entries[index].1)
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, key: &str) -> Option<&mut f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| &mut self.entries[index].1)
|
||||
}
|
||||
@@ -49,17 +62,21 @@ impl NumericFactorMap {
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, key: Cow<'static, str>, value: f64) -> Option<f64> {
|
||||
self.insert_compact(compact_key(key), value)
|
||||
}
|
||||
|
||||
pub fn insert_compact(&mut self, key: CompactString, value: f64) -> Option<f64> {
|
||||
if self
|
||||
.entries
|
||||
.last()
|
||||
.is_none_or(|(last, _)| last.as_ref() < key.as_ref())
|
||||
.is_none_or(|(last, _)| last.as_str() < key.as_str())
|
||||
{
|
||||
self.entries.push((key, value));
|
||||
return None;
|
||||
}
|
||||
match self
|
||||
.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key.as_ref()))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key.as_str()))
|
||||
{
|
||||
Ok(index) => Some(std::mem::replace(&mut self.entries[index].1, value)),
|
||||
Err(index) => {
|
||||
@@ -71,19 +88,19 @@ impl NumericFactorMap {
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_ref().cmp(key))
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
.ok()
|
||||
.map(|index| self.entries.remove(index).1)
|
||||
}
|
||||
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Cow<'static, str>, &mut f64) -> bool) {
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&CompactString, &mut f64) -> bool) {
|
||||
self.entries.retain_mut(|(key, value)| keep(key, value));
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> Iter<'_> {
|
||||
Iter(self.entries.iter())
|
||||
}
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &Cow<'static, str>> + ExactSizeIterator {
|
||||
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &CompactString> + ExactSizeIterator {
|
||||
self.entries.iter().map(|(key, _)| key)
|
||||
}
|
||||
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
|
||||
@@ -104,9 +121,9 @@ impl Index<&str> for NumericFactorMap {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (Cow<'static, str>, f64)>);
|
||||
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
|
||||
impl<'a> Iterator for Iter<'a> {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.0.next().map(|(k, v)| (k, v))
|
||||
}
|
||||
@@ -121,14 +138,14 @@ impl DoubleEndedIterator for Iter<'_> {
|
||||
}
|
||||
impl ExactSizeIterator for Iter<'_> {}
|
||||
impl<'a> IntoIterator for &'a NumericFactorMap {
|
||||
type Item = (&'a Cow<'static, str>, &'a f64);
|
||||
type Item = (&'a CompactString, &'a f64);
|
||||
type IntoIter = Iter<'a>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.iter()
|
||||
}
|
||||
}
|
||||
impl IntoIterator for NumericFactorMap {
|
||||
type Item = (Cow<'static, str>, f64);
|
||||
type Item = (CompactString, f64);
|
||||
type IntoIter = std::vec::IntoIter<Self::Item>;
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
self.entries.into_iter()
|
||||
@@ -137,6 +154,11 @@ impl IntoIterator for NumericFactorMap {
|
||||
|
||||
impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(iter: T) -> Self {
|
||||
iter.into_iter().map(|(key, value)| (compact_key(key), value)).collect()
|
||||
}
|
||||
}
|
||||
impl FromIterator<(CompactString, f64)> for NumericFactorMap {
|
||||
fn from_iter<T: IntoIterator<Item = (CompactString, f64)>>(iter: T) -> Self {
|
||||
let mut entries: Vec<_> = iter.into_iter().collect();
|
||||
// Stable sorting preserves last-value-wins for repeated input keys.
|
||||
if !entries.windows(2).all(|pair| pair[0].0 <= pair[1].0) {
|
||||
@@ -155,6 +177,11 @@ impl FromIterator<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
}
|
||||
impl Extend<(Cow<'static, str>, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (Cow<'static, str>, f64)>>(&mut self, iter: T) {
|
||||
self.extend(iter.into_iter().map(|(key, value)| (compact_key(key), value)));
|
||||
}
|
||||
}
|
||||
impl Extend<(CompactString, f64)> for NumericFactorMap {
|
||||
fn extend<T: IntoIterator<Item = (CompactString, f64)>>(&mut self, iter: T) {
|
||||
let mut incoming: Self = iter.into_iter().collect();
|
||||
if incoming.is_empty() {
|
||||
return;
|
||||
@@ -194,9 +221,7 @@ impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
|
||||
}
|
||||
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
|
||||
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
entries.into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,8 +244,8 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
}
|
||||
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
|
||||
let mut entries = Vec::new();
|
||||
while let Some((key, value)) = map.next_entry::<String, f64>()? {
|
||||
entries.push((Cow::Owned(key), value));
|
||||
while let Some((key, value)) = map.next_entry::<CompactString, f64>()? {
|
||||
entries.push((key, value));
|
||||
}
|
||||
Ok(entries.into_iter().collect())
|
||||
}
|
||||
@@ -233,6 +258,56 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
|
||||
let mut map = NumericFactorMap::from([
|
||||
(Cow::Borrowed("amount"), 125.25),
|
||||
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
|
||||
(Cow::Borrowed("signal"), -0.0),
|
||||
]);
|
||||
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
|
||||
map.reserve_exact(2);
|
||||
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
|
||||
let buffer = map.entries.as_ptr();
|
||||
map.insert(Cow::Borrowed("pre_close"), 12.5);
|
||||
map.insert(Cow::Borrowed("no_limit"), 0.0);
|
||||
assert_eq!(map.entries.as_ptr(), buffer);
|
||||
assert_eq!(map.len(), 5);
|
||||
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
|
||||
let before = map.entries.as_ptr();
|
||||
map.reserve_exact(0);
|
||||
assert_eq!(map.entries.as_ptr(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
|
||||
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
|
||||
let map = NumericFactorMap::from([
|
||||
(Cow::Owned("dynamic_factor_20".to_owned()), -0.0),
|
||||
(Cow::Borrowed(LONG), 1.0),
|
||||
]);
|
||||
let cloned = map.clone();
|
||||
let short = cloned.keys().find(|key| key.as_str() == "dynamic_factor_20").unwrap();
|
||||
assert!(!short.is_heap_allocated());
|
||||
let long = cloned.keys().find(|key| key.as_str() == LONG).unwrap();
|
||||
assert_eq!(long.as_static_str(), Some(LONG));
|
||||
assert_eq!(cloned["dynamic_factor_20"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(std::mem::size_of::<(CompactString, f64)>(), std::mem::size_of::<(Cow<'static, str>, f64)>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_dynamic_unicode_and_short_keys_keep_the_same_json_map() {
|
||||
let entries = ["", "a", "a_field_longer_than_the_inline_string_capacity", "价格因子", "ths_up_days_stock"]
|
||||
.into_iter().enumerate().map(|(index, key)| (Cow::Owned(key.to_string()), index as f64 + 0.25))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let map = NumericFactorMap::from(entries.clone());
|
||||
assert_eq!(serde_json::to_string(&map).unwrap(), serde_json::to_string(&entries).unwrap());
|
||||
let decoded: NumericFactorMap = serde_json::from_str(&serde_json::to_string(&map).unwrap()).unwrap();
|
||||
assert_eq!(decoded, map);
|
||||
assert!(!decoded.keys().find(|key| key.as_str() == "ths_up_days_stock").unwrap().is_heap_allocated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_order_removal_and_values_match_tree_map() {
|
||||
let mut flat = NumericFactorMap::new();
|
||||
@@ -249,14 +324,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
flat.retain(|_, value| *value > 100.0);
|
||||
tree.retain(|_, value| *value > 100.0);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
std::mem::size_of::<NumericFactorMap>(),
|
||||
@@ -275,8 +350,8 @@ mod tests {
|
||||
let flat: NumericFactorMap = input.clone().into_iter().collect();
|
||||
let tree: BTreeMap<_, _> = input.into_iter().collect();
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(flat["z"], 4.0);
|
||||
}
|
||||
@@ -327,13 +402,14 @@ mod tests {
|
||||
flat.extend(incoming.clone());
|
||||
tree.extend(incoming);
|
||||
assert_eq!(
|
||||
flat.iter().collect::<Vec<_>>(),
|
||||
tree.iter().collect::<Vec<_>>()
|
||||
flat.iter().map(|(key, value)| (key.as_str(), value)).collect::<Vec<_>>(),
|
||||
tree.iter().map(|(key, value)| (key.as_ref(), value)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(flat.keys().last(), Some(Cow::Borrowed("shared"))));
|
||||
assert_eq!(flat.keys().last().map(CompactString::as_str), Some("shared"));
|
||||
assert!(!flat.keys().last().unwrap().is_heap_allocated());
|
||||
flat.extend([(Cow::Borrowed("zz"), f64::NAN)]);
|
||||
assert!(flat["zz"].is_nan());
|
||||
flat.extend(std::iter::empty());
|
||||
flat.extend(std::iter::empty::<(CompactString, f64)>());
|
||||
assert_eq!(flat.len(), tree.len() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
|
||||
}
|
||||
|
||||
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
|
||||
// A runner bundle also contains source/extract copies. Follow the same
|
||||
// authoritative spec selection as the execution loader, not those copies.
|
||||
if let Some(spec) = value.get("strategySpec").or_else(|| value.get("strategy_spec")) {
|
||||
return specs_in_value(spec);
|
||||
}
|
||||
let mut specs = Vec::new();
|
||||
match value {
|
||||
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
|
||||
@@ -268,9 +273,15 @@ mod tests {
|
||||
for (pool_key, source_key) in [("stockPool", "sourceCode"), ("stock_pool", "source_code")] {
|
||||
let value = json!({pool_key:pool,source_key:source,"runtimeExpressions":{"trading":{"buyFilterExpr":expr}}});
|
||||
assert_eq!(specs_in_value(&value).unwrap().len(), 2);
|
||||
for wrapper in ["strategySpec", "strategy_spec"] {
|
||||
let bundle = json!({wrapper:value,"strategy_source":{"source_code":source},
|
||||
"strategy_extract":{"parameters":{"source_code":source}}});
|
||||
assert_eq!(specs_in_value(&bundle).unwrap().len(), 2);
|
||||
}
|
||||
let mut invalid = value.clone();
|
||||
invalid[pool_key]["exit_signals"][0]["when_expr"] = json!("pattern_signal(not-json)");
|
||||
assert!(specs_in_value(&invalid).is_err(), "invalid actual conditions must still fail");
|
||||
assert!(specs_in_value(&json!({"strategySpec":invalid})).is_err());
|
||||
}
|
||||
assert_eq!(specs_in_value(&json!({"sourceCode":format!("risk.stop_loss({expr})")})).unwrap().len(),1);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ use crate::numeric_expr_vm::{
|
||||
self, EvalError as NumericVmEvalError, Program as NumericVmProgram,
|
||||
Scratch as NumericVmScratch, Value as NumericVmValue, ValueType as NumericVmValueType,
|
||||
};
|
||||
use crate::numeric_factors::NumericFactorMap;
|
||||
use crate::portfolio::PortfolioState;
|
||||
use crate::holding_policy::{AutomaticTradeProtection, AutomaticTradePermission, HoldingLifecycleEvidence};
|
||||
|
||||
@@ -651,6 +652,9 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub buy_scale_expr: String,
|
||||
pub exposure_expr: String,
|
||||
pub position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
||||
pub runtime_position_exposure_timeline: crate::position_exposure::PositionExposureTimeline,
|
||||
pub runtime_position_exposure_schedule: BTreeMap<NaiveDate, f64>,
|
||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||
pub portfolio_loss_control: Option<PortfolioLossConfig>,
|
||||
pub stop_loss_expr: String,
|
||||
@@ -740,6 +744,11 @@ impl PlatformExprStrategyConfig {
|
||||
buy_scale_expr: "1.0".to_string(),
|
||||
exposure_expr: "1.0".to_string(),
|
||||
position_exposure_schedule: BTreeMap::new(),
|
||||
position_exposure_timeline: crate::position_exposure::PositionExposureTimeline::default(
|
||||
),
|
||||
runtime_position_exposure_timeline:
|
||||
crate::position_exposure::PositionExposureTimeline::default(),
|
||||
runtime_position_exposure_schedule: BTreeMap::new(),
|
||||
portfolio_drawdown_control: None,
|
||||
portfolio_loss_control: None,
|
||||
stop_loss_expr: String::new(),
|
||||
@@ -845,6 +854,7 @@ fn band_low(index_close) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn scheduled_position_exposure(
|
||||
schedule: &BTreeMap<NaiveDate, f64>,
|
||||
decision_date: NaiveDate,
|
||||
@@ -957,6 +967,16 @@ struct DayExpressionState {
|
||||
available_text_factor_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn collect_available_factor_names<'a>(names: impl Iterator<Item = &'a str>) -> BTreeSet<String> {
|
||||
// BTreeSet::from_iter first sorts a Vec containing every repeated name.
|
||||
// The daily universe has many rows but usually few distinct factor fields.
|
||||
let mut unique = BTreeSet::new();
|
||||
for name in names {
|
||||
unique.insert(name);
|
||||
}
|
||||
unique.into_iter().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StockExpressionState {
|
||||
symbol: Arc<str>,
|
||||
@@ -1010,7 +1030,7 @@ struct StockExpressionState {
|
||||
stock_volume_ma60: f64,
|
||||
stock_volume_ma100: f64,
|
||||
current_series_end: Option<usize>,
|
||||
extra_factors: BTreeMap<String, f64>,
|
||||
extra_factors: NumericFactorMap,
|
||||
extra_text_factors: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
@@ -1042,7 +1062,6 @@ impl<'a> StockStateSnapshotSource<'a> for IndexedStockStateSnapshotSource<'a> {
|
||||
}
|
||||
self.data
|
||||
.market_by_symbol_id(self.factor_date, symbol_id)
|
||||
.or_else(|| self.execution_market(symbol_id))
|
||||
}
|
||||
|
||||
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
|
||||
@@ -1071,7 +1090,6 @@ impl<'a> StockStateSnapshotSource<'a> for ViewStockStateSnapshotSource<'a, '_> {
|
||||
}
|
||||
self.factor
|
||||
.market(symbol_id)
|
||||
.or_else(|| self.execution_market(symbol_id))
|
||||
}
|
||||
|
||||
fn factor(&self, symbol_id: u32) -> Option<&'a DailyFactorSnapshot> {
|
||||
@@ -4386,25 +4404,23 @@ impl PlatformExprStrategy {
|
||||
is_month_start: date.day() == 1,
|
||||
is_month_end,
|
||||
available_factor_names: if self.stock_extra_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_snapshot_rows_on(date)
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
row.extra_factors.keys().map(|key| key.to_string()).chain(
|
||||
row.extra_factors.keys().map(|key| key.as_ref()).chain(
|
||||
row.adjustment_factor_backward1
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string()),
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
available_text_factor_names: if self.stock_text_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_text_rows_on(date)
|
||||
.iter()
|
||||
.map(|row| row.field.clone())
|
||||
.collect()
|
||||
.map(|row| row.field.as_str()))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
@@ -4736,7 +4752,6 @@ impl PlatformExprStrategy {
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let feature_market = source.feature_market(symbol_id).unwrap_or(market);
|
||||
let factor = source.factor(symbol_id).ok_or_else(|| {
|
||||
BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "factor",
|
||||
@@ -4744,6 +4759,13 @@ impl PlatformExprStrategy {
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let feature_market = source.feature_market(symbol_id).ok_or_else(|| {
|
||||
BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market",
|
||||
date: factor_date,
|
||||
symbol: symbol.to_string(),
|
||||
})
|
||||
})?;
|
||||
let intraday_same_day_factor = self.uses_intraday_execution_quotes()
|
||||
&& factor_date == date
|
||||
&& !ctx.is_lagged_execution();
|
||||
@@ -4894,12 +4916,12 @@ impl PlatformExprStrategy {
|
||||
.iter()
|
||||
.filter(|(field, _)| {
|
||||
self.stock_extra_factor_map_required
|
||||
|| self.stock_extra_factor_identifiers.contains(field.as_ref())
|
||||
|| self.stock_extra_factor_identifiers.contains(field.as_str())
|
||||
})
|
||||
.map(|(field, value)| (field.to_string(), *value))
|
||||
.map(|(field, value)| (field.clone(), *value))
|
||||
.collect()
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
NumericFactorMap::new()
|
||||
};
|
||||
if !self.config.completed_session_factor_fields.is_empty() {
|
||||
let visible_date = completed_session_factor_date(
|
||||
@@ -4914,7 +4936,7 @@ impl PlatformExprStrategy {
|
||||
.and_then(|row| row.extra_factors.get(field.as_str()))
|
||||
.copied()
|
||||
.unwrap_or(f64::NAN);
|
||||
extra_factors.insert(field.clone(), value);
|
||||
extra_factors.insert(field.clone().into(), value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4925,7 +4947,7 @@ impl PlatformExprStrategy {
|
||||
.contains(BACKWARD_ADJUSTMENT_FACTOR_FIELD))
|
||||
&& let Some(value) = factor.adjustment_factor_backward1
|
||||
{
|
||||
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.to_string(), value);
|
||||
extra_factors.insert(BACKWARD_ADJUSTMENT_FACTOR_FIELD.into(), value);
|
||||
}
|
||||
|
||||
let state = StockExpressionState {
|
||||
@@ -5642,7 +5664,7 @@ impl PlatformExprStrategy {
|
||||
Dynamic::from(stock.stock_volume_ma100),
|
||||
);
|
||||
for (key, value) in &stock.extra_factors {
|
||||
factors.insert(key.clone().into(), Dynamic::from(*value));
|
||||
factors.insert(key.as_str().into(), Dynamic::from(*value));
|
||||
}
|
||||
for (key, value) in &stock.extra_text_factors {
|
||||
factors.insert(key.clone().into(), Dynamic::from(value.clone()));
|
||||
@@ -8640,13 +8662,28 @@ impl PlatformExprStrategy {
|
||||
let strategy_exposure = self
|
||||
.eval_float(ctx, &self.config.exposure_expr, day, None, None)?
|
||||
.clamp(0.0, 1.0);
|
||||
let risk_on_exposure = scheduled_position_exposure(
|
||||
&self.config.position_exposure_schedule,
|
||||
ctx.execution_date,
|
||||
)
|
||||
.unwrap_or(strategy_exposure)
|
||||
.clamp(0.0, 1.0);
|
||||
let mut exposure = risk_on_exposure;
|
||||
let risk_on_exposure = self
|
||||
.config
|
||||
.position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.position_exposure_schedule,
|
||||
strategy_exposure,
|
||||
)
|
||||
.unwrap_or(strategy_exposure)
|
||||
.clamp(0.0, 1.0);
|
||||
let mut exposure = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.runtime_position_exposure_schedule,
|
||||
risk_on_exposure,
|
||||
)
|
||||
.unwrap_or(risk_on_exposure)
|
||||
.clamp(0., 1.);
|
||||
if let Some(controller) = self.portfolio_drawdown_controller.as_mut() {
|
||||
exposure = controller.update(ctx.decision_date, day.total_value, exposure)?.target_exposure;
|
||||
}
|
||||
@@ -9970,6 +10007,30 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
}
|
||||
for bps in [
|
||||
self.config
|
||||
.position_exposure_timeline
|
||||
.scale_at(portfolio_loss_decision_at(ctx)),
|
||||
self.config
|
||||
.runtime_position_exposure_timeline
|
||||
.scale_at(portfolio_loss_decision_at(ctx)),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let before = intents.len();
|
||||
intents = intents
|
||||
.into_iter()
|
||||
.map(|intent| {
|
||||
crate::position_exposure::scale_explicit_intent(intent, bps, ctx.open_orders)
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(BacktestError::Execution)?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
diagnostics.push(format!("position_override_scale requested_bps={bps} original_intents={before} emitted_intents={}",intents.len()));
|
||||
}
|
||||
Ok((intents, diagnostics))
|
||||
}
|
||||
|
||||
@@ -10331,6 +10392,7 @@ impl PlatformExprStrategy {
|
||||
) -> (Vec<u32>, Vec<FidcRiskDecisionAudit>) {
|
||||
let mut symbol_ids = Vec::new();
|
||||
let mut decisions = Vec::new();
|
||||
let selection_checks_enabled = self.config.risk_config.static_rules.selection_checks_enabled();
|
||||
let mut eligible_symbols = vec![false; ctx.data.symbol_count()];
|
||||
let execution_day = ctx.data.daily_snapshot_view(date);
|
||||
let factor_day = ctx.data.daily_snapshot_view(factor_date);
|
||||
@@ -10376,7 +10438,9 @@ impl PlatformExprStrategy {
|
||||
let Some(market) = execution_day.market(symbol_id) else {
|
||||
continue;
|
||||
};
|
||||
let (reject_from_universe, selection_decision) = if collect_risk_decisions {
|
||||
let (reject_from_universe, selection_decision) = if !selection_checks_enabled {
|
||||
(false, None)
|
||||
} else if collect_risk_decisions {
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
candidate,
|
||||
@@ -12371,6 +12435,43 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
|
||||
impl Strategy for PlatformExprStrategy {
|
||||
fn bind_runtime_position_configuration(
|
||||
&mut self,
|
||||
events: &[crate::position_exposure::PositionExposureEvent],
|
||||
legacy: &BTreeMap<NaiveDate, i32>,
|
||||
) -> Result<(), BacktestError> {
|
||||
let timeline = crate::position_exposure::PositionExposureTimeline::from_events(events)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if legacy.values().any(|value| !(0..=10000).contains(value)) {
|
||||
return Err(BacktestError::Execution(
|
||||
"invalid runtime exposure schedule".into(),
|
||||
));
|
||||
}
|
||||
self.config.runtime_position_exposure_timeline = timeline;
|
||||
self.config.runtime_position_exposure_schedule = legacy
|
||||
.iter()
|
||||
.map(|(date, bps)| (*date, f64::from(*bps) / 10000.))
|
||||
.collect();
|
||||
Ok(())
|
||||
}
|
||||
fn on_observed_manual_execution(
|
||||
&mut self,
|
||||
execution: &crate::manual_execution::ManualReplayApplication,
|
||||
) -> Result<(), BacktestError> {
|
||||
let date = execution
|
||||
.executed_at
|
||||
.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap())
|
||||
.date_naive();
|
||||
let history = match execution.side {
|
||||
OrderSide::Buy => &mut self.protection_last_buys,
|
||||
OrderSide::Sell => &mut self.protection_last_sells,
|
||||
};
|
||||
history
|
||||
.entry(execution.symbol.clone())
|
||||
.and_modify(|previous| *previous = (*previous).max(date))
|
||||
.or_insert(date);
|
||||
Ok(())
|
||||
}
|
||||
fn name(&self) -> &str {
|
||||
self.config.strategy_name.as_str()
|
||||
}
|
||||
@@ -12722,16 +12823,22 @@ impl PlatformExprStrategy {
|
||||
let mut symbols = ctx.portfolio.positions().keys().cloned().collect::<BTreeSet<_>>();
|
||||
symbols.extend(self.protection_last_sells.keys().cloned());
|
||||
symbols.extend(policy.locks.iter().map(|lock| lock.symbol.clone()));
|
||||
symbols.extend(ctx.portfolio.observed_successor_symbols().map(str::to_owned));
|
||||
self.automatic_trade_permissions.clear();
|
||||
self.automatic_holding_days.clear();
|
||||
for symbol in symbols {
|
||||
let position = ctx.portfolio.position(&symbol).filter(|position| position.quantity > 0);
|
||||
let last_observed = |history: &BTreeMap<String, NaiveDate>| std::iter::once(symbol.as_str())
|
||||
.chain(ctx.portfolio.corporate_predecessors(&symbol))
|
||||
.filter_map(|symbol| history.get(symbol).copied()).max();
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(), opened_date: position.and_then(|position| position.opened_date()),
|
||||
last_buy_date: self.protection_last_buys.get(&symbol).copied().into_iter().chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: self.protection_last_sells.get(&symbol).copied(),
|
||||
last_buy_date: last_observed(&self.protection_last_buys).into_iter()
|
||||
.chain(position.and_then(|position|position.last_buy_date())).max(),
|
||||
last_sell_date: last_observed(&self.protection_last_sells),
|
||||
};
|
||||
let permission = policy.evaluate(&symbol, ctx.execution_date, &evidence, ctx.data.calendar()).map_err(BacktestError::Execution)?;
|
||||
let permission = policy.evaluate_with_predecessors(&symbol, ctx.execution_date, &evidence,
|
||||
ctx.data.calendar(), ctx.portfolio.corporate_predecessors(&symbol)).map_err(BacktestError::Execution)?;
|
||||
if let Some(opened) = evidence.opened_date
|
||||
&& let (Some(start), Some(end)) = (ctx.data.calendar().index_of(opened), ctx.data.calendar().index_of(ctx.execution_date)) {
|
||||
self.automatic_holding_days.insert(symbol.clone(), end.saturating_sub(start) as i64);
|
||||
@@ -14546,6 +14653,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use rhai::{Dynamic, Map};
|
||||
|
||||
use super::{
|
||||
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
@@ -14573,6 +14681,27 @@ mod tests {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_sparse_and_repeated_fields() {
|
||||
let fields = ["amount", "model_score", "amount", "adjustment_factor_backward1"];
|
||||
let names = (0..5_000).flat_map(|_| fields.iter().copied());
|
||||
let expected = names.clone().collect::<BTreeSet<_>>()
|
||||
.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
assert!(super::collect_available_factor_names(std::iter::empty()).is_empty());
|
||||
assert_eq!(super::collect_available_factor_names(["today_only"].into_iter()),
|
||||
BTreeSet::from(["today_only".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_wide_dynamic_field_identity() {
|
||||
let fields = (0..4_000).map(|index| format!("dynamic_{index:04}"))
|
||||
.chain(["Model_score".to_string(), "model_score".to_string()]).collect::<Vec<_>>();
|
||||
let expected = fields.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let names = fields.iter().rev().chain(fields.iter()).map(String::as_str);
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_filter_attaches_denials_without_rewriting_selection() {
|
||||
let prev = d(2025, 1, 2);
|
||||
@@ -14718,7 +14847,7 @@ mod tests {
|
||||
let date = d(2025, 1, 2);
|
||||
let symbol = "000001.SZ";
|
||||
let parts = single_symbol_platform_data(&[date], symbol).snapshot_components();
|
||||
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote {
|
||||
let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000,
|
||||
volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()),
|
||||
@@ -17542,6 +17671,18 @@ mod tests {
|
||||
.stock_state_with_factor_date(&ctx, date, date, present_symbol)
|
||||
.expect("factor map stock state");
|
||||
assert!(map_stock.extra_factors.contains_key("unused_factor"));
|
||||
let mut numeric_state = (*map_stock).clone();
|
||||
numeric_state.extra_factors.insert("negative_zero".into(), -0.0);
|
||||
numeric_state.extra_factors.insert("undefined_value".into(), f64::NAN);
|
||||
let copied_state = numeric_state.clone();
|
||||
assert_eq!(copied_state.extra_factors["negative_zero"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert!(copied_state.extra_factors["undefined_value"].is_nan());
|
||||
let exposed = copied_state.extra_factors.iter()
|
||||
.map(|(key, value)| (key.as_str().into(), Dynamic::from(*value)))
|
||||
.collect::<Map>();
|
||||
assert_eq!(exposed["negative_zero"].as_float().unwrap().to_bits(), (-0.0_f64).to_bits());
|
||||
assert!(exposed["undefined_value"].as_float().unwrap().is_nan());
|
||||
assert!(!exposed.contains_key("missing_factor"));
|
||||
let map_day = map_strategy
|
||||
.day_state(&ctx, date)
|
||||
.expect("factor map day state");
|
||||
@@ -17846,7 +17987,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -17989,7 +18130,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18077,7 +18218,7 @@ mod tests {
|
||||
lower_limit: 4.50,
|
||||
price_tick: 0.01,
|
||||
};
|
||||
let quote = IntradayExecutionQuote {
|
||||
let quote = IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18209,7 +18350,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -18454,7 +18595,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
|
||||
@@ -18871,7 +19012,7 @@ mod tests {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
|
||||
@@ -18884,7 +19025,7 @@ mod tests {
|
||||
amount_delta: 23_990.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: second_date.and_hms_opt(10, 31, 0).expect("valid timestamp"),
|
||||
@@ -19149,7 +19290,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19162,7 +19303,7 @@ mod tests {
|
||||
amount_delta: 146_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19175,7 +19316,7 @@ mod tests {
|
||||
amount_delta: 145_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19450,7 +19591,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19463,7 +19604,7 @@ mod tests {
|
||||
amount_delta: 146_300.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19699,7 +19840,7 @@ mod tests {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -19712,7 +19853,7 @@ mod tests {
|
||||
amount_delta: 56_450.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: first_date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -19725,7 +19866,7 @@ mod tests {
|
||||
amount_delta: 49_300.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second_date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: second_date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -20027,7 +20168,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -20485,7 +20626,7 @@ mod tests {
|
||||
vec![candidate],
|
||||
vec![benchmark],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 30, 0).expect("timestamp"),
|
||||
@@ -20672,7 +20813,7 @@ mod tests {
|
||||
prev_close: 998.0,
|
||||
volume: 1_000_000,
|
||||
};
|
||||
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote {
|
||||
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -21231,6 +21372,37 @@ mod tests {
|
||||
assert_eq!(stock.market_cap, 8.0);
|
||||
assert_eq!(stock.market_cap_bn, 8.0);
|
||||
assert!(stock.touched_upper_limit);
|
||||
|
||||
let missing_feature_day = DataSet::from_components(
|
||||
vec![data.instrument(symbol).unwrap().clone()],
|
||||
data.market_snapshot_rows_on(date).to_vec(),
|
||||
data.factor_snapshot_rows_on(factor_date).iter()
|
||||
.chain(data.factor_snapshot_rows_on(date)).cloned().collect(),
|
||||
data.candidate_snapshot_rows_on(date).to_vec(),
|
||||
vec![data.benchmark(date).unwrap().clone()],
|
||||
).unwrap();
|
||||
let gap_ctx = StrategyContext {data: &missing_feature_day, decision_date: factor_date, ..ctx};
|
||||
let mut gap_config = PlatformExprStrategyConfig::generic();
|
||||
gap_config.matching_type = MatchingType::NextBarOpen;
|
||||
gap_config.stock_filter_expr = "close > 10.0".into();
|
||||
let gap_strategy = PlatformExprStrategy::new(gap_config);
|
||||
let indexed = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, factor_date, symbol);
|
||||
assert!(matches!(&indexed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market", date: missing_date, symbol: missing_symbol,
|
||||
})) if *missing_date == factor_date && missing_symbol == symbol),
|
||||
"missing decision-date OHLCV must not become execution-date values: {indexed:?}");
|
||||
let execution_view = missing_feature_day.daily_snapshot_view(date);
|
||||
let feature_view = missing_feature_day.daily_snapshot_view(factor_date);
|
||||
let viewed = gap_strategy.uncached_selection_stock_state_from_views_by_symbol_id(
|
||||
&gap_ctx, date, factor_date, missing_feature_day.symbol_id(symbol).unwrap(), symbol,
|
||||
&execution_view, &feature_view,
|
||||
);
|
||||
assert!(matches!(&viewed, Err(crate::BacktestError::Data(crate::data::DataSetError::MissingSnapshot {
|
||||
kind: "feature_market", date: missing_date, ..
|
||||
})) if *missing_date == factor_date), "daily views must preserve the same missing-date boundary: {viewed:?}");
|
||||
assert!(gap_strategy.stock_state_cache.borrow().is_empty());
|
||||
let same_day = gap_strategy.stock_state_with_factor_date(&gap_ctx, date, date, symbol).unwrap();
|
||||
assert_eq!(same_day.close, 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -22285,7 +22457,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -22936,7 +23108,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23090,7 +23262,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23103,7 +23275,7 @@ mod tests {
|
||||
amount_delta: 110_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23247,7 +23419,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23260,7 +23432,7 @@ mod tests {
|
||||
amount_delta: 108_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23404,7 +23576,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("timestamp"),
|
||||
@@ -23417,7 +23589,7 @@ mod tests {
|
||||
amount_delta: 110_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23574,7 +23746,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("timestamp"),
|
||||
@@ -23889,7 +24061,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -24045,7 +24217,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -24214,7 +24386,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
|
||||
@@ -24372,7 +24544,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -24494,7 +24666,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("valid timestamp"),
|
||||
@@ -24507,7 +24679,7 @@ mod tests {
|
||||
amount_delta: 1_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(14, 58, 59).expect("valid timestamp"),
|
||||
@@ -24520,7 +24692,7 @@ mod tests {
|
||||
amount_delta: 2_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(14, 59, 2).expect("valid timestamp"),
|
||||
@@ -24743,7 +24915,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 39, 59).unwrap(),
|
||||
@@ -24872,6 +25044,8 @@ mod tests {
|
||||
],
|
||||
vec![
|
||||
market(factor_date, signal, 10.0, 11.0, 9.0),
|
||||
market(factor_date, limit_symbol, 1.80, 1.98, 1.62),
|
||||
market(factor_date, fallback_symbol, 4.20, 4.62, 3.78),
|
||||
market(decision_date, signal, 10.0, 11.0, 9.0),
|
||||
market(execution_date, signal, 10.0, 11.0, 9.0),
|
||||
market(decision_date, limit_symbol, 2.20, 2.42, 1.98),
|
||||
@@ -24980,7 +25154,7 @@ mod tests {
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: limit_symbol.to_string(),
|
||||
timestamp: execution_date
|
||||
@@ -24995,7 +25169,7 @@ mod tests {
|
||||
amount_delta: 233.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: fallback_symbol.to_string(),
|
||||
timestamp: execution_date
|
||||
@@ -25056,6 +25230,8 @@ mod tests {
|
||||
.stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol)
|
||||
.expect("previous factor-day state");
|
||||
assert_eq!(prior_factor_state.amount, 20_000_000.0);
|
||||
assert_eq!(prior_factor_state.close, 1.80);
|
||||
assert_eq!(decision_day_state.close, 2.20);
|
||||
|
||||
let decision = strategy.on_day(&ctx).expect("platform decision");
|
||||
|
||||
@@ -25233,7 +25409,7 @@ mod tests {
|
||||
})
|
||||
.collect(),
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: execution_date,
|
||||
symbol: candidate_symbol.to_string(),
|
||||
timestamp: execution_date.and_hms_opt(9, 33, 0).unwrap(),
|
||||
@@ -27761,7 +27937,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -28084,7 +28260,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -28242,7 +28418,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -28572,7 +28748,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 40, 0).expect("timestamp"),
|
||||
@@ -29342,7 +29518,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -29525,7 +29701,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -29736,7 +29912,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -29987,7 +30163,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30203,7 +30379,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30569,7 +30745,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30750,7 +30926,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -30763,7 +30939,7 @@ mod tests {
|
||||
amount_delta: 105_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30776,7 +30952,7 @@ mod tests {
|
||||
amount_delta: 90_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: held_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30789,7 +30965,7 @@ mod tests {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: buy_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -30978,7 +31154,7 @@ mod tests {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: delayed_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -30991,7 +31167,7 @@ mod tests {
|
||||
amount_delta: 4_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: held_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31004,7 +31180,7 @@ mod tests {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: buy_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31215,7 +31391,7 @@ mod tests {
|
||||
.flat_map(|symbol| {
|
||||
let mut quotes = Vec::new();
|
||||
if *symbol == delayed_symbol {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -31229,7 +31405,7 @@ mod tests {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -31455,7 +31631,7 @@ mod tests {
|
||||
.flat_map(|symbol| {
|
||||
let mut quotes = Vec::new();
|
||||
if *symbol == delayed_symbol {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).expect("valid timestamp"),
|
||||
@@ -31469,7 +31645,7 @@ mod tests {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
});
|
||||
}
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32141,7 +32317,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -32430,7 +32606,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32641,7 +32817,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -32848,7 +33024,7 @@ mod tests {
|
||||
Vec::new(),
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: (*symbol).to_string(),
|
||||
timestamp: date.and_hms_opt(14, 59, 0).expect("valid timestamp"),
|
||||
@@ -32991,7 +33167,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).expect("valid timestamp"),
|
||||
@@ -33214,7 +33390,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"),
|
||||
@@ -33351,7 +33527,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -33492,7 +33668,7 @@ mod tests {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: decision_date,
|
||||
symbol: other_symbol.to_string(),
|
||||
timestamp: decision_date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -36140,6 +36316,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
@@ -36288,6 +36465,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 9.9,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_buy".to_string(),
|
||||
},
|
||||
OpenOrderView {
|
||||
@@ -36302,6 +36480,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -182,6 +182,15 @@ impl PlatformExprStrategy {
|
||||
scope.push(symbol)
|
||||
}
|
||||
}
|
||||
let allocation_weights = self
|
||||
.config
|
||||
.runtime_position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
.or_else(|| {
|
||||
self.config
|
||||
.position_exposure_timeline
|
||||
.allocation_weights_at(portfolio_loss_decision_at(ctx))
|
||||
});
|
||||
let members = scope
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -198,18 +207,36 @@ impl PlatformExprStrategy {
|
||||
take_profit: constraints.default_take_profit,
|
||||
});
|
||||
member.requested_order = index as i32;
|
||||
if let Some(weights) = allocation_weights {
|
||||
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
|
||||
}
|
||||
member
|
||||
})
|
||||
.collect();
|
||||
let (base_ratio, reserve_cash) =
|
||||
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let base_exposure = self
|
||||
.config
|
||||
.position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.position_exposure_schedule,
|
||||
f64::from(base_ratio) / 10000.,
|
||||
)
|
||||
.unwrap_or(f64::from(base_ratio) / 10000.);
|
||||
let ratio = self
|
||||
.config
|
||||
.position_exposure_schedule
|
||||
.range(..=ctx.decision_date)
|
||||
.next_back()
|
||||
.map(|(_, value)| (*value * 10000.).round() as i64)
|
||||
.runtime_position_exposure_timeline
|
||||
.exposure_at(
|
||||
portfolio_loss_decision_at(ctx),
|
||||
ctx.execution_date,
|
||||
&self.config.runtime_position_exposure_schedule,
|
||||
base_exposure,
|
||||
)
|
||||
.or(Some(base_exposure))
|
||||
.map(|value| (value * 10000.).round() as i64)
|
||||
.unwrap_or(i64::from(base_ratio));
|
||||
let invest_ratio_bps = i32::try_from(ratio)
|
||||
.ok()
|
||||
|
||||
@@ -949,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
|
||||
pub exposure_expr: Option<String>,
|
||||
#[serde(default, alias = "position_exposure_schedule")]
|
||||
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
|
||||
#[serde(default, alias = "position_exposure_events")]
|
||||
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
|
||||
#[serde(default)]
|
||||
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
|
||||
#[serde(default)]
|
||||
@@ -2228,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
|
||||
expr.clone()
|
||||
};
|
||||
}
|
||||
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
|
||||
for point in &risk.position_exposure_schedule {
|
||||
let effective_date = NaiveDate::parse_from_str(
|
||||
point.effective_date.trim(),
|
||||
|
||||
@@ -138,18 +138,30 @@ impl Position {
|
||||
if quantity == 0 {
|
||||
return;
|
||||
}
|
||||
let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount");
|
||||
self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount);
|
||||
}
|
||||
|
||||
fn buy_with_fixed_gross(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
gross_amount: FixedMoney,
|
||||
) {
|
||||
let previous_quantity = self.quantity;
|
||||
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date)));
|
||||
self.last_buy_date = Some(
|
||||
self.last_buy_date
|
||||
.map_or(date, |previous| previous.max(date)),
|
||||
);
|
||||
if previous_quantity == 0 {
|
||||
self.opened_date = Some(date);
|
||||
} else if let Some(opened) = self.opened_date {
|
||||
self.opened_date = Some(opened.min(date));
|
||||
}
|
||||
let previous_average_price = self.average_price;
|
||||
let previous_average_cost = self.average_cost;
|
||||
let gross_amount = fixed_money_or_panic(
|
||||
execution_price * quantity as f64,
|
||||
"position buy gross amount",
|
||||
);
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -200,6 +212,20 @@ impl Position {
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol));
|
||||
}
|
||||
let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?;
|
||||
self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds)
|
||||
}
|
||||
|
||||
fn sell_with_fixed_gross(
|
||||
&mut self,
|
||||
quantity: u32,
|
||||
execution_price: f64,
|
||||
mark_price: f64,
|
||||
total_proceeds: FixedMoney,
|
||||
) -> Result<f64, String> {
|
||||
if quantity > self.quantity {
|
||||
return Err(format!(
|
||||
@@ -208,10 +234,17 @@ impl Position {
|
||||
));
|
||||
}
|
||||
|
||||
let total_proceeds = fixed_money(
|
||||
execution_price * quantity as f64,
|
||||
"position sell gross amount",
|
||||
)?;
|
||||
// A delayed receipt or a successor conversion can append an older
|
||||
// acquisition after a newer lot. Deplete by actual acquisition date;
|
||||
// stable ordering preserves same-day receipts and their attached fees.
|
||||
if quantity > 0
|
||||
&& self
|
||||
.lots
|
||||
.windows(2)
|
||||
.any(|pair| pair[0].acquired_date > pair[1].acquired_date)
|
||||
{
|
||||
self.lots.sort_by_key(|lot| lot.acquired_date);
|
||||
}
|
||||
let mut remaining = quantity;
|
||||
let mut remaining_proceeds = total_proceeds;
|
||||
let mut realized = FixedMoney::ZERO;
|
||||
@@ -676,9 +709,33 @@ pub struct PortfolioState {
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
pending_cash_flows: Vec<PendingCashFlow>,
|
||||
day_sold_symbols: BTreeSet<String>,
|
||||
// Observed holding conversions, never a catalog alias or a new target.
|
||||
// Kept after a position becomes flat so an active date lock is not lost.
|
||||
corporate_predecessors: BTreeMap<String, BTreeSet<String>>,
|
||||
stock_pool_states: std::collections::BTreeMap<String,crate::stock_pool_state::StockPoolExecutionState>,
|
||||
}
|
||||
|
||||
pub(crate) struct PortfolioCheckpoint {
|
||||
saved: PortfolioState,
|
||||
position_order: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl PortfolioCheckpoint {
|
||||
pub(crate) fn restore(mut self, current: &mut PortfolioState) {
|
||||
if let Some(order) = self.position_order.take() {
|
||||
let mut positions = IndexMap::with_capacity(order.len());
|
||||
for symbol in order {
|
||||
let position = self.saved.positions.shift_remove(&symbol)
|
||||
.or_else(|| current.positions.shift_remove(&symbol))
|
||||
.expect("unchanged checkpoint position must remain present");
|
||||
positions.insert(symbol, position);
|
||||
}
|
||||
self.saved.positions = positions;
|
||||
}
|
||||
*current = self.saved;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PendingCashFlow {
|
||||
pub payable_date: NaiveDate,
|
||||
@@ -698,9 +755,36 @@ pub(crate) struct SuccessorConversionOutcome {
|
||||
}
|
||||
|
||||
impl PortfolioState {
|
||||
/// Ordinary single-security orders need not duplicate every other lot.
|
||||
/// Complex portfolio intents request the complete checkpoint instead.
|
||||
pub(crate) fn checkpoint(&self, symbols: Option<&BTreeSet<String>>) -> PortfolioCheckpoint {
|
||||
let Some(symbols) = symbols else {
|
||||
return PortfolioCheckpoint { saved: self.clone(), position_order: None };
|
||||
};
|
||||
PortfolioCheckpoint {
|
||||
saved: Self {
|
||||
initial_cash: self.initial_cash, units: self.units, cash: self.cash,
|
||||
external_cash_flow_total: self.external_cash_flow_total,
|
||||
cash_liabilities: self.cash_liabilities, management_fee_rate: self.management_fee_rate,
|
||||
management_fees: self.management_fees,
|
||||
// prune_flat_positions can remove an unrelated zero row.
|
||||
positions: self.positions.iter().filter(|(symbol, position)| position.quantity == 0 || symbols.contains(*symbol))
|
||||
.map(|(symbol, position)| (symbol.clone(), position.clone())).collect(),
|
||||
cash_receivables: self.cash_receivables.clone(), pending_cash_flows: self.pending_cash_flows.clone(),
|
||||
day_sold_symbols: self.day_sold_symbols.clone(), corporate_predecessors: self.corporate_predecessors.clone(),
|
||||
stock_pool_states: self.stock_pool_states.clone(),
|
||||
},
|
||||
position_order: Some(self.positions.keys().cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(initial_cash: f64) -> Self {
|
||||
let initial_cash = fixed_money(initial_cash, "initial cash")
|
||||
.expect("initial cash must be finite fixed-point money");
|
||||
Self::from_fixed_initial_cash(initial_cash)
|
||||
}
|
||||
|
||||
pub(crate) fn from_fixed_initial_cash(initial_cash: FixedMoney) -> Self {
|
||||
Self {
|
||||
initial_cash,
|
||||
units: initial_cash,
|
||||
@@ -713,6 +797,7 @@ impl PortfolioState {
|
||||
cash_receivables: Vec::new(),
|
||||
pending_cash_flows: Vec::new(),
|
||||
day_sold_symbols: BTreeSet::new(),
|
||||
corporate_predecessors: BTreeMap::new(),
|
||||
stock_pool_states: std::collections::BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -723,6 +808,16 @@ impl PortfolioState {
|
||||
self.initial_cash.to_f64()
|
||||
}
|
||||
|
||||
pub(crate) fn initial_cash_fixed(&self) -> FixedMoney { self.initial_cash }
|
||||
|
||||
pub(crate) fn corporate_predecessors(&self, symbol: &str) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.get(symbol).into_iter().flatten().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn observed_successor_symbols(&self) -> impl Iterator<Item = &str> {
|
||||
self.corporate_predecessors.keys().map(String::as_str)
|
||||
}
|
||||
|
||||
pub(crate) fn stock_pool_execution_state(&self,pool_id:&str)->crate::stock_pool_state::StockPoolExecutionState{
|
||||
self.stock_pool_states.get(pool_id).cloned().unwrap_or_default()
|
||||
}
|
||||
@@ -754,6 +849,53 @@ impl PortfolioState {
|
||||
self.cash.to_f64()
|
||||
}
|
||||
|
||||
pub(crate) fn cash_fixed(&self) -> FixedMoney { self.cash }
|
||||
|
||||
pub(crate) fn financial_replay_identity(&self) -> serde_json::Value {
|
||||
let positions = self.positions.iter().filter(|(_, position)| position.quantity > 0)
|
||||
.map(|(symbol, position)| {
|
||||
let mut lots = position.lots.iter().map(|lot| (lot.acquired_date, lot.quantity,
|
||||
lot.entry_value.to_decimal_string(), lot.cost_basis.to_decimal_string())).collect::<Vec<_>>();
|
||||
lots.sort();
|
||||
(symbol.clone(), serde_json::json!({"quantity":position.quantity,"lots":lots,
|
||||
"openedDate":position.opened_date,"lastBuyDate":position.last_buy_date}))
|
||||
}).collect::<std::collections::BTreeMap<_, _>>();
|
||||
let mut receivables = self.cash_receivables.iter().map(|row| (row.symbol.clone(), row.ex_date,
|
||||
row.payable_date, fixed_money_or_panic(row.amount, "receivable identity").to_decimal_string(), row.reason.clone())).collect::<Vec<_>>();
|
||||
receivables.sort();
|
||||
let mut identity = serde_json::json!({"cash":self.cash.to_decimal_string(),"positions":positions,"receivables":receivables});
|
||||
if !self.corporate_predecessors.is_empty() {
|
||||
identity["corporatePredecessors"] = serde_json::json!(self.corporate_predecessors);
|
||||
}
|
||||
identity
|
||||
}
|
||||
|
||||
pub(crate) fn financial_position_basis(&self, symbol: &str) -> FixedMoney {
|
||||
self.positions.get(symbol).map_or(FixedMoney::ZERO, Position::total_cost_basis)
|
||||
}
|
||||
|
||||
pub(crate) fn replace_replayed_financial_book(&mut self, mut replayed: PortfolioState) -> Result<(), String> {
|
||||
if replayed.cash < FixedMoney::ZERO || replayed.initial_cash != self.initial_cash {
|
||||
return Err("manual corporate replay changed initial capital or borrowed cash".into());
|
||||
}
|
||||
for (symbol, position) in &mut replayed.positions {
|
||||
if let Some(current) = self.positions.get(symbol).filter(|current| current.quantity > 0) {
|
||||
position.last_price = current.last_price;
|
||||
position.refresh_day_pnl();
|
||||
}
|
||||
}
|
||||
self.cash = replayed.cash;
|
||||
self.positions = replayed.positions;
|
||||
self.cash_receivables = replayed.cash_receivables;
|
||||
self.day_sold_symbols = replayed.day_sold_symbols;
|
||||
// Corrected actual receipts can prove a position was fully sold
|
||||
// before conversion. Do not retain a now-disproved financial link.
|
||||
self.corporate_predecessors = replayed.corporate_predecessors;
|
||||
// Existing issued units, explicit cash-flow/financing facts, and task
|
||||
// target state are observed controls, not counterfactual new orders.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn external_cash_flow_total(&self) -> f64 {
|
||||
self.external_cash_flow_total.to_f64()
|
||||
}
|
||||
@@ -789,13 +931,117 @@ impl PortfolioState {
|
||||
}
|
||||
|
||||
pub fn apply_cash_delta(&mut self, delta: f64) -> Result<(), String> {
|
||||
self.apply_cash_delta_fixed(fixed_money(delta, "cash delta")?)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_cash_delta_fixed(&mut self, delta: FixedMoney) -> Result<(), String> {
|
||||
self.cash = self
|
||||
.cash
|
||||
.checked_add(fixed_money(delta, "cash delta")?)
|
||||
.checked_add(delta)
|
||||
.ok_or_else(|| "fixed-point cash overflow".to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply one fully observed external fill atomically. Its money is already
|
||||
/// quantized from the original decimal amounts, not from a float product.
|
||||
pub(crate) fn apply_observed_manual_fill(
|
||||
&mut self,
|
||||
trade_date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: crate::events::OrderSide,
|
||||
quantity: u32,
|
||||
price: f64,
|
||||
mark_price: f64,
|
||||
gross: FixedMoney,
|
||||
fees: FixedMoney,
|
||||
) -> Result<FixedMoney, String> {
|
||||
use crate::events::OrderSide;
|
||||
if symbol.trim().is_empty()
|
||||
|| quantity == 0
|
||||
|| quantity > i32::MAX as u32
|
||||
|| !price.is_finite()
|
||||
|| price <= 0.
|
||||
|| !mark_price.is_finite()
|
||||
|| mark_price <= 0.
|
||||
|| gross <= FixedMoney::ZERO
|
||||
|| fees < FixedMoney::ZERO
|
||||
{
|
||||
return Err("invalid observed manual fill".into());
|
||||
}
|
||||
let mut position = self
|
||||
.positions
|
||||
.get(symbol)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Position::new(symbol));
|
||||
let delta = match side {
|
||||
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
|
||||
OrderSide::Sell => gross.checked_sub(fees),
|
||||
}
|
||||
.ok_or("manual fill cash delta overflow")?;
|
||||
let next_cash = self
|
||||
.cash
|
||||
.checked_add(delta)
|
||||
.filter(|cash| *cash >= FixedMoney::ZERO)
|
||||
.ok_or("manual fill disagrees with shadow available cash")?;
|
||||
let next_cost = position
|
||||
.day_trade_cost
|
||||
.checked_add(fees)
|
||||
.ok_or("manual trade cost overflow")?;
|
||||
match side {
|
||||
OrderSide::Buy => {
|
||||
let total_quantity = position
|
||||
.quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual position quantity overflow")?;
|
||||
FixedMoney::from_f64(mark_price * f64::from(total_quantity))
|
||||
.ok_or("manual marked position value overflow")?;
|
||||
position
|
||||
.day_buy_quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual daily buy quantity overflow")?;
|
||||
position
|
||||
.day_trade_quantity_delta
|
||||
.checked_add(quantity as i32)
|
||||
.ok_or("manual daily quantity delta overflow")?;
|
||||
position
|
||||
.day_buy_value
|
||||
.checked_add(gross)
|
||||
.ok_or("manual daily buy value overflow")?;
|
||||
let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?;
|
||||
position
|
||||
.total_cost_basis()
|
||||
.checked_add(total_basis)
|
||||
.ok_or("manual aggregate position basis overflow")?;
|
||||
position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross);
|
||||
position
|
||||
.lots
|
||||
.last_mut()
|
||||
.ok_or("manual buy produced no lot")?
|
||||
.cost_basis = total_basis;
|
||||
position.average_cost += fees.to_f64() / f64::from(position.quantity);
|
||||
}
|
||||
OrderSide::Sell => {
|
||||
if quantity > position.sellable_qty(trade_date) {
|
||||
return Err("manual fill disagrees with shadow sellable holdings or T+1".into());
|
||||
}
|
||||
position
|
||||
.day_sell_quantity
|
||||
.checked_add(quantity)
|
||||
.ok_or("manual daily sell quantity overflow")?;
|
||||
position
|
||||
.day_trade_quantity_delta
|
||||
.checked_sub(quantity as i32)
|
||||
.ok_or("manual daily quantity delta overflow")?;
|
||||
position.sell_with_fixed_gross(quantity, price, mark_price, gross)?;
|
||||
}
|
||||
}
|
||||
position.day_trade_cost = next_cost;
|
||||
position.refresh_day_pnl();
|
||||
self.positions.insert(symbol.to_string(), position);
|
||||
self.cash = next_cash;
|
||||
Ok(delta)
|
||||
}
|
||||
|
||||
pub fn prune_flat_positions(&mut self) {
|
||||
let mut sold_symbols = Vec::new();
|
||||
self.positions.retain(|symbol, position| {
|
||||
@@ -1374,7 +1620,7 @@ impl PortfolioState {
|
||||
}
|
||||
successor.refresh_day_pnl();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
let outcome = SuccessorConversionOutcome {
|
||||
old_symbol: old_symbol_owned,
|
||||
new_symbol: new_symbol.to_string(),
|
||||
old_quantity,
|
||||
@@ -1386,7 +1632,14 @@ impl PortfolioState {
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
})
|
||||
};
|
||||
if converted_quantity > 0 {
|
||||
let mut predecessors = self.corporate_predecessors.get(old_symbol).cloned().unwrap_or_default();
|
||||
predecessors.insert(old_symbol.to_owned());
|
||||
predecessors.remove(new_symbol);
|
||||
self.corporate_predecessors.entry(new_symbol.to_owned()).or_default().extend(predecessors);
|
||||
}
|
||||
Some(outcome)
|
||||
}
|
||||
|
||||
fn sum_fixed_money(
|
||||
@@ -1438,6 +1691,35 @@ mod tests {
|
||||
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||
PriceField,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn scoped_checkpoint_restores_order_flat_rows_and_progress_without_copying_untouched_lots() {
|
||||
let date = NaiveDate::from_ymd_opt(2026,9,15).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10000.);
|
||||
portfolio.position_mut("000001.SZ").buy(date,100,10.);
|
||||
portfolio.position_mut("000002.SZ").buy(date,200,10.);
|
||||
portfolio.position_mut("000003.SZ").buy(date,100,10.);
|
||||
portfolio.position_mut("000003.SZ").sell(100,11.).unwrap();
|
||||
let flat_realized = portfolio.position("000003.SZ").unwrap().realized_pnl;
|
||||
let untouched_lots = portfolio.position("000002.SZ").unwrap().lots.as_ptr();
|
||||
let before = portfolio.financial_replay_identity();
|
||||
let order = portfolio.positions.keys().cloned().collect::<Vec<_>>();
|
||||
let checkpoint = portfolio.checkpoint(Some(&BTreeSet::from(["000001.SZ".into(), "000004.SZ".into()])));
|
||||
assert!(!checkpoint.saved.positions.contains_key("000002.SZ"));
|
||||
portfolio.position_mut("000001.SZ").sell(100,11.).unwrap();
|
||||
portfolio.prune_flat_positions();
|
||||
portfolio.position_mut("000004.SZ").buy(date,100,12.);
|
||||
portfolio.apply_cash_delta(100.).unwrap();
|
||||
portfolio.stock_pool_states.insert("changed".into(), Default::default());
|
||||
checkpoint.restore(&mut portfolio);
|
||||
assert_eq!(portfolio.financial_replay_identity(), before);
|
||||
assert_eq!(portfolio.positions.keys().cloned().collect::<Vec<_>>(), order);
|
||||
assert_eq!(portfolio.position("000002.SZ").unwrap().lots.as_ptr(), untouched_lots);
|
||||
assert_eq!(portfolio.position("000003.SZ").unwrap().realized_pnl, flat_realized);
|
||||
assert!(portfolio.stock_pool_states.is_empty());
|
||||
assert!(portfolio.position("000004.SZ").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cash_ledger_accumulates_micro_yuan_exactly() {
|
||||
let mut portfolio = PortfolioState::new(1_000_000.0);
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
//! 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(
|
||||
default,
|
||||
skip_serializing_if = "Option::is_none",
|
||||
alias = "allocation_weights_bps"
|
||||
)]
|
||||
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
|
||||
#[serde(flatten)]
|
||||
pub action: PositionExposureAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PositionExposureTimeline {
|
||||
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
if let Some(weights) = &event.allocation_weights_bps {
|
||||
let target = match event.action {
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps,
|
||||
} => target_exposure_bps,
|
||||
PositionExposureAction::Scale { requested_bps } => requested_bps,
|
||||
PositionExposureAction::Restore => {
|
||||
return Err(
|
||||
"restoring strategy allocation cannot carry manual weights".into()
|
||||
);
|
||||
}
|
||||
};
|
||||
validate_allocation_weights(weights, target)?;
|
||||
}
|
||||
result.events.insert(
|
||||
(event.effective_at, event.sequence),
|
||||
(event.action.clone(), event.allocation_weights_bps.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,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
|
||||
self.events
|
||||
.range(..=(at, u64::MAX))
|
||||
.next_back()
|
||||
.and_then(|(_, (_, weights))| weights.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_allocation_weights(
|
||||
weights: &BTreeMap<String, i32>,
|
||||
exposure_bps: i32,
|
||||
) -> Result<(), String> {
|
||||
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
|
||||
return Err("invalid allocation exposure or weight count".into());
|
||||
}
|
||||
for (symbol, weight) in weights {
|
||||
if !(0..=10000).contains(weight)
|
||||
|| !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
|
||||
code.len() == 6
|
||||
&& code.bytes().all(|byte| byte.is_ascii_digit())
|
||||
&& matches!(exchange, "SH" | "SZ" | "BJ")
|
||||
})
|
||||
{
|
||||
return Err(
|
||||
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (weights.is_empty() && exposure_bps != 0)
|
||||
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
|
||||
{
|
||||
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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,
|
||||
allocation_weights_bps: None,
|
||||
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,
|
||||
allocation_weights_bps: None,
|
||||
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 allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
|
||||
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
|
||||
let event = PositionExposureEvent {
|
||||
event_id: "allocation".into(),
|
||||
sequence: 1,
|
||||
effective_at: at,
|
||||
action: PositionExposureAction::Set {
|
||||
target_exposure_bps: 8000,
|
||||
},
|
||||
allocation_weights_bps: Some(weights.clone()),
|
||||
};
|
||||
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
|
||||
assert!(
|
||||
timeline
|
||||
.allocation_weights_at(at - chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
|
||||
for action in [
|
||||
PositionExposureAction::Set {
|
||||
target_exposure_bps: 5000,
|
||||
},
|
||||
PositionExposureAction::Restore,
|
||||
] {
|
||||
let next = PositionExposureEvent {
|
||||
event_id: "new".into(),
|
||||
sequence: 2,
|
||||
effective_at: at + chrono::Duration::seconds(1),
|
||||
action,
|
||||
allocation_weights_bps: None,
|
||||
};
|
||||
assert!(
|
||||
PositionExposureTimeline::from_events(&[event.clone(), next])
|
||||
.unwrap()
|
||||
.allocation_weights_at(at + chrono::Duration::seconds(1))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
|
||||
.is_err()
|
||||
);
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
|
||||
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticRiskRuleConfig {
|
||||
pub(crate) fn selection_checks_enabled(&self) -> bool {
|
||||
(self.blacklist_enabled && !self.blacklisted_symbols.is_empty())
|
||||
|| self.selection_state_checks_enabled()
|
||||
}
|
||||
|
||||
fn selection_state_checks_enabled(&self) -> bool {
|
||||
self.reject_st_selection
|
||||
|| self.reject_star_st_selection
|
||||
|| self.reject_paused_selection
|
||||
|| self.reject_inactive_selection
|
||||
|| self.reject_new_listing_selection
|
||||
|| self.reject_kcb_selection
|
||||
|| self.reject_bjse_selection
|
||||
|| self.reject_one_yuan_selection
|
||||
|| self.reject_upper_limit_selection
|
||||
|| self.reject_lower_limit_selection
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradingConstraintConfig {
|
||||
/// Shared execution limits. These fields intentionally use the same
|
||||
@@ -654,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec<String> {
|
||||
fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
|
||||
let fields = missing_risk_state_fields(code);
|
||||
if fields.is_empty() {
|
||||
return config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection;
|
||||
return config.static_rules.selection_state_checks_enabled();
|
||||
}
|
||||
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
|
||||
}
|
||||
@@ -778,18 +789,7 @@ fn missing_single_field_rejected(
|
||||
RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
|
||||
},
|
||||
_ => match scope {
|
||||
RiskCheckScope::Selection => {
|
||||
config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
}
|
||||
RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
|
||||
RiskCheckScope::Buy => {
|
||||
config.static_rules.reject_st_buy
|
||||
|| config.static_rules.reject_star_st_buy
|
||||
@@ -914,6 +914,69 @@ mod tests {
|
||||
position
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() {
|
||||
let fields = [
|
||||
"reject_st_selection", "reject_star_st_selection", "reject_paused_selection",
|
||||
"reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection",
|
||||
"reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection",
|
||||
"reject_lower_limit_selection",
|
||||
];
|
||||
let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap();
|
||||
let declared = base.as_object().unwrap().keys()
|
||||
.filter(|key| key.ends_with("_selection"))
|
||||
.map(String::as_str).collect::<BTreeSet<_>>();
|
||||
assert_eq!(declared, fields.into_iter().collect());
|
||||
for mask in 0..(1_u32 << fields.len()) {
|
||||
for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let mut value = base.clone();
|
||||
for (bit, field) in fields.iter().enumerate() {
|
||||
value[*field] = serde_json::json!(mask & (1 << bit) != 0);
|
||||
}
|
||||
value["blacklist_enabled"] = serde_json::json!(blacklist_enabled);
|
||||
value["blacklisted_symbols"] = if populated {
|
||||
serde_json::json!(["002633.SZ"])
|
||||
} else { serde_json::json!([]) };
|
||||
let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() {
|
||||
let date = d(2025, 2, 6);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.is_st = true;
|
||||
candidate.is_star_st = true;
|
||||
candidate.is_paused = true;
|
||||
candidate.is_new_listing = true;
|
||||
candidate.is_kcb = true;
|
||||
candidate.is_one_yuan = true;
|
||||
candidate.allow_buy = false;
|
||||
let snapshot = market(date, 0.9, 0.9);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
assert!(!config.static_rules.selection_checks_enabled());
|
||||
let instrument = instrument("delisted", Some(date));
|
||||
for code in [None, Some("not_listed"), Some("inactive_or_delisted"),
|
||||
Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"),
|
||||
Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] {
|
||||
candidate.risk_level_code = code.map(str::to_owned);
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date, &candidate, &snapshot, Some(&instrument), &config), None);
|
||||
}
|
||||
candidate.risk_level_code = None;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, 0.9, &config), Some("paused"));
|
||||
assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused"));
|
||||
let mut blacklist_only = config;
|
||||
blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string());
|
||||
assert!(blacklist_only.static_rules.selection_checks_enabled());
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||
let day = d(2025, 2, 6);
|
||||
|
||||
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
|
||||
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
|
||||
match stage {
|
||||
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
|
||||
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
|
||||
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
ScheduleStage::Minute => None,
|
||||
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
use std::ops::Index;
|
||||
|
||||
use super::prefix_sums;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) enum ReferenceMatchedValues {
|
||||
Identical,
|
||||
Owned(Vec<f64>),
|
||||
}
|
||||
|
||||
impl ReferenceMatchedValues {
|
||||
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
|
||||
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
|
||||
match self {
|
||||
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.extend_from_slice(&reference[..previous_len]);
|
||||
values.push(value);
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), previous_len);
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
|
||||
match self {
|
||||
Self::Identical => reference,
|
||||
Self::Owned(values) => {
|
||||
debug_assert_eq!(values.len(), reference.len());
|
||||
values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
|
||||
assert!(index < reference.len(), "series index out of bounds");
|
||||
match self {
|
||||
Self::Owned(values) => values[index] = value,
|
||||
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
|
||||
Self::Identical => {
|
||||
let mut values = reference.to_vec();
|
||||
values[index] = value;
|
||||
*self = Self::Owned(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prefix(&self) -> Self {
|
||||
match self {
|
||||
Self::Identical => Self::Identical,
|
||||
Self::Owned(values) => Self::Owned(prefix_sums(values)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RepeatedValues<T> {
|
||||
repeated: T,
|
||||
values: Option<Vec<T>>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<T: Default + Clone + Eq> RepeatedValues<T> {
|
||||
pub(super) fn new() -> Self {
|
||||
Self { repeated: T::default(), values: None, len: 0 }
|
||||
}
|
||||
|
||||
pub(super) fn push(&mut self, value: &T, capacity: usize) {
|
||||
if let Some(values) = &mut self.values {
|
||||
values.push(value.clone());
|
||||
} else if self.len == 0 {
|
||||
self.repeated = value.clone();
|
||||
} else if *value != self.repeated {
|
||||
let mut values = Vec::with_capacity(capacity);
|
||||
values.resize(self.len, std::mem::take(&mut self.repeated));
|
||||
values.push(value.clone());
|
||||
self.values = Some(values);
|
||||
}
|
||||
self.len += 1;
|
||||
}
|
||||
|
||||
pub(super) fn set(&mut self, index: usize, value: T) {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
if let Some(values) = &mut self.values {
|
||||
values[index] = value;
|
||||
} else if value != self.repeated {
|
||||
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
|
||||
values[index] = value;
|
||||
self.values = Some(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Index<usize> for RepeatedValues<T> {
|
||||
type Output = T;
|
||||
|
||||
fn index(&self, index: usize) -> &T {
|
||||
assert!(index < self.len, "series index out of bounds");
|
||||
match &self.values {
|
||||
Some(values) => &values[index],
|
||||
None => &self.repeated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(values: &[f64]) -> Vec<u64> {
|
||||
values.iter().map(|value| value.to_bits()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_prices_share_only_after_exact_bit_comparison() {
|
||||
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in reference.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], reference.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Identical));
|
||||
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
|
||||
let prefix = prefix_sums(&reference);
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
|
||||
|
||||
let original = column.clone();
|
||||
column.set(1, 0., &reference);
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
|
||||
assert_eq!(bits(original.values(&reference)), bits(&reference));
|
||||
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
|
||||
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
|
||||
for actual in [
|
||||
[10., 0., reference[2], 13.],
|
||||
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
|
||||
] {
|
||||
let mut column = ReferenceMatchedValues::Identical;
|
||||
for (index, value) in actual.iter().copied().enumerate() {
|
||||
column.push(value, &reference[..=index], actual.len());
|
||||
}
|
||||
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
|
||||
assert_eq!(bits(column.values(&reference)), bits(&actual));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..128 { column.push(&7_u64, 128); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[127], 7);
|
||||
column.set(0, 7);
|
||||
assert!(column.values.is_none());
|
||||
let mut changed = column.clone();
|
||||
changed.set(64, 9);
|
||||
assert_eq!(changed[64], 9);
|
||||
assert_eq!(changed[63], 7);
|
||||
assert_eq!(column[64], 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
|
||||
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
|
||||
let mut column = RepeatedValues::new();
|
||||
for _ in 0..12 { column.push(&repeated, 16); }
|
||||
assert!(column.values.is_none());
|
||||
assert_eq!(column[0], repeated);
|
||||
column.push(&Some("closing".to_string()), 16);
|
||||
assert_eq!(column[11], repeated);
|
||||
assert_eq!(column[12].as_deref(), Some("closing"));
|
||||
column.set(5, None);
|
||||
assert_eq!(column[5], None);
|
||||
assert_eq!(column[4], repeated);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "series index out of bounds")]
|
||||
fn repeated_values_reject_out_of_range_access() {
|
||||
let column = RepeatedValues::<u64>::new();
|
||||
let _ = column[0];
|
||||
}
|
||||
}
|
||||
@@ -479,7 +479,7 @@ pub struct StockPoolSelection {
|
||||
pub generation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize)]
|
||||
pub struct StockPoolDecisionConstraints {
|
||||
pub execution_date: Option<NaiveDate>,
|
||||
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
|
||||
@@ -545,7 +545,7 @@ pub struct StockPoolPlan {
|
||||
|
||||
/// A signal-time contract. Only the broker/execution adapter supplies later
|
||||
/// prices, actual cash and holdings; strategy code never sees those inputs.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FrozenStockPoolIntent {
|
||||
pub pool_id: String,
|
||||
pub signal_date: NaiveDate,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
//! Condition facts are distinct from the quote's per-observation fill capacity.
|
||||
//! Only a complete, declared raw-minute prefix can prove a session total.
|
||||
use std::collections::BTreeMap;
|
||||
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
|
||||
use rust_decimal::Decimal;
|
||||
use crate::data::IntradayExecutionQuote;
|
||||
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
|
||||
|
||||
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
|
||||
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
|
||||
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
|
||||
})
|
||||
}
|
||||
|
||||
/// The cash-equity minute feed includes the opening observation and a separate
|
||||
/// post-close segment. Trading eligibility remains owned by the dated rules.
|
||||
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
|
||||
let minute = time.hour() * 60 + time.minute();
|
||||
let next = match minute {
|
||||
570..=689 | 781..=899 | 906..=929 => minute + 1,
|
||||
690 => 781,
|
||||
900 => 906,
|
||||
_ => return None,
|
||||
};
|
||||
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SessionTotalsCache {
|
||||
pub date: Option<NaiveDate>,
|
||||
pub symbols: BTreeMap<String, MinutePrefix>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct MinutePrefix {
|
||||
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
|
||||
failure: String,
|
||||
}
|
||||
|
||||
impl MinutePrefix {
|
||||
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
|
||||
let mut values = BTreeMap::new();
|
||||
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
|
||||
let mut volume = 0_u64;
|
||||
let mut amount = Decimal::ZERO;
|
||||
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
for quote in quotes {
|
||||
let time = quote.timestamp.time();
|
||||
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
|
||||
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
|
||||
break;
|
||||
}
|
||||
if time != expected {
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
|
||||
break;
|
||||
}
|
||||
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
|
||||
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
|
||||
break;
|
||||
}
|
||||
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
|
||||
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
|
||||
quote.amount_delta.to_string().parse::<Decimal>().ok()
|
||||
} else { None };
|
||||
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
|
||||
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
|
||||
break;
|
||||
};
|
||||
volume = next_volume;
|
||||
amount = next_amount;
|
||||
values.insert(time, (Decimal::from(volume), amount));
|
||||
let Some(next) = next_minute(time) else { break };
|
||||
expected = next;
|
||||
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
|
||||
}
|
||||
Self { values, failure }
|
||||
}
|
||||
|
||||
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
|
||||
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
|
||||
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
|
||||
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
|
||||
}
|
||||
#[test]
|
||||
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
|
||||
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
|
||||
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
|
||||
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
|
||||
rows[2].volume_delta = 999999;
|
||||
rows[2].amount_delta = f64::NAN;
|
||||
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
|
||||
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
|
||||
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
|
||||
}
|
||||
#[test]
|
||||
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
|
||||
let first = quote(9,30,100,1000.);
|
||||
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
|
||||
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
|
||||
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
|
||||
}
|
||||
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
|
||||
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
|
||||
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
|
||||
}
|
||||
#[test]
|
||||
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
|
||||
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||
loop {
|
||||
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
|
||||
let Some(next) = next_minute(time) else { break }; time=next;
|
||||
}
|
||||
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
|
||||
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
|
||||
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
|
||||
fn real_source_session_prefix_matches_observed_checkpoints() {
|
||||
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
|
||||
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
|
||||
assert_eq!(rows.len(),242);
|
||||
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
|
||||
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
|
||||
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
|
||||
}
|
||||
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
|
||||
|
||||
pub trait Strategy {
|
||||
fn name(&self) -> &str;
|
||||
fn bind_runtime_position_configuration(
|
||||
&mut self,
|
||||
events: &[crate::position_exposure::PositionExposureEvent],
|
||||
legacy: &BTreeMap<NaiveDate, i32>,
|
||||
) -> Result<(), BacktestError> {
|
||||
if !events.is_empty() || !legacy.is_empty() {
|
||||
return Err(BacktestError::Execution(
|
||||
"strategy does not implement runtime position configuration".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
BTreeSet::new()
|
||||
}
|
||||
@@ -40,6 +52,12 @@ pub trait Strategy {
|
||||
) -> Result<(), BacktestError> {
|
||||
Ok(())
|
||||
}
|
||||
/// External, already executed manual activity. It is not a new strategy
|
||||
/// order and must not be run through order generation or transaction costs.
|
||||
fn on_observed_manual_execution(
|
||||
&mut self,
|
||||
_execution: &crate::manual_execution::ManualReplayApplication,
|
||||
) -> Result<(), BacktestError> { Ok(()) }
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -102,6 +120,7 @@ pub struct OpenOrderView {
|
||||
pub avg_price: f64,
|
||||
pub transaction_cost: f64,
|
||||
pub limit_price: f64,
|
||||
pub reserved_cash: Option<f64>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -497,6 +516,7 @@ impl StrategyContext<'_> {
|
||||
.iter()
|
||||
.filter(|order| order.side == OrderSide::Buy)
|
||||
.map(|order| {
|
||||
if let Some(reserved) = order.reserved_cash { return reserved; }
|
||||
let price = if order.limit_price.is_finite() {
|
||||
order.limit_price.max(0.0)
|
||||
} else {
|
||||
@@ -975,7 +995,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||
pub struct StrategyDecision {
|
||||
pub buy_denials: BTreeMap<String, String>,
|
||||
pub rebalance: bool,
|
||||
@@ -988,6 +1008,15 @@ pub struct StrategyDecision {
|
||||
}
|
||||
|
||||
impl StrategyDecision {
|
||||
pub(crate) fn is_portfolio_target_only(&self) -> bool {
|
||||
(self.rebalance && self.order_intents.is_empty())
|
||||
|| (self.order_intents.len() == 1
|
||||
&& matches!(
|
||||
self.order_intents[0].unwrapped(),
|
||||
OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. }
|
||||
))
|
||||
}
|
||||
|
||||
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
|
||||
let mut symbols = BTreeSet::new();
|
||||
if self.rebalance {
|
||||
@@ -1001,9 +1030,24 @@ impl StrategyDecision {
|
||||
}
|
||||
|
||||
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||
if self.is_portfolio_target_only() && other.is_portfolio_target_only() {
|
||||
let mut previous = std::mem::replace(self, other);
|
||||
previous
|
||||
.diagnostics
|
||||
.push("unsubmitted_portfolio_target_superseded".into());
|
||||
self.notes.splice(0..0, previous.notes);
|
||||
self.diagnostics.splice(0..0, previous.diagnostics);
|
||||
return;
|
||||
}
|
||||
self.buy_denials.append(&mut other.buy_denials);
|
||||
self.rebalance |= other.rebalance;
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
if other.rebalance {
|
||||
// Rebalance targets are a complete portfolio, not an additive
|
||||
// list. A newer unsent target replaces the earlier allocation.
|
||||
self.rebalance = true;
|
||||
self.target_weights = std::mem::take(&mut other.target_weights);
|
||||
} else {
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
}
|
||||
self.exit_symbols.append(&mut other.exit_symbols);
|
||||
self.order_intents.append(&mut other.order_intents);
|
||||
self.notes.append(&mut other.notes);
|
||||
@@ -1023,13 +1067,59 @@ impl StrategyDecision {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg(test)]
|
||||
mod decision_merge_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() {
|
||||
let mut earlier = StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]),
|
||||
exit_symbols: BTreeSet::from(["risk_exit".into()]),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "explicit".into(),
|
||||
quantity: 100,
|
||||
reason: "explicit action".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
earlier.merge_from(StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("C".into(), 1.)]),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)]));
|
||||
assert!(earlier.rebalance);
|
||||
assert!(earlier.exit_symbols.contains("risk_exit"));
|
||||
assert_eq!(earlier.order_intents.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() {
|
||||
let mut decision = StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("A".into(), 1.)]),
|
||||
..Default::default()
|
||||
};
|
||||
decision.merge_from(StrategyDecision::default());
|
||||
assert_eq!(decision.target_weights.len(), 1);
|
||||
decision.merge_from(StrategyDecision {
|
||||
rebalance: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(decision.target_weights.is_empty());
|
||||
assert!(decision.rebalance);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum AlgoOrderStyle {
|
||||
Vwap,
|
||||
Twap,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum OrderTimeInForce {
|
||||
Day,
|
||||
Ioc,
|
||||
@@ -1058,7 +1148,7 @@ impl OrderTimeInForce {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum TargetPortfolioOrderPricing {
|
||||
LimitPrices(BTreeMap<String, f64>),
|
||||
AlgoOrder {
|
||||
@@ -1068,7 +1158,7 @@ pub enum TargetPortfolioOrderPricing {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub enum OrderIntent {
|
||||
StockPool {
|
||||
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
|
||||
|
||||
@@ -254,7 +254,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
|
||||
},
|
||||
ManualSection {
|
||||
title: "corporate_actions.dividend_reinvestment".to_string(),
|
||||
detail: "支持 corporate_actions.dividend_reinvestment(true)。开启后,现金分红到账会优先按 round lot 回补成同一只股票,零头保留为现金。".to_string(),
|
||||
detail: "历史兼容的回测账务再投模型:corporate_actions.dividend_reinvestment(true) 在分红结算时按调整后的参考价分配整手股数,零头留现金,费用为0;来源标记为 dividend_reinvestment,不是交易所委托或真实自动买入。新策略不应手工处理公司行为。".to_string(),
|
||||
},
|
||||
ManualSection {
|
||||
title: "execution.matching_type / execution.slippage".to_string(),
|
||||
|
||||
@@ -105,6 +105,158 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
|
||||
reason: "configured_strategy_action".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
|
||||
for sell_during_lock in [false, true] {
|
||||
let sale = if sell_during_lock {
|
||||
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
|
||||
} else {
|
||||
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
|
||||
};
|
||||
let actions = [
|
||||
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
|
||||
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
|
||||
sale,
|
||||
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
|
||||
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
|
||||
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
|
||||
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
|
||||
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
|
||||
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let mut parts = data().snapshot_components();
|
||||
for row in &mut parts.market {
|
||||
if row.date >= d(15) {
|
||||
row.day_open = 5.;
|
||||
row.open = 5.;
|
||||
row.high = 5.;
|
||||
row.low = 5.;
|
||||
row.close = 5.;
|
||||
row.last_price = 5.;
|
||||
row.bid1 = 5.;
|
||||
row.ask1 = 5.;
|
||||
row.prev_close = 5.;
|
||||
row.upper_limit = 5.5;
|
||||
row.lower_limit = 4.5;
|
||||
}
|
||||
}
|
||||
parts.corporate_actions.push(fidc_core::CorporateAction {
|
||||
date: d(15),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 1.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
});
|
||||
let data = DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap();
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000001.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode =
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.automatic_trade_protection = AutomaticTradeProtection {
|
||||
buy_protection_days: 3,
|
||||
sell_cooldown_days: 3,
|
||||
max_holding_days: 1,
|
||||
locks: vec![AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(),
|
||||
start_date: d(16),
|
||||
end_date: Some(d(17)),
|
||||
}],
|
||||
};
|
||||
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
),
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(11)),
|
||||
end_date: Some(d(18)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 3);
|
||||
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
|
||||
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
|
||||
assert_eq!(
|
||||
(
|
||||
result.fills[0].date,
|
||||
result.fills[0].side,
|
||||
result.fills[0].quantity,
|
||||
result.fills[0].price
|
||||
),
|
||||
(d(18), OrderSide::Sell, 200, 5.)
|
||||
);
|
||||
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
|
||||
for day in [14, 15] {
|
||||
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
|
||||
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
|
||||
}
|
||||
}
|
||||
for day in [16, 17] {
|
||||
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
|
||||
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
|
||||
}
|
||||
assert!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
|
||||
);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert!(
|
||||
result
|
||||
.equity_curve
|
||||
.iter()
|
||||
.all(|point| point.external_cash_flow == 0.)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
|
||||
let mut config = PlatformExprStrategyConfig::generic();
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
|
||||
@@ -177,6 +177,157 @@ fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
|
||||
struct ConvertedSale {
|
||||
dates: [NaiveDate; 3],
|
||||
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
|
||||
}
|
||||
impl Strategy for ConvertedSale {
|
||||
fn name(&self) -> &str {
|
||||
"successor FIFO"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
|
||||
("000001.SZ", 100)
|
||||
} else if ctx.execution_date == self.dates[1] {
|
||||
("000002.SZ", 100)
|
||||
} else {
|
||||
let holding = ctx.portfolio.position("000002.SZ").unwrap();
|
||||
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
|
||||
("000002.SZ", -200)
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
order_intents: vec![fidc_core::OrderIntent::Shares {
|
||||
symbol: symbol.into(),
|
||||
quantity,
|
||||
reason: "dated lot test".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
|
||||
let symbols = ["000001.SZ", "000002.SZ"];
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for date in dates {
|
||||
for symbol in symbols {
|
||||
let price = if symbol == symbols[0] {
|
||||
10.
|
||||
} else if date == dates[2] {
|
||||
6.
|
||||
} else {
|
||||
20.
|
||||
};
|
||||
let mut quote = stock_market_snapshot(date);
|
||||
quote.symbol = symbol.into();
|
||||
quote.day_open = price;
|
||||
quote.open = price;
|
||||
quote.high = price;
|
||||
quote.low = price;
|
||||
quote.close = price;
|
||||
quote.last_price = price;
|
||||
quote.bid1 = price;
|
||||
quote.ask1 = price;
|
||||
quote.prev_close = price;
|
||||
quote.upper_limit = price * 1.1;
|
||||
quote.lower_limit = price * 0.9;
|
||||
market.push(quote);
|
||||
let mut factor = stock_factor_snapshot(date);
|
||||
factor.symbol = symbol.into();
|
||||
factors.push(factor);
|
||||
let mut candidate = stock_candidate(date);
|
||||
candidate.symbol = symbol.into();
|
||||
candidates.push(candidate);
|
||||
}
|
||||
}
|
||||
let data = DataSet::from_components_with_actions(
|
||||
symbols
|
||||
.into_iter()
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
dates.map(benchmark_snapshot).into(),
|
||||
vec![CorporateAction {
|
||||
date: dates[2],
|
||||
symbol: symbols[0].into(),
|
||||
payable_date: None,
|
||||
share_cash: 0.,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: Some(symbols[1].into()),
|
||||
successor_ratio: Some(2.),
|
||||
successor_cash: Some(0.),
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ConvertedSale {
|
||||
dates,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(dates[0]),
|
||||
end_date: Some(dates[2]),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
|
||||
assert_eq!(result.fills.len(), 3);
|
||||
assert_eq!(result.fills[2].quantity, 200);
|
||||
assert_eq!(result.fills[2].symbol, symbols[1]);
|
||||
let remaining = result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == symbols[1])
|
||||
.unwrap();
|
||||
assert_eq!(remaining.quantity, 100);
|
||||
assert_eq!(remaining.realized_pnl, 200.);
|
||||
assert!(
|
||||
result
|
||||
.position_events
|
||||
.iter()
|
||||
.any(|event| event.symbol == symbols[0]
|
||||
&& event.quantity_after == 0
|
||||
&& event.reason.starts_with("successor_conversion"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_reinvests_dividend_receivable_in_round_lots() {
|
||||
let buy_date = d(2025, 1, 1);
|
||||
|
||||
@@ -5,7 +5,7 @@ use fidc_core::{
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -163,7 +163,48 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
|
||||
struct AccountDependentQuoteReader;
|
||||
impl Strategy for AccountDependentQuoteReader {
|
||||
fn name(&self) -> &str { "account_dependent_quote_reader" }
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> { vec![t(10, 18, 0)] }
|
||||
fn decision_quote_symbols(&mut self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
Ok(if ctx.portfolio.cash() < 50_000.0 {
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
} else { BTreeSet::new() })
|
||||
}
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
|
||||
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
|
||||
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
|
||||
"quote scope must match this account, not a fixed-capital planning account");
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let date = d(2026, 1, 5);
|
||||
for initial_cash in [10_000.0, 100_000.0] {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash, benchmark_code:"000852.SH".into(),
|
||||
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
|
||||
execution_price_field:PriceField::Close,
|
||||
};
|
||||
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
|
||||
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
|
||||
observation_kind:Default::default(), date:request.date, symbol,
|
||||
timestamp:request.date.and_time(t(10,17,59)), last_price:10.0,bid1:10.0,ask1:10.0,
|
||||
bid1_volume:10_000,ask1_volume:10_000,volume_delta:10_000,amount_delta:100_000.0,
|
||||
trading_phase:Some("continuous".into()),
|
||||
}).collect()));
|
||||
engine.run().expect("account-dependent quote planning");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
|
||||
let date = d(2026, 1, 5);
|
||||
let data = single_day_quote_plan_data(date);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
@@ -186,17 +227,13 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||
};
|
||||
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||
let preplanned = Arc::new(BTreeMap::from([(
|
||||
date,
|
||||
BTreeSet::from(["000001.SZ".to_string()]),
|
||||
)]));
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 17, 59)),
|
||||
@@ -210,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
assert_eq!(
|
||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||
0,
|
||||
"the strategy plan must not be recomputed after a complete plan is supplied"
|
||||
1,
|
||||
"quote planning must use the actual run context"
|
||||
);
|
||||
assert_eq!(
|
||||
*loader_calls.lock().expect("loader counter mutex"),
|
||||
1,
|
||||
"the supplied symbols must still pass through the normal quote loader"
|
||||
0,
|
||||
"an empty runtime scope must not fetch unrequested symbols"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -411,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(t(10, 39, 59)),
|
||||
@@ -556,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: first,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: first.and_time(t(10, 39, 59)),
|
||||
@@ -569,7 +605,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
|
||||
amount_delta: 100_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: second,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: second.and_time(t(10, 39, 59)),
|
||||
@@ -826,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
|
||||
Ok(request
|
||||
.symbols
|
||||
.into_iter()
|
||||
.map(|symbol| IntradayExecutionQuote {
|
||||
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: request.date,
|
||||
symbol,
|
||||
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
use chrono::{NaiveDate, NaiveTime};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks,
|
||||
DataSet, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
use std::{cell::RefCell, collections::BTreeSet, rc::Rc};
|
||||
|
||||
const SYMBOL: &str = "000001.SZ";
|
||||
fn day(value: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
|
||||
}
|
||||
|
||||
fn data() -> DataSet {
|
||||
let days = [11, 14, 15].map(day);
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![fidc_core::Instrument {
|
||||
symbol: SYMBOL.into(),
|
||||
name: "fixture".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(day(1)),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
days.iter()
|
||||
.map(|&date| {
|
||||
let price = if date == day(11) { 10. } else { 8.95 };
|
||||
fidc_core::DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: Some(format!("{date} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::CandidateEligibility {
|
||||
date,
|
||||
symbol: SYMBOL.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
})
|
||||
.collect(),
|
||||
days.iter()
|
||||
.map(|&date| fidc_core::BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.collect(),
|
||||
vec![fidc_core::CorporateAction {
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
payable_date: Some(day(14)),
|
||||
share_cash: 1.05,
|
||||
share_bonus: 0.,
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: None,
|
||||
successor_ratio: None,
|
||||
successor_cash: None,
|
||||
}],
|
||||
[(9, 15), (9, 31)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(14),
|
||||
symbol: SYMBOL.into(),
|
||||
timestamp: day(14).and_hms_opt(hour, minute, 0).unwrap(),
|
||||
last_price: 8.95,
|
||||
bid1: 8.95,
|
||||
ask1: 8.95,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
volume_delta: 10000,
|
||||
amount_delta: 89500.,
|
||||
trading_phase: Some("continuous".into()),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
struct Hold {
|
||||
seen: Rc<RefCell<Vec<(NaiveTime, u32)>>>,
|
||||
}
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"accounting reinvestment contract"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
[SYMBOL.into()].into()
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(11) {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.into(),
|
||||
quantity: 1000,
|
||||
reason: "initial".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &fidc_core::IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.seen.borrow_mut().push((
|
||||
ctx.current_time().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(SYMBOL)
|
||||
.map_or(0, |position| position.quantity),
|
||||
));
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> BacktestEngine<Hold, ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BacktestEngine::new(
|
||||
data(),
|
||||
Hold {
|
||||
seen: Rc::new(RefCell::new(Vec::new())),
|
||||
},
|
||||
BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0008)
|
||||
.with_minimum_commission(0.),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 50000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(11)),
|
||||
end_date: Some(day(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_dividend_reinvestment(true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accounting_reinvestment_has_an_explicit_origin_clock_and_progress_delivery() {
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let reinvest = result
|
||||
.fills
|
||||
.iter()
|
||||
.find(|fill| fill.reason == "dividend_reinvestment")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
reinvest.quantity,
|
||||
reinvest.price,
|
||||
reinvest.commission,
|
||||
reinvest.order_id
|
||||
),
|
||||
(100, 8.95, 0., None)
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(reinvest).unwrap()["origin"],
|
||||
"dividend_reinvestment"
|
||||
);
|
||||
assert_eq!(reinvest.execution_timestamp, day(14).and_hms_opt(0, 0, 0));
|
||||
let received = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert!(
|
||||
received
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.reason == "dividend_reinvestment")
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn manual_source(delayed: bool) -> fidc_core::manual_execution::ManualExecutionReplay {
|
||||
let observed = if delayed {
|
||||
"2026-09-14T01:15:00Z"
|
||||
} else {
|
||||
"2026-09-11T06:00:01Z"
|
||||
};
|
||||
let mut source: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-09-15T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":"2026-09-11T05:59:59Z","confirmationObservedAt":"2026-09-11T05:59:59Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"manual-order","sourceAdapter":"paper","symbol":SYMBOL,"side":"Buy","quantity":1000,
|
||||
"orderCreatedAt":"2026-09-11T05:59:59Z","terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":"manual-fill","observationEventId":"receipt","observationSequence":1,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"tradeDate":"2026-09-11","executedAt":"2026-09-11T06:00:00Z","observedAt":observed,
|
||||
"timestampPrecision":"second","quantity":1000,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_receipt_before_market_open_reconciles_accounting_not_future_market_fills() {
|
||||
let timely = engine()
|
||||
.with_observed_manual_executions(manual_source(false))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let delayed = engine()
|
||||
.with_observed_manual_executions(manual_source(true))
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 2200);
|
||||
assert_eq!(
|
||||
delayed.holdings_summary[0].quantity,
|
||||
timely.holdings_summary[0].quantity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.corporate_cash_delta,
|
||||
"155"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekend_receipts_and_morning_allocations_are_in_the_next_progress_batch() {
|
||||
let mut source = manual_source(true);
|
||||
let observed = "2026-09-12T02:00:00Z".parse().unwrap();
|
||||
let order = &mut source.actions[0].orders[0];
|
||||
order.terminal_observed_at = observed;
|
||||
order.fills[0].observed_at = observed;
|
||||
order.fills[0].fee_observed_at = observed;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut progress = Vec::new();
|
||||
let result = engine()
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run_with_progress(|event| progress.push(event.clone()))
|
||||
.unwrap();
|
||||
let monday = progress.iter().find(|event| event.date == day(14)).unwrap();
|
||||
assert_eq!(monday.daily_manual_fill_count, 1);
|
||||
assert_eq!(monday.manual_executions[0].observed_at, observed);
|
||||
assert!(
|
||||
monday
|
||||
.fills
|
||||
.iter()
|
||||
.any(|fill| fill.origin == fidc_core::FillOrigin::DividendReinvestment)
|
||||
);
|
||||
assert!(
|
||||
monday
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.kind == fidc_core::ProcessEventKind::ManualExecutionObserved)
|
||||
);
|
||||
assert_eq!(
|
||||
progress
|
||||
.iter()
|
||||
.map(|event| event.daily_fill_count)
|
||||
.sum::<usize>(),
|
||||
result.fills.len() + result.manual_executions.len()
|
||||
);
|
||||
}
|
||||
|
||||
fn exposure_event(id: &str, sequence: u64, at: &str, action: fidc_core::position_exposure::PositionExposureAction)
|
||||
-> fidc_core::position_exposure::PositionExposureEvent {
|
||||
fidc_core::position_exposure::PositionExposureEvent {
|
||||
event_id: id.into(), sequence, effective_at: at.parse().unwrap(), allocation_weights_bps: None, action,
|
||||
}
|
||||
}
|
||||
|
||||
fn cleared_reinvestment_case(
|
||||
events: Vec<fidc_core::position_exposure::PositionExposureEvent>,
|
||||
legacy: std::collections::BTreeMap<NaiveDate, i32>,
|
||||
extra_buy_delayed: Option<bool>,
|
||||
) -> fidc_core::BacktestResult {
|
||||
let mut parts = data().snapshot_components();
|
||||
parts.corporate_actions[0].payable_date = Some(day(15));
|
||||
let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions, parts.execution_quotes).unwrap();
|
||||
let mut source = manual_source(false);
|
||||
let mut sale = source.actions[0].clone();
|
||||
sale.action_id = "clear".into(); sale.audit_event_ids = vec!["clear-audit".into()];
|
||||
sale.confirmed_at = "2026-09-14T05:59:59Z".parse().unwrap();
|
||||
sale.confirmation_observed_at = sale.confirmed_at;
|
||||
let order = &mut sale.orders[0];
|
||||
order.order_id = "clear-order".into(); order.side = fidc_core::OrderSide::Sell;
|
||||
order.order_created_at = sale.confirmed_at;
|
||||
order.terminal_observed_at = "2026-09-14T06:00:01Z".parse().unwrap();
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = "clear-fill".into(); fill.observation_event_id = "clear-receipt".into();
|
||||
fill.observation_sequence = 2; fill.fee_observation_event_id = "clear-receipt".into();
|
||||
fill.fee_observation_sequence = 2; fill.trade_date = day(14);
|
||||
fill.executed_at = "2026-09-14T06:00:00Z".parse().unwrap();
|
||||
fill.observed_at = order.terminal_observed_at; fill.fee_observed_at = order.terminal_observed_at;
|
||||
fill.price = "8.95".parse().unwrap();
|
||||
source.actions.push(sale);
|
||||
if let Some(delayed) = extra_buy_delayed {
|
||||
let mut extra = source.actions[0].clone();
|
||||
extra.action_id = "extra".into(); extra.audit_event_ids = vec!["extra-audit".into()];
|
||||
extra.confirmed_at = "2026-09-11T06:00:59Z".parse().unwrap();
|
||||
extra.confirmation_observed_at = extra.confirmed_at;
|
||||
let order = &mut extra.orders[0];
|
||||
order.order_id = "extra-order".into(); order.order_created_at = extra.confirmed_at;
|
||||
order.terminal_observed_at = if delayed { "2026-09-15T01:15:00Z" } else { "2026-09-11T06:01:01Z" }.parse().unwrap();
|
||||
let fill = &mut order.fills[0];
|
||||
fill.trade_id = "extra-fill".into(); fill.observation_event_id = "extra-receipt".into();
|
||||
fill.observation_sequence = if delayed { 3 } else { 2 };
|
||||
fill.fee_observation_event_id = "extra-receipt".into(); fill.fee_observation_sequence = fill.observation_sequence;
|
||||
fill.executed_at = "2026-09-11T06:01:00Z".parse().unwrap(); fill.observed_at = order.terminal_observed_at;
|
||||
fill.fee_observed_at = order.terminal_observed_at;
|
||||
if !delayed {
|
||||
source.actions[1].orders[0].fills[0].observation_sequence = 3;
|
||||
source.actions[1].orders[0].fills[0].fee_observation_sequence = 3;
|
||||
}
|
||||
source.actions.push(extra);
|
||||
}
|
||||
source.position_exposure_events = events;
|
||||
source.legacy_position_exposure_bps = legacy;
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = SYMBOL.into(); config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false; config.matching_type = MatchingType::CurrentBarClose;
|
||||
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false),
|
||||
BacktestConfig { initial_cash: 50000., benchmark_code: "000300.SH".into(), start_date: Some(day(11)),
|
||||
end_date: Some(day(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Close })
|
||||
.with_dividend_reinvestment(true).with_observed_manual_executions(source).unwrap().run().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effective_manual_zero_must_not_recreate_a_cleared_position_on_dividend_payment() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let zero = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
|
||||
let result = cleared_reinvestment_case(vec![zero], Default::default(), None);
|
||||
assert!(result.fills.is_empty(), "{:?}", result.fills);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
assert_eq!(result.equity_curve.last().unwrap().cash, 49998.);
|
||||
assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_exposure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clear_without_a_manual_zero_keeps_the_declared_legacy_reinvestment_model() {
|
||||
let result = cleared_reinvestment_case(vec![], Default::default(), None);
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!((result.fills[0].quantity, result.fills[0].price, result.fills[0].commission), (100, 8.95, 0.));
|
||||
assert_eq!(result.fills[0].gross_amount, 895.);
|
||||
assert_eq!(result.fills[0].net_cash_flow, -895.);
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinvestment_respects_zero_restore_same_instant_sequence_and_legacy_granularity() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let before = "2026-09-14T07:00:00Z";
|
||||
let settlement = "2026-09-14T16:00:00Z";
|
||||
let later = "2026-09-15T01:31:00Z";
|
||||
let zero = || exposure_event("zero", 1, before, Action::Scale { requested_bps: 0 });
|
||||
let cases = vec![
|
||||
(vec![zero()], Default::default(), false),
|
||||
(vec![exposure_event("zero-at-settlement", 1, settlement, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
|
||||
(vec![exposure_event("later-zero", 1, later, Action::Set { target_exposure_bps: 0 })], Default::default(), true),
|
||||
(vec![zero(), exposure_event("restore", 2, before, Action::Restore)], Default::default(), true),
|
||||
(vec![exposure_event("restore", 1, before, Action::Restore), exposure_event("last-zero", 2, before, Action::Set { target_exposure_bps: 0 })], Default::default(), false),
|
||||
(vec![exposure_event("restore", 2, before, Action::Restore), zero()], Default::default(), true),
|
||||
(vec![zero(), exposure_event("later-restore", 2, later, Action::Restore)], Default::default(), false),
|
||||
(vec![], std::collections::BTreeMap::from([(day(14), 0)]), false),
|
||||
(vec![exposure_event("restore-legacy", 1, before, Action::Restore)], std::collections::BTreeMap::from([(day(14), 0)]), true),
|
||||
(vec![exposure_event("positive", 1, before, Action::Set { target_exposure_bps: 3000 })], Default::default(), true),
|
||||
];
|
||||
for (events, legacy, allowed) in cases {
|
||||
let result = cleared_reinvestment_case(events, legacy, None);
|
||||
assert_eq!(result.fills.len(), usize::from(allowed));
|
||||
assert_eq!(result.equity_curve.last().unwrap().cash, if allowed { 49103. } else { 49998. });
|
||||
assert_eq!(result.holdings_summary.iter().map(|holding| holding.quantity).sum::<u32>(), if allowed {100} else {0});
|
||||
assert!(result.order_events.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_zero_member_weight_or_omission_blocks_only_that_reinvestment() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
for included in [false, true] {
|
||||
for weight in [0, 10000] {
|
||||
let mut event = exposure_event("allocation", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 5000 });
|
||||
let mut weights = std::collections::BTreeMap::from([("000002.SZ".into(), if included {10000-weight} else {10000})]);
|
||||
if included { weights.insert(SYMBOL.into(), weight); }
|
||||
event.allocation_weights_bps = Some(weights);
|
||||
let allowed = included && weight > 0;
|
||||
let result = cleared_reinvestment_case(vec![event], Default::default(), None);
|
||||
assert_eq!(result.fills.len(), usize::from(allowed));
|
||||
if !allowed { assert!(result.equity_curve.last().unwrap().notes.contains("runtime_zero_allocation")); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_corporate_replay_uses_the_same_zero_policy_and_retains_actual_manual_shares() {
|
||||
use fidc_core::position_exposure::PositionExposureAction as Action;
|
||||
let event = exposure_event("zero", 1, "2026-09-14T07:00:00Z", Action::Set { target_exposure_bps: 0 });
|
||||
let timely = cleared_reinvestment_case(vec![event.clone()], Default::default(), Some(false));
|
||||
let late = cleared_reinvestment_case(vec![event], Default::default(), Some(true));
|
||||
assert!(timely.fills.is_empty()); assert!(late.fills.is_empty());
|
||||
assert_eq!(timely.equity_curve.last().unwrap().cash, 41047.);
|
||||
assert_eq!(late.equity_curve.last().unwrap().cash, timely.equity_curve.last().unwrap().cash);
|
||||
assert_eq!(late.holdings_summary[0].quantity, 1000);
|
||||
assert_eq!(late.holdings_summary[0].quantity, timely.holdings_summary[0].quantity);
|
||||
assert_eq!(late.manual_executions.last().unwrap().corporate_adjustment.as_ref().unwrap().corporate_cash_delta, "1050");
|
||||
}
|
||||
@@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_directive_notifications_include_the_actual_recorded_fill() {
|
||||
struct Observed {
|
||||
inner: FuturesOrderStrategy,
|
||||
seen: Rc<RefCell<Vec<u64>>>,
|
||||
}
|
||||
impl Strategy for Observed {
|
||||
fn name(&self) -> &str {
|
||||
"observed-futures-directive"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_process_event(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
event: &ProcessEvent,
|
||||
) -> Result<(), fidc_core::BacktestError> {
|
||||
if event.kind == ProcessEventKind::Trade
|
||||
&& event.symbol.as_deref() == Some("IF2501")
|
||||
{
|
||||
let id = event.order_id.unwrap();
|
||||
assert!(
|
||||
ctx.fills
|
||||
.iter()
|
||||
.any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501")
|
||||
);
|
||||
assert!(
|
||||
ctx.order_events
|
||||
.iter()
|
||||
.any(|order| order.order_id == Some(id)
|
||||
&& order.status == OrderStatus::Filled)
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.current_datetime().map(|time| time.date()),
|
||||
Some(ctx.execution_date)
|
||||
);
|
||||
self.seen.borrow_mut().push(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
Observed {
|
||||
inner: FuturesOrderStrategy,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 100_000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(2025, 1, 2)),
|
||||
end_date: Some(d(2025, 1, 3)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_futures_initial_cash(500_000.);
|
||||
let result = engine.run().unwrap();
|
||||
assert_eq!(
|
||||
*seen.borrow(),
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == "IF2501")
|
||||
.map(|fill| fill.order_id.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(seen.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
@@ -2209,7 +2293,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2222,7 +2306,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
amount_delta: 10_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2235,7 +2319,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
||||
amount_delta: 20_400.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 19, 0),
|
||||
@@ -2341,7 +2425,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||
let date = d(2025, 1, 2);
|
||||
let mut data = single_day_anchor_data(date);
|
||||
data.add_execution_quotes(vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||
@@ -2354,7 +2438,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||
amount_delta: 10_200.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 2, 10, 19, 0),
|
||||
@@ -2519,7 +2603,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let quotes = vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date2,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 3, 14, 30, 0),
|
||||
@@ -2532,7 +2616,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
amount_delta: 10_150.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 6, 10, 18, 0),
|
||||
@@ -2545,7 +2629,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
|
||||
amount_delta: 10_250.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: date3,
|
||||
symbol: "000001.SZ".to_string(),
|
||||
timestamp: dt(2025, 1, 6, 10, 19, 0),
|
||||
@@ -2748,6 +2832,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 12.0,
|
||||
reserved_cash: None,
|
||||
reason: "pending_buy".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
|
||||
@@ -146,7 +146,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -159,7 +159,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 19, 0).unwrap(),
|
||||
@@ -172,7 +172,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
|
||||
amount_delta: 10_000.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(10, 20, 0).unwrap(),
|
||||
@@ -373,7 +373,7 @@ fn broker_executes_explicit_order_value_buy() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -527,7 +527,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
|
||||
@@ -663,7 +663,7 @@ fn broker_executes_order_shares_and_order_lots() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -1104,7 +1104,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
|
||||
@@ -1117,7 +1117,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 35, 0).unwrap(),
|
||||
@@ -1920,7 +1920,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -2153,7 +2153,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(9, 33, 0).unwrap(),
|
||||
@@ -2273,7 +2273,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
|
||||
@@ -2509,7 +2509,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2522,7 +2522,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2682,7 +2682,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2695,7 +2695,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2839,7 +2839,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 17, 59).unwrap(),
|
||||
@@ -2852,7 +2852,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -2865,7 +2865,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
|
||||
@@ -2878,7 +2878,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 40).unwrap(),
|
||||
@@ -3001,7 +3001,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 0, 0).unwrap(),
|
||||
@@ -3014,7 +3014,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 15, 0).unwrap(),
|
||||
@@ -3027,7 +3027,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
|
||||
amount_delta: 0.0,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
},
|
||||
IntradayExecutionQuote {
|
||||
IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 30, 0).unwrap(),
|
||||
@@ -3165,7 +3165,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -3284,7 +3284,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
Vec::new(),
|
||||
vec![IntradayExecutionQuote {
|
||||
vec![IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date,
|
||||
symbol: "000002.SZ".to_string(),
|
||||
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
|
||||
@@ -4915,7 +4915,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
|
||||
},
|
||||
],
|
||||
Vec::new(),
|
||||
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote {
|
||||
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: 100_000, amount_delta: 100_000.0 * price,
|
||||
|
||||
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
|
||||
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
|
||||
for offset in 0..bars_per_day {
|
||||
let timestamp = session_start + Duration::minutes(offset as i64);
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: *date,
|
||||
symbol: SYMBOL.to_string(),
|
||||
timestamp,
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use fidc_core::manual_execution::{MANUAL_REPLAY_SCHEMA, ManualExecutionReplay};
|
||||
use fidc_core::{
|
||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||
ChinaAShareCostModel, ChinaEquityRuleHooks, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DataSet, Instrument, MatchingType, PriceField, Strategy,
|
||||
};
|
||||
|
||||
fn date(day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 9, day).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Action {
|
||||
Split,
|
||||
Dividend,
|
||||
Successor,
|
||||
}
|
||||
|
||||
fn data(action: Action) -> DataSet {
|
||||
data_with_successor_metadata(action, true)
|
||||
}
|
||||
|
||||
fn data_with_successor_metadata(action: Action, include_successor: bool) -> DataSet {
|
||||
let days = [10, 11, 14, 15].map(date);
|
||||
let mut market = Vec::new();
|
||||
let mut factors = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for day in days {
|
||||
for symbol in ["000001.SZ", "000002.SZ"] {
|
||||
if matches!(action, Action::Successor) && symbol == "000001.SZ" && day >= date(14) {
|
||||
continue;
|
||||
}
|
||||
let price = if day < date(14)
|
||||
|| (symbol == "000002.SZ" && !matches!(action, Action::Successor))
|
||||
{
|
||||
10.
|
||||
} else if matches!(action, Action::Dividend) {
|
||||
9.
|
||||
} else {
|
||||
5.
|
||||
};
|
||||
market.push(DailyMarketSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
timestamp: Some(format!("{day} 15:00:00")),
|
||||
day_open: price,
|
||||
open: price,
|
||||
high: price,
|
||||
low: price,
|
||||
close: price,
|
||||
last_price: price,
|
||||
bid1: price,
|
||||
ask1: price,
|
||||
prev_close: price,
|
||||
volume: 100000,
|
||||
minute_volume: 100000,
|
||||
bid1_volume: 100000,
|
||||
ask1_volume: 100000,
|
||||
trading_phase: Some("continuous".into()),
|
||||
paused: false,
|
||||
upper_limit: price * 1.1,
|
||||
lower_limit: price * 0.9,
|
||||
price_tick: 0.01,
|
||||
});
|
||||
factors.push(DailyFactorSnapshot {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
market_cap_bn: 10.,
|
||||
free_float_cap_bn: 10.,
|
||||
pe_ttm: 10.,
|
||||
turnover_ratio: None,
|
||||
effective_turnover_ratio: None,
|
||||
adjustment_factor_backward1: Some(1.),
|
||||
extra_factors: Default::default(),
|
||||
});
|
||||
candidates.push(CandidateEligibility {
|
||||
date: day,
|
||||
symbol: symbol.into(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
["000001.SZ", "000002.SZ"]
|
||||
.into_iter()
|
||||
.filter(|symbol| include_successor || *symbol != "000002.SZ")
|
||||
.map(|symbol| Instrument {
|
||||
symbol: symbol.into(),
|
||||
name: symbol.into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(date(1)),
|
||||
delisted_at: (matches!(action, Action::Successor) && symbol == "000001.SZ")
|
||||
.then_some(date(14)),
|
||||
status: "active".into(),
|
||||
})
|
||||
.collect(),
|
||||
market,
|
||||
factors,
|
||||
candidates,
|
||||
days.map(|day| BenchmarkSnapshot {
|
||||
date: day,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 100000,
|
||||
})
|
||||
.into(),
|
||||
vec![CorporateAction {
|
||||
date: date(14),
|
||||
symbol: "000001.SZ".into(),
|
||||
payable_date: Some(date(14)),
|
||||
share_cash: if matches!(action, Action::Dividend) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_bonus: if matches!(action, Action::Split) {
|
||||
1.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
share_gift: 0.,
|
||||
issue_quantity: 0.,
|
||||
issue_price: 0.,
|
||||
reform: false,
|
||||
adjust_factor: None,
|
||||
successor_symbol: matches!(action, Action::Successor).then(|| "000002.SZ".into()),
|
||||
successor_ratio: matches!(action, Action::Successor).then_some(2.),
|
||||
successor_cash: matches!(action, Action::Successor).then_some(0.5),
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(delayed: bool, sell: bool) -> ManualExecutionReplay {
|
||||
let trades = if sell {
|
||||
vec![
|
||||
("initial-buy", "Buy", 10, 200, false),
|
||||
("sale", "Sell", 11, 100, delayed),
|
||||
]
|
||||
} else {
|
||||
vec![("buy", "Buy", 11, 100, delayed)]
|
||||
};
|
||||
let actions = trades.into_iter().enumerate().map(|(index, (id, side, day, quantity, late))| {
|
||||
let executed = format!("2026-09-{day:02}T06:00:00Z").parse::<DateTime<Utc>>().unwrap();
|
||||
let observed = if late { "2026-09-15T05:00:00Z".parse().unwrap() } else { executed + chrono::Duration::seconds(1) };
|
||||
let created = executed - chrono::Duration::seconds(1);
|
||||
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
|
||||
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
|
||||
"tradeId":id,"observationEventId":id,"observationSequence":index+1,"tradeDate":date(day),
|
||||
"executedAt":executed,"observedAt":observed,"feeObservationEventId":id,"feeObservationSequence":index+1,
|
||||
"feeObservedAt":observed,"timestampPrecision":"second","quantity":quantity,"price":"10","totalFee":"1"
|
||||
}]
|
||||
}]})
|
||||
}).collect::<Vec<_>>();
|
||||
let mut source: ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),
|
||||
"contentSha256":"","observationCutoff":"2026-09-15T08:00:00Z","actions":actions,
|
||||
})).unwrap();
|
||||
source.content_sha256 = source.content_digest().unwrap();
|
||||
source.validate().unwrap();
|
||||
source
|
||||
}
|
||||
|
||||
struct Hold;
|
||||
impl Strategy for Hold {
|
||||
fn name(&self) -> &str {
|
||||
"manual corporate observation"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn run_custom<S: Strategy>(
|
||||
data: DataSet,
|
||||
source: ManualExecutionReplay,
|
||||
strategy: S,
|
||||
cash_dividends: bool,
|
||||
adjust_cost: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::NextBarOpen)
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
BacktestEngine::new(
|
||||
data,
|
||||
strategy,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 10000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)),
|
||||
end_date: Some(date(15)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_cash_dividends(cash_dividends)
|
||||
.with_cash_dividend_cost_basis_adjustment(adjust_cost)
|
||||
.with_observed_manual_executions(source)
|
||||
.unwrap()
|
||||
.run()
|
||||
}
|
||||
|
||||
fn run(
|
||||
action: Action,
|
||||
delayed: bool,
|
||||
sell: bool,
|
||||
) -> Result<fidc_core::BacktestResult, fidc_core::BacktestError> {
|
||||
run_custom(data(action), source(delayed, sell), Hold, true, true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_buy_does_not_lose_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, false).unwrap();
|
||||
let delayed = run(action, true, false).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 1);
|
||||
assert!(delayed.fills.is_empty());
|
||||
if matches!(action, Action::Successor)
|
||||
&& let Ok(directory) = std::env::var("FIDC_CORPORATE_QA_OUTPUT")
|
||||
{
|
||||
use std::io::Write;
|
||||
let path = std::path::Path::new(&directory).join("corporate-successor-result.json");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(path).unwrap();
|
||||
file.write_all(&serde_json::to_vec(&serde_json::json!({
|
||||
"source":delayed.manual_execution_source.as_deref(), "applications":delayed.manual_executions,
|
||||
})).unwrap()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_sale_does_not_keep_unearned_corporate_entitlements() {
|
||||
for action in [Action::Split, Action::Dividend, Action::Successor] {
|
||||
let timely = run(action, false, true).unwrap();
|
||||
let delayed = run(action, true, true).unwrap();
|
||||
let project = |result: &fidc_core::BacktestResult| {
|
||||
(
|
||||
result.equity_curve.last().unwrap().cash,
|
||||
result.equity_curve.last().unwrap().total_equity,
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(project(&delayed), project(&timely), "{action:?}");
|
||||
assert_eq!(delayed.manual_executions.len(), 2);
|
||||
assert!(delayed.fills.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paper_and_broker_observations_require_the_same_frozen_successor_scope() {
|
||||
for adapter in ["paper", "gt", "qmt"] {
|
||||
for delayed in [false, true] {
|
||||
for sell in [false, true] {
|
||||
let mut replay = source(delayed, sell);
|
||||
for action in &mut replay.actions {
|
||||
for order in &mut action.orders { order.source_adapter = Some(adapter.into()); }
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
replay.validate().unwrap();
|
||||
let complete = run_custom(data(Action::Successor), replay.clone(), Hold, true, true).unwrap();
|
||||
assert_eq!(complete.holdings_summary[0].symbol, "000002.SZ");
|
||||
assert_eq!(complete.holdings_summary[0].quantity, 200);
|
||||
assert!(complete.fills.is_empty());
|
||||
assert!(complete.order_events.is_empty());
|
||||
let error = run_custom(data_with_successor_metadata(Action::Successor, false),
|
||||
replay, Hold, true, true).unwrap_err();
|
||||
assert!(error.to_string().contains("successor_instrument_missing"),
|
||||
"{adapter} delayed={delayed} sell={sell}: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protected_successor_run(delayed: bool, locked: bool, amount: i32)
|
||||
-> fidc_core::BacktestResult {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if locked { 0 } else { 3 },
|
||||
locks: if locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
protected_successor_case(delayed, policy, amount, "partial")
|
||||
}
|
||||
|
||||
fn protected_successor_case(delayed: bool,
|
||||
policy: fidc_core::holding_policy::AutomaticTradeProtection, amount: i32, scenario: &str)
|
||||
-> fidc_core::BacktestResult {
|
||||
let mut config = fidc_core::PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = "000002.SZ".into();
|
||||
config.benchmark_symbol = "000300.SH".into();
|
||||
config.rotation_enabled = false;
|
||||
config.matching_type = MatchingType::CurrentBarClose;
|
||||
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
|
||||
config.explicit_action_schedule = Some(fidc_core::PlatformRebalanceSchedule {
|
||||
frequency: fidc_core::PlatformScheduleFrequency::Daily,
|
||||
time_rule: Some(fidc_core::ScheduleTimeRule::physical_time(14, 30)),
|
||||
});
|
||||
config.automatic_trade_protection = policy;
|
||||
config.explicit_actions = vec![fidc_core::PlatformTradeAction::Order {
|
||||
kind: fidc_core::PlatformExplicitOrderKind::Shares, symbol: "000002.SZ".into(),
|
||||
amount_expr: amount.to_string(), when_expr: Some("decision_date == \"2026-09-15\"".into()),
|
||||
limit_price_expr: None, time_in_force: None, start_time_expr: None, end_time_expr: None,
|
||||
reason: "configured_successor_action".into(),
|
||||
}];
|
||||
let data = successor_execution_data();
|
||||
let mut replay = source(delayed, true);
|
||||
if scenario == "sold_before" {
|
||||
let order = &mut replay.actions[1].orders[0];
|
||||
order.quantity = 200; order.fills[0].quantity = 200;
|
||||
} else if scenario == "cleared_after" {
|
||||
let mut row = serde_json::to_value(&replay.actions[1]).unwrap();
|
||||
let at = "2026-09-15T05:30:00Z";
|
||||
let receipt = "2026-09-15T05:30:01Z";
|
||||
row["actionId"] = "clear".into(); row["auditEventIds"] = serde_json::json!(["audit-clear"]);
|
||||
row["confirmedAt"] = at.into(); row["confirmationObservedAt"] = at.into();
|
||||
let order = &mut row["orders"][0];
|
||||
order["orderId"] = "clear-order".into(); order["brokerOrderId"] = "clear-order".into();
|
||||
order["symbol"] = "000002.SZ".into(); order["quantity"] = 200.into();
|
||||
order["orderCreatedAt"] = at.into(); order["terminalObservedAt"] = receipt.into();
|
||||
let fill = &mut order["fills"][0];
|
||||
fill["tradeId"] = "clear-trade".into(); fill["observationEventId"] = "clear-receipt".into();
|
||||
fill["observationSequence"] = 3.into(); fill["tradeDate"] = "2026-09-15".into();
|
||||
fill["executedAt"] = at.into(); fill["observedAt"] = receipt.into();
|
||||
fill["feeObservationEventId"] = "clear-receipt".into(); fill["feeObservationSequence"] = 3.into();
|
||||
fill["feeObservedAt"] = receipt.into(); fill["price"] = "5".into(); fill["quantity"] = 200.into();
|
||||
replay.actions.push(serde_json::from_value(row).unwrap());
|
||||
}
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
BacktestEngine::new(data, fidc_core::PlatformExprStrategy::new(config),
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose)
|
||||
.with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(date(10)), end_date: Some(date(15)), decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Close,
|
||||
}).with_observed_manual_executions(replay).unwrap().run().unwrap()
|
||||
}
|
||||
|
||||
fn successor_execution_data() -> DataSet {
|
||||
let parts = data(Action::Successor).snapshot_components();
|
||||
DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market,
|
||||
parts.factors, parts.candidates, parts.benchmarks, parts.corporate_actions,
|
||||
[30, 31].into_iter().map(|minute| fidc_core::IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: date(15), symbol: "000002.SZ".into(), timestamp: date(15).and_hms_opt(14,minute,0).unwrap(),
|
||||
last_price: 5., bid1: 5., ask1: 5., bid1_volume: 100000, ask1_volume: 100000,
|
||||
volume_delta: 100000, amount_delta: 500000., trading_phase: Some("continuous".into()),
|
||||
}).collect()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_pool_rebalance_applies_inherited_protection_without_rewriting_its_target() {
|
||||
use fidc_core::stock_pool_execution as pool;
|
||||
struct NativePool { locked: bool, expires: u32, exposure: i32 }
|
||||
impl Strategy for NativePool {
|
||||
fn name(&self) -> &str { "native pool successor protection" }
|
||||
fn requires_minute_callbacks(&self) -> bool { false }
|
||||
fn schedule_rules(&self) -> Vec<fidc_core::ScheduleRule> {
|
||||
vec![fidc_core::ScheduleRule::daily("pool", fidc_core::ScheduleStage::OnDay)
|
||||
.with_time_rule(fidc_core::ScheduleTimeRule::physical_time(14,30))]
|
||||
}
|
||||
fn on_scheduled(&mut self, ctx: &fidc_core::StrategyContext<'_>, _: &fidc_core::ScheduleRule)
|
||||
-> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != date(15) { return Ok(Default::default()); }
|
||||
let symbols = vec!["000002.SZ".to_owned()];
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
sell_cooldown_days: if self.locked { 0 } else { 3 },
|
||||
locks: if self.locked { vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(self.expires)),
|
||||
}] } else { vec![] }, ..Default::default()
|
||||
};
|
||||
let contract = pool::FrozenStockPoolIntent {
|
||||
pool_id: "pool".into(), signal_date: date(15), frozen_equity: 10000.into(),
|
||||
selection: pool::StockPoolSelection { trade_date: date(15), requested_symbols: symbols.clone(),
|
||||
normal_trading_symbols: symbols.clone(), risk_eligible_symbols: symbols.clone(), final_symbols: symbols,
|
||||
exclusion_reasons: Default::default(), inherited_from_generation: None, explicit_empty: false,
|
||||
generation: Some("latest".into()),
|
||||
}, members: vec![pool::StockPoolMemberSpec { symbol: "000002.SZ".into(), requested_order: 0,
|
||||
recommendation_reason: String::new(), target_weight_bps: None, stop_loss: None, take_profit: None }],
|
||||
rule: pool::StockPoolExecutionRule { pricing_mode: pool::POOL_PRICE_FIRST_TICK.into(),
|
||||
window_start: "14:30".into(), window_end: "15:00".into(), automatic_trade_protection: policy,
|
||||
..Default::default() }, constraints: pool::StockPoolDecisionConstraints {
|
||||
target_holding_count: Some(1), ..Default::default() },
|
||||
invest_ratio_bps: self.exposure, reserve_cash: 0.into(), out_of_pool_policy: "hold".into(), generation: "latest".into(),
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision { order_intents: vec![fidc_core::OrderIntent::StockPool { contract: Box::new(contract) }], ..Default::default() })
|
||||
}
|
||||
}
|
||||
for delayed in [false, true] {
|
||||
for (locked, expires, exposure) in [(false, 15, 10000), (true, 15, 10000), (true, 15, 0), (true, 14, 10000)] {
|
||||
let result = BacktestEngine::new(successor_execution_data(), NativePool { locked, expires, exposure },
|
||||
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(14,30,0).unwrap())
|
||||
.with_volume_limit(false).with_liquidity_limit(false),
|
||||
BacktestConfig { initial_cash: 10000., benchmark_code: "000300.SH".into(), start_date: Some(date(10)),
|
||||
end_date: Some(date(15)), decision_lag_trading_days: 0, execution_price_field: PriceField::Last })
|
||||
.with_observed_manual_executions(source(delayed, true)).unwrap().run().unwrap();
|
||||
if expires == 14 {
|
||||
assert!(!result.fills.is_empty(), "positive control {:?}", result.order_events);
|
||||
} else {
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} locked={locked} exposure={exposure}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_keeps_sell_cooldown_for_timely_and_delayed_receipts() {
|
||||
for delayed in [false, true] {
|
||||
let result = protected_successor_run(delayed, false, 100);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "sell_fill_cooldown"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converted_holding_does_not_lose_its_configured_date_lock() {
|
||||
for delayed in [false, true] {
|
||||
for amount in [-100, 100] {
|
||||
let result = protected_successor_run(delayed, true, amount);
|
||||
assert!(result.fills.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.fills);
|
||||
assert!(result.order_events.is_empty(), "delayed={delayed} amount={amount}: {:?}", result.order_events);
|
||||
assert!(result.risk_decisions.iter().any(|row| row.symbol == "000002.SZ"
|
||||
&& row.date == date(15) && !row.accepted && row.rule_code == "automatic_trade_locked"),
|
||||
"orders={:?} risk={:?} notes={:?}", result.order_events, result.risk_decisions,
|
||||
result.equity_curve.iter().map(|row| (&row.date, &row.notes)).collect::<Vec<_>>());
|
||||
assert_eq!(result.holdings_summary[0].quantity, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successor_lock_expires_on_the_original_configured_date_not_the_conversion_date() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(14)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let result = protected_successor_case(delayed, policy, 100, "partial");
|
||||
assert_eq!(result.fills.len(), 1);
|
||||
assert_eq!(result.fills[0].quantity, 100);
|
||||
assert_eq!(result.holdings_summary[0].quantity, 300);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_survives_a_manual_clear_after_conversion_but_not_a_disproved_conversion() {
|
||||
for delayed in [false, true] {
|
||||
let policy = fidc_core::holding_policy::AutomaticTradeProtection {
|
||||
locks: vec![fidc_core::holding_policy::AutomaticTradeLock {
|
||||
symbol: "000001.SZ".into(), start_date: date(11), end_date: Some(date(15)),
|
||||
}], ..Default::default()
|
||||
};
|
||||
let cleared = protected_successor_case(delayed, policy.clone(), 100, "cleared_after");
|
||||
assert!(cleared.fills.is_empty());
|
||||
assert!(cleared.order_events.is_empty());
|
||||
assert!(cleared.holdings_summary.is_empty());
|
||||
assert_eq!(cleared.manual_executions.len(), 3);
|
||||
let unconverted = protected_successor_case(delayed, policy, 100, "sold_before");
|
||||
assert_eq!(unconverted.fills.len(), 1);
|
||||
assert_eq!(unconverted.holdings_summary[0].quantity, 100);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corporate_replay_preserves_issued_orders_cash_flows_financing_and_charged_fees() {
|
||||
struct ExistingActivity {
|
||||
receiving_days: usize,
|
||||
}
|
||||
impl Strategy for ExistingActivity {
|
||||
fn name(&self) -> &str {
|
||||
"corporate replay with original activity"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &fidc_core::StrategyContext<'_>,
|
||||
) -> Result<fidc_core::StrategyDecision, fidc_core::BacktestError> {
|
||||
use fidc_core::OrderIntent;
|
||||
let order_intents = if ctx.execution_date == date(10) {
|
||||
vec![
|
||||
OrderIntent::DepositWithdraw {
|
||||
amount: 500.,
|
||||
receiving_days: self.receiving_days,
|
||||
reason: "original deposit".into(),
|
||||
},
|
||||
OrderIntent::FinanceRepay {
|
||||
amount: 200.,
|
||||
reason: "original financing".into(),
|
||||
},
|
||||
OrderIntent::SetManagementFeeRate {
|
||||
rate: 0.001,
|
||||
reason: "original fee policy".into(),
|
||||
},
|
||||
]
|
||||
} else if ctx.execution_date == date(11) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000002.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "unrelated stock".into(),
|
||||
}]
|
||||
} else if ctx.execution_date == date(14) {
|
||||
vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "already issued after corporate action".into(),
|
||||
}]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
Ok(fidc_core::StrategyDecision {
|
||||
order_intents,
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
fn management_fee(
|
||||
&mut self,
|
||||
_: &fidc_core::StrategyContext<'_>,
|
||||
_: f64,
|
||||
) -> Result<Option<f64>, fidc_core::BacktestError> {
|
||||
Ok(Some(0.25))
|
||||
}
|
||||
}
|
||||
for receiving_days in [0, 1] {
|
||||
for sell in [false, true] {
|
||||
let timely = run_custom(
|
||||
data(Action::Split),
|
||||
source(false, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
let delayed = run_custom(
|
||||
data(Action::Split),
|
||||
source(true, sell),
|
||||
ExistingActivity { receiving_days },
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&timely.fills).unwrap(),
|
||||
serde_json::to_value(&delayed.fills).unwrap()
|
||||
);
|
||||
assert_eq!(delayed.fills.len(), 2);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed
|
||||
.equity_curve
|
||||
.iter()
|
||||
.map(|row| row.external_cash_flow)
|
||||
.sum::<f64>(),
|
||||
500.
|
||||
);
|
||||
assert_eq!(delayed.manual_executions.len(), if sell { 2 } else { 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_and_disabled_dividends_keep_the_configured_cash_and_cost_contract() {
|
||||
for paid in [false, true] {
|
||||
for enabled in [false, true] {
|
||||
for adjust_cost in [false, true] {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Dividend).snapshot_components();
|
||||
parts.corporate_actions[0].payable_date =
|
||||
Some(date(if paid { 14 } else { 16 }));
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let timely =
|
||||
run_custom(fixture(), source(false, false), Hold, enabled, adjust_cost)
|
||||
.unwrap();
|
||||
let delayed =
|
||||
run_custom(fixture(), source(true, false), Hold, enabled, adjust_cost).unwrap();
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().cash,
|
||||
timely.equity_curve.last().unwrap().cash
|
||||
);
|
||||
let financial = |result: &fidc_core::BacktestResult| {
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.map(|row| {
|
||||
(
|
||||
row.symbol.clone(),
|
||||
row.quantity,
|
||||
row.average_cost,
|
||||
row.last_price,
|
||||
row.market_value,
|
||||
row.unrealized_pnl,
|
||||
row.realized_pnl,
|
||||
row.pnl,
|
||||
row.dividend_receivable,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
// Receipt-day turnover is deliberately different when the
|
||||
// dividend option is disabled and no adjustment is required.
|
||||
assert_eq!(financial(&delayed), financial(&timely));
|
||||
assert_eq!(
|
||||
delayed.manual_executions[0].corporate_adjustment.is_some(),
|
||||
enabled
|
||||
);
|
||||
if enabled && !paid {
|
||||
assert_eq!(delayed.terminal_audit.cash_receivable_count, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_fill_replays_aggregate_split_rounding_not_an_independent_rounded_fragment() {
|
||||
let fixture = || {
|
||||
let mut parts = data(Action::Split).snapshot_components();
|
||||
parts.corporate_actions[0].share_bonus = 0.125;
|
||||
for row in &mut parts.market {
|
||||
if row.symbol == "000001.SZ" && row.date >= date(14) {
|
||||
row.day_open = 8.89;
|
||||
row.open = 8.89;
|
||||
row.close = 8.89;
|
||||
row.last_price = 8.89;
|
||||
row.high = 8.89;
|
||||
row.low = 8.89;
|
||||
row.prev_close = 8.89;
|
||||
row.bid1 = 8.89;
|
||||
row.ask1 = 8.89;
|
||||
row.upper_limit = 9.78;
|
||||
row.lower_limit = 8.;
|
||||
}
|
||||
}
|
||||
DataSet::from_components_with_actions(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let input = |delayed| {
|
||||
let mut value = source(delayed, true);
|
||||
value.actions[0].orders[0].quantity = 100;
|
||||
value.actions[0].orders[0].fills[0].quantity = 100;
|
||||
value.actions[1].orders[0].side = fidc_core::OrderSide::Buy;
|
||||
value.content_sha256 = value.content_digest().unwrap();
|
||||
value
|
||||
};
|
||||
let timely = run_custom(fixture(), input(false), Hold, true, true).unwrap();
|
||||
let delayed = run_custom(fixture(), input(true), Hold, true, true).unwrap();
|
||||
assert_eq!(timely.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(delayed.holdings_summary[0].quantity, 225);
|
||||
assert_eq!(
|
||||
delayed.equity_curve.last().unwrap().total_equity,
|
||||
timely.equity_curve.last().unwrap().total_equity
|
||||
);
|
||||
assert_eq!(
|
||||
delayed.manual_executions[1]
|
||||
.corporate_adjustment
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.positions["000001.SZ"]
|
||||
.quantity_before,
|
||||
113
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use fidc_core::{
|
||||
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
|
||||
};
|
||||
use rust_decimal::Decimal;
|
||||
use fidc_core::IntradayExecutionQuote;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
fn day(n: u32) -> NaiveDate {
|
||||
@@ -142,7 +143,7 @@ fn data_with_fund_rules(
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
|
||||
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { observation_kind: Default::default(),
|
||||
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
|
||||
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
|
||||
@@ -223,6 +224,117 @@ fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_zero_target_prevents_resuming_the_previous_unsubmitted_buy_leg() {
|
||||
use fidc_core::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext};
|
||||
struct Probe;
|
||||
impl Strategy for Probe {
|
||||
fn name(&self) -> &str {
|
||||
"fresh-target-before-resume"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("earlier-pool", ScheduleStage::Minute)
|
||||
.with_time_rule(ScheduleTimeRule::physical_time(9, 30)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != day(5) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
let mut old = contract(day(5), 2, false);
|
||||
old.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
|
||||
old.rule.window_end = "13:30".into();
|
||||
old.rule.pricing_mode = POOL_PRICE_FORMULA_LIMIT.into();
|
||||
old.generation = "earlier-pool-at-open".into();
|
||||
Ok(decision(old))
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date == day(2) {
|
||||
return Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: code(1),
|
||||
quantity: 100,
|
||||
reason: "original-holding".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
assert!(ctx.open_orders.is_empty());
|
||||
let mut latest = contract(day(5), 2, false);
|
||||
latest.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
|
||||
latest.rule.window_end = "13:30".into();
|
||||
latest.invest_ratio_bps = 0;
|
||||
latest.generation = "fresh-zero-at-1300".into();
|
||||
Ok(decision(latest))
|
||||
}
|
||||
}
|
||||
let mut rows = data(false).snapshot_components();
|
||||
let mut quotes = Vec::new();
|
||||
for mut quote in rows.execution_quotes {
|
||||
if quote.date > day(5) {
|
||||
continue;
|
||||
}
|
||||
let mut afternoon = quote.clone();
|
||||
afternoon.timestamp = quote.date.and_hms_opt(13, 0, 0).unwrap();
|
||||
quotes.push(afternoon);
|
||||
if quote.date == day(5) && quote.symbol == code(1) {
|
||||
quote.volume_delta = 100;
|
||||
quote.amount_delta = quote.last_price * 100.;
|
||||
}
|
||||
quotes.push(quote);
|
||||
}
|
||||
rows.execution_quotes = quotes;
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
rows.instruments,
|
||||
rows.market,
|
||||
rows.factors,
|
||||
rows.candidates,
|
||||
rows.benchmarks,
|
||||
rows.corporate_actions,
|
||||
rows.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker(true)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap());
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
Probe,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30_000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.fills.len(), 3, "{:?}", result.fills);
|
||||
assert!(result.fills.iter().all(|fill| fill.symbol == code(1)));
|
||||
assert_eq!(result.fills[1].side, fidc_core::OrderSide::Sell);
|
||||
assert_eq!(
|
||||
result.fills[2].execution_timestamp,
|
||||
day(5).and_hms_opt(13, 0, 0)
|
||||
);
|
||||
assert_eq!(result.fills[1].order_id, result.fills[2].order_id);
|
||||
assert_eq!(result.fills[1].quantity + result.fills[2].quantity, 100);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
|
||||
let data = data_with_suspension(1_000_000, Some(day(6)));
|
||||
@@ -489,6 +601,72 @@ fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
|
||||
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_execution_price_does_not_satisfy_an_unobserved_order_book_condition() {
|
||||
let data = data(false);
|
||||
for field in ["bid1", "ask1"] {
|
||||
let broker = broker(false);
|
||||
let mut account = PortfolioState::new(30000.);
|
||||
let mut intent = contract(day(2), 1, false);
|
||||
intent.rule.trigger_mode = "condition".into();
|
||||
intent.rule.buy_condition = format!("{field}>0");
|
||||
let result = broker.execute_with_event_dates(day(5), day(2), day(2), &mut account, &data, &decision(intent));
|
||||
assert!(result.unwrap_err().to_string().contains(field));
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(account.cash(), 30000.);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cumulative_conditions_do_not_consume_future_bars_or_inflate_fill_capacity() {
|
||||
let mut data = data(false);
|
||||
let mut quotes = Vec::new();
|
||||
for n in 1..=2 {
|
||||
let price = if n == 1 {20.} else {10.};
|
||||
for (minute, volume) in [(30,600), (31,0), (32,400)] {
|
||||
quotes.push(IntradayExecutionQuote {
|
||||
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
|
||||
date: day(5), symbol: code(n), timestamp: day(5).and_hms_opt(9,minute,0).unwrap(),
|
||||
last_price: price, bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
|
||||
volume_delta: volume, amount_delta: volume as f64 * price, trading_phase: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
data.replace_execution_quotes(quotes.clone());
|
||||
let at = chrono::NaiveTime::from_hms_opt(9,32,0).unwrap();
|
||||
for condition in ["volume>=1000", "amount>=20000"] {
|
||||
let broker=broker(true).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);
|
||||
intent.rule.buy_condition=condition.into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100,"{condition}: {report:?}");
|
||||
assert_eq!(data.execution_quotes_on(day(5),&code(1))[2].volume_delta,400);
|
||||
}
|
||||
let mut future=quotes.last().unwrap().clone();future.symbol=code(1);future.timestamp=day(5).and_hms_opt(9,33,0).unwrap();future.volume_delta=9000;future.amount_delta=180000.;
|
||||
data.add_execution_quotes(vec![future]);
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
|
||||
let mut account=PortfolioState::new(30000.);
|
||||
let mut intent=contract(day(5),1,false);intent.rule.buy_condition="volume>1000".into();intent.rule.trigger_mode="condition".into();
|
||||
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
|
||||
assert!(report.fill_events.is_empty(),"future volume must not satisfy this signal: {report:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_total_cache_is_invalidated_without_mutating_other_dataset_clones() {
|
||||
let mut original=data(false);
|
||||
let quote=IntradayExecutionQuote { observation_kind:fidc_core::data::QuoteObservationKind::MinuteBar,date:day(5),symbol:code(1),timestamp:day(5).and_hms_opt(9,30,0).unwrap(),last_price:20.,bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:100,amount_delta:2000.,trading_phase:None };
|
||||
original.replace_execution_quotes(vec![quote.clone()]);
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
let mut changed=original.clone();let mut next=quote.clone();next.timestamp=day(5).and_hms_opt(9,31,0).unwrap();
|
||||
changed.add_execution_quotes(vec![next.clone()]);
|
||||
assert_eq!(changed.execution_session_totals(&code(1),next.timestamp).unwrap().0,Decimal::from(200));
|
||||
assert!(original.execution_session_totals(&code(1),next.timestamp).is_err());
|
||||
changed.remove_execution_quotes_on_date(day(5));
|
||||
assert!(changed.execution_session_totals(&code(1),quote.timestamp).is_err());
|
||||
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||
let intent = contract(day(2), 1, false);
|
||||
@@ -561,6 +739,181 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() {
|
||||
for timed in [false,true] {
|
||||
let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(),
|
||||
members:contract(day(2),1,false).members,exit_signals:vec![],
|
||||
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
|
||||
timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() };
|
||||
let risk=if timed {serde_json::json!({"positionExposureEvents":[
|
||||
{"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0},
|
||||
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
|
||||
]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})};
|
||||
let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({
|
||||
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]},
|
||||
"runtimeExpressions":{"risk":risk}
|
||||
})).unwrap();
|
||||
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into();
|
||||
config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into();
|
||||
config.matching_type=MatchingType::NextBarOpen;
|
||||
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig {
|
||||
// The raw engine retains its first signal day as a cash baseline;
|
||||
// Jan 2's signal executes Jan 5, across the fixture weekend.
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),
|
||||
decision_lag_trading_days:1,execution_price_field:PriceField::Open,
|
||||
}).run().unwrap();
|
||||
assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills);
|
||||
assert_eq!(result.fills[0].symbol,code(1));
|
||||
assert_eq!(result.fills[0].quantity,if timed {300}else{100});
|
||||
assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)});
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_allocation_is_separate_from_the_frozen_pool_and_restores_its_weights() {
|
||||
let program = StockPoolProgram {
|
||||
schema_version: 1,
|
||||
pool_id: "manual-allocation".into(),
|
||||
version_id: "v1".into(),
|
||||
members: contract(day(2), 2, false).members,
|
||||
exit_signals: vec![],
|
||||
allocation_policy: serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
|
||||
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
|
||||
stop_take_policy: serde_json::json!({}),
|
||||
out_of_pool_policy: "hold".into(),
|
||||
};
|
||||
let mut cfg = platform_expr_config_from_value(
|
||||
"manual-allocation",
|
||||
"000300.SH",
|
||||
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}}),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.market_cap_field = "close".into();
|
||||
cfg.market_cap_lower_expr = "0".into();
|
||||
cfg.market_cap_upper_expr = "1e12".into();
|
||||
cfg.stock_filter_expr = "true".into();
|
||||
cfg.selection_limit_expr = "2".into();
|
||||
cfg.selection_candidate_limit_expr = "2".into();
|
||||
cfg.rank_expr = "0".into();
|
||||
cfg.matching_type = MatchingType::NextBarOpen;
|
||||
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-01-06T08:00:00Z","actions":[],"positionExposureEvents":[
|
||||
{"eventId":"weights","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":8000,"allocationWeightsBps":{"000001.SZ":3000,"000002.SZ":7000}},
|
||||
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
|
||||
]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let result = BacktestEngine::new(
|
||||
data(false),
|
||||
PlatformExprStrategy::new(cfg),
|
||||
broker(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(6)),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
let quantities = |date| {
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.filter(|row| row.date == date)
|
||||
.map(|row| (row.symbol.clone(), row.quantity))
|
||||
.collect::<BTreeMap<_, _>>()
|
||||
};
|
||||
assert_eq!(
|
||||
quantities(day(5)),
|
||||
BTreeMap::from([(code(1), 300), (code(2), 1600)]),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert_eq!(
|
||||
quantities(day(6)),
|
||||
BTreeMap::from([(code(1), 700), (code(2), 1500)]),
|
||||
"{:?}",
|
||||
result.fills
|
||||
);
|
||||
assert!(
|
||||
result.manual_executions.is_empty(),
|
||||
"parameter events are not fabricated fills"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_manual_holding_data_does_not_become_a_pool_candidate() {
|
||||
let program = StockPoolProgram {
|
||||
schema_version: 1,
|
||||
pool_id: "manual-data-scope".into(),
|
||||
version_id: "v1".into(),
|
||||
members: vec![contract(day(2), 1, false).members.remove(0)],
|
||||
exit_signals: vec![],
|
||||
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000}),
|
||||
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
|
||||
stop_take_policy: serde_json::json!({}),
|
||||
out_of_pool_policy: "hold".into(),
|
||||
};
|
||||
let mut cfg = platform_expr_config_from_value(
|
||||
"manual-data-scope",
|
||||
"000300.SH",
|
||||
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1)]}}),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.market_cap_field = "close".into();
|
||||
cfg.market_cap_lower_expr = "0".into();
|
||||
cfg.market_cap_upper_expr = "1e12".into();
|
||||
cfg.stock_filter_expr = "true".into();
|
||||
cfg.selection_limit_expr = "1".into();
|
||||
cfg.selection_candidate_limit_expr = "2".into();
|
||||
cfg.rank_expr = "0".into();
|
||||
cfg.matching_type = MatchingType::NextBarOpen;
|
||||
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05","executedAt":"2026-01-05T01:31:00Z","observedAt":"2026-01-05T01:31:01Z",
|
||||
"feeObservationEventId":"receipt","feeObservationSequence":1,"feeObservedAt":"2026-01-05T01:31:01Z","timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
|
||||
let order = serde_json::json!({"orderId":"external-order","sourceAdapter":"paper","symbol":code(2),"side":"Buy","quantity":100,"orderCreatedAt":"2026-01-05T01:30:00Z","terminalObservedAt":"2026-01-05T01:31:01Z","terminalStatus":"filled","fills":[fill]});
|
||||
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-01-06T08:00:00Z",
|
||||
"actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],"confirmedAt":"2026-01-05T01:29:59Z","confirmationObservedAt":"2026-01-05T01:29:59Z","outcome":"orders_terminal","orders":[order]}]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let result = BacktestEngine::new(
|
||||
data(false),
|
||||
PlatformExprStrategy::new(cfg),
|
||||
broker(false),
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(6)),
|
||||
decision_lag_trading_days: 1,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run()
|
||||
.unwrap();
|
||||
assert!(
|
||||
result.fills.iter().all(|fill| fill.symbol != code(2)),
|
||||
"extra data cannot authorize an extra candidate"
|
||||
);
|
||||
assert_eq!(result.manual_executions.len(), 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.holdings_summary
|
||||
.iter()
|
||||
.find(|row| row.symbol == code(2))
|
||||
.unwrap()
|
||||
.quantity,
|
||||
100
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||
for (ordinary, risk, quote, sold) in [
|
||||
@@ -789,6 +1142,411 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
|
||||
use fidc_core::strategy::{Strategy,StrategyContext};
|
||||
use std::{cell::RefCell,rc::Rc};
|
||||
struct ObservedPool { inner:EtfPoolSignal, observations:Rc<RefCell<Vec<(chrono::NaiveDateTime,u32,usize)>>> }
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self)->&str {"ETF actual opening clock"}
|
||||
fn initial_subscriptions(&self)->BTreeSet<String> {BTreeSet::from([code(1)])}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {self.inner.decision_quote_times()}
|
||||
fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)}
|
||||
fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {self.inner.on_day(ctx)}
|
||||
fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp,
|
||||
ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));}
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let mut data=etf_fallback_fixture(time);
|
||||
let quote=data.execution_quotes_on(day(5),&code(1))[0].clone();
|
||||
data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| {
|
||||
let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row
|
||||
}).collect());
|
||||
let observations=Rc::new(RefCell::new(Vec::new()));
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,BacktestConfig {
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
|
||||
}).with_execution_quote_loader(|_|Ok(vec![])).run().unwrap();
|
||||
let observations=observations.borrow();
|
||||
assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0));
|
||||
assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1));
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn late_opening_rule_sees_the_etf_open_fill_after_earlier_quote_callbacks() {
|
||||
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
|
||||
use fidc_core::strategy::{Strategy, StrategyContext};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
struct ObservedPool {
|
||||
inner: EtfPoolSignal,
|
||||
observations: Rc<RefCell<Vec<(String, chrono::NaiveDateTime, u32, usize)>>>,
|
||||
}
|
||||
impl ObservedPool {
|
||||
fn record(&self, label: &str, ctx: &StrategyContext<'_>) {
|
||||
if ctx.execution_date == day(5) {
|
||||
self.observations.borrow_mut().push((
|
||||
label.into(),
|
||||
ctx.current_datetime().unwrap(),
|
||||
ctx.portfolio
|
||||
.position(&code(2))
|
||||
.map_or(0, |position| position.quantity),
|
||||
ctx.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.count(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self) -> &str {
|
||||
"late opening with ETF fill"
|
||||
}
|
||||
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||
BTreeSet::from([code(1)])
|
||||
}
|
||||
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
|
||||
self.inner.decision_quote_times()
|
||||
}
|
||||
fn decision_quote_symbols(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
self.inner.decision_quote_symbols(ctx)
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("open", ScheduleStage::OpenAuction)
|
||||
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.record("opening", ctx);
|
||||
Ok(Default::default())
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_minute(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &IntradayExecutionQuote,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.record("quote", ctx);
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
|
||||
let mut data = etf_fallback_fixture(time);
|
||||
let quote = data.execution_quotes_on(day(5), &code(1))[0].clone();
|
||||
data.add_execution_quotes(
|
||||
[(9, 15), (9, 30), (9, 32)]
|
||||
.into_iter()
|
||||
.map(|(hour, minute)| {
|
||||
let mut row = quote.clone();
|
||||
row.timestamp = day(5).and_hms_opt(hour, minute, 0).unwrap();
|
||||
row
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let observations = Rc::new(RefCell::new(Vec::new()));
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
ObservedPool {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
observations: observations.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.run()
|
||||
.unwrap();
|
||||
let seen = observations.borrow();
|
||||
assert_eq!(
|
||||
seen[..4],
|
||||
[
|
||||
("quote".into(), day(5).and_hms_opt(9, 15, 0).unwrap(), 0, 0),
|
||||
(
|
||||
"quote".into(),
|
||||
day(5).and_hms_opt(9, 30, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
(
|
||||
"opening".into(),
|
||||
day(5).and_hms_opt(9, 31, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
(
|
||||
"quote".into(),
|
||||
day(5).and_hms_opt(9, 32, 0).unwrap(),
|
||||
3700,
|
||||
1
|
||||
),
|
||||
]
|
||||
);
|
||||
let etf = result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(etf.len(), 1);
|
||||
assert_eq!(
|
||||
(etf[0].quantity, etf[0].price, etf[0].execution_timestamp),
|
||||
(3700, 4., Some(day(5).and_hms_opt(9, 30, 0).unwrap()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_signal_day_executes_the_etf_open_before_later_deferred_stock_orders() {
|
||||
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
|
||||
use fidc_core::strategy::{Strategy, StrategyContext};
|
||||
struct DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal,
|
||||
quantity: i32,
|
||||
}
|
||||
impl Strategy for DeferredStockAndEtf {
|
||||
fn name(&self) -> &str {
|
||||
"no signal ETF and deferred stock"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
|
||||
self.inner.decision_quote_times()
|
||||
}
|
||||
fn decision_quote_symbols(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
self.inner.decision_quote_symbols(ctx)
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("deferred-stock", ScheduleStage::AfterTrading)
|
||||
.with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
|
||||
]
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
Ok(if ctx.execution_date == day(2) && self.quantity != 0 {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: code(1),
|
||||
quantity: self.quantity,
|
||||
reason: "after-close stock order".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
} else {
|
||||
StrategyDecision::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
|
||||
let mut parts = etf_fallback_fixture(time).snapshot_components();
|
||||
parts.factors.retain(|row| row.date != day(5));
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
parts.instruments,
|
||||
parts.market,
|
||||
parts.factors,
|
||||
parts.candidates,
|
||||
parts.benchmarks,
|
||||
parts.corporate_actions,
|
||||
parts.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
for quantity in [100, -100, 0] {
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data.clone(),
|
||||
DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
quantity,
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.run()
|
||||
.unwrap();
|
||||
let fills = result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.date == day(5))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(fills.len(), if quantity < 0 { 2 } else { 1 }, "{fills:?}");
|
||||
assert_eq!(
|
||||
(
|
||||
fills[0].symbol.clone(),
|
||||
fills[0].quantity,
|
||||
fills[0].price,
|
||||
fills[0].execution_timestamp
|
||||
),
|
||||
(code(2), 3700, 4., day(5).and_hms_opt(9, 30, 0))
|
||||
);
|
||||
if quantity < 0 {
|
||||
assert_eq!(
|
||||
(
|
||||
fills[1].symbol.clone(),
|
||||
fills[1].side,
|
||||
fills[1].quantity,
|
||||
fills[1].execution_timestamp
|
||||
),
|
||||
(
|
||||
code(1),
|
||||
fidc_core::OrderSide::Sell,
|
||||
100,
|
||||
Some(day(5).and_time(time))
|
||||
)
|
||||
);
|
||||
} else if quantity > 0 {
|
||||
// The later stock buy cannot spend money that the 09:30 ETF fill
|
||||
// already consumed. It is rejected, not allowed to shrink that fill.
|
||||
assert!(
|
||||
result.order_events.iter().any(|order| order.date == day(5)
|
||||
&& order.symbol == code(1)
|
||||
&& order.status == fidc_core::OrderStatus::Rejected
|
||||
&& order.reason.contains("cash")),
|
||||
"{:?}",
|
||||
result.order_events
|
||||
);
|
||||
}
|
||||
if quantity != 0 {
|
||||
assert!(
|
||||
result.equity_curve.iter().any(
|
||||
|point| point.date == day(5) && point.diagnostics.contains("no_new_signal")
|
||||
)
|
||||
);
|
||||
}
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
for minute in [15, 31] {
|
||||
let observed = format!("2026-01-05T01:{minute}:00Z");
|
||||
let created = format!("2026-01-05T01:{}:00Z", minute - 1);
|
||||
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05",
|
||||
"executedAt":observed,"observedAt":observed,"feeObservationEventId":"receipt","feeObservationSequence":1,
|
||||
"feeObservedAt":observed,"timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
|
||||
let order = serde_json::json!({"orderId":"manual-order","sourceAdapter":"paper","symbol":code(1),"side":"Sell","quantity":100,
|
||||
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[fill]});
|
||||
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
|
||||
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
|
||||
"observationCutoff":"2026-01-05T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
|
||||
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[order]}]})).unwrap();
|
||||
replay.content_sha256 = replay.content_digest().unwrap();
|
||||
let broker = broker(false)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result = BacktestEngine::new(
|
||||
data.clone(),
|
||||
DeferredStockAndEtf {
|
||||
inner: EtfPoolSignal {
|
||||
at: time,
|
||||
condition: String::new(),
|
||||
},
|
||||
quantity: 0,
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.with_execution_quote_loader(|_| Ok(vec![]))
|
||||
.with_observed_manual_executions(replay)
|
||||
.unwrap()
|
||||
.run();
|
||||
if minute < 30 {
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("manual observation conflicts with pending shadow orders")
|
||||
);
|
||||
} else {
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result.manual_executions.len(), 1);
|
||||
assert_eq!(
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == code(2))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
result
|
||||
.daily_holdings
|
||||
.iter()
|
||||
.find(|position| position.date == day(5) && position.symbol == code(1))
|
||||
.unwrap()
|
||||
.quantity,
|
||||
1400
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- 成交日及后续N个完整正式交易日均受保护。例如周五成交、N=3,保护到下周三结束,周四恢复;不按72小时或自然日替代。
|
||||
- 最长持有从连续持仓第一次实际买入开始,跨正式交易日计数;加仓、部分卖出和有证据的证券转换不重置,完全卖出再买入开启新周期。锁定和买入保护优先于最长持有退出。
|
||||
- 日期锁定禁止自动买卖,已接受的挂单不自动撤销;手工路径只绕过自动策略保护,不绕过账户授权、T+1、券商和风控。
|
||||
- 已校验且实际发生的持仓换股继承原保护期限与日期锁,不因改代码解锁或重计时;无实际转换的目录映射不继承。换股后手工清仓仍受原有效日期锁;确认换股前已清仓时不保留推定关系。原生策略、股票池与ETF顺延消费的修复及未发布边界见 `successor-protection-20260914.md`。
|
||||
- 保留的真实持仓继续占用资金与席位,不把未完成卖出当现金。最长持有退出先形成唯一最终目标,不能叠加一笔策略部分卖出和一笔框架全量卖出。
|
||||
- 在线上下文重建必须注入已经校验的真实成交/持仓快照,不能把重建日或旧行情日当建仓日。期货或股票期货混合账户未纳入本合同,显式拒绝。
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 回报上下文、盘前意图与尚未提交的目标
|
||||
|
||||
2026-09-14。本轮已配套发布177,annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭。
|
||||
|
||||
## 已复现问题
|
||||
|
||||
1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。
|
||||
2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。
|
||||
3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。
|
||||
4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。
|
||||
5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。
|
||||
|
||||
## 本轮处理
|
||||
|
||||
- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。
|
||||
- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。
|
||||
- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。
|
||||
- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。
|
||||
- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。
|
||||
- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。
|
||||
|
||||
## 回归证据
|
||||
|
||||
- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。
|
||||
- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。
|
||||
- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。
|
||||
- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。
|
||||
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
|
||||
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
|
||||
|
||||
精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件。
|
||||
|
||||
## 发布与真实历史复验
|
||||
|
||||
已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。
|
||||
|
||||
三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写:
|
||||
|
||||
| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 |
|
||||
| --- | --- | --- | ---: |
|
||||
| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 |
|
||||
| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 |
|
||||
| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 |
|
||||
|
||||
重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。
|
||||
|
||||
API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`,Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`。
|
||||
|
||||
六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。
|
||||
|
||||
原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`。
|
||||
|
||||
## 继续范围
|
||||
|
||||
显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"verified_at": "2026-09-13T22:22:38.597836+00:00",
|
||||
"tag": "v2026.9.14.5",
|
||||
"processes": {
|
||||
"fidc-backtest-service-highmem177.service": {
|
||||
"pid": 3692551,
|
||||
"sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc",
|
||||
"journal_since": "2026-09-13T22:15:53.225719+00:00",
|
||||
"journal_lines": 54,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-trading-control-highmem177.service": {
|
||||
"pid": 3697497,
|
||||
"sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-market-data-highmem177.service": {
|
||||
"pid": 3697498,
|
||||
"sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-strategy-runtime-highmem177.service": {
|
||||
"pid": 3697688,
|
||||
"sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-paper-trading-highmem177.service": {
|
||||
"pid": 3697792,
|
||||
"sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-live-trading-highmem177.service": {
|
||||
"pid": 3697762,
|
||||
"sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3",
|
||||
"journal_since": "2026-09-13T22:21:09.122548+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
|
||||
"source_stale": false,
|
||||
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
|
||||
},
|
||||
"source_checkouts": {
|
||||
"fidc-backtest-engine": {
|
||||
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
|
||||
"runtime_commit": "81acc5422878abc855fca72b35766ffad6159200",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-backtest-service": {
|
||||
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
|
||||
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-trading-platform": {
|
||||
"head": "94f99d20f49f6cd1810996706cb94f610c302385",
|
||||
"runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"omniquant": {
|
||||
"head": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"tracked_dirty": false
|
||||
}
|
||||
},
|
||||
"ui_unchanged": {
|
||||
"commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"pid": 3089476
|
||||
},
|
||||
"http_cases": [
|
||||
{
|
||||
"name": "manual_first",
|
||||
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "automatic_first",
|
||||
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "stock24",
|
||||
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
|
||||
"trade_count": 51,
|
||||
"holding_count": 21,
|
||||
"final_equity": 9685563.876924999,
|
||||
"old_result_unchanged": true
|
||||
}
|
||||
],
|
||||
"durable_events": [
|
||||
{
|
||||
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
|
||||
"count": 18,
|
||||
"unique_keys": 18,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
|
||||
"count": 32,
|
||||
"unique_keys": 32,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
}
|
||||
],
|
||||
"trading_state": {
|
||||
"paper": {
|
||||
"configuration": {
|
||||
"count": 3,
|
||||
"hash": "93f3224edef59c381164e0236529dacc"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 0
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"configuration": {
|
||||
"count": 0,
|
||||
"hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 1,
|
||||
"today_orders": 0,
|
||||
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
|
||||
"route_mode": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manual_facts_unchanged": {
|
||||
"paper": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 3,
|
||||
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
|
||||
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
|
||||
"new_orders": 0
|
||||
},
|
||||
"live": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 0,
|
||||
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
|
||||
"new_orders": 0
|
||||
}
|
||||
},
|
||||
"broker_submission": false,
|
||||
"linux_core_tests": {
|
||||
"passed": 834,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log"
|
||||
},
|
||||
"scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.",
|
||||
"native_replays": 6
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"verified_at": "2026-09-13T20:22:24.750379+00:00",
|
||||
"tag": "v2026.9.14.4",
|
||||
"processes": {
|
||||
"fidc-backtest-service-highmem177.service": {
|
||||
"pid": 3612875,
|
||||
"sha256": "4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2",
|
||||
"journal_since": "2026-09-13T20:14:17.444770+00:00",
|
||||
"journal_lines": 54,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-trading-control-highmem177.service": {
|
||||
"pid": 3617963,
|
||||
"sha256": "cd587928591fef952f2e98b47aa338a1702def5edf016a7da9751296667f6674",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-market-data-highmem177.service": {
|
||||
"pid": 3617964,
|
||||
"sha256": "2efb1d3ad7d510cf85e6047dd6d1981d0a30d768ff3d33c842adc52211002bdc",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-strategy-runtime-highmem177.service": {
|
||||
"pid": 3618140,
|
||||
"sha256": "3d7f3f2e8756e7f3439075344fe9c8bc0b55df33e7e6f251282712274c00339d",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-paper-trading-highmem177.service": {
|
||||
"pid": 3618260,
|
||||
"sha256": "0383b1d6cc7b3c48c6902dd7fd4760a38698c1916e0eaff63be26fe3f6b1a2ab",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-live-trading-highmem177.service": {
|
||||
"pid": 3618246,
|
||||
"sha256": "583f52e204aeb416574ee17daa20cebed49e0659a194c81e8072a9249824e48e",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
|
||||
"source_stale": false,
|
||||
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
|
||||
},
|
||||
"source_checkouts": {
|
||||
"fidc-backtest-engine": {
|
||||
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
|
||||
"runtime_commit": "237ee15a518a668297959509daffc4b88995f310",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-backtest-service": {
|
||||
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
|
||||
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-trading-platform": {
|
||||
"head": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
|
||||
"runtime_commit": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"omniquant": {
|
||||
"head": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"tracked_dirty": false
|
||||
}
|
||||
},
|
||||
"ui_unchanged": {
|
||||
"commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"pid": 3089476
|
||||
},
|
||||
"http_cases": [
|
||||
{
|
||||
"name": "manual_first",
|
||||
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "automatic_first",
|
||||
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "stock24",
|
||||
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
|
||||
"trade_count": 51,
|
||||
"holding_count": 21,
|
||||
"final_equity": 9685563.876924999,
|
||||
"old_result_unchanged": true
|
||||
}
|
||||
],
|
||||
"durable_events": [
|
||||
{
|
||||
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
|
||||
"count": 18,
|
||||
"unique_keys": 18,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
|
||||
"count": 32,
|
||||
"unique_keys": 32,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
}
|
||||
],
|
||||
"trading_state": {
|
||||
"paper": {
|
||||
"configuration": {
|
||||
"count": 3,
|
||||
"hash": "93f3224edef59c381164e0236529dacc"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 0
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"configuration": {
|
||||
"count": 0,
|
||||
"hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 1,
|
||||
"today_orders": 0,
|
||||
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
|
||||
"route_mode": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manual_facts_unchanged": {
|
||||
"paper": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 3,
|
||||
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
|
||||
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
|
||||
"new_orders": 0
|
||||
},
|
||||
"live": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 0,
|
||||
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
|
||||
"new_orders": 0
|
||||
}
|
||||
},
|
||||
"broker_submission": false,
|
||||
"linux_core_tests": {
|
||||
"passed": 822,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log": "/srv/fidc/canonical/run/fidc-private/evidence/clock-candidate-gqx8g70l/linux-core-tests.log"
|
||||
},
|
||||
"cleanup": {
|
||||
"apply": true,
|
||||
"deleted": [
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/holding-protection-stage-wywd2682/fidc-trading-platform/debug/incremental",
|
||||
"kind": "incremental_compiler_state",
|
||||
"bytes": 9553190912,
|
||||
"device": 2101,
|
||||
"inode": 39877787,
|
||||
"mtime_ns": 1789318996850462500,
|
||||
"links": 176,
|
||||
"size_bytes": 12288
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/incremental",
|
||||
"kind": "incremental_compiler_state",
|
||||
"bytes": 2103459840,
|
||||
"device": 2101,
|
||||
"inode": 29904450,
|
||||
"mtime_ns": 1789318494701447400,
|
||||
"links": 46,
|
||||
"size_bytes": 4096
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_backtest_service-46310ff8aeeb4040",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 398401536,
|
||||
"device": 2101,
|
||||
"inode": 29934330,
|
||||
"mtime_ns": 1789117975169420000,
|
||||
"links": 1,
|
||||
"size_bytes": 399242488
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-18c9b2429fdf6026",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 207015936,
|
||||
"device": 2101,
|
||||
"inode": 29918792,
|
||||
"mtime_ns": 1789166943885717800,
|
||||
"links": 1,
|
||||
"size_bytes": 207144208
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-42f704a330411730",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 191205376,
|
||||
"device": 2101,
|
||||
"inode": 29933759,
|
||||
"mtime_ns": 1789117542401109800,
|
||||
"links": 1,
|
||||
"size_bytes": 191333360
|
||||
}
|
||||
],
|
||||
"reclaimed_allocated_bytes": 12453273600,
|
||||
"before": {
|
||||
"total": 1749269057536,
|
||||
"used": 1659299954688,
|
||||
"free": 1035599872
|
||||
},
|
||||
"after": {
|
||||
"total": 1749269057536,
|
||||
"used": 1647166930944,
|
||||
"free": 13168623616
|
||||
},
|
||||
"observed_free_change": 12133023744
|
||||
},
|
||||
"scope": "Intraday clock release verification; historical simulation only, not a performance or real broker liquidity acceptance.",
|
||||
"native_replays": 6
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/lot-lifecycle-20260914-AEGDYL",
|
||||
"build": "/srv/fidc/canonical/build/lot-lifecycle-20260914-AEGDYL",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
|
||||
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
|
||||
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 60.008,
|
||||
"passed": 889,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "7d0760a024a564cd2c18b7bf3b41ab8ba6f95d7e259d49a43191f0ebe666989e"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 102.011,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "ab55da8d47425a0acc6c33dc4bc352f463ad4e1aac887b0169a90f18cb6763cb"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 118.011,
|
||||
"passed": 589,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "ff6946b18567dad6dab41b6c36f65ea1181d7ee6b7d53b6ceb568b1dedb7abae"
|
||||
}
|
||||
],
|
||||
"available_bytes": 94029492224
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "5560614fa2ce2b0c905c44011ff4147df1fe7c04be93cd3c1410cd16fca025de",
|
||||
"immutable_source_file_count": 962,
|
||||
"test_scope": "fidc-lot-lifecycle-check-20260914-AEGDYL.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "b4c68be29b98dc814fa75641f629dd18e1dd803b",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Delayed observations spanning already applied corporate actions and remaining cross-mode matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/corporate-replay-20260914-ZAnvlO",
|
||||
"build": "/srv/fidc/canonical/build/corporate-replay-20260914-ZAnvlO",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
|
||||
"fidc-backtest-service": "879a5a7fe07620415b00023f4628cc81e45903fb",
|
||||
"fidc-trading-platform": "559f5b1d24d98a67606e859a1f2105bee4c0bc01"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 58.007,
|
||||
"passed": 895,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "d50fc04699eaa39399016e3b9f470e9adf3e8da0d968ada2e212ef606bdfb37a"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 94.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "eafd3d792dc54ff90efaae9aa1e9e0c15bb191f8b679585c982b8c1bab3211ad"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 148.015,
|
||||
"passed": 591,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "2ae31905c43697177af50825270d4f7830361d6e1d5e3d22a26179dd726abe36"
|
||||
}
|
||||
],
|
||||
"available_bytes": 90635833344
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "9b61a3586248c80bac30ce2107c9323a4d9a1ac7bdf29a42d3c423bd5c615ec4",
|
||||
"immutable_source_file_count": 972,
|
||||
"test_scope": "fidc-corporate-replay-check-20260914-ZAnvlO.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "05f1cbbe00ffe75b72de8a24cea6a4e40e8142b0",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Dividend reinvestment execution clocks, successor data scope, target state and cross-mode ETF matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/zero-reinvestment-20260915-Yovt6m",
|
||||
"build": "/srv/fidc/canonical/build/zero-reinvestment-20260915-Yovt6m",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "818552bc969e0ea40081f0770c1d97db574a1981",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.008,
|
||||
"passed": 919,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "87a4daa4db1c0f735399ce243feff8e514e5f75f72b0dbfdb69da0c21d02f2b4"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 90.029,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "e7f15eb7463f1cc33b7122020549af082d2070ad38392d0918c9c7309a68cc26"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 112.014,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "4faa52e9a87056c192341622d0273942f3daf963cd75a381ad913c76d47aed29"
|
||||
}
|
||||
],
|
||||
"available_bytes": 77131395072
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"baseline_engine": "534ab42906c7e7a4e253b7791cc143dec894a7f2",
|
||||
"candidate_engine": "818552bc969e0ea40081f0770c1d97db574a1981",
|
||||
"reproduced": {
|
||||
"manual_zero_effective_before_payment": true,
|
||||
"original_position_cleared": true,
|
||||
"unexpected_accounting_allocation": {
|
||||
"symbol": "000001.SZ",
|
||||
"quantity": 100,
|
||||
"price": 8.95
|
||||
},
|
||||
"original_gross_output": 894.9999999999999,
|
||||
"actual_posted_amount": 895
|
||||
},
|
||||
"verified": {
|
||||
"global_set_and_scale_zero_leave_cash": true,
|
||||
"zero_or_excluded_manual_member_leaves_cash": true,
|
||||
"same_instant_uses_sequence": true,
|
||||
"input_array_order_does_not_change_result": true,
|
||||
"future_settings_are_not_backdated": true,
|
||||
"restore_does_not_mean_forced_full_exposure": true,
|
||||
"legacy_day_granularity_preserved": true,
|
||||
"normal_settlement_and_late_replay_share_control": true,
|
||||
"actual_manual_fills_and_cash_entitlements_not_erased": true,
|
||||
"invalid_cash_is_rejected_atomically": true,
|
||||
"unused_reinvestment_reference_not_read_under_zero": true,
|
||||
"no_control_keeps_legacy_price_quantity_and_fees": true
|
||||
},
|
||||
"exact_outcomes": {
|
||||
"cleared_with_zero": {
|
||||
"cash": 49998,
|
||||
"quantity": 0
|
||||
},
|
||||
"cleared_without_zero": {
|
||||
"cash": 49103,
|
||||
"quantity": 100,
|
||||
"price": 8.95,
|
||||
"fee": 0,
|
||||
"gross": 895
|
||||
},
|
||||
"actual_extra_buy_timely_and_late": {
|
||||
"cash": 41047,
|
||||
"quantity": 1000,
|
||||
"corporate_cash_adjustment": 1050,
|
||||
"optional_allocations": 0
|
||||
}
|
||||
},
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 919,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"new_test_functions": 6,
|
||||
"production_runtime_modified": false,
|
||||
"production_order_or_cancel_requests": 0,
|
||||
"limits": [
|
||||
"isolated replay not actual Source/Runner acceptance",
|
||||
"does not evaluate arbitrary strategy expressions at settlement",
|
||||
"Source runtime freeze unchanged",
|
||||
"no new production UI or broker validation"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T16:34:44.320112+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "818552bc969e0ea40081f0770c1d97db574a1981",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 989,
|
||||
"source_snapshot_manifest_sha256": "9ffdc1bc86e1b4b085caa694e921fc230ce0f09c57d34254058ae28e631d5bb3",
|
||||
"test_scope": "fidc-zero-reinvestment-check-20260915-Yovt6m.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/opening-clock-20260914-UUx5ru",
|
||||
"build": "/srv/fidc/canonical/build/opening-clock-20260914-UUx5ru",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
|
||||
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
|
||||
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.007,
|
||||
"passed": 885,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "9a887a23fc656d0f448502ecb2fca5fcfd82db15346f567e6a8b0bcaa88180d7"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 90.054,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "048a7690ce874bccc58e3111decd183c3f88f649397c59cdfbaa3e4533662589"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 118.011,
|
||||
"passed": 589,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "ec4f51b30dd69d9c86812cbb1b1e310c277fdfcf29465978b8d87eb83f40204c"
|
||||
}
|
||||
],
|
||||
"available_bytes": 97373085696
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "bf01cd040bdcbeaa0c81a93153eb8e505dd7dcb8c761731f7f3ed81b1b2b6b67",
|
||||
"immutable_source_file_count": 959,
|
||||
"test_scope": "fidc-opening-clock-check-20260914-UUx5ru.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Remaining corporate-action, holding-protection and full parameter matrix"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/reinvestment-20260914-dBw0O5",
|
||||
"build": "/srv/fidc/canonical/build/reinvestment-20260914-dBw0O5",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "984f9d308dbddec8134b8aed249f82b962839505",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 54.005,
|
||||
"passed": 901,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "ed7d00793dbbb037b9c806687841de00507ef8f59a174ab4c6ab38cbd419fe35"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 88.008,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "d4c6e129353c9cb738bb277c68bd390ac8b1e8df4db44340bd63e71fd60fa9da"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.01,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "57cd4b3f900f01f74864a1bb07a726b5d350b42384153f9094d9f316ddb7063c"
|
||||
}
|
||||
],
|
||||
"available_bytes": 87221923840
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"status": "tested_not_deployed",
|
||||
"source_snapshot_manifest_sha256": "8452dc9dedb6b3b3a48f37a572f4fe653728c856eda9aedd2644079448a55d65",
|
||||
"immutable_source_file_count": 978,
|
||||
"test_scope": "fidc-reinvestment-check-20260914-dBw0O5.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_pid": 1700096,
|
||||
"source_runtime_changed": false,
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active",
|
||||
"version": "v2026.9.14.8"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"engine_development_checkout": "984f9d308dbddec8134b8aed249f82b962839505",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0,
|
||||
"remaining": [
|
||||
"Source freeze authorization and physical partition validation",
|
||||
"Actual Source and Runner shadow joint acceptance",
|
||||
"Unaccepted Arrow performance candidate gate",
|
||||
"Successor data scope, target state, cleared-position references and cross-mode ETF matrix"
|
||||
],
|
||||
"source_runtime_tracked_clean": true
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"schema": "fidc.selection-risk-plan-acceptance/v1",
|
||||
"rows": [
|
||||
{
|
||||
"name": "control-1",
|
||||
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
|
||||
"wallSeconds": 30.986483575077727,
|
||||
"engineSeconds": 8.79,
|
||||
"dataSeconds": 8.445,
|
||||
"validationSeconds": 12.244,
|
||||
"resultSeconds": 1.292,
|
||||
"maxRssKiB": 7090392,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-2",
|
||||
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
|
||||
"wallSeconds": 13.274638780159876,
|
||||
"engineSeconds": 6.739,
|
||||
"dataSeconds": 5.19,
|
||||
"validationSeconds": 0.209,
|
||||
"resultSeconds": 1.003,
|
||||
"maxRssKiB": 7092040,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-3",
|
||||
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
|
||||
"wallSeconds": 13.043757867999375,
|
||||
"engineSeconds": 6.732,
|
||||
"dataSeconds": 5.159,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1,
|
||||
"maxRssKiB": 7089984,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-1",
|
||||
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
|
||||
"wallSeconds": 12.976857921108603,
|
||||
"engineSeconds": 6.682,
|
||||
"dataSeconds": 5.132,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.021,
|
||||
"maxRssKiB": 7091752,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-2",
|
||||
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
|
||||
"wallSeconds": 12.927155625075102,
|
||||
"engineSeconds": 6.64,
|
||||
"dataSeconds": 5.128,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.01,
|
||||
"maxRssKiB": 7092320,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-3",
|
||||
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
|
||||
"wallSeconds": 12.926160736009479,
|
||||
"engineSeconds": 6.664,
|
||||
"dataSeconds": 5.113,
|
||||
"validationSeconds": 0.006,
|
||||
"resultSeconds": 1.006,
|
||||
"maxRssKiB": 7091128,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-control",
|
||||
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
|
||||
"wallSeconds": 15.628366323187947,
|
||||
"engineSeconds": 8.199,
|
||||
"dataSeconds": 5.234,
|
||||
"validationSeconds": 0.694,
|
||||
"resultSeconds": 1.33,
|
||||
"maxRssKiB": 7108340,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-candidate",
|
||||
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
|
||||
"wallSeconds": 14.82603678200394,
|
||||
"engineSeconds": 8.087,
|
||||
"dataSeconds": 5.276,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.322,
|
||||
"maxRssKiB": 7108656,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-control",
|
||||
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
|
||||
"wallSeconds": 13.775856785941869,
|
||||
"engineSeconds": 7.374,
|
||||
"dataSeconds": 4.893,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.358,
|
||||
"maxRssKiB": 7119352,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-candidate",
|
||||
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
|
||||
"wallSeconds": 13.927610703045502,
|
||||
"engineSeconds": 7.239,
|
||||
"dataSeconds": 5.137,
|
||||
"validationSeconds": 0.003,
|
||||
"resultSeconds": 1.368,
|
||||
"maxRssKiB": 7119784,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-control",
|
||||
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
|
||||
"wallSeconds": 18.176081838086247,
|
||||
"engineSeconds": 11.154,
|
||||
"dataSeconds": 4.585,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 2.268,
|
||||
"maxRssKiB": 7158556,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-candidate",
|
||||
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
|
||||
"wallSeconds": 18.627057212870568,
|
||||
"engineSeconds": 11.013,
|
||||
"dataSeconds": 5.2,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 2.267,
|
||||
"maxRssKiB": 7152664,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
}
|
||||
],
|
||||
"sharedInputFiles": 9257,
|
||||
"sharedInputBytes": 12596608049,
|
||||
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
|
||||
"verifiedFactBlocks": 3506,
|
||||
"status": "candidate-not-deployed",
|
||||
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
|
||||
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
|
||||
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
|
||||
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
|
||||
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
|
||||
"bounds": [
|
||||
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
|
||||
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
|
||||
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
|
||||
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
|
||||
"No Source/trading service was changed and no paused research/signal task resumed.",
|
||||
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
{
|
||||
"schema": "fidc.series-column-storage-acceptance/v1",
|
||||
"rows": [
|
||||
{
|
||||
"name": "control-1",
|
||||
"receiptSha256": "fa8f2265f1d3ed2cdeef02f62840fec00a266ace3cf9399cb81d33110f3b115c",
|
||||
"wallSeconds": 23.878584733000025,
|
||||
"engineSeconds": 6.612,
|
||||
"dataSeconds": 5.213,
|
||||
"datasetConstructSeconds": 1.901,
|
||||
"loopSeconds": 1.734,
|
||||
"validationSeconds": 10.871,
|
||||
"resultSeconds": 1.007,
|
||||
"maxRssKiB": 7137676,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "candidate-1",
|
||||
"receiptSha256": "edd7e575013932ae58603e9fa810626573e1b0eaa00d01426d736cb4740aaf86",
|
||||
"wallSeconds": 24.12557172914967,
|
||||
"engineSeconds": 6.674,
|
||||
"dataSeconds": 4.818,
|
||||
"datasetConstructSeconds": 1.589,
|
||||
"loopSeconds": 1.646,
|
||||
"validationSeconds": 11.47,
|
||||
"resultSeconds": 1.011,
|
||||
"maxRssKiB": 6463660,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rotation-control-2",
|
||||
"receiptSha256": "6a5b37c2881c7a4177ce69cef2440c3dd7a0846c6ca9a4811c68f173b16f7183",
|
||||
"wallSeconds": 18.18023332185112,
|
||||
"engineSeconds": 9.509,
|
||||
"dataSeconds": 7.272,
|
||||
"datasetConstructSeconds": 2.824,
|
||||
"loopSeconds": 2.822,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.206,
|
||||
"maxRssKiB": 7138628,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "rotation-candidate-2",
|
||||
"receiptSha256": "f50ca6af6496213d558af646862e5a86334077bb9080e4e7c6c9602a0cabd789",
|
||||
"wallSeconds": 15.078223099000752,
|
||||
"engineSeconds": 8.171,
|
||||
"dataSeconds": 5.543,
|
||||
"datasetConstructSeconds": 2.094,
|
||||
"loopSeconds": 2.066,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.177,
|
||||
"maxRssKiB": 6454624,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "candidate-3",
|
||||
"receiptSha256": "c8bce9a8eeda769f00e72118c9ea955d323fdad902dc271590d6e377b5a99196",
|
||||
"wallSeconds": 13.124768079956993,
|
||||
"engineSeconds": 6.649,
|
||||
"dataSeconds": 5.103,
|
||||
"datasetConstructSeconds": 1.853,
|
||||
"loopSeconds": 1.676,
|
||||
"validationSeconds": 0.208,
|
||||
"resultSeconds": 1.017,
|
||||
"maxRssKiB": 6457728,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "control-3",
|
||||
"receiptSha256": "1f999a89aadab1baf4a70cda96a314ab237302722cdce895bb5fe26dea4306ec",
|
||||
"wallSeconds": 12.725315875839442,
|
||||
"engineSeconds": 6.583,
|
||||
"dataSeconds": 4.955,
|
||||
"datasetConstructSeconds": 1.713,
|
||||
"loopSeconds": 1.675,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.013,
|
||||
"maxRssKiB": 7140772,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trend-40-control",
|
||||
"receiptSha256": "55b1976d15ba531477ff92f07f953a0a8a9b7cab96f01fb0ce5546f9aeda7406",
|
||||
"wallSeconds": 14.779385674046353,
|
||||
"engineSeconds": 7.867,
|
||||
"dataSeconds": 5.418,
|
||||
"datasetConstructSeconds": 1.921,
|
||||
"loopSeconds": 1.952,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.35,
|
||||
"maxRssKiB": 7157844,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trend-40-candidate",
|
||||
"receiptSha256": "86a3f452168d059f034e91f7317890af49d9763b75745dd3d39a948b00c1d72d",
|
||||
"wallSeconds": 15.011793529149145,
|
||||
"engineSeconds": 8.011,
|
||||
"dataSeconds": 5.168,
|
||||
"datasetConstructSeconds": 1.851,
|
||||
"loopSeconds": 1.739,
|
||||
"validationSeconds": 0.376,
|
||||
"resultSeconds": 1.315,
|
||||
"maxRssKiB": 6470768,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-control",
|
||||
"receiptSha256": "f5e6c9cb90db0dc7095e388b4ee939d4c6609824f3c065e0a08144a80f3d9395",
|
||||
"wallSeconds": 14.010676869889721,
|
||||
"engineSeconds": 7.298,
|
||||
"dataSeconds": 5.172,
|
||||
"datasetConstructSeconds": 1.893,
|
||||
"loopSeconds": 1.7,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.388,
|
||||
"maxRssKiB": 7167408,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-candidate",
|
||||
"receiptSha256": "f8caf67a6010d882a064678cf5c57f48c53c85b7c82dde708c029ee52e9e56b2",
|
||||
"wallSeconds": 13.87669027899392,
|
||||
"engineSeconds": 7.219,
|
||||
"dataSeconds": 5.117,
|
||||
"datasetConstructSeconds": 1.837,
|
||||
"loopSeconds": 1.691,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.362,
|
||||
"maxRssKiB": 6478492,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-control",
|
||||
"receiptSha256": "0f589d11fec4a37f80635446fa445b7c7a9ae58e202e5b0c2533aa257fd94c81",
|
||||
"wallSeconds": 18.577259425073862,
|
||||
"engineSeconds": 10.96,
|
||||
"dataSeconds": 5.163,
|
||||
"datasetConstructSeconds": 1.891,
|
||||
"loopSeconds": 1.698,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 2.282,
|
||||
"maxRssKiB": 7210220,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-candidate",
|
||||
"receiptSha256": "5ae74a1b311479a39c9863c2fba487951631353f9ae2a523d919f2d0a8f592e7",
|
||||
"wallSeconds": 18.476340716006234,
|
||||
"engineSeconds": 10.989,
|
||||
"dataSeconds": 5.051,
|
||||
"datasetConstructSeconds": 1.844,
|
||||
"loopSeconds": 1.645,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 2.284,
|
||||
"maxRssKiB": 6527236,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"source_stale": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"sharedInputFiles": 9257,
|
||||
"sharedInputBytes": 12596608049,
|
||||
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
|
||||
"verifiedFactBlocks": 3506,
|
||||
"status": "candidate-not-deployed",
|
||||
"scope": "exact in-memory column reuse; separate DayOpen correctness fix included in both control and candidate",
|
||||
"controlRunnerSha256": "8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d",
|
||||
"candidateRunnerSha256": "40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43",
|
||||
"candidateApiSha256": "c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e",
|
||||
"engineCommit": "996b909608589fb1987f33c0cfb4c62099f69617",
|
||||
"serviceCommit": "443ed421c2c9c854a01fab69ce58957690504570",
|
||||
"boundaries": [
|
||||
"The DayOpen prefix correction is present in both storage A/B binaries.",
|
||||
"No file format, cache schema, input values or execution policy changed for the storage comparison.",
|
||||
"Original shared inputs were hashed and remained unchanged; results were recalculated into private artifacts.",
|
||||
"The last pair ran candidate before control. It did not establish a general latency improvement.",
|
||||
"Source remains frozen and paused research/signal tasks were not resumed.",
|
||||
"The independent same-day intraday clock counterexample remains unresolved."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/order-recovery-20260915-VjcwWa",
|
||||
"build": "/srv/fidc/canonical/build/order-recovery-20260915-VjcwWa",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "695fdee4b8ba4b456313f06415c128a828b43f56",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.009,
|
||||
"passed": 926,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "1e966ddca61fdf592a3333df6c459688988f248e2503d45550634cf1184a5921"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 114.012,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "571a20700bd185304e3e13dbe7cd2d2dcf38f8d7e9d177bddcbf01cee68c0f3b"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 146.014,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "342dad04bade99f034da22cf3dcaadb40854ece7e1b57cfbeeb6240fc1ee8fee"
|
||||
}
|
||||
],
|
||||
"available_bytes": 73738203136
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"baseline_engine": "4c6147e2ee9e76b5d3aaa8b9c3a70a67777b1f98",
|
||||
"candidate_engine": "695fdee4b8ba4b456313f06415c128a828b43f56",
|
||||
"reproduced": {
|
||||
"accepted_simulator_orders_before_error": 2,
|
||||
"accepted_simulator_orders_after_error_before_fix": 0,
|
||||
"first_order_prior_filled_quantity": 100,
|
||||
"error": "historical_slippage_calibration_missing"
|
||||
},
|
||||
"verified": {
|
||||
"original_order_identity_and_cumulative_fills_retained": true,
|
||||
"retry_after_data_repair_matches_clean_call": true,
|
||||
"prior_success_not_rolled_back": true,
|
||||
"etf_targets_and_progress_retained": true,
|
||||
"quote_consumption_and_commission_restored": true,
|
||||
"callback_unwind_restores_unpublished_state_and_context": true,
|
||||
"untouched_lots_not_copied": true,
|
||||
"position_order_and_flat_rows_preserved": true
|
||||
},
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 926,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"new_test_functions": 7,
|
||||
"microprofile": {
|
||||
"mode": "unoptimized isolated simulator only",
|
||||
"securities": 30,
|
||||
"initial_lots_per_security": 20,
|
||||
"calls": 500,
|
||||
"full_checkpoint_us": [
|
||||
27608,
|
||||
40229,
|
||||
35652,
|
||||
11085
|
||||
],
|
||||
"scoped_checkpoint_us": [
|
||||
22132,
|
||||
26813,
|
||||
24778,
|
||||
11539
|
||||
],
|
||||
"protected_order": [
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
],
|
||||
"successful_outcomes_identical": true,
|
||||
"production_throughput_conclusion": false
|
||||
},
|
||||
"limits": [
|
||||
"not real GT/QMT rollback",
|
||||
"not automatic retry",
|
||||
"failed caller must stop or retry its explicit request",
|
||||
"external hook side effects, OOM and power failure not covered",
|
||||
"Source runtime freeze unchanged"
|
||||
],
|
||||
"production_runtime_modified": false,
|
||||
"production_order_or_cancel_requests": 0
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T17:38:22.023395+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "695fdee4b8ba4b456313f06415c128a828b43f56",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 994,
|
||||
"source_snapshot_manifest_sha256": "bcefde438955ff4eda51494e96913a23d5e628375d45f8f09a498020224a7bb6",
|
||||
"test_scope": "fidc-order-recovery-check-20260915-VjcwWa.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/successor-20260914-Sx15GO",
|
||||
"build": "/srv/fidc/canonical/build/successor-20260914-Sx15GO",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 58.008,
|
||||
"passed": 907,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "9fa0e1cfcc55722beaacf22e743f4a4e097df8c967894b13b4d7dfd7e18540bc"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 88.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "50c659d618fccf51ce33e15a879d086934efb4a537f82dafb5f4c133d46eecce"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.011,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "7a2676f29c4d5867b6df64779d356c33217004ed0ddad73af75255eb0143d57a"
|
||||
}
|
||||
],
|
||||
"available_bytes": 83883585536
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"observed_date": "2026-09-14",
|
||||
"baseline_engine": "d4e7cdd5b53e97666afb40c512223def0401539c",
|
||||
"candidate_engine": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"scope": "isolated core regression; not Source or broker acceptance",
|
||||
"reproduced": [
|
||||
{
|
||||
"test": "successor_without_frozen_instrument_metadata_is_not_an_implicit_new_security",
|
||||
"before": "returned success and created 300 shares of an instrument absent from the frozen metadata",
|
||||
"after": "successor_instrument_missing, original ledger and notes unchanged"
|
||||
},
|
||||
{
|
||||
"test": "invalid_successor_terms_cannot_be_replaced_with_one_share_or_zero_cash",
|
||||
"before": "missing ratio accepted as 1.0",
|
||||
"after": "all 13 malformed term cases rejected without mutation"
|
||||
},
|
||||
{
|
||||
"test": "a_late_corporate_batch_failure_keeps_prior_cash_positions_and_notes",
|
||||
"before": "an overflowing cash leg failed after deleting the old position and retaining a successor plus a cash receivable",
|
||||
"after": "whole batch rejected, including original target quantity and weight state, notes unchanged"
|
||||
}
|
||||
],
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 907,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"completed_engine_replay_cases": 12,
|
||||
"engine_replay_cases": "paper/gt/qmt observation origins x timely/late x buy/sell; each paired with complete/missing successor metadata",
|
||||
"live_order_or_cancel_requests": 0,
|
||||
"source_runtime_modified": false,
|
||||
"ui_modified": false
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T15:05:40.175433+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "59a0c95aaec9c14ddd7384bb451d6d90eb3abec7",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 981,
|
||||
"source_snapshot_manifest_sha256": "a8da0ee559821758d98eb7e4ae27beaa683ce32b708dc29f90bc751808382ff3",
|
||||
"test_scope": "fidc-successor-check-20260914-Sx15GO.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"source": "/srv/fidc/programs/runtime-snapshots/conversion-protection-20260914-51wBBB",
|
||||
"build": "/srv/fidc/canonical/build/conversion-protection-20260914-51wBBB",
|
||||
"commits": {
|
||||
"fidc-backtest-engine": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"fidc-backtest-service": "234d85bdb8ed12b552ba8e9eff47fddc5e683acc",
|
||||
"fidc-trading-platform": "d7d492541e60eb16e58dbff74b664b8ac85f6ccf"
|
||||
},
|
||||
"source_unchanged": true,
|
||||
"results": [
|
||||
{
|
||||
"repository": "fidc-backtest-engine",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 56.007,
|
||||
"passed": 913,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log_sha256": "07ee830295ac22961dc877f75d0016e3755917037b167ac65f9e31d449c68991"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-trading-platform",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 90.009,
|
||||
"passed": 625,
|
||||
"failed": 0,
|
||||
"ignored": 63,
|
||||
"log_sha256": "e88a14c056fa69f0186eb681cd16ea1ce254b57e8cc48e34f0231bce7901588a"
|
||||
},
|
||||
{
|
||||
"repository": "fidc-backtest-service",
|
||||
"exit_code": 0,
|
||||
"elapsed_seconds": 110.011,
|
||||
"passed": 594,
|
||||
"failed": 0,
|
||||
"ignored": 16,
|
||||
"log_sha256": "0ea63308d32bfc8ab0da4d71f73e770e7a6f273117e714dfcccb50e26ef70b54"
|
||||
}
|
||||
],
|
||||
"available_bytes": 80519344128
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"date": "2026-09-14",
|
||||
"baseline_engine": "ad76bdb6ae13f8ccd963e8fed4badff72a3846c3",
|
||||
"candidate_engine": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"reproduced_before_fix": [
|
||||
{
|
||||
"case": "timely sale then conversion inside sell cooldown",
|
||||
"unexpected_market_fill": {
|
||||
"symbol": "000002.SZ",
|
||||
"side": "Buy",
|
||||
"quantity": 100,
|
||||
"price": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"case": "original security lock still active after conversion",
|
||||
"unexpected_market_fill": {
|
||||
"symbol": "000002.SZ",
|
||||
"side": "Sell",
|
||||
"quantity": 100,
|
||||
"price": 5
|
||||
}
|
||||
}
|
||||
],
|
||||
"verified": {
|
||||
"configured_dates_preserved": true,
|
||||
"no_new_orders_when_protected": true,
|
||||
"expiration_positive_control_fills": true,
|
||||
"clear_after_conversion_keeps_active_lock": true,
|
||||
"full_sale_before_conversion_removes_disproved_link": true,
|
||||
"original_strategy_members_and_weights_not_rewritten": true,
|
||||
"entry_points": [
|
||||
"platform explicit strategy",
|
||||
"native stock pool",
|
||||
"deferred ETF open consumer"
|
||||
],
|
||||
"new_test_functions": 6,
|
||||
"isolated_configuration_cases": 23
|
||||
},
|
||||
"local_tests": {
|
||||
"core": {
|
||||
"passed": 913,
|
||||
"ignored": 9
|
||||
},
|
||||
"trading": {
|
||||
"passed": 625,
|
||||
"ignored": 63
|
||||
},
|
||||
"runner": {
|
||||
"passed": 463,
|
||||
"ignored": 9
|
||||
},
|
||||
"api": {
|
||||
"passed": 129,
|
||||
"ignored": 7
|
||||
}
|
||||
},
|
||||
"ui_modified": false,
|
||||
"production_runtime_modified": false,
|
||||
"production_order_or_cancel_requests": 0,
|
||||
"limits": [
|
||||
"not production Source/Runner acceptance",
|
||||
"not a broker connection or corporate action acceptance",
|
||||
"online conversion lineage facts still required",
|
||||
"Source runtime freeze unchanged"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"observed_at": "2026-09-14T15:47:13.093567+00:00",
|
||||
"status": "tested_not_deployed",
|
||||
"runtime_services": [
|
||||
{
|
||||
"service": "omniquant-highmem177.service",
|
||||
"pid": 987464,
|
||||
"state": "active"
|
||||
},
|
||||
{
|
||||
"service": "fidc-backtest-service-highmem177.service",
|
||||
"pid": 4108679,
|
||||
"state": "active",
|
||||
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
|
||||
},
|
||||
{
|
||||
"service": "fidc-paper-trading-highmem177.service",
|
||||
"pid": 3765829,
|
||||
"state": "active",
|
||||
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
|
||||
},
|
||||
{
|
||||
"service": "fidc-live-trading-highmem177.service",
|
||||
"pid": 3765783,
|
||||
"state": "active",
|
||||
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
|
||||
},
|
||||
{
|
||||
"service": "fidc-strategy-runtime-highmem177.service",
|
||||
"pid": 3765705,
|
||||
"state": "active",
|
||||
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
|
||||
},
|
||||
{
|
||||
"service": "fidc-trading-control-highmem177.service",
|
||||
"pid": 3765532,
|
||||
"state": "active",
|
||||
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
|
||||
}
|
||||
],
|
||||
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"source_runtime_tracked_clean": true,
|
||||
"engine_development_checkout": "ba4b77fd746963687b8029b47806f943ed6ce03f",
|
||||
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
|
||||
"immutable_source_file_count": 985,
|
||||
"source_snapshot_manifest_sha256": "6ae0cb8f0e571b4848e2cba6b39368a19434cc43729cfe07aad6fdce531d2814",
|
||||
"test_scope": "fidc-conversion-protection-check-20260914-51wBBB.scope",
|
||||
"test_cpu_quota_percent": 400,
|
||||
"test_memory_max_gib": 12,
|
||||
"source_runtime_modified": false,
|
||||
"production_order_or_cancel_requests_by_this_verification": 0,
|
||||
"production_task_configuration_writes_by_this_verification": 0
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
# 表达式上下文性能验收
|
||||
|
||||
## 范围
|
||||
|
||||
本轮优化 FIDC 引擎的逐股票表达式上下文,不修改策略、因子值、窗口、时间可见性、
|
||||
选股/订单规则、费用、成交价、风控或公司行为。Source 保持 `d5b682c6d097`,
|
||||
研究和信号保持暂停。其他用户任务只读观察,不更改其进程、亲和性或配置。
|
||||
|
||||
已完成编译、回归、正式回放与177发布验收,不能据此关闭整个目标。
|
||||
|
||||
## 重复开销
|
||||
|
||||
1. DataSet 已采用 `NumericFactorMap`,但 `StockExpressionState` 仍把数值因子
|
||||
重建为 `BTreeMap<String, f64>`,增加树节点和字符串分配。
|
||||
2. 每日可用因子名集合先为每个证券复制名称,再由集合丢弃重复名称。
|
||||
|
||||
候选在表达式上下文延续紧凑数值存储。每日名称仍按当日真实字段生成完整集合,
|
||||
仅改为先对借用名称去重,再为唯一名称分配字符串;文本因子同理。
|
||||
没有用全局/未来日期目录替代当日字段,没有缓存选股结果或账户状态。
|
||||
Rhai `factors[...]`、缺失、NaN、负零、别名、覆盖顺序与已完成交易日可见性保持原行为。
|
||||
|
||||
## CPU 计时
|
||||
|
||||
Runner 新增以下运行计时,HTTP benchmark 同样保留:
|
||||
|
||||
- `engineTaskWallSeconds`:实际引擎同步任务的墙钟耗时,包含其同步数据读取等待。
|
||||
- `engineThreadCpuSeconds`:Linux `CLOCK_THREAD_CPUTIME_ID` 实测的引擎调用线程CPU时间。
|
||||
不包含其他并行线程、I/O等待或未被调度的时间;不可当作整个进程总CPU时间。
|
||||
读取不可用、跨线程或时钟异常保持null,不填0。
|
||||
- `engineTaskCount`:实际执行引擎的次数,包含原有修复循环的重放。
|
||||
|
||||
这些是 `engineSeconds` 的子指标,禁止再次加到总耗时。正常耗时与诊断运行分开保存。
|
||||
计时不改写历史制品,旧记录缺少该指标时保持未知。
|
||||
|
||||
## 验收设置
|
||||
|
||||
- 固定引擎基线 `fe7243b`;候选为177的 `07b7b18`,对应本地 `df1862e`。
|
||||
- 两边使用同一计时版service `a9df11a`;`b5d22ff`仅补充benchmark字段读取。
|
||||
- 2021-08-23至2025-11-17、初始1000万、原冻结runtime与策略,1025个执行交易日。
|
||||
- 保留原 `session_capacity_audit`,不能当作实际开盘流动性验证。
|
||||
- 官方benchmark入口、Boris执行、同CPU资源与Source版本、新进程、相同数据缓存副本、
|
||||
新结果目录,不复用回测结果。
|
||||
- 引擎780项、runner408项、API113项、脚本10项通过;9/8/3项手动或外部环境用例分别忽略。
|
||||
- 专项延伸验证紧凑因子的克隆、Rhai映射暴露、缺失、NaN及负零;CPU计时验证睡眠和跨线程边界。
|
||||
|
||||
证据根:`/srv/fidc/canonical/run/research/engine-context-20260913`。
|
||||
|
||||
## 独立进程对照
|
||||
|
||||
| 次序 | 样本 | 完整墙钟 | Source校验 | 数据准备 | 引擎墙钟 | 引擎线程CPU |
|
||||
|---|---|---:|---:|---:|---:|---:|
|
||||
| 1 | control-1 | 31.234s | 11.202s | 8.393s | 10.461s | 10.458s |
|
||||
| 2 | candidate-1 | 17.002s | 0.004s | 8.404s | 7.406s | 7.404s |
|
||||
| 3 | candidate-2 | 18.203s | 0.003s | 8.371s | 7.411s | 7.408s |
|
||||
| 4 | control-2 | 22.983s | 0.004s | 8.323s | 13.401s | 13.398s |
|
||||
| 5 | control-3 | 30.714s | 0.005s | 15.318s | 13.999s | 13.990s |
|
||||
| 6 | candidate-3 | 25.336s | 0.005s | 13.524s | 10.471s | 10.468s |
|
||||
|
||||
首个基线的Source校验等待原样保留,不事后改称预热,不把11.202秒归因于引擎改动。
|
||||
后段样本出现主机负载/缓存竞争变化,数据准备也变慢,不能直接用全组平均墙钟夸大提速。
|
||||
相邻低负载对照的引擎线程CPU为10.458至7.404秒,后段为13.990至10.468秒。
|
||||
CPU计时与任务墙钟非常接近,证明样本主要在执行CPU工作,而不是等待HTTP;
|
||||
这不代表没有SMT、内存带宽或其他用户CPU竞争。
|
||||
|
||||
六次均为21,393笔成交,账户、权益、委托、成交、持仓和风控canonical及结果制品完全一致。
|
||||
每份63个数据缓存文件经完整SHA核对相同,没有复制或读取旧回测结果。
|
||||
|
||||
## HTTP 对照
|
||||
|
||||
| 状态 | 版本 | 运行ID | 总耗时 | 引擎耗时 |
|
||||
|---|---|---|---:|---:|
|
||||
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232559582_3166774_4 | 21.987s | 11.328s |
|
||||
| 清DataSet,磁盘/Source保持 | 原版 | btr_1789232585690_3166774_5 | 21.684s | 11.259s |
|
||||
| 复用DataSet | 原版 | btr_1789232669598_3166774_6 | 11.820s | 11.031s |
|
||||
| 复用DataSet | 原版 | btr_1789232684861_3166774_7 | 11.857s | 11.067s |
|
||||
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232818009_3320588_0 | 17.296s | 7.537s |
|
||||
| 清DataSet,磁盘/Source保持 | 新版 | btr_1789232839269_3320588_1 | 17.413s | 7.627s |
|
||||
| 复用DataSet | 新版 | btr_1789232898983_3320588_2 | 8.549s | 7.738s |
|
||||
| 复用DataSet | 新版 | btr_1789232910904_3320588_3 | 8.586s | 7.784s |
|
||||
|
||||
同状态HTTP均值:重建DataSet从21.836至17.355秒,减少约20.5%;
|
||||
复用DataSet从11.839至8.568秒,减少约27.6%。两种状态分开比较,
|
||||
没有把8.568秒当作Source冷启动成绩。与上一轮不同时间的15/17秒样本不作直接百分比对比。
|
||||
|
||||
原版API没有线程CPU字段,保持null;新版本每次实际执行引擎一次,
|
||||
两次重建的线程CPU为7.535/7.624秒。没有用新版本计时回填旧记录。
|
||||
八次HTTP和六次独立回放的canonical及结果制品SHA全部相同,终态审计clean。
|
||||
|
||||
## 发布状态
|
||||
|
||||
177通过官方安装器发布 engine `07b7b181b60138c6ef1c965543c0e3192ac65903`、
|
||||
service `b5d22ffab16f851eced3028e12fa02627ee4c399`。
|
||||
运行身份 `fdd8652a47a5935be4d891beb3b8b0f3e19a468be166a902a2a97b85a9c9e01e`。
|
||||
|
||||
- API SHA:`bf22f58946c3fa495161eb381a400d4e28d7c8d327ee46f5645d83a8308117cf`。
|
||||
- Runner SHA:`7b3849cd8af33d650db242add80c49cfdd32e8cc8686a614da7b3b4016ce2a60`。
|
||||
- 生产在用构建根:`/srv/fidc/canonical/build/engine-context-candidate-20260913`,禁止清理。
|
||||
- 原生因子能力目录发布前后字节相同,SHA为
|
||||
`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`。
|
||||
- API PID3320588,Boris、active、NRestarts=0;Source仍为PID1700096/d5,研究未恢复。
|
||||
|
||||
[完整结构化验收证据](evidence/expression-context-performance-20260913.json),
|
||||
SHA256 `f526950e018354c1305922beebf4063ae3823004f8c5ab20510a452f98b7b7ea`。
|
||||
|
||||
## 边界
|
||||
|
||||
本轮真实长区间案例含一个原生扩展因子,动态映射、缺失及多字段语义另由引擎回归覆盖;
|
||||
不宣称所有策略都具有相同比例提速。Source冷路径仍受独立冻结约束,
|
||||
信号闭环和全部策略/分钟区间/财务PIT不在本轮通过范围内。
|
||||
@@ -0,0 +1,75 @@
|
||||
# 特征日行情缺口与跨日回退
|
||||
|
||||
## 问题
|
||||
|
||||
审查发现股票表达式上下文的三个位置把缺少的特征日行情回退到执行/当前市场日:
|
||||
两个 `StockStateSnapshotSource::feature_market` 实现,以及最终构建器的 `unwrap_or(market)`。
|
||||
当 `factor_date` 早于 `date` 时,这会把后来日期的OHLCV用于原本指定的历史特征日。
|
||||
这是错误日期代用,并具有前视风险;不据此推断所有历史回测都已触发此路径。
|
||||
|
||||
真实代码的合成缺口回归已复现:2025-04-03因子快照存在但行情缺失,
|
||||
2025-04-07行情存在,旧版返回close=20.0、volume=45600、open=19.0,
|
||||
而非报告4月3日行情缺失。此回归使用合成数据验证代码路径,不是行情数据造假或真实市场收益样本。
|
||||
|
||||
## 修改
|
||||
|
||||
- 两个行情读取入口只返回指定特征日期的快照,删除向执行日的回退。
|
||||
- 构建器缺少快照时返回 `MissingSnapshot { kind: "feature_market", date: factor_date, symbol }`。
|
||||
- 保持原市场、候选和因子缺失错误优先级;失败状态不写入股票上下文缓存。
|
||||
- 同日上下文继续使用同日快照;正常跨日上下文仍将历史OHLCV与执行报价分开。
|
||||
- 不调整价格、因子、窗口、风险、手续费、滑点、成交量或原始数据,不引入替代缓存。
|
||||
|
||||
新增回归覆盖索引读取、每日视图读取、错误缓存边界和同日合法输入。
|
||||
原next-open涨跌停测试只有前一日因子、没有对应行情,原先隐式依赖了该回退。
|
||||
已补充两只证券各自明确的历史行情,并断言历史价1.80与决策日价2.20分开;
|
||||
不放宽时点规则,也不改真实数据。
|
||||
|
||||
## 当前状态
|
||||
|
||||
177的红色回归已确认为行为失败;最初缺少错误枚举限定名的编译失败另存,不作为复现证据。
|
||||
修复后完整引擎783项、runner410项、API113项通过,分别9/8/3项既有外部或手动测试忽略。
|
||||
已通过官方入口发布到回测服务;Paper/Live/Strategy Runtime没有在本轮重建或重启,
|
||||
不能将共享源码修复等同于全部消费者已经部署。
|
||||
|
||||
## 真实回放
|
||||
|
||||
固定原策略、2021-08-23至2025-11-17、初始1000万及原冻结bundle。
|
||||
保留历史`session_capacity_audit`,不能当作开盘容量验收。
|
||||
全部运行重新执行引擎,Source/磁盘数据缓存保持,不缓存回测结果。
|
||||
|
||||
| 状态 | 版本 | 运行ID | 总耗时 | 数据准备 | 引擎 |
|
||||
|---|---|---|---:|---:|---:|
|
||||
| 清DataSet内存 | 原版 | btr_1789251904666_3596554_4 | 17.689s | 8.664s | 8.141s |
|
||||
| 清DataSet内存 | 原版 | btr_1789251925997_3596554_5 | 17.082s | 8.333s | 7.947s |
|
||||
| 清DataSet内存 | 修复版 | btr_1789252042334_3735010_0 | 15.668s | 7.247s | 7.608s |
|
||||
| 清DataSet内存 | 修复版 | btr_1789252061568_3735010_1 | 15.717s | 6.949s | 7.985s |
|
||||
| DataSet复用 | 修复版 | btr_1789252206579_3735010_2 | 8.790s | 0.006s | 7.988s |
|
||||
| DataSet复用 | 修复版 | btr_1789252217644_3735010_3 | 9.892s | 0.006s | 7.880s |
|
||||
|
||||
六次均21,393笔成交,账户、权益、委托、成交、持仓、风控canonical及完整制品SHA一致,
|
||||
终态clean,每次引擎执行次数为1。真实完整数据没有触发新增缺失错误。
|
||||
最后一次包含1.233秒Source合同验证,不能把DataSet复用等同于Source无等待。
|
||||
本轮未观察到该样本的性能回退,但这是正确性修复;主机负载及数据读取也有波动,
|
||||
不将17秒至15秒归因于普遍算法提速,更不外推所有策略。
|
||||
|
||||
canonical:`3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7`。
|
||||
结果制品:`1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9`。
|
||||
|
||||
## 发布证据
|
||||
|
||||
177 engine `e3b39295787c4fd896753d633e457deddf9f1232`,service `106a89d8bb74af494cdf84d9d3af5ec4bcb249cd`。
|
||||
|
||||
- API:`86f0a0385410db8ab308edf892f4ad6376c0a706c0ebbac0f397a23539d782c0`。
|
||||
- Runner:`aebdd37ad30ee73f11a9ffd206ad8c978ced19a257cb51849881b0e5bdce36ee`。
|
||||
- 运行身份:`d196bd4740b1b506c42515a689ae155a21e37b8092956b99a298b8d7934e53c7`。
|
||||
- 在用构建根:`/srv/fidc/canonical/build/feature-date-candidate-20260913`,禁止清理。
|
||||
- PID3735010、Boris、active、NRestarts=0;收据时cgroup约7.91GiB,峰值约9.29GiB。
|
||||
- 原生目录字节SHA仍为`cec37331a476bc39bdea32c308581b8ac2f86d005d8dd4cc7ba228c5d9dc9a2e`。
|
||||
- Source保持d5/PID1700096,研究和信号未恢复,没有向券商提交订单。
|
||||
|
||||
证据根 `/srv/fidc/canonical/run/research/feature-date-integrity-20260913`。
|
||||
[结构化证据](evidence/feature-date-market-integrity-20260913.json),
|
||||
SHA256 `56e70172916c45060106aca3eb006984735a3f85e6b13d2c325c409e83b8962b`。
|
||||
更多策略、真实缺口数据审计、Paper/Live消费者发布及完整财务PIT仍未完成。
|
||||
Source目录缓存的隔离后继验证单独见Alpha Factory的`docs/native-condition-transport-20260913.md`,
|
||||
不能把本轮回测发布当作Source冻结解除。
|
||||
@@ -0,0 +1,70 @@
|
||||
# 日内时钟与手工回放前置问题
|
||||
|
||||
2026-09-14。本轮日内时钟与工作中算法单修复已配套发布177,annotated tag `v2026.9.14.4`。当前Engine237ee15 / Service e81bf47 / Trading dab98e0;完整手工影子回放尚未实现,不据本阶段关闭Goal。
|
||||
|
||||
## 已复现的精确反例
|
||||
|
||||
`engine::tests::minute_observer_never_sees_a_later_fill_from_a_coarse_phase`使用实际BacktestEngine/BrokerSimulator测试入口、同一证券及合法测试日行情。开盘竞价回调生成100股限价10.0的委托,全天存在09:30、10:00、10:15、13:00、13:01报价,后续分钟回调读取真实模拟账本。
|
||||
|
||||
- CurrentBarClose/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- NextBarOpen/一天信号滞后/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- CurrentBarClose/13:00窗口:实际FillEvent时间13:00、数量100,但09:30、10:00、10:15回调均已观察到100股,失败。完整观察序列为`[(09:30,100),(10:00,100),(10:15,100),(13:00,100),(13:01,100)]`,不是仅日志显示错误。
|
||||
|
||||
根因路径是粗粒度auction/on_day阶段调用broker时使用未来的全局intraday_execution_start_time,先将13:00成交写进PortfolioState,随后引擎才从09:30开始遍历分钟事件。正常09:30路径已有边界,不能因为一次测试通过就断言所有时点安全,也不能把所有粗粒度调用一概认定有问题。
|
||||
|
||||
首次盘前调度夹具没有产生订单,因此不作为时钟证据;改用明确返回委托的open_auction回调完成上述复现。盘前on_scheduled普通委托是否被忽略应另行核对其正式合同,不能当空成功。
|
||||
|
||||
## 必须按真实执行时序修复
|
||||
|
||||
不能删掉早间回调或给显示持仓做遮掩。需要使已生成的未来执行意图、待执行批次、订单回报、策略回调、手工意图及实际投影按执行时钟前进;保留独立信号日与数据可见性。不能仅把新订单延迟却让依赖持仓的后续策略回调仍提前计算。
|
||||
|
||||
需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。
|
||||
|
||||
上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。
|
||||
|
||||
## 本轮新增证据
|
||||
|
||||
- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。
|
||||
- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。
|
||||
- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。
|
||||
- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。
|
||||
- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为0,09:31为3700且仅一笔ETF成交。不合成ETF分钟线。
|
||||
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
|
||||
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
|
||||
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
|
||||
|
||||
本机Core 822项通过、9项原有ignore;Trading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。
|
||||
|
||||
## 发布前置与剩余边界
|
||||
|
||||
177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;首次Linux测试在18.02秒触及1GiB余量保护并中止,只停止本次Cargo进程,未重启服务,保留`clock-candidate-cena8gz9/first-attempt.json`及日志,不能算测试通过。
|
||||
|
||||
初次把清理预览的`reclaimed_allocated_bytes=0`误读为没有候选;完整plan实际已有5项、12,453,273,600字节。正式工具引用/锁/身份复核后仅清理2处闲置debug增量缓存和3个过期测试可执行文件,保留最新测试、全部静态/共享库、release、源码、行情及结果,余量恢复13,168,623,616字节。收据位于`/srv/fidc/canonical/run/fidc-private/evidence/clock-default-cleanup-20260914-0422/`。暂拟的静态库清理选项未执行并已撤回;最终Service脚本5ec8dc8只明确区分计划量与实际回收量,保持原清理边界。
|
||||
|
||||
代码修复已推送Engine `237ee15a518a668297959509daffc4b88995f310`;官方复用审计确认target-backtest无运行引用,新一轮仍保留1GiB余量保护,并独立保存重建前的旧二进制及SHA。实际构建读取只读Git archive快照237ee15与生产Service e81bf47,不夹带尚未生产验收的并行缓存规划代码,不覆盖维护工作树。
|
||||
|
||||
Linux精确快照Core822、Trading613通过。首次配套优化构建276.06秒成功,但收据写入因/tmp的跨用户既有文件保护失败;改为原子替换收据后,重新核对同一快照/测试/制品,未把日志缺失或异常算通过。前一轮日志及旧二进制仍保留,最终收据`/tmp/fidc-clock-candidate-20260914.json`。
|
||||
|
||||
## 发布与真实合同验收
|
||||
|
||||
Engine `237ee15a518a668297959509daffc4b88995f310`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `dab98e0cc09793df15b8c72841a6dc7e9a58a208`均有已推送annotated tag `v2026.9.14.4`。API/Runner于04:14:17 CST切换,五交易服务于04:19:57切换;04:22只读复验实际SHA、迁移、旧单及配置。
|
||||
|
||||
| 已冻结原合同 | 原生A/B | 生产HTTP | 成交 / 期末持仓 | 期末权益 |
|
||||
| --- | --- | --- | --- | ---: |
|
||||
| 手选优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303 | 10 / 4 | 9706248.648662 |
|
||||
| 自动优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054 | 10 / 4 | 9706248.648662 |
|
||||
| 许总24只原v3 | 完整Canonical及四类逐行导出相同 | btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74 | 51 / 21 | 9685563.876924999 |
|
||||
|
||||
共六次独立原生执行、三次持久幂等HTTP提交,旧请求/旧结果未改写。候选顺序、父订单及卖后续买合同保持;重复目标委托0。三条新记录各有5个交易日事件,持久事件总数27/18/32、唯一键数完全相等;旧流式样本仍27条/5日。上述数据来自原历史合同,仍属日终容量审计,不证明实时盘口容量;1秒样本与首轮12秒Source准备不作为性能提速证据。
|
||||
|
||||
API二进制SHA `4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2`,Runner `8b98a2ae9a13899e87d9931162d1637de7e9ab81844c284e00135904cda7b0e4`,运行实现身份 `96cf0dcfcec94c6f7e2a9fc64ba8b8e8547b12c869ad6a61a0e492f6c76b5d57`。当前不可变API目录`/srv/fidc/canonical/run/backtest-api/releases/clock-237ee15-c37rs7zq`,回退目录`/srv/fidc/canonical/run/build/clock-rollback-7qnhgco7`;交易回退目录`holding-protection-rollback-dkd1njej`。
|
||||
|
||||
五交易服务逐一核对实际文件SHA与manifest,新增ERROR日志0;3Paper/0Live、配置、旧活动委托、3个未确认Paper预览、迁移、shadow配置0及disabled均未变化,发布后Paper/Live新订单0。Source d5/PID1700096、UI6a2/PID3089476未重启,研究/信号暂停保持。177维护中的Engine9a54156工作树完整保留,不把该未部署候选冒充本次运行代码;实际编译来自237/e81和237/dab只读快照。
|
||||
|
||||
完整原始回放与HTTP收据:`/srv/fidc/canonical/run/research/stock-pool-clock-20260914/`。发布/审计收据:`/tmp/fidc-clock-{api-release,trading-release,final-audit}-20260914.json`。非敏感汇总已归档`docs/evidence/intraday-clock-20260914/acceptance.json`。
|
||||
|
||||
## 下一步
|
||||
|
||||
通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前阶段声明完整Goal完成。下一轮直接处理这些缺口,不重新做已通过的金额、页头、流式及本轮三组回放;当前仍不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。
|
||||
|
||||
Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 迟到成交、批次成本与持有保护
|
||||
|
||||
2026-09-14。阶段修复;完整股票池目标仍未完成,未据此发布生产。
|
||||
|
||||
## 根因与修复
|
||||
|
||||
旧持仓账本按收到买入回报的顺序追加批次,卖出直接扣列表首批。当较早成交的回报晚到,或旧证券换股并入已有新证券持仓时,列表先后不再等于取得日期。T+1 校验计算了合法老批次数量,却实际扣掉新批次;剩余旧批次可能再次被当作可卖。FIFO 成本与已实现/未实现盈亏随之错配。
|
||||
|
||||
另一问题是已有持仓收到更早买入事实时,`opened_date` 没有更新,最长持有期从较晚日期起算。
|
||||
|
||||
修复在账本扣减入口按真实取得日期稳定执行 FIFO;同日回报及其费用保持原关联,不重排收到的外部事件,不补单、不回写历史结果。正常日期顺序不排序,零股操作不排序。连续持仓的已知开仓日取较早日期,最近买入日仍取较晚日期;缺失的原始建仓日期不靠新买入猜测填充。移动均价展示合同与固定精度现金/费用不变。
|
||||
|
||||
## 负向证据
|
||||
|
||||
基线 `b2eaaa0d269d4aee5e2e500cd0f2b2edbda648b8` 上新增两个测试实际失败:
|
||||
|
||||
- 9月14日新买100股先被观察,9月11日老买100股随后才被观察。旧 `opened_date` 仍为9月14日,期望9月11日。
|
||||
- 随后卖出100股时,旧代码扣了新批次,剩余未实现盈亏为 -0.75,而按老批次先卖应为 -1000.25。该样例分别使用20元/10元买入、0.25/0.75元买入费用和0.5元卖出费用;只有证券身份数据,不冒充真实市场行情。
|
||||
|
||||
## 回归覆盖
|
||||
|
||||
- 回报仍按原观察序号应用;老买入不得在收到之前进入持仓。
|
||||
- 合法卖出老100股后,新100股仍不可在9月14日卖出。第二次冲突卖出拒绝且现金、股数、游标原子保留;现金7998.5、出入金0、剩余FIFO成本2000.25,费用没有串到另一批次。
|
||||
- 最长持有期使用9月11日,买后3个交易日保护使用最新买入日9月14日,保护优先于最长持有退出。
|
||||
- 整段引擎换股:旧股较早买入100股、已有新股较晚买入100股,旧股按2倍换成新股200股。随后卖200股先扣旧来源,留下新买100股;不重置开仓/最近买入日,成交来源及换股事件保留,已实现不含费用盈亏200。
|
||||
- 整段平台表达式:真实手工两买一卖、次日送转、3日买后保护/卖后禁买、16日至17日显式锁定、最长持有退出同时配置。14日至15日审计分别记录保护和禁买,16日至17日记录锁定;18日只生成一笔卖200股、5元的最长持有退出,不重复附加显式卖单。三笔手工来源保留、不计出入金,旧股转成200股后计时不重置。
|
||||
- 同一整段测试另验16日锁定期间的已确认手工卖出200股:与14日保护期间卖100股两个变体分别验证。前者15日送转后400股,手工卖出后200股;两个变体最终都在18日仅自动退出剩余200股。锁定仍阻止自动交易,不阻止已授权手工事实入账;T+1未绕过,买后保护和卖后禁买分别沿实际日期计算。
|
||||
|
||||
本机Core889、Trading625、Runner460/API127通过,ignore另计;针对性完整审计断言另行通过。两次测试编写阶段的私有方法/辅助函数名编译错误已修正,不计作框架失败或通过证据。不是实际Source或GT交易验收。
|
||||
|
||||
## 仍须继续
|
||||
|
||||
实际 Source/Runner 联合回放和未准入 Arrow 性能门禁尚未通过;Source明确冻结仍待独立解除授权。另需继续验证迟到回报跨越已经执行过的除权/派息/换股事件、跨模式历史持有事实及其余参数矩阵。本节只证明列出的组合,不能外推全部公司行为或关闭完整目标。
|
||||
|
||||
旧opening-clock-UUx5ru与FewUWP收据均不包含本次账本修复,不得覆盖。后续新的Linux/发布证据另附,本轮不修改原池、任务、历史或交易开关。
|
||||
|
||||
## Linux复验与当前状态
|
||||
|
||||
代码修复e9c9ecb、两组锁定/保护变体b4c68be均已提交推送;177引擎开发树已更新到b4c68be、tracked clean。新的`lot-lifecycle-20260914-AEGDYL`只读快照绑定Engineb4c68be/Service49f2800/Tradingae83fd3,962个文件、独立build根、4CPU/12GiB及1GiB容量线。
|
||||
|
||||
Core889、Trading625、Runner/API589(含Linux额外2项平台测试)通过,源码前后不变,测试已结束。收据[linux-tests.json](evidence/late-fill-lifecycle-20260914/linux-tests.json)及[runtime.json](evidence/late-fill-lifecycle-20260914/runtime.json)。本轮只是新快照测试,没有再构建release或创建发布tag,不覆盖已完成批次。
|
||||
|
||||
Source仍d5/PID1700096、tracked clean,生产UI及五后端PID/实际二进制SHA与上一轮相同;保留Service并行开发5991e77。没有委托/撤单/任务配置写入,没有解冻Source或重启生产。完整Goal继续上述剩余矩阵及正式Source/Runner验收,不把这些确定性测试外推为真实交易通过。
|
||||
@@ -0,0 +1,52 @@
|
||||
# 迟到成交跨公司行为:校正候选
|
||||
|
||||
2026-09-14。仅开发验收,未发布生产;完整股票池目标保持进行中。
|
||||
|
||||
## 已复现
|
||||
|
||||
相同成交与同一数据/配置,仅推迟回报收到时间,原实现会丢失或多留公司行为权益:
|
||||
|
||||
- 买100股后送转2倍:及时回报为200股、权益9999;迟到回报只有100股、权益9499。
|
||||
- 原持有200股、送转前卖100股:及时回报最后200股、权益9998;迟到回报最后300股、权益10498。
|
||||
- 派息会漏记或多记应收/现金;换股后的迟到旧代码成交不能直接写回旧证券持仓。
|
||||
|
||||
这些是隔离的确定性引擎样例,不是实际GT账户金额。
|
||||
|
||||
## 实现
|
||||
|
||||
正常公司行为与校正共用 `corporate_book` 的原始计算逻辑。迟到回报跨过已处理的有效公司行为时,先复算当前已观察前缀,核对现金、批次取得日/成本及应收身份;覆盖不完整就失败,不以账户最终快照覆盖结果。
|
||||
|
||||
随后在私有经济账本按实际成交时间放入已经确认的手工事实,重放已发出的模拟成交、原现金变动与已执行的公司行为。原始策略不重跑,委托不补造,旧成交/历史日终行不重写;不存在的税费或现金对价不推算。普通日线成交的内部顺序使用冻结撮合模型,不把模型时刻写成实际券商时间。
|
||||
|
||||
现金与持仓在真实回报收到时原子替换;失败不推进游标。已发行单位、融资/资金流控制和任务目标状态保留,不重新发行单位或改用户参数。已收管理费和既有委托价格/费用保持原事实。现金计算直接使用固定金额,避免大额资金再经浮点转换。
|
||||
|
||||
校正新增独立审计:公司行为日期、冻结参数和SHA;现金前后与权益现金变化;逐代码股数及成本前后;前后账本摘要。原始成交股数、价格、费用和资金变动仍分开保存。换股后的卖后禁买事实沿已证实的后继代码传递,不冒充新增交易。
|
||||
|
||||
这是冻结回测合同内的经济账本校正,不是对实际GT税后派息或权益到账的独立核验。
|
||||
|
||||
## 验证范围
|
||||
|
||||
- 买/卖两方向 × 送转、派息、换股的最终股数/现金/权益对照通过。
|
||||
- 期间存在已执行的同股/其他股票买入、即期或延期入金、显式融资及固定已收管理费,对照通过;原模拟成交列表逐字段不变。
|
||||
- 派息启用/禁用、已付/待付、成本调整开/关八组合通过。禁用时不改变原观察日成交统计,仅比较最终经济事实,不将其误称同日成交。
|
||||
- 两个100股批次、1.125倍送转,合计225股;不是把两段分别取整得到226股。
|
||||
- 原子投影失败测试、来源绑定/金额/日期/后继链篡改拒绝测试通过。
|
||||
- 本机Core895、交易625、Runner462/API127、前端2277通过;原ignore/skip不计通过。三项私有PG提交/权限/租约用例实际执行通过,最初错误筛选匹配0项的命令不计通过。
|
||||
|
||||
Runner正常构建保留现有Mac专属dead-code提示;格式化辅助脚本在lib.rs的模块排序比较处主动停止,没有强行覆盖模块顺序。最终源码检查与测试仍单独执行。
|
||||
|
||||
## 贯通与剩余门禁
|
||||
|
||||
提交能力增加 `corporate_adjustments: v1`,旧消费者不接受本轮手工输入。共享最终/流式成交投影保留校正结构并核对固定金额;页面保留原始交易,额外证据无效时单独报错而不藏掉原交易。
|
||||
|
||||
真实Rust样例经共享投影导出后,通过本机HTTP加载实际审计弹窗,1440/390、短屏及明暗主题共六组通过;无横向溢出、页面错误或交易写请求。原生Tab曾短暂离开弹窗,已补循环焦点;Esc回到原按钮。详见OmniQuant同名文档及 `docs/evidence/manual-corporate-replay-20260914/browser.json`。
|
||||
|
||||
Source d5明确冻结尚未获准解除,实际Source/Runner/生产联合验收仍未完成。新Linux配套验收与生产发布另列,不用旧AEGDYL/UUx5ru/FewUWP收据冒充本轮。继续核对红利再投入场时钟、后继证券数据范围、目标状态及跨模式ETF等剩余矩阵;本轮通过不代表完整Goal完成。
|
||||
|
||||
## Linux配套补验
|
||||
|
||||
已推送功能提交:Engine05f1cbb、Service879a5a7、Trading559f5b1、UIf3c5470b(双远端已核对)。177引擎开发源码已ff到05,保留Service并行5991e77。独立只读`corporate-replay-20260914-ZAnvlO`快照972文件,4CPU/12GiB、boris运行、1GiB容量线保持。
|
||||
|
||||
Linux Core895、Trading625、Runner/API591全部通过(原9/63/16项ignore不计),源码前后未变。收据[linux-tests.json](evidence/manual-corporate-replay-20260914/linux-tests.json)及[runtime.json](evidence/manual-corporate-replay-20260914/runtime.json)。测试已结束;没有新的release构建、tag或生产重启,不覆盖旧完成快照。
|
||||
|
||||
生产UI/Paper/Live/Backtest等PID/实际SHA与前阶段相同,Source d5/PID1700096且tracked clean。没有真实委托、撤单或配置/通知写入。下一步直接处理上述剩余边界及正式Source/Runner准入,不重复本轮确定性测试或据此关闭完整Goal。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 手工观察主时钟接入候选
|
||||
|
||||
最新显式开盘/跨日 ETF 修复与验收见 [opening-and-deferred-clock-20260914.md](opening-and-deferred-clock-20260914.md)。下面保留早期阶段证据;原“开盘晚于配置窗口一律拒绝”已改为共享时钟与真实收盘边界,不再依赖订阅是否启用。
|
||||
|
||||
2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。
|
||||
|
||||
## 本阶段已实现
|
||||
|
||||
- BacktestEngine可显式绑定严格v2手工观察输入。原始回报时刻驱动账本;同一时刻按真实观察序号逐笔原子应用,回调能看到100、200而不是第一笔就看到两笔总量。
|
||||
- 默认盘前、开盘、盘中、收盘/结算及当日晚到回报纳入处理;跨会话观察先于下一会话公司行为,不生成行情行;结束后仍未覆盖的观察明确失败,不截断成成功。
|
||||
- 手工成交写独立来源及应用明细,不冒充模拟策略FillEvent。账户变化不计作出入金;手续费只扣一次,最终费用来源/时间仍单独保留。
|
||||
- 股数及现金改变后通知策略,真实买卖日期更新持有保护和卖后禁买证据;券商模拟器的当日卖后禁买规则同样接收手工卖出,不把手工绕过自动条件理解为抹掉真实成交历史。
|
||||
- 分钟时钟不必依赖策略订阅或同一时刻市场报价,手工价格也不会伪造为市场行情。已有挂单/待执行目标冲突仍明确拒绝,不替用户撤单或重建目标。
|
||||
- 流式数量、原始观察明细及换手率纳入手工应用;纯无成交的来源不会改变自然策略时钟。
|
||||
|
||||
## 已复现并修正的问题
|
||||
|
||||
旧默认OpenAuction回调在09:31,接着却可能执行09:30日内步骤。手工09:27观察会由此先进入09:31再倒退到09:30。已把默认开盘阶段放在09:25,并保留显式调度时间。
|
||||
|
||||
盘前08:50/09:10规则原来在同一状态上顺序计算,不能正确看到夹在两者之间的08:55回报。现按实际到期时间交错处理回报、调度、资金等指令和撤改控制;盘前阶段若跨越开盘阶段,明确报告冲突,不把晚时点状态带回早时点。
|
||||
|
||||
## 当前验证
|
||||
|
||||
Core872通过(9项原ignore不计通过),交易工作区619普通测试通过;不是实际券商行情验收。此前默认阶段样例最终600股、现金3991、权益9991、出入金0,原四个基础用例保留。
|
||||
|
||||
本轮新增8项回归,不重复把基础样例当新验收:
|
||||
|
||||
- OpenAuction 09:20/09:26、AfterTrading 15:15/16:00、Settlement 16:10与09:22/15:30/16:05手工观察交错。原候选09:20提前读到100股,16:00/16:10仍只读到100股;修复后依次为0/100/100/200/300股。
|
||||
- 盘后16:00的100股与结算16:10的200股显式指令,下一交易日各执行一次、共300股,信号日价格10不冒充执行日价格12。lag0/lag1保留原信号日、意图创建日和实际成交日;测试还抓到立即成交记录曾被统一注释为新信号日,已按批次原始日期记录。
|
||||
- 多个完整目标在进入待执行队列时就只保留最新一份;次日新的0%完整目标不会先执行旧买入。显式股数指令不作为完整目标覆盖。
|
||||
- 结束日期的两笔显式意图没有生成委托/成交,完整原指令留在terminalAudit;完整目标只留最新一份。NaN/Inf不能在JSON中被悄悄变成null。
|
||||
- 存在真实行情/风控但没有新因子选股快照的下一交易日,仍执行已有指令,不等到后日再运行。声明盘后阶段的策略使用完整市场日历,外部指标可用同一`backtest_execution_dates_with_rules`对齐;当前Runner的Platform策略只暴露OpenAuction/OnDay/Minute,不宣称已支持配置盘后阶段。
|
||||
- 显式开盘调度越过已配置执行窗口被拒绝;盘后GTC撤单立即作为控制执行,不变成次日新委托。
|
||||
|
||||
盘后处理使用正常账本/报价/风控入口,不创建模拟外部Fill,不越过结束日期。旧DAY订单仍按到期失效,下一日处理的是尚未提交的策略意图,并非延长旧订单有效期。无新信号的报价时钟复用有序迭代器,不复制整日Tick列表。
|
||||
|
||||
结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。
|
||||
|
||||
## 必须继续
|
||||
|
||||
1. 本轮已覆盖上述显式阶段与跨日用例;仍需补完整混合时钟矩阵,特别是显式开盘晚于盘中报价/ETF开盘、无新信号日同时有ETF待执行目标、公司行为和跨日保护组合。不得修改market_open已有09:31语义或把这些未验组合静默跳过以让测试通过。
|
||||
2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。
|
||||
3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。
|
||||
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
|
||||
|
||||
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6,Source d5/PID1700096冻结与研究暂停不改。
|
||||
|
||||
## 2026-09-14 运行级仓位配置补充
|
||||
|
||||
v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。
|
||||
## 逐日手工交付补充
|
||||
|
||||
手工观察输入可通过Arc与进度投影共享;默认紧凑进度保留当日手工应用及独立累计计数,原生明细开关不改。新增可失败进度回调,投影来源/计数错误会终止本次回测,不忽略错误后返回成功。Core879本机通过,当前完整版本Linux及发布验收未完成;共享最终/逐日投影与真实本机WebSocket证据见Service `docs/manual-stream-projection-20260914.md`。
|
||||
@@ -0,0 +1,45 @@
|
||||
# 手工成交观察回放:基础合同与当前断点
|
||||
|
||||
2026-09-14。当前候选已升级v2并与交易端权威读取配套,仍未接入Runner/API或引擎主时钟、未发布。交易最近发布是166998d/v2026.9.14.6,回测仍81acc54/e81;完整Goal和手工影子回放均未完成。
|
||||
|
||||
## v2读取合同补充
|
||||
|
||||
默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。
|
||||
|
||||
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
|
||||
|
||||
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。
|
||||
|
||||
最新main a29c434的DayOpen和列存变更已按ff-only保留合入;组合Core860通过,其中本模块18项。交易端读取四类来源及验证范围见fidc-trading-platform/docs/manual-replay-capture-20260914.md。未将整仓无订单、Paper一例与Live一例外推完整参数/时钟/券商验收,不据此解除门禁。
|
||||
|
||||
## 已实现
|
||||
|
||||
`manual_execution`提供`fidc.observed-manual-executions/v2`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。下面保留初版阶段的实现说明,费用和时间字段以本节v2补充为准。
|
||||
|
||||
- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。
|
||||
- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。
|
||||
- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。
|
||||
- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。
|
||||
- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。
|
||||
- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。
|
||||
- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。
|
||||
|
||||
本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。
|
||||
|
||||
## 已核对的持久化入口
|
||||
|
||||
Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids;`paper_fills`及`paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。
|
||||
|
||||
Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。
|
||||
|
||||
费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_tax;Live事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。
|
||||
|
||||
## 必须继续,不能把本阶段当完成
|
||||
|
||||
1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。
|
||||
2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。
|
||||
3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。
|
||||
4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。
|
||||
5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。
|
||||
|
||||
下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。
|
||||
@@ -0,0 +1,39 @@
|
||||
# 人工零仓位与可选红利再投的一致性
|
||||
|
||||
2026-09-15。开发候选,未发布生产;完整目标保持未完成。
|
||||
|
||||
## 已核对的模型和反例
|
||||
|
||||
ALV `position_model.py::_handle_dividend_payable` 与FIDC现有再投都是参考价、整手、零费用的历史账务模型,不是市场委托;两边默认再投关闭。只有显式开启该模型时,才出现本轮组合问题:用户已清仓并在派息前将人工仓位设为0%,派息日仍账务买入100股。完整回放已在修复前实际复现。
|
||||
|
||||
修复不把这项功能改成开盘市价单,不改变原参考价或整手定量。没有人工零仓位时,清仓本身不能作为猜测用户设置的依据,继续按原已声明的再投模型计算。
|
||||
|
||||
## 控制优先级
|
||||
|
||||
- 只读取已校验、绑定运行审计的人工仓位/权重事件。上海交易日00:00是当前历史模型的结算入账时点,不是券商成交时间;使用此时已经生效的最新 `(effective_at, sequence)`。
|
||||
- 已生效的人工Scale 0、Set 0禁止可选再投。明确的人工分配中,证券权重0或被排除也只让该证券分红留为现金,不替它买其他股票。
|
||||
- 同时刻按真实序号,输入数组顺序不能改变结果。较晚才生效的零仓位或恢复不得倒改早先结算;Restore覆盖旧人工限制,但不代表强制100%仓位。旧日级控制保留日级粒度。
|
||||
- 没有人工限制、恢复跟随或非零且未排除该证券时,保留原再投模型,不对价格、手续费或分红金额另加比例计算。
|
||||
- 分红到账、实际成交、送转/换股等既有金融事实不会因0%被抹掉。0%停止的是可选新增分配,不伪造清仓或强制卖出已有/T+1持仓。
|
||||
|
||||
正常结算与迟到回报经济重放使用同一控制判断,控制源显式传递,不能只在策略下单层截断。日志包含 `runtime_zero_exposure` 或 `runtime_zero_allocation`、生效时间/序号以及 `cash_retained=true`。
|
||||
|
||||
## 金额口径
|
||||
|
||||
原100股×8.95元的账本已经扣895元,但新成交记录的浮点乘积可能输出894.9999999999999。现在记录与现金扣账使用同一个微元金额,输出895和-895;保留原价格、股数、零费用和旧历史结果,不将显示尾差当成真实资金差额。
|
||||
|
||||
## 验证和边界
|
||||
|
||||
新增六项专项:有效0%、无控制的原模型、时间/序号/旧日级/恢复组合、逐股0和排除、迟到权益重放、显式0%不读取无用的再投价格且坏现金仍原子失败。清仓0%样例最终现金49998、持仓0;没有0%仍100股、8.95、零费。额外实际买入迟到的样例,不掩盖真实1000股持仓,及时/迟到最终现金都为41047,权益现金校正1050,无可选再投。
|
||||
|
||||
本机Core919、Trading625、Runner463/API129全量通过,原9/63/16项ignore不计。没有修改UI、在线配置、账户、通知或交易路由;没有进行新的私有PG、实际Source/Runner、生产页面或券商委托验收。本修复针对经审计人工控制,不提前执行策略表达式来猜测其意图。
|
||||
|
||||
Source d5明确冻结保持。仍需完成正式换股字段/范围闭包、在线转换事实及重建、旧目标和活动单边界、其余生命周期/ETF矩阵与真实Source/Runner联合验收。Linux用新快照独立验证,不复用上一轮收据;未建release/tag或重启生产。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
功能提交 `818552bc969e0ea40081f0770c1d97db574a1981` 已推送并核对,177引擎开发树已快进至此,Service并行5991e77保留。只读快照 `/srv/fidc/programs/runtime-snapshots/zero-reinvestment-20260915-Yovt6m` 含989文件,独立同名build根;4CPU/12GiB、boris执行、1GiB保护线不改。源码manifest SHA `9ffdc1bc86e1b4b085caa694e921fc230ce0f09c57d34254058ae28e631d5bb3`。
|
||||
|
||||
Linux Core919、Trading625、Runner/API594共2138项全过,原9/63/16项ignore不计,源码前后不变。结束时可用77131395072字节,scope已inactive,不重复本批或覆盖前阶段收据。证据见 `docs/evidence/manual-zero-reinvestment-20260915/{local-tests,linux-tests,runtime}.json`。
|
||||
|
||||
2026-09-15 00:34 CST逐服务比较,现有UI/五后端PID及后端实际SHA与上一轮相同,Source d5且tracked clean。未构建release、未建tag、未重启生产或修改任务/账户/路由/通知,没有实际委托/撤单;源码同步不是运行版本发布。
|
||||
@@ -0,0 +1,43 @@
|
||||
# 显式开盘、跨日 ETF 与委托时钟修复
|
||||
|
||||
2026-09-14。候选本机及独立 Linux 已验证,尚未发布;完整股票池工作不以本阶段关闭。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
1. `market_open(0, 0)` 的既有语义为 09:31。旧引擎先执行该开盘回调,再执行 09:30 行情回调。09:31 的手工买入 100 股提前出现在 09:30 上下文,造成未来状态可见。
|
||||
2. 开盘回调顺序修正后,`MinuteLast` 撮合仍把配置的 09:30 窗口起点当作实际执行时刻,将 09:31 新订单记成 09:30 成交;新挂单也可能记成较早起点。
|
||||
3. 没有新因子/选股快照的下一交易日,原实现先执行 13:00 的普通待执行指令,再执行 ETF 的 09:30 开盘目标。负向测试中第一笔是股票 100 股、20 元、13:00,后面才出现较早 ETF 成交。此顺序会影响实际现金分配,不能只排序最终表格。
|
||||
4. 行情可用但剩余现金不足一手时,报价撮合丢失预算阻断原因,最终错误显示“intraday quote liquidity exhausted”。
|
||||
|
||||
## 修复合同
|
||||
|
||||
- 开盘调度、已订阅行情、ETF 开盘、实际手工观察、委托窗口与到期时刻进入同一时间序列。保留 09:31 的已配置含义,不改成 09:25 避开反例。开盘阶段不允许越过收盘阶段;其可用性不再依赖是否订阅分钟回调。
|
||||
- 新订阅从实际启用时刻开始接收后续行情,不重放较早缓存报价;仍订阅中的证券不丢失其较早合法行情。
|
||||
- 新委托和续撮使用当前执行时刻,原委托创建时刻在后续重试中保留。行情来源时刻与成交时刻分开,日线/分钟/ETF 既有定价合同、价格精度、费用和证券规则不改。
|
||||
- 无新信号日不重新生成策略目标;只执行已存在意图、ETF 目标及挂单。相同时间先处理到期 ETF,再执行普通批次,后续按真实报价和到期时刻推进。没有可用信号上下文时只发布原始事实,不伪造策略回调。
|
||||
- 手工观察遇到尚未结束的影子订单/ETF 目标仍明确拒绝。不能把 09:30 的成交提前应用以让 09:15 的手工冲突消失。
|
||||
- 零成交预算阻断保留资金不足/金额预算/非法价原因,不伪装成流动性不足。真实无行情或容量不足的规则保持。
|
||||
|
||||
## 本机验证
|
||||
|
||||
源基线 Engine `232e9ae1546842224d7a21aa07d3c0696ece4b11`;Service `49f280075e6b9dce2ef149fc190cfe663411b905`、Trading `ae83fd30f56a5420a235022b0a169aaf0bae55cf`。
|
||||
|
||||
- Core 885 项通过,9 项原 ignore 不计通过。
|
||||
- Trading 工作区 625 项通过,63 项私有依赖 ignore 不计通过;没有重复运行已有数据库夹具。
|
||||
- Runner 460、API 127 项通过,16 项 ignore 不计通过。Mac 原 `is_source_row_file` dead-code 警告仍存在。
|
||||
- 手工 09:31 买入:09:30 回调 0 股,09:31 开盘回调 100 股;后续报价只处理一次。
|
||||
- NextBarOpen/MinuteLast × 订阅/未订阅四种组合:09:31 的 100 股新单恰好成交一次,时间均为 09:31;后续重试不改原创建时刻。
|
||||
- ETF 和晚开盘:09:15 回调未持有;09:30 成交 3,700 股、4 元;09:31 开盘及 09:32 行情各看见该唯一成交。
|
||||
- 无新信号日:ETF 09:30 先成交 3,700 股。13:00 股票卖出 100 股随后成交;股票买入 100 股的对照因剩余现金不足而拒绝,不能抢先花费 ETF 应使用的现金。无普通待执行意图的 ETF 单独分支也通过。
|
||||
- 同一无信号日的手工卖出:09:15 与未结束 ETF 目标冲突时拒绝;09:31、ETF 完成后的真实手工卖出应用一次,原股票持仓从 1,500 到 1,400 股。
|
||||
- 定位过程的失败、类型修正和资金不足断言修正不计通过;没有改动原池或补造行情。
|
||||
|
||||
## 发布与剩余工作
|
||||
|
||||
功能提交 `13c89e8d59f21df6280d722f369d0ec8d9a6457e` 已推送并核对 main,177 引擎开发源码已快进到同一 SHA,tracked clean。保留 Service 并行开发提交 `5991e7733d9e2a770f740e971924b12cc5f7d29d`;联合测试使用已推送 Service49f2800/Tradingae83fd3,不覆盖该工作树。
|
||||
|
||||
新的只读 `opening-clock-20260914-UUx5ru` 快照 959 个文件,4CPU/12GiB独立 scope、boris 执行、1GiB磁盘线不变。Linux Core885、Trading625、Runner/API589(Linux额外2项平台测试)全部通过,源码前后不变。收据 [linux-tests.json](evidence/opening-clock-20260914/linux-tests.json),只读生产状态 [runtime.json](evidence/opening-clock-20260914/runtime.json)。这是独立程序测试,不是 Source 正式数据回放或生产交易验收。
|
||||
|
||||
本修复不在已构建的 `manual-stream-20260914-FewUWP` 二进制中。该目录及既有 Linux 收据继续保留,不能覆盖或改写成包含本修复。本轮没有新 release 构建/发布 tag;仍须真实 Source/Runner 联合验收及未准入 Arrow 性能门禁。
|
||||
|
||||
Source `d5` 版本冻结、研究/信号暂停、Live disabled 与旧任务/历史不变。Source 清单权威修复和新验证合同仍待明确解冻授权;本阶段没有发单、撤单或生产重启。后续继续公司行为、跨日保护/禁买及完整参数/适配器矩阵,不把以上确定性例子外推为全量生产完成。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 仓位事件执行合同
|
||||
|
||||
2026-09-13。`runtimeExpressions.risk.positionExposureEvents` 使用带eventId、严格唯一sequence、UTC有效时点的事件;必须明确指定set、scale或restore。缺失动作、重复身份、非法比例和无时区日期均拒绝。
|
||||
|
||||
- scale用于人工比例乘数:普通轮动仍先计算策略自身仓位,0%指数择时不会被人工100%覆盖。显式权益买入和目标类委托,以及SignalBook产生的意图,同样按比例处理;不修改原SignalBook。
|
||||
- 卖出/减仓增量、零目标清仓、取消、订阅、现金流和价格不被缩量。对已有买单增加数量只缩放增加部分;无法确定被改单身份时拒绝。期货等未定义类型不静默转换。
|
||||
- set用于股票池投入比例等明确绝对目标;restore恢复原策略/池规则,不转换成100%。旧日期级positionExposureSchedule保留原粒度,新的恢复事件不再回落到旧人工值。
|
||||
- 比例按实际执行时点读取;股票池不再用信号日读取覆盖值。原引擎首信号日现金基线和next-open调度合同不改变。
|
||||
- 不改变OHLCV、费用、价格精度、证券生命周期或成交量容量合同。
|
||||
|
||||
验证覆盖同日多次调整、未来事件隔离、0/30/50/100%、20%原策略恢复、显式委托与现金流、以及原始引擎跨周末的股票池回放:1月2日信号在1月5日执行,1月5日覆盖在该日生效,1月6日恢复20%而不是100%。测试行情明确是隔离夹具,不代表真实历史或券商成交验收。
|
||||
|
||||
交易侧用不可变操作审计提供事件,保留运行任务/账户绑定和原始请求。此模块不自己下单或创建新的回测,不读取用户资金账户。未完成的独立人工调仓命令与逐笔人工交易影子回放仍需另行验收,不能据时间线通过声明所有调仓路径完成。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 红利再投来源、入账时钟及流式完整性
|
||||
|
||||
2026-09-14。候选未发布;完整Goal继续。
|
||||
|
||||
## 先核对语义
|
||||
|
||||
读取AiQuant `alv/portfolio/accounts/position_model.py::_handle_dividend_payable`与FIDC原实现后确认:现有开关是历史兼容的账务再投模型,按调整后的参考价分配整手股数、零费用,不是交易所委托。不能把盘前分配直接解释成市场成交提前发生,也不能为了修时钟擅自改为开盘价、收费市价单或调用GT。
|
||||
|
||||
保留此价格/数量/费用合同,新增明确`FillOrigin::DividendReinvestment`及结算入账时刻。普通市场成交默认来源不序列化,旧普通字段和原历史不改写。账务来源不得携带委托编号、市场时钟或手续费;规则由Core验证。
|
||||
|
||||
## 实际错误与修复
|
||||
|
||||
- 红利再投缺来源/时间,被手工公司行为复算当作09:30市场成交,09:15的回报因此报“future financial fact”。现在正常与复算使用同一结算模型,账务分配按结算阶段处理,不伪造或重跑市场订单。
|
||||
- 已观察时点的原账务记录保留。迟到成交改变已知权益后,重新计算账务分配并记录校正;它不是不能改变的交易所成交。1000股额外买入的例子,最终2200股,与及时观察对照一致,权益现金调整155。
|
||||
- 每日计数/流式切片原来在盘前结算之后才开始,漏掉晨间再投和周末回报。现从会话处理前开始,下一代表交易日完整交付期间观察,原实际观察时间不改,不补造非交易日行情。
|
||||
- 活跃证券缺参考价/元信息明确失败,结算批次原子回滚;终止上市等已证明生命周期不允许分配时保留现金并记录原因,不重建旧证券。未改变ST/停牌等市场委托风控:账务模型不代表市场买入获准。
|
||||
|
||||
## 交付
|
||||
|
||||
Runner对明确账务来源生成独立记录,不要求或伪造委托。API分开市场成交与账务分配的件数/金额;合入手工成交时也不把账务金额加回市场成交额。兼容总事件数仍保留,新增市场件数和账务件数。账务时钟在传输时显式带上海偏移;不猜测旧普通记录的时区。
|
||||
|
||||
提交能力增加`accounting_allocations:v1`,旧消费者不能处理本轮完整手工合同。Core能力说明和FIDC策略Agent注册表同步澄清其历史账务性质,新策略仍不得自行编写公司行为处理。
|
||||
|
||||
## 验证
|
||||
|
||||
本机Core901、Trading625、Runner463/API129及三项私有PG通过;原ignore单列。前端2278测试、类型/定向lint/主题检查与独立构建通过。共享Rust投影的实际表格在1440/390明暗四组无文档溢出、错误或交易写入;窄屏表格保留横向滚动。初验发现无时区标记及独立挂载缺少工作台令牌导致浅色选中项不清晰,已修正生产投影和共享组件令牌回退,复验通过。
|
||||
|
||||
旧价8.95、100股、零费用仍相同;无委托来源及进度覆盖、周末观察、迟到权益再投、非法来源拒绝、缺参考原子失败和退市现金保留均有专项。机械字段迁移时重复插入和测试导入问题在编译阶段纠正,不计作业务通过。
|
||||
|
||||
本次只证明这些边界,不等于真实Source/GT权益到账或全参数完成。仍须做后继证券范围、目标状态、持仓清空后参考与跨模式ETF的完整核对;实际Source/Runner及Arrow候选准入仍受Source冻结约束。Linux和发布证据另列,不能复用旧ZAnvlO等收据。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
已推送:Engine984f9d3、Service234d85b、Tradingd7d4925、UId9f7907d(双远端)、Agentc739854。177引擎开发树已ff到984,保留Service并行5991e77。978文件只读`reinvestment-20260914-dBw0O5`快照与独立build根,4CPU/12GiB、boris执行、1GiB空间线保持。
|
||||
|
||||
Linux Core901、Trading625、Runner/API594全部通过,源码前后不变;原9/63/16项ignore不计。新收据[linux-tests.json](evidence/reinvestment-origin-20260914/linux-tests.json)及[runtime.json](evidence/reinvestment-origin-20260914/runtime.json)。本批测试已结束,不重复启动或覆盖历史收据;无新的release构建和发布tag。
|
||||
|
||||
生产UI及五后端PID/实际SHA保持前阶段值,Source d5/PID1700096、tracked clean。没有生产重启、配置/账户/通知写入或实际券商请求;完整Goal继续剩余矩阵与正式数据准入。
|
||||
@@ -0,0 +1,146 @@
|
||||
# Selection Risk Plan Performance
|
||||
|
||||
## Status
|
||||
|
||||
Published to Backtest in the combined 665653c/501f6d0 release described below.
|
||||
The change removes selection calls that have
|
||||
no possible effect under the current frozen policy. It does not disable any
|
||||
configured rule, execution-day check or strategy expression. Engine time falls
|
||||
slightly in the measured cases; this is not the solution to the main remaining
|
||||
data construction cost and is not a general whole-backtest speedup claim.
|
||||
|
||||
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
|
||||
was subsequently resolved by business-main work and published in the clock and
|
||||
81acc54 callback releases. That correction is not attributed to this candidate.
|
||||
Current published Backtest uses 501f6d0/665653c; the measurements below retain
|
||||
their earlier c98 baseline. Complete manual-replay integration remains open.
|
||||
Source d5b682c6 remains frozen;
|
||||
research and signal work stay paused. No trading operation was submitted.
|
||||
|
||||
## Evidence Leading to the Change
|
||||
|
||||
The official HTTP diagnostic replay btr_1789322878865_2871869_0 preserved the
|
||||
original canonical and result-store SHA. Ten bounded Boris-only GDB snapshots
|
||||
showed source inventory, PreparedDayBuilder, factor normalization and price
|
||||
series construction, followed by repeated selection risk calls. GDB pauses are
|
||||
not normal performance measurements and snapshot counts are not flamegraph
|
||||
percentages. Source/target PID, binary SHA and CPU/thread resources stayed fixed.
|
||||
|
||||
The diagnostic helper now shares the existing canonical executable policy with
|
||||
the saved-run profiler: it accepts both audited build roots and immutable API
|
||||
release directories, but not arbitrary paths. Seven related tests passed.
|
||||
|
||||
## Implementation and Correctness
|
||||
|
||||
StaticRiskRuleConfig reports whether selection has an enabled state rule or an
|
||||
enabled nonempty blacklist. The strategy computes this once before iterating
|
||||
candidate symbols. If no such rule exists, the old selection function would
|
||||
always return None, so that no-op call is omitted. Explicit universe conditions,
|
||||
market/factor checks and all buy/sell execution paths are unchanged.
|
||||
|
||||
The ten state flags are also shared with the existing missing-risk-state checks
|
||||
to avoid maintaining three separate flag lists. Blacklist presence is kept
|
||||
separate: a blacklist is not missing market-risk data. No cross-strategy cache,
|
||||
strategy identifier, fixed date, trading time or account state is introduced.
|
||||
|
||||
Tests enumerate all 4,096 combinations of ten selection flags and blacklist
|
||||
enabled/populated states. The flag list is checked against the serialized
|
||||
configuration, so adding a selection field requires updating the activation
|
||||
test. Further tests retain missing-state behavior and show that paused buys
|
||||
and sells remain rejected when selection checks are inactive.
|
||||
|
||||
On 177: 805 core unit/integration tests passed (9 ignored), 448 runner tests
|
||||
passed (9 ignored), 119 API tests passed (5 ignored), and 28 benchmark/profiler
|
||||
tests passed. These counts do not resolve the independently recorded clock
|
||||
failure, which is not part of this frozen committed test tree.
|
||||
|
||||
## Reproducible Shared-Input Method
|
||||
|
||||
Each of the twelve replays has a new process and a new private result root.
|
||||
The official runner benchmark gained --shared-runtime-cache. It resolves the
|
||||
explicit cache root from the declared Boris service, requires canonical private
|
||||
storage, hashes existing inputs before and after, and refuses any changed or
|
||||
removed original. This mode cannot invoke copied-input disposal.
|
||||
|
||||
All twelve runs used the same 9,257 files / 12,596,608,049 bytes. Their complete
|
||||
input inventories, file identities and byte SHA values are equal. No new Arrow
|
||||
or binary cache input appeared. No backtest result was reused. Hash preparation
|
||||
and verification are outside the measured runner interval; this is a shared
|
||||
warm-input test, not raw-disk cold IO. Unlike the earlier copied-cache method,
|
||||
it does not allocate another approximately 2 GB per replay on the nearly full
|
||||
SSD. Original inputs and every result remain intact.
|
||||
|
||||
The common execution interval is 2021-08-23 through 2025-11-17 with 10,000,000
|
||||
initial cash and each case's unchanged frozen strategy/bundle. This is not five
|
||||
complete execution years. CPU affinity and 8 Rayon / 16 Tokio threads match the
|
||||
declared reference service; no global resource limit was increased.
|
||||
|
||||
## Measurements
|
||||
|
||||
| Case | Wall seconds | Source validation | Data preparation | Engine |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Rotation control 1 | 30.986 | 12.244 | 8.445 | 8.790 |
|
||||
| Rotation candidate 1 | 12.977 | 0.004 | 5.132 | 6.682 |
|
||||
| Rotation control 2 | 13.275 | 0.209 | 5.190 | 6.739 |
|
||||
| Rotation candidate 2 | 12.927 | 0.005 | 5.128 | 6.640 |
|
||||
| Rotation candidate 3 | 12.926 | 0.006 | 5.113 | 6.664 |
|
||||
| Rotation control 3 | 13.044 | 0.005 | 5.159 | 6.732 |
|
||||
| Trend 40 control | 15.628 | 0.694 | 5.234 | 8.199 |
|
||||
| Trend 40 candidate | 14.826 | 0.004 | 5.276 | 8.087 |
|
||||
| Pullback 40 control | 13.776 | 0.005 | 4.893 | 7.374 |
|
||||
| Pullback 40 candidate | 13.928 | 0.003 | 5.137 | 7.239 |
|
||||
| Volume momentum 80 control | 18.176 | 0.004 | 4.585 | 11.154 |
|
||||
| Volume momentum 80 candidate | 18.627 | 0.005 | 5.200 | 11.013 |
|
||||
|
||||
Rotation engine medians are 6.739 versus 6.664 seconds, approximately 1.1%.
|
||||
The other paired engine reductions are approximately 1.4%, 1.8% and 1.3%.
|
||||
These are small CPU-path improvements. Pullback and volume total latency did
|
||||
not improve because their preparation times were higher. The first control's
|
||||
Source wait and unexplained slower construction are recorded, not attributed
|
||||
to this code or discarded to manufacture a large speedup. Peak RSS stays about
|
||||
6.76-6.83 GiB; there is no significant memory reduction claim.
|
||||
|
||||
Each case matches its independent prior baseline for all six canonical
|
||||
sections and store bytes: 21,393 / 29,776 / 31,862 / 51,300 fills. Result receipts,
|
||||
runtime/strategy identities, physical manifests and 3,506 fact blocks were
|
||||
verified. The shared input inventory SHA is in the acceptance record. Full
|
||||
unaltered receipts remain on 177; the repository stores the compact verified
|
||||
summary rather than repeating the 9,257-file inventory in every document.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Prioritize direct typed-column reuse during daily snapshot and DataSet
|
||||
construction; approximately five seconds of preparation remain in these warm
|
||||
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
|
||||
Source cold-query and contract-validation latency remain separate tasks under
|
||||
the Source freeze. The cache-boundary candidate later passed its conditional
|
||||
cross-window/full-input gate and the combination passed daily HTTP publication;
|
||||
neither establishes cold or universal performance. Financial PIT, broader minute
|
||||
behavior, signal lifecycle and UI factor
|
||||
condition acceptance are not claimed complete.
|
||||
|
||||
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
|
||||
- Candidate service source: 4e23c7558d8301ba697543c39d5604289bb82c53.
|
||||
- Control runner SHA: b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d.
|
||||
- Candidate runner SHA: 1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb.
|
||||
- Candidate API SHA: 30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501.
|
||||
- Evidence root: /srv/fidc/canonical/run/research/selection-risk-plan-20260914.
|
||||
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
|
||||
|
||||
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
|
||||
|
||||
## Combined Release
|
||||
|
||||
After merging engine 665653c, 860 core / 448 runner / 119 API tests and six
|
||||
additional new-process replays passed. The guarded official workflow deployed
|
||||
Backtest only, then nine HTTP runs matched their respective canonical/store
|
||||
baselines. A multi-strategy sequence proved actual immutable DataSet hit counts
|
||||
0/1/2/3/4 with distinct strategy results and repeatable trend results. Default
|
||||
90-day cleared-DataSet HTTP mean 13.646 before versus 13.740 seconds after does
|
||||
not demonstrate a general latency gain.
|
||||
|
||||
The active root /srv/fidc/canonical/build/factor-reserve-20260913 is protected
|
||||
from reuse/cleanup. Source, paused research, trading services and all execution
|
||||
permissions remain unchanged. This does not activate the manual-replay module.
|
||||
Actual identities, timings and evidence are maintained in
|
||||
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Exact Series Column Storage
|
||||
|
||||
## Status
|
||||
|
||||
The subsequent business-main merge includes the separately published 81acc54
|
||||
clock/callback fixes and 5e11f3d manual-replay foundation. The combined version
|
||||
passed 857 core, 448 runner and 119 API tests, six long reference replays and
|
||||
three additional strategy replays;
|
||||
see `/Users/boris/WorkSpace/fidc-backtest-service/docs/arrow-factor-scratch-rejection-20260914.md`.
|
||||
The scratch candidate from that experiment was removed. Series storage is
|
||||
now published to Backtest only in the combined 665653c/501f6d0 release below;
|
||||
existing measurements retain their original versions.
|
||||
|
||||
The original twelve real long replays preserve their independent
|
||||
business baselines and reduce peak RSS by about 9.5%. Construction latency is
|
||||
mixed, including a reversed pair where the control is faster. This is accepted
|
||||
as evidence of a smaller working set, not as a proved general speedup or closure
|
||||
of the main performance objective. Original results are retained unchanged.
|
||||
|
||||
The Source implementation remains d5b682c6d09704ff23d725a8dd8b155db3eb6967.
|
||||
Research/signal work remains paused. The initial experiments ran while e81bf47/c98
|
||||
was published; later business work published e81bf47/81acc54. This performance
|
||||
task did not restart Source, trading or another user's process. The original
|
||||
clock counterexample was resolved by that business work; complete manual-replay
|
||||
integration remains open and is not proved by these performance tests.
|
||||
|
||||
## Separate DayOpen Correction
|
||||
|
||||
Code inspection found that PriceField::DayOpen selected the Open prefix sums,
|
||||
although direct history access returned day_open. For day_open values 10/12
|
||||
and open values 20/24, that path computes 22 instead of the expected 11.
|
||||
The correction adds its own day-open prefix and a regression checking both
|
||||
fields plus empty/insufficient windows. No price field is substituted.
|
||||
|
||||
This correction was built and tested independently before the storage change:
|
||||
806 core unit/integration tests, 448 runner tests and 119 API tests passed.
|
||||
The resulting control runner is
|
||||
8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d.
|
||||
It also preserves the real rotation baseline. Both subsequent A/B variants
|
||||
include the fix, so corrected calculation semantics are not counted as speedup.
|
||||
|
||||
## Storage Design
|
||||
|
||||
SymbolPriceSeries previously allocated separate vectors for last/bid/ask,
|
||||
their prefix, timestamps, trading phases and three quote-volume fields, even
|
||||
when actual data repeated or exactly matched the existing close series.
|
||||
|
||||
- ReferenceMatchedValues aliases the existing column only after every consumed
|
||||
value matches by f64::to_bits. A mismatch materializes the exact preceding
|
||||
values and continues as an owned vector. No missing/invalid price is replaced
|
||||
by close; signed zero and NaN payload differences prevent sharing.
|
||||
- RepeatedValues retains the actual first value and logical length. It avoids
|
||||
expanding equal values, including nonzero volumes and Some strings. The
|
||||
first difference materializes the exact prior values. None is distinct from
|
||||
an empty string; no value is inferred from the backtest frequency.
|
||||
- Intraday updates materialize only changed columns. Cloned views retain their
|
||||
original values and immutable daily base. Last-price prefix sums use the same
|
||||
accumulation order and actual values as before. History cutoffs are unchanged.
|
||||
|
||||
There is no new dependency, unsafe code, strategy-specific branch, disk schema,
|
||||
source-data rewrite or account/result sharing. Construction and data validation
|
||||
remain in the existing paths. The overlay comment now accurately states that
|
||||
quote fields affect Last history while daily OHLC remains unchanged.
|
||||
|
||||
The full candidate passes 813 core unit/integration tests (9 ignored), 448 runner
|
||||
tests (9 ignored) and 119 API tests (5 ignored). New tests cover exact bit
|
||||
identity, distinct zero/NaN values, repeated nonzero/string values, mutation
|
||||
isolation, unknown dates, full snapshot equality and history-date cutoffs.
|
||||
These tests do not prove the separately known same-day execution-clock issue.
|
||||
|
||||
## Real A/B
|
||||
|
||||
All cases execute 2021-08-23 through 2025-11-17 with their unchanged frozen
|
||||
strategy/runtime/bundle and 10,000,000 initial cash. This is not five complete
|
||||
execution years. Every run is a new process with private result artifacts and
|
||||
the same verified shared inputs: 9,257 files / 12,596,608,049 bytes. No original
|
||||
input changed and no Arrow/bin input was newly created. Hashing is outside the
|
||||
runner timer; no result is reused. Source/OS caches are not cold.
|
||||
|
||||
| Case | Wall s | Data s | DataSet construction s | Engine s | RSS KiB |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| Rotation control 1 | 23.879 | 5.213 | 1.901 | 6.612 | 7,137,676 |
|
||||
| Rotation candidate 1 | 24.126 | 4.818 | 1.589 | 6.674 | 6,463,660 |
|
||||
| Rotation control 2 | 18.180 | 7.272 | 2.824 | 9.509 | 7,138,628 |
|
||||
| Rotation candidate 2 | 15.078 | 5.543 | 2.094 | 8.171 | 6,454,624 |
|
||||
| Rotation candidate 3 | 13.125 | 5.103 | 1.853 | 6.649 | 6,457,728 |
|
||||
| Rotation control 3 | 12.725 | 4.955 | 1.713 | 6.583 | 7,140,772 |
|
||||
| Trend 40 control | 14.779 | 5.418 | 1.921 | 7.867 | 7,157,844 |
|
||||
| Trend 40 candidate | 15.012 | 5.168 | 1.851 | 8.011 | 6,470,768 |
|
||||
| Pullback 40 control | 14.011 | 5.172 | 1.893 | 7.298 | 7,167,408 |
|
||||
| Pullback 40 candidate | 13.877 | 5.117 | 1.837 | 7.219 | 6,478,492 |
|
||||
| Volume 80 control | 18.577 | 5.163 | 1.891 | 10.960 | 7,210,220 |
|
||||
| Volume 80 candidate | 18.476 | 5.051 | 1.844 | 10.989 | 6,527,236 |
|
||||
|
||||
The final rotation pair deliberately ran candidate before control. Rotation
|
||||
RSS medians are 7,138,628 versus 6,457,728 KiB, about 665 MiB / 9.5% lower.
|
||||
Other strategy pairs save about 670-673 MiB. These are measured process peaks,
|
||||
not estimates obtained by adding cgroup limits or counting mmap as private RAM.
|
||||
|
||||
Construction medians are 1.901 versus 1.853 seconds for rotation. The first pair
|
||||
has a larger reduction, but other samples and the reversed pair do not support
|
||||
a universal 16% construction or total-latency claim. Source validation waits and
|
||||
independent phase variation remain in the full evidence. A read-only host sample
|
||||
showed load near 49 and thermal readings 53/58/69 C; it does not prove the cause
|
||||
of timing variation. No host policy or another user's workload was changed.
|
||||
|
||||
All six canonical sections and result-store SHA match the appropriate existing
|
||||
baselines: 21,393 / 29,776 / 31,862 / 51,300 fills. Result and request evidence,
|
||||
physical manifests and all 3,506 fact blocks were verified. No earlier failed
|
||||
or successful receipt was rewritten. Complete receipts remain on 177; only the
|
||||
compact verified summary is stored here to avoid duplicating input inventories.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Do not publish this as the main performance fix. Next, target the remaining
|
||||
daily snapshot/factor construction and direct typed-column reuse, avoiding
|
||||
new per-access branches or post-hoc compression passes. Cold-query acceptance,
|
||||
real minute-mode acceptance remain outstanding for this storage change. The
|
||||
combined version subsequently passed daily HTTP publication below. The original
|
||||
clock issue was fixed by subsequent business
|
||||
work, not this experiment. Signal lifecycle, financial PIT and UI factor conditions
|
||||
remain outside this completed storage experiment.
|
||||
|
||||
- Engine candidate: 996b909608589fb1987f33c0cfb4c62099f69617.
|
||||
- Service source: 443ed421c2c9c854a01fab69ce58957690504570.
|
||||
- Candidate runner: 40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43.
|
||||
- Candidate API: c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e.
|
||||
- Evidence: /srv/fidc/canonical/run/research/series-column-storage-20260914.
|
||||
|
||||
[Verified summary](evidence/series-column-storage-20260914/acceptance.json).
|
||||
|
||||
## Combined Release
|
||||
|
||||
Engine 665653c / service 501f6d0 passed 860 core, 448 runner and 119 API tests,
|
||||
six additional independent-process replays and nine post-publication HTTP runs.
|
||||
Complete canonical/store results remain equal to each strategy's own baseline.
|
||||
An adjacent original/new rotation pair measures 15.279/14.579 seconds and
|
||||
6,923,640/6,369,900 KiB peak RSS, but the default-window HTTP means are essentially
|
||||
unchanged (13.646/13.740 seconds). Reduced memory and conditional cross-window
|
||||
reuse are not promoted to a universal latency improvement.
|
||||
|
||||
The official Backtest-only publication preserves Source d5, paused research,
|
||||
trading services and execution permissions. Its active build root
|
||||
/srv/fidc/canonical/build/factor-reserve-20260913 must not be overwritten or
|
||||
reused. Shared DataSet acceptance proves input reuse while distinct strategies
|
||||
execute independently; no results are cached. Identities and original receipts:
|
||||
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# 模拟器异常恢复与活动委托保留
|
||||
|
||||
2026-09-15。开发候选,未发布生产;不是实际GT/QMT撤改单功能。
|
||||
|
||||
## 已复现的问题
|
||||
|
||||
活动委托恢复先用 `mem::take` 取出整批订单。某只证券历史滑点证据缺失而返回错误后,当前订单及后续未处理订单被一起丢掉。原有两笔GTC模拟委托,一笔已累计成交100股,错误后队列直接为空。若前一笔已经在本次调用内部撮合,资金/持仓/手续费和行情消费也可能改变,但整个调用没有返回成功报告,重试将不一致。
|
||||
|
||||
## 事务边界
|
||||
|
||||
- 只保护一次模拟器调用中尚未成功返回的内部结果:资金、实际批次持仓、订单及累计成交、股票池/ETF顺延目标、执行游标/成交量消费、手续费状态和内部编号,以及临时运行上下文。
|
||||
- 本次调用明确失败或回调解栈时恢复检查点,错误继续向调用方返回;不吞错、不自动重跑。调用方仍应修复输入后重试原请求或发出明确新请求,不能把失败请求当成已接受的新目标。
|
||||
- 已成功返回的旧成交和报告、进入本次调用前已确认的手工事实不回滚。真实券商订单和回报不在该内存事务内,不能撤销或伪造实际GT/QMT结果。
|
||||
- 正常业务拒绝仍是有效结果:报告成功返回时,其他成功成交与拒绝记录一同保留,不因为有Rejected状态就整批回退。
|
||||
- 普通执行及ETF顺延消费共用边界,嵌套调用只保留一次检查点;临时日期、委托有效期、风险限制等在回调异常后不会泄漏到下一次调用。硬件断电、OOM以及用户自定义钩子的外部副作用不属于此证明。
|
||||
|
||||
## 开销控制
|
||||
|
||||
初版完整复制全部持仓。现普通单证券指令只保存可能变动的持仓与可被清理的零股记录,保留原位置顺序;完整组合、新类型指令和股票池阶段使用保守的完整检查点。未触及的持仓批次不复制,失败时才重新组装;无工作、无交易的调用不建检查点。
|
||||
|
||||
隔离未优化编译配置下,30只证券、每只20个初始批次、500次调用:初版保护样例约35.7—40.2ms,缩小持仓范围后约24.8—26.8ms;无保护对照11.5—22.1ms,首轮/并发噪声存在。保护有成本,不能称无性能回退,更不能据此宣称生产整段回测提速。四次对照的委托、成交和经济账本完全相同,正式Source/Runner性能准入仍保留。
|
||||
|
||||
## 验证
|
||||
|
||||
七项新增专项覆盖:日线/分钟、买/卖、第一或第二笔失败,已有部分成交;补齐数据后的原编号恢复与正常一次执行逐字段一致;此前成功调用不受后续失败影响;ETF两个顺延目标和进度完整保留;异常解栈;公开回调的临时上下文;局部检查点的位置顺序、零股和未复制批次;成功路径微基准的结果等价。
|
||||
|
||||
本机Core926、Trading625、Runner463/API129全量通过,原9/63/16项ignore不计。测试初次String/CompactString赋值错误及筛选名匹配0项已纠正,0项不当作通过;新增批次对照也改用实际更晚的执行时钟,不把未来报价当可立即成交。
|
||||
|
||||
未修改UI、在线账户、交易路由或任务;未做新的私有PG或真实券商验收。Source d5冻结保持,跨公司行为的实际券商委托调整、正式换股数据/范围、在线事实重建与真正Source/Runner联合验收仍未完成。Linux使用本轮新只读快照,不复用旧收据;没有release/tag或生产重启。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
功能提交 `695fdee4b8ba4b456313f06415c128a828b43f56` 已推送并核对;177引擎开发树已快进到此提交,Service并行5991e77保留。只读快照 `/srv/fidc/programs/runtime-snapshots/order-recovery-20260915-VjcwWa` 含994文件,独立同名build;4CPU/12GiB、boris执行、1GiB线不改,manifest SHA `bcefde438955ff4eda51494e96913a23d5e628375d45f8f09a498020224a7bb6`。
|
||||
|
||||
Linux Core926、Trading625、Runner/API594共2145项全过,原9/63/16项ignore单列;源码前后不变,结束时可用73738203136字节。测试已结束、scope已inactive,不重复本批或覆盖旧收据。新证据为 `docs/evidence/simulator-order-recovery-20260915/{local-tests,linux-tests,runtime}.json`。
|
||||
|
||||
2026-09-15 01:38 CST逐服务比较,现有UI及五后端PID和实际二进制SHA与上一轮相同,Source d5且tracked clean。没有新release构建、tag、生产重启、配置写入或实际委托。独立Linux测试不替代正式Source/Runner、完整吞吐量或真实券商验收。
|
||||
@@ -0,0 +1,28 @@
|
||||
# 股票池卖出批次与买入续执行
|
||||
|
||||
2026-09-13开发,2026-09-14 00:00至00:06 CST完成177配套发布,annotated tag v2026.9.13.16。Engine c98bcc3、Service aa3fe40、Trading b1d402e;不是完整股票池验收结论。
|
||||
|
||||
## 原问题
|
||||
|
||||
真实混合四证券的手选优先/自动优先回测在09-11出现600276.SH与300811.SZ买量差异。冻结信号权益均9,733,801.863803、90%预算8,760,421.67742270,前一日持仓/现金也相同。原进程日志证明卖出000333.SZ 500股仍为Pending时,买单已经根据未释放的总仓位预算被创建或取消;其后卖单实际成交,执行器不再继续尚未提交的买入阶段。不能仅因为账户还有现金就忽略仓位预算,也不能通过重新跑策略/重复补单掩盖。
|
||||
|
||||
确定性回归在旧实现中稳定复现:200股卖出限价未成交,实际成交回报处理后新标的仍没有持仓;无需网络或外部数据。现增加每池单一未提交执行阶段,sell_then_buy在卖单活动期间不创建买单,报告终结后沿同一冻结信号/权益/配置,根据当时真实现金、持仓和报价只执行买入腿。策略不再次调用,已经提交的委托不替换、不去重补救。
|
||||
|
||||
## 边界
|
||||
|
||||
- 分批成交等待整批活动委托终结;余量保持原order_id。买入以真实成交后资金与仓位预算重新定量,不借预计卖出款。
|
||||
- 每池新意图先替换尚未提交阶段,已提交订单仍保留;同一次止盈/止损清仓的证券保留禁买事实,不能在等待后重新当作未建仓候选买回。
|
||||
- 买单真实提交日/时刻与原信号日分开。next-open卖单延迟后,新买单使用执行时点真实分钟报价,不回到09:30或用日线开盘价代替缺失报价。原始挂单起点不变。
|
||||
- 原窗口结束为排他边界,休市不创建买单;过期只终止未提交阶段,原券商模拟订单按原DAY/GTC时钟自然处理。交易日结束清除未提交阶段并记录原因,不跨日重用。
|
||||
- 引擎即使没有策略分钟订阅,也为活动批次维护真实报价时钟,并加载待买标的;不新增策略回调。
|
||||
- 未修改Source、行情/生命周期门禁、风控、原用户配置或历史结果。PreOpenCash/SamePointNet不因本补丁被强改成SellThenBuy。
|
||||
|
||||
## 当前测试
|
||||
|
||||
9项新增专项覆盖未成交卖出续买、部分成交/买单ID、窗口结束、新信号覆盖、发送前新价/日期、缺价拒绝、止盈清仓禁回买、跨日清理和不订阅分钟的完整引擎执行。全工作区803项通过、9项外部/专项忽略单列;配套Trading613通过,Runner本机432通过、9项忽略。完整引擎测试夹具需显式提供每日因子与候选,缺少两者会得到无执行日期,不能据空运行当作成功。
|
||||
|
||||
177独立进程对三个原请求分别执行原版和修复版,共六次原生回放;原版各自与原历史Canonical相等,原请求及数据包不变。修复后两种优先级均10成交/4持仓/权益9,706,248.648662,逐股数量、费用、时钟、逐日权益和持仓完全一致(订单ID仍按各自原顺序生成,不伪装为同一Canonical)。原24只回放51成交/21持仓/权益9,685,563.876924999,不强求保留旧54笔:09-08和09-10卖出晚于窗口,未提交买入阶段到期;09-11卖出09:31完成后继续买入。混合样本09-09与09-10同样在窗外不新建买单,09-11在09:34完成卖出后续买,已提交DAY单可在窗口后继续成交。
|
||||
|
||||
生产API三次验收分别为btr_req_6854471517438a896378785b96a81e4ab41f0d77f898bf37、btr_req_0d32c6e07598c16728992374f1800804ad2cd06d85f18d15、btr_req_4ae4ee17bf90bbba5ca579a79c7d4e1c410fc2d4506e5800,均与对应原生候选Canonical相同;旧结果/配置回读保持。未提交券商委托、创建交易任务或改写配置,Source冻结及研究/信号暂停保持。完整逐笔回执在177 /srv/fidc/canonical/run/research/stock-pool-sell-buy-20260913,部署回执/tmp/fidc-sell-buy-api-release-20260913.json与/tmp/fidc-sell-buy-trading-release-20260913.json。
|
||||
|
||||
优先级在真实资金或仓位约束不足时仍可影响分配,不能将本例结论外推所有排序。完整Goal下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。
|
||||
@@ -0,0 +1,35 @@
|
||||
# 换股证据与公司行为批次原子性
|
||||
|
||||
2026-09-14。开发候选,未发布生产;完整目标保持未完成。
|
||||
|
||||
## 已复现的错误
|
||||
|
||||
1. 正常公司行为入口未核对后继证券是否存在于冻结数据:源持仓100股即使缺后继资料,仍可先派息、送转并生成300股后继持仓。手工校正入口此前虽有单独检查,但正常入口没有,两个路径口径不同。
|
||||
2. `successor_ratio_value` 将缺失、零、负数和非有限比例静默替换为1;非法现金字段也可能替换为0,错误输入因此成为看似成功的换股。
|
||||
3. 批次先更新派息/送转、后处理换股现金。当后续现金超出金额合同而失败时,旧证券已经消失,新证券和应收款仍留在账本;目标股数调整及日志也可能部分提交。
|
||||
|
||||
负向测试在修复前实际失败。测试代码初次访问私有日期字段的编译错误先改为正式访问方法;这不是业务反例。用于金额超界的首个有限大数仍在金额合同范围内,已改用确实超界的有限值后复现部分更新,不将未超界样例称为错误。
|
||||
|
||||
## 处理合同
|
||||
|
||||
- 正常和手工权益校正使用同一换股条款/冻结证券资料校验。必须提供明确、有限、正的换股比例;代码不能缺失、含首尾空格或指向自身。孤立的比例/现金字段也拒绝。没有声明现金组成时仍代表没有现金腿,但明确提供的非法数字不得补0。
|
||||
- 缺少源证券或后继证券资料时明确报告代码及公司行为日期,不生成隐式证券,不把价格行当成证券资料,更不把后继代码加入策略候选。
|
||||
- 有经济影响的公司行为在独立账本副本中完成整个批次,成功后一次更新资金、持仓、应收、任务目标单位及说明。失败原状态不变;没有涉及实际持仓的正常日不克隆整个账本。
|
||||
- 送转/换股股数超过事件和持仓的表示范围时明确失败,不能靠浮点转整数的饱和转换或负数delta继续运行。
|
||||
- 保留已声明条款、原取得日期及最近买入日期、成本和原策略配置;不伪造委托/市场成交,不把换股当作重新选股。没有重写已提交订单或猜测新目标权重。
|
||||
|
||||
## 验证及剩余工作
|
||||
|
||||
本机Core907、Trading625、Runner463/API129通过;原9/63/16项ignore分别保留,不计通过。本次未改UI,也没有重复上一轮UI测试。
|
||||
|
||||
新增缺后继资料、13类坏条款、金额失败全批回滚(含原目标单位/权重)、送转/换股数量溢出、有效换股保留两类取得日期等专项。整段引擎覆盖Paper/GT/QMT三种已审计来源、及时/迟到及买/卖12组合:完整资料均保留200股后继持仓且没有新增市场订单/成交;缺资料均明确失败。这是隔离回放,不是券商连接/真实委托验收。
|
||||
|
||||
Source当前公司行为Arrow字段及Runner `CorporateActionRowRecord`仍未提供换股条款,日快照仍显式为无换股字段。这是独立的正式数据能力缺口,不能以本轮核心校验冒充已完成后继证券自动取数。完整范围闭包、目标状态跨公司行为语义、清空后参考、ETF跨模式及实际Source/Runner仍需继续验证。Source运行版本冻结保持,不改旧合同SHA、不注册替代合同或启用交易;后端发布还未准入。
|
||||
|
||||
## Linux配套验收及运行边界
|
||||
|
||||
功能提交 `59a0c95aaec9c14ddd7384bb451d6d90eb3abec7` 已推送并核对远端;177引擎开发树已快进到此提交,Service并行开发树5991e77保持。新只读快照 `/srv/fidc/programs/runtime-snapshots/successor-20260914-Sx15GO` 含981个文件,独立build根同名;4CPU/12GiB、boris执行,1GiB磁盘线保持。源码manifest SHA `a8da0ee559821758d98eb7e4ae27beaa683ce32b708dc29f90bc751808382ff3`。
|
||||
|
||||
Linux Core907、Trading625、Runner/API594共2126项全部通过,原9/63/16项ignore不计;源码前后不变。结束时可用83883585536字节,scope已inactive,不重复启动同批。收据见 `docs/evidence/successor-contract-20260914/{local-tests,linux-tests,runtime}.json`;没有新的release构建或tag,不复用旧reinvestment/AEGDYL/UUx5ru/FewUWP结果。
|
||||
|
||||
23:05 CST逐服务比较,现有UI与五后端PID、后端实际二进制SHA均与上一轮相同,Source d5且tracked clean。没有生产服务重启、任务/账户/通知写入或实际委托/撤单。只同步开发源码不是发布验收;未对当前生产进行新UI或GT测试。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 换股后的策略保护继承
|
||||
|
||||
2026-09-14。开发候选;生产未发布,完整目标仍未完成。
|
||||
|
||||
## 反例与根因
|
||||
|
||||
原代码只给跨公司行为的迟到成交附带后继代码保护;及时回报之后正常换股则没有相同处理。日期锁也只比较当前代码,原持仓换股后会失去锁定。完整引擎反例已复现:及时卖出旧证券后的禁买期内,新证券又买入100股;旧证券仍处日期锁定内,新证券却卖出100股。
|
||||
|
||||
初始15:00样例虽产生了不该生成的委托,但因没有之后的报价而到期,不能把无成交视为保护通过。改用14:30调度和14:30/14:31报价后,实际回放成交证明了上述错误。ETF正向对照最初缺候选资格行,补齐隔离输入后正常成交,未放宽生产数据或风控校验。
|
||||
|
||||
## 统一语义
|
||||
|
||||
- 仅记录已真实影响持仓的、条款与证券身份已经校验的转换关系。目录别名、请求中的代码或尚未发生的公司行为不能使另一证券受锁定;不向候选池或策略目标添加证券。
|
||||
- 连续转换保留已证明的前身关系,保护读取原实际买卖日期;日期锁沿关系生效,但到期日仍为原配置,不从换股日重新计时。买后保护和最长持有继续使用原取得/买入日期。
|
||||
- 换股后手工清仓不删除仍有效的日期锁关系,防止自动重新买回绕过锁定。若迟到回报证明在换股前已经全部卖出,则原本推定的持仓转换关系应被校正掉,不将旧锁误加给新证券。
|
||||
- 关系进入经济账本的重放校验;无换股时原账本摘要形状不变。权益校正整体替换已验证关系,不并入已被新事实推翻的旧关系;原参数、目标权重、未提交目标及已发订单不擅自换成新代码。
|
||||
- 普通表达式策略、股票池普通调仓、ETF顺延开盘消费三个入口均使用同一保护内核。股票池与ETF共用经实际持仓关系解析的保护证据,不只修表面策略层。
|
||||
- 移除按公司行为引用列表直接扩展卖出代码的两个旁路,避免无实际持仓转换也被误认为曾卖出新证券。原股票的真实卖出记录和已发订单仍保留。
|
||||
|
||||
## 验证
|
||||
|
||||
6项新增专项覆盖23个隔离配置场景:及时/迟到回报、买卖两方向、禁买期、日期锁、原日期到期、转换后手工清仓、转换前已清仓,股票池0%/100%目标以及ETF顺延开盘。已知保护有效时验证零委托/零成交及200股原持仓;到期对照必须能够真实回放成交,不能靠缺报价或被其他风险拒绝冒充保护正确。
|
||||
|
||||
本机Core913、Trading625、Runner463/API129全量通过;原9/63/16项ignore不计。没有新增私有数据库、生产页面或券商实测;UI和其他业务仓代码未改。此次修复不改变配置为0/null/空的保护规则,也不是全局共享风控配置。
|
||||
|
||||
## 发布边界与后续
|
||||
|
||||
Source公司行为接口仍缺正式换股条款及所需数据范围闭包,在线重建也仍需要权威转换持仓事实,不能仅凭最终股数或证券名称猜关系。本轮原生回放与Linux测试不是GT/QMT公司行为实盘验收。Source d5冻结保持,不改旧合同、不注册替代合同或恢复交易。
|
||||
|
||||
继续正式Source/Runner联合验收、在线转换事实持久化/重建、旧目标及活动单边界、清空/0%后再投参考及其余ETF跨模式矩阵。需先核对原账务模型与仓位参数的关系,不能为了通过保护样例擅自改变再投定价、费用或策略意图。
|
||||
|
||||
## Linux配套验收
|
||||
|
||||
功能提交 `ba4b77fd746963687b8029b47806f943ed6ce03f` 已推送并核对,177引擎开发树已快进至此,Service并行5991e77保留。新只读快照 `/srv/fidc/programs/runtime-snapshots/conversion-protection-20260914-51wBBB` 含985文件,独立同名build根;4CPU/12GiB、boris运行、1GiB保护线不改。源码manifest SHA `6ae0cb8f0e571b4848e2cba6b39368a19434cc43729cfe07aad6fdce531d2814`。
|
||||
|
||||
Linux Core913、Trading625、Runner/API594共2132项全过,原9/63/16项ignore单列,源码前后不变;结束时可用80519344128字节。scope已inactive,不再重复本批测试或覆盖前阶段收据。证据为 `docs/evidence/successor-protection-20260914/{local-tests,linux-tests,runtime}.json`。
|
||||
|
||||
23:47 CST逐服务比对,现有UI/五后端PID及后端实际SHA与上一轮完全相同,Source d5且tracked clean。没有新release构建、发布tag、生产重启、账户/任务/路由/通知写入或券商委托;开发树同步不等于运行版本发布,完整目标仍需上述联合验收。
|
||||
Reference in New Issue
Block a user