Compare commits

...

40 Commits

Author SHA1 Message Date
boris 81acc54228 修复回报上下文与盘前意图并在提交前采用最新完整目标 2026-09-14 06:06:57 +08:00
boris 600808b171 归档日内时钟配套发布与九次历史执行验收 2026-09-14 04:26:14 +08:00
boris d2f1b64af1 记录时钟候选验证与磁盘保护后的正式缓存清理 2026-09-14 04:06:18 +08:00
boris 237ee15a51 修复日内时钟提前记账并按原订单续执行算法单 2026-09-14 03:52:52 +08:00
boris 3a3091a2cf docs(perf): record small selection CPU savings and full shared-input parity 2026-09-14 03:05:55 +08:00
boris d2aa16a2f0 perf(risk): avoid per-symbol selection checks when the frozen policy has none 2026-09-14 02:18:24 +08:00
boris 0576cf9b6d 记录日内晚窗口提前影响早间持仓的隔离反例 2026-09-14 02:08:20 +08:00
boris 636e0dfd05 记录卖出回报续买修复的真实回放与配套发布 2026-09-14 00:10:58 +08:00
boris c98bcc3eb2 修复股票池卖单回报后未继续执行买入阶段 2026-09-13 23:43:26 +08:00
boris 53af3a6a85 perf(data): support exact reservation for known numeric field additions 2026-09-13 22:12:40 +08:00
boris 70c6f7e90b fix(data): expose actual snapshot row counts without cloning data 2026-09-13 17:11:06 +08:00
boris 0ed6752a73 perf(engine): deduplicate daily factor names before allocating sorted output 2026-09-13 16:47:16 +08:00
boris 3e8cc63b9a Revert "perf(engine): share immutable daily factor schemas and numeric buffers"
This reverts commit 5d0823c060bfd2a42a3f86a381e874004ab7f6af.
2026-09-13 14:16:07 +08:00
boris be171683c9 Revert "test(engine): retain static schema names across shared factor rows"
This reverts commit ce0dc0a106f0a98230bb9c428537ec086b968273.
2026-09-13 14:16:07 +08:00
boris 0a6fab9038 Revert "perf(engine): keep empty numeric maps on a direct lookup path"
This reverts commit a63dd94045f3a4b95dbfc917d5d8afa5c22f1897.
2026-09-13 14:16:07 +08:00
boris e8abf43cd4 perf(engine): keep empty numeric maps on a direct lookup path 2026-09-13 13:49:20 +08:00
boris 2286bfa757 test(engine): retain static schema names across shared factor rows 2026-09-13 13:07:52 +08:00
boris 93809eea1b perf(engine): share immutable daily factor schemas and numeric buffers 2026-09-13 13:02:17 +08:00
boris f7f0ff2951 Merge remote-tracking branch 'origin/main' 2026-09-13 11:37:38 +08:00
boris effa0c6456 test(engine): validate quote demand across different account capital 2026-09-13 11:21:09 +08:00
boris b1ca2dfada fix(engine): resolve decision quote scope from the actual run context 2026-09-13 11:18:39 +08:00
boris d15abc18ae test(engine): reproduce account-sensitive quote scope bypass 2026-09-13 11:07:57 +08:00
boris 9370dfe6e9 Merge remote-tracking branch 'origin/main' 2026-09-13 11:06:22 +08:00
boris b19108558f 统一仓位调整时点并保留策略择时与退出意图 2026-09-13 11:05:12 +08:00
boris fe05384f80 test(data): compare compact names by value and allocation class 2026-09-13 09:57:47 +08:00
boris 0ff90c4329 refactor(data): use explicit string views for compact numeric names 2026-09-13 09:55:16 +08:00
boris c85daae608 perf(data): inline numeric factor keys and preserve borrowed static names 2026-09-13 09:53:06 +08:00
boris f73513e2d4 Revert "perf(data): sort compact market-cap keys without revisiting wide snapshots"
This reverts commit 4e953b6e98.
2026-09-13 07:33:05 +08:00
boris 4e953b6e98 perf(data): sort compact market-cap keys without revisiting wide snapshots 2026-09-13 07:12:42 +08:00
boris b232847e40 docs(strategy): record feature-date gap reproduction and replay acceptance 2026-09-13 06:34:22 +08:00
boris e3b3929578 test(strategy): supply declared historical prices in next-open fixture 2026-09-13 06:09:49 +08:00
boris 20e73d567b fix(strategy): require exact feature-date market snapshots 2026-09-13 06:06:18 +08:00
boris cf4498668b test(strategy): qualify the expected missing-market error 2026-09-13 06:03:54 +08:00
boris 3f39943ee4 test(strategy): reject execution-day fallback for missing decision-day market 2026-09-13 05:59:40 +08:00
boris 5c65e65c6f perf(data): parallelize bounded daily symbol indices 2026-09-13 03:25:37 +08:00
boris f3c70ea566 docs(perf): record expression context CPU and real HTTP parity evidence 2026-09-13 01:13:44 +08:00
boris 07b7b181b6 perf(strategy): retain compact factor values in expression contexts 2026-09-13 00:50:07 +08:00
boris fe7243bbc3 perf(data): normalize owned daily bundles in parallel with stable errors 2026-09-12 19:30:28 +08:00
boris 875e31f71f fix(stock-pool): separate cumulative condition facts from quote capacity 2026-09-12 18:50:12 +08:00
boris 61bd14d001 fix(backtest): inspect the authoritative strategy inside runner bundles 2026-09-12 16:55:16 +08:00
32 changed files with 13284 additions and 727 deletions
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(&quotes)),
run(data_with_snapshot(&quotes, 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
);
}
+134 -20
View File
@@ -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)]
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() {
@@ -41,14 +70,58 @@ fn pool_positions(
}
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
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 +173,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 +185,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 +207,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 +232,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 +258,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 +272,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 +297,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 +348,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 +367,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 +415,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 &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
@@ -328,8 +438,8 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
fallback_references.insert(symbol.clone(), reference);
}
}
let quotes =
self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor)?;
let (quotes, unavailable) = self.pool_quote_inputs(date, data, &quote_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)
@@ -448,7 +558,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)?;
@@ -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);
}
+235 -75
View File
@@ -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,6 +303,14 @@ 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,
@@ -1407,6 +1417,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 +1586,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 +1919,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 +2250,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 +2269,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 +2450,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 +2466,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 +2567,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 +2580,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 +4401,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 +4414,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 +4450,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 +4542,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 +4584,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 +4601,8 @@ where
debug_assert!(symbol_ids.windows(2).all(|window| window[0] < window[1]));
(*date, symbol_ids)
})
.collect::<Vec<_>>()
.into_iter()
.collect()
}
@@ -4644,7 +4682,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 +4693,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 +5200,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 +5343,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 +5445,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 +5485,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 +5563,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 +6276,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 +6355,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 +6485,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,
File diff suppressed because it is too large Load Diff
+2
View File
@@ -26,6 +26,7 @@ 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 +38,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;
+103 -27
View File
@@ -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);
}
}
+11
View File
@@ -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);
}
+187 -90
View File
@@ -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,7 @@ 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 portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
pub portfolio_loss_control: Option<PortfolioLossConfig>,
pub stop_loss_expr: String,
@@ -740,6 +742,7 @@ 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(),
portfolio_drawdown_control: None,
portfolio_loss_control: None,
stop_loss_expr: String::new(),
@@ -845,6 +848,7 @@ fn band_low(index_close) {
}
}
#[cfg(test)]
fn scheduled_position_exposure(
schedule: &BTreeMap<NaiveDate, f64>,
decision_date: NaiveDate,
@@ -957,6 +961,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 +1024,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 +1056,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 +1084,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 +4398,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 +4746,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 +4753,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 +4910,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 +4930,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 +4941,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 +5658,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,9 +8656,9 @@ 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,
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);
@@ -9970,6 +9986,12 @@ impl PlatformExprStrategy {
}
}
}
if let Some(bps)=self.config.position_exposure_timeline.scale_at(portfolio_loss_decision_at(ctx)) {
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 +10353,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 +10399,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,
@@ -14546,6 +14571,7 @@ mod tests {
use std::sync::Arc;
use chrono::{NaiveDate, NaiveTime};
use rhai::{Dynamic, Map};
use super::{
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
@@ -14573,6 +14599,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 +14765,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 +17589,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 +17905,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 +18048,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 +18136,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 +18268,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 +18513,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 +18930,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 +18943,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 +19208,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 +19221,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 +19234,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 +19509,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 +19522,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 +19758,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 +19771,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 +19784,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 +20086,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 +20544,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 +20731,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 +21290,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 +22375,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 +23026,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 +23180,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 +23193,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 +23337,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 +23350,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 +23494,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 +23507,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 +23664,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 +23979,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 +24135,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 +24304,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 +24462,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 +24584,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 +24597,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 +24610,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 +24833,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 +24962,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 +25072,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 +25087,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 +25148,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 +25327,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 +27855,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 +28178,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 +28336,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 +28666,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 +29436,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 +29619,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 +29830,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 +30081,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 +30297,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 +30663,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 +30844,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 +30857,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 +30870,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 +30883,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 +31072,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 +31085,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 +31098,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 +31309,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 +31323,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 +31549,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 +31563,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 +32235,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 +32524,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 +32735,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 +32942,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 +33085,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 +33308,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 +33445,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 +33586,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 +36234,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 +36383,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 +36398,7 @@ mod tests {
avg_price: 0.0,
transaction_cost: 0.0,
limit_price: 10.2,
reserved_cash: None,
reason: "pending_limit_sell".to_string(),
},
];
+4 -6
View File
@@ -204,12 +204,10 @@ impl PlatformExprStrategy {
let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?;
let ratio = self
.config
.position_exposure_schedule
.range(..=ctx.decision_date)
.next_back()
.map(|(_, value)| (*value * 10000.).round() as i64)
let ratio = 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.)
.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(),
+428
View File
@@ -0,0 +1,428 @@
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum PositionExposureAction {
Scale {
#[serde(rename = "requestedBps", alias = "requested_bps")]
requested_bps: i32,
},
Set {
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
target_exposure_bps: i32,
},
Restore,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionExposureEvent {
#[serde(alias = "event_id")]
pub event_id: String,
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), PositionExposureAction>,
}
impl PositionExposureTimeline {
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
let mut result = Self::default();
let mut ids = BTreeSet::new();
let mut sequences = BTreeSet::new();
for event in events {
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
return Err("position exposure event id is missing or duplicated".into());
}
if event.sequence == 0 || !sequences.insert(event.sequence) {
return Err("position exposure event sequence must be positive and unique".into());
}
if let PositionExposureAction::Scale { requested_bps } = event.action
&& !(0..=10000).contains(&requested_bps)
{
return Err("position exposure scale must be between 0 and 10000 bps".into());
}
if let PositionExposureAction::Set {
target_exposure_bps,
} = event.action
&& !(0..=10_000).contains(&target_exposure_bps)
{
return Err("position exposure target must be between 0 and 10000 bps".into());
}
result
.events
.insert((event.effective_at, event.sequence), event.action.clone());
}
Ok(result)
}
/// Legacy day-level contracts remain day-level; never invent intraday times.
pub fn exposure_at(
&self,
at: DateTime<Utc>,
execution_date: NaiveDate,
legacy: &BTreeMap<NaiveDate, f64>,
strategy_exposure: f64,
) -> Option<f64> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
}
Some(PositionExposureAction::Set {
target_exposure_bps,
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
Some(PositionExposureAction::Restore) => None,
None => legacy
.range(..=execution_date)
.next_back()
.map(|(_, value)| *value),
}
}
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, action)| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None,
}
}
}
/// Scale new buys and desired targets without weakening sell/reduction or
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
pub fn scale_explicit_intent(
mut intent: crate::OrderIntent,
bps: i32,
open_orders: &[crate::OpenOrderView],
) -> Result<Option<crate::OrderIntent>, String> {
use crate::OrderIntent as I;
if !(0..=10000).contains(&bps) {
return Err("position scale out of range".into());
}
if bps == 10000 {
return Ok(Some(intent));
}
if let I::WithTimeInForce {
intent: inner,
time_in_force,
} = intent
{
return Ok(
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
}),
);
}
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
let amount = |value: f64, target: bool| -> Result<f64, String> {
if !value.is_finite() || (target && value < 0.) {
return Err("position override received an invalid original amount".into());
}
Ok(if value > 0. {
value * f64::from(bps) / 10000.
} else {
value
})
};
match &mut intent {
I::Shares { quantity, .. }
| I::LimitShares { quantity, .. }
| I::Lots { lots: quantity, .. }
| I::LimitLots { lots: quantity, .. } => {
if *quantity > 0 {
*quantity = integer(*quantity);
if *quantity == 0 {
return Ok(None);
}
}
}
I::TargetShares {
target_quantity, ..
}
| I::LimitTargetShares {
target_quantity, ..
} => {
if *target_quantity < 0 {
return Err("position override received a negative target quantity".into());
}
*target_quantity = integer(*target_quantity);
}
I::Value { value, .. }
| I::LimitValue { value, .. }
| I::AlgoValue { value, .. }
| I::Percent { percent: value, .. }
| I::LimitPercent { percent: value, .. }
| I::AlgoPercent { percent: value, .. } => {
*value = amount(*value, false)?;
if *value == 0. {
return Ok(None);
}
}
I::TargetValue { target_value, .. }
| I::LimitTargetValue { target_value, .. }
| I::TimedTargetValue { target_value, .. }
| I::TargetPercent {
target_percent: target_value,
..
}
| I::LimitTargetPercent {
target_percent: target_value,
..
} => {
*target_value = amount(*target_value, true)?;
}
I::TargetPortfolioSmart { target_weights, .. } => {
for value in target_weights.values_mut() {
*value = amount(*value, true)?;
}
}
I::ModifyOrder {
order_id,
new_total_quantity: Some(quantity),
..
} => {
let order = open_orders
.iter()
.find(|order| order.order_id == *order_id)
.ok_or("position override cannot resolve the order being modified")?;
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
*quantity = order.requested_quantity + extra as u32;
}
}
I::Futures { .. } | I::StockPool { .. } => {
return Err("manual equity scaling cannot transform this intent kind".into());
}
I::ModifyOrder { .. }
| I::CancelOrder { .. }
| I::CancelSymbol { .. }
| I::CancelAll { .. }
| I::UpdateUniverse { .. }
| I::Subscribe { .. }
| I::Unsubscribe { .. }
| I::DepositWithdraw { .. }
| I::FinanceRepay { .. }
| I::SetManagementFeeRate { .. } => {}
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
}
Ok(Some(intent))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
let event = PositionExposureEvent {
event_id: "scale".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Scale {
requested_bps: 5000,
},
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
Some(0.)
);
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
Some(0.1)
);
let restored = PositionExposureEvent {
event_id: "restore".into(),
sequence: 2,
effective_at: at,
action: PositionExposureAction::Restore,
};
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
assert_eq!(
timeline
.exposure_at(
at,
at.date_naive(),
&BTreeMap::from([(at.date_naive(), 1.)]),
0.2
)
.unwrap_or(0.2),
0.2
);
}
#[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I;
let symbol = "000001.SZ".to_string();
let reason = "fixture".to_string();
for bps in [0, 3000, 5000, 10000] {
let ratio = f64::from(bps) / 10000.;
let buy = I::LimitShares {
symbol: symbol.clone(),
quantity: 1000,
limit_price: 12.345,
reason: reason.clone(),
};
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
if bps == 0 {
assert!(scaled.is_none())
} else if let Some(I::LimitShares {
quantity,
limit_price,
..
}) = scaled
{
assert_eq!(quantity, (1000. * ratio) as i32);
assert_eq!(limit_price, 12.345);
} else {
panic!("wrong intent")
}
let sell = I::Shares {
symbol: symbol.clone(),
quantity: -1000,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(sell, bps, &[]).unwrap(),
Some(I::Shares {
quantity: -1000,
..
})
));
let clear = I::TargetShares {
symbol: symbol.clone(),
target_quantity: 0,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(clear, bps, &[]).unwrap(),
Some(I::TargetShares {
target_quantity: 0,
..
})
));
let target = I::TargetPercent {
symbol: symbol.clone(),
target_percent: 0.2,
reason: reason.clone(),
};
if let Some(I::TargetPercent { target_percent, .. }) =
scale_explicit_intent(target, bps, &[]).unwrap()
{
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
} else {
panic!("wrong target")
}
let deposit = I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(deposit, bps, &[]).unwrap(),
Some(I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
..
})
));
}
assert!(
scale_explicit_intent(
I::TargetValue {
symbol,
target_value: f64::NAN,
reason
},
0,
&[]
)
.is_err()
);
}
#[test]
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
])).unwrap();
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
for (time, expected) in [
("09:30:00", Some(0.8)),
("10:00:00", Some(0.)),
("12:59:59", Some(0.)),
("13:00:00", Some(0.5)),
("14:00:00", None),
("15:00:00", None),
] {
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(at, date, &legacy, 0.2),
expected,
"{time}"
);
}
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
None
);
}
#[test]
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
for (key, value) in [
("action", json!("other")),
("effectiveAt", json!("2026-09-10 09:30:00")),
("targetExposureBps", json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
}
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
let mut invalid = event.clone();
invalid.action = PositionExposureAction::Set {
target_exposure_bps: 10001,
};
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
let mut duplicate = event.clone();
duplicate.event_id = "two".into();
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
}
}
+85 -22
View File
@@ -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);
@@ -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");
}
}
+74 -2
View File
@@ -102,6 +102,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 +498,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 {
@@ -988,6 +990,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 +1012,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,6 +1049,52 @@ impl StrategyDecision {
}
}
#[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)]
pub enum AlgoOrderStyle {
Vwap,
@@ -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),
+93 -8
View File
@@ -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();
+25 -25
View File
@@ -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,
@@ -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,37 @@ 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 parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [
@@ -789,6 +998,42 @@ 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 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();
@@ -0,0 +1,35 @@
# 回报上下文、盘前意图与尚未提交的目标
2026-09-14。本轮为v2026.9.14.4之后的候选,当前只有本机验证,尚未发布;完整股票池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构建、真实历史合同回放和配套发布;不得把本机验证当生产或真实券商成交验收。
## 继续范围
显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。
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,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,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 PID3320588Boris、active、NRestarts=0Source仍为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冻结解除。
+70
View File
@@ -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股,修复后为009:31为3700且仅一笔ETF成交。不合成ETF分钟线。
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
本机Core 822项通过、9项原有ignoreTrading工作区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日志03Paper/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`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
+13
View File
@@ -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,126 @@
# Selection Risk Plan Performance
## Status
Candidate tested, not deployed. 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)
remains open. This work does not remove that test or its evidence, change the
execution clock, or turn day-level parity into full framework acceptance.
The published service stays at e81bf47/c98bcc3. 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 earlier cache-boundary candidate still needs its missing
cold/same-window acceptance, and this combined candidate has no HTTP publication
gate yet. Financial PIT, minute-clock 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).
@@ -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下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。