Compare commits

..

61 Commits

Author SHA1 Message Date
boris b4c68be29b 补验锁定期间手工卖出与送转后的自动退出 2026-09-14 19:17:59 +08:00
boris e9c9ecbd48 修复迟到成交和换股批次的FIFO与持有期 2026-09-14 19:15:01 +08:00
boris b2eaaa0d26 记录开盘和跨日时钟Linux联合回归证据 2026-09-14 18:53:29 +08:00
boris 13c89e8d59 修复开盘与跨日ETF执行时钟及资金阻断原因 2026-09-14 18:42:25 +08:00
boris 232e9ae154 保留手工逐日进度并在交付校验失败时终止 2026-09-14 17:30:00 +08:00
boris f8955bfb18 接入独立手工仓位时间线并保留原策略配置 2026-09-14 15:48:44 +08:00
boris fb8192a286 修正盘后意图保留及手工观察的阶段时序 2026-09-14 12:27:30 +08:00
boris 7f0c6a008a 将手工观察接入执行时钟并保留来源与账本语义 2026-09-14 11:41:15 +08:00
boris 93de28d369 docs(perf): record combined risk and series storage Backtest release 2026-09-14 11:14:59 +08:00
boris 665653c3fe 完善手工回放的最终费用和真实观察时间合同 2026-09-14 10:15:03 +08:00
boris a29c434be9 docs(perf): reconcile storage acceptance with current business main 2026-09-14 10:04:00 +08:00
boris 4c96d0c31f Merge remote-tracking branch 'origin/main' 2026-09-14 09:15:58 +08:00
boris 5e11f3da22 建立手工成交观察合同与原子回放游标 2026-09-14 07:30:19 +08:00
boris 8e7ae69b0b 归档回报与最新目标的配套发布及生产回测证据 2026-09-14 06:25:35 +08:00
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 9a54156df9 docs(data): record DayOpen correctness and exact-column memory acceptance 2026-09-14 04:00:41 +08:00
boris 237ee15a51 修复日内时钟提前记账并按原订单续执行算法单 2026-09-14 03:52:52 +08:00
boris 996b909608 perf(data): retain exact repeated and reference-matched series columns 2026-09-14 03:31:28 +08:00
boris c62ae1206f fix(data): use day-open values for day-open rolling averages 2026-09-14 03:18:56 +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
boris 531df39911 chore(test): remove obsolete tree-map import after compact snapshot merge 2026-09-12 16:29:56 +08:00
boris e912e2a6f5 Merge remote-tracking branch 'origin/main' 2026-09-12 16:26:06 +08:00
boris b1f2fcb85c fix(stock-pool): scan decoded native conditions instead of serialized source echoes 2026-09-12 16:23:18 +08:00
boris 828690a51e perf(data): compact immutable snapshots with current execution rules 2026-09-12 16:02:24 +08:00
61 changed files with 21152 additions and 2210 deletions
Generated
+30
View File
@@ -145,6 +145,15 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.59" version = "1.2.59"
@@ -192,6 +201,20 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "compact_str"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"serde",
"static_assertions",
"zmij",
]
[[package]] [[package]]
name = "const-random" name = "const-random"
version = "0.1.18" version = "0.1.18"
@@ -316,6 +339,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ahash 0.8.12", "ahash 0.8.12",
"chrono", "chrono",
"compact_str",
"indexmap", "indexmap",
"rayon", "rayon",
"rhai", "rhai",
@@ -2044,3 +2068,9 @@ dependencies = [
"quote", "quote",
"syn 3.0.5", "syn 3.0.5",
] ]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
compact_str = { version = "=0.10.0", features = ["serde"] }
rust_decimal.workspace = true rust_decimal.workspace = true
ahash.workspace = true ahash.workspace = true
chrono.workspace = true chrono.workspace = true
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::holding_policy::HoldingLifecycleEvidence;
use crate::stock_pool_execution as pool; use crate::stock_pool_execution as pool;
use rust_decimal::{Decimal, prelude::ToPrimitive}; 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> { fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
if !value.is_finite() { if !value.is_finite() {
@@ -41,14 +70,58 @@ fn pool_positions(
} }
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> { 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( fn pool_quote_inputs(
&self, &self,
date: NaiveDate, date: NaiveDate,
data: &DataSet, data: &DataSet,
symbols: &BTreeSet<String>, symbols: &BTreeSet<String>,
execution_clock: Option<NaiveDateTime>, execution_clock: Option<NaiveDateTime>,
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> { cumulative_conditions: bool,
symbols ) -> Result<(Vec<pool::MarketSnapshot>, Vec<String>), BacktestError> {
let mut unavailable = Vec::new();
let quotes = symbols
.iter() .iter()
.map(|symbol| { .map(|symbol| {
let snapshot = data.market(date, symbol).ok_or_else(|| { let snapshot = data.market(date, symbol).ok_or_else(|| {
@@ -100,7 +173,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
snapshot, snapshot,
quote, quote,
OrderSide::Buy, OrderSide::Buy,
self.matching_type, self.matching_type_for_algo_request(None),
) )
.ok_or_else(|| { .ok_or_else(|| {
BacktestError::Execution(format!( BacktestError::Execution(format!(
@@ -112,7 +185,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
snapshot, snapshot,
quote, quote,
OrderSide::Sell, OrderSide::Sell,
self.matching_type, self.matching_type_for_algo_request(None),
) )
.ok_or_else(|| { .ok_or_else(|| {
BacktestError::Execution(format!( BacktestError::Execution(format!(
@@ -134,11 +207,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
None, None,
calibration.as_ref(), 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, quote.last_price,
snapshot.prev_close, snapshot.prev_close,
Some(quote.volume_delta as f64), totals.map(|total| total.0),
Some(quote.amount_delta), totals.map(|total| total.1),
Some(quote.bid1), Some(quote.bid1),
Some(quote.ask1), Some(quote.ask1),
buy, buy,
@@ -153,13 +232,24 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
} }
// A daily open does not reveal the session's volume/turnover. // A daily open does not reveal the session's volume/turnover.
let completed = self.effective_execution_price_field(date) == PriceField::Close; 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, price,
snapshot.prev_close, 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, None,
Some(price),
Some(price),
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?, self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, 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(), symbol: symbol.clone(),
last_price: decimal(price, "price")?, last_price: decimal(price, "price")?,
prev_close: Some(decimal(prev, "prev_close")?), prev_close: Some(decimal(prev, "prev_close")?),
volume: volume.map(|v| decimal(v, "volume")).transpose()?, volume,
turnover: amount.map(|v| decimal(v, "amount")).transpose()?, turnover: amount,
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?, bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?, ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")), 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")?), 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> { 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>, global_execution_cursor: &mut Option<NaiveDateTime>,
commission_state: &mut BTreeMap<u64, f64>, commission_state: &mut BTreeMap<u64, f64>,
report: &mut BrokerExecutionReport, 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> { ) -> Result<(), BacktestError> {
if contract.signal_date > date if contract.signal_date > date
|| contract.frozen_equity < Decimal::ZERO || contract.frozen_equity < Decimal::ZERO
@@ -246,6 +348,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
.cloned() .cloned()
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
scope.extend(portfolio.positions().keys().cloned()); 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 official_dates = data.calendar().iter().collect::<Vec<_>>();
let initial_positions = pool_positions(portfolio, date)?; let initial_positions = pool_positions(portfolio, date)?;
let state = portfolio 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); 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 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() { 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 report
.diagnostics .diagnostics
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into()); .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()); .push("paused".into());
} }
} }
let before_positions = portfolio
.positions()
.keys()
.cloned()
.collect::<BTreeSet<_>>();
// All delayed symbols in a generation share immutable configuration. // All delayed symbols in a generation share immutable configuration.
// Do not duplicate an N-member pool N times in a large mixed pool. // Do not duplicate an N-member pool N times in a large mixed pool.
let mut deferred_configuration = None; let mut deferred_configuration = None;
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] { 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(); let mut fallback_references = BTreeMap::new();
for symbol in &quote_scope { for symbol in &quote_scope {
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? { 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); fallback_references.insert(symbol.clone(), reference);
} }
} }
let quotes = let (quotes, unavailable) = self.pool_quote_inputs(date, data, &quote_scope, *global_execution_cursor,
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 positions = pool_positions(portfolio, date)?;
let execution_state = portfolio let execution_state = portfolio
.stock_pool_execution_state(&contract.pool_id) .stock_pool_execution_state(&contract.pool_id)
@@ -448,7 +558,11 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
Decimal::ZERO, Decimal::ZERO,
Some(&fee), 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 let mut updated = execution_state
.record_plan(contract.signal_date, &contract.generation, &plan) .record_plan(contract.signal_date, &contract.generation, &plan)
.map_err(BacktestError::Execution)?; .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);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3
View File
@@ -311,6 +311,7 @@ pub enum ProcessEventKind {
OrderUpdateReject, OrderUpdateReject,
OrderUnsolicitedUpdate, OrderUnsolicitedUpdate,
Trade, Trade,
ManualExecutionObserved,
UniverseUpdated, UniverseUpdated,
UniverseSubscribed, UniverseSubscribed,
UniverseUnsubscribed, UniverseUnsubscribed,
@@ -358,6 +359,7 @@ impl ProcessEventKind {
Self::OrderUpdateReject => "order_update_reject", Self::OrderUpdateReject => "order_update_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update", Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade", Self::Trade => "trade",
Self::ManualExecutionObserved => "manual_execution_observed",
Self::UniverseUpdated => "universe_updated", Self::UniverseUpdated => "universe_updated",
Self::UniverseSubscribed => "universe_subscribed", Self::UniverseSubscribed => "universe_subscribed",
Self::UniverseUnsubscribed => "universe_unsubscribed", Self::UniverseUnsubscribed => "universe_unsubscribed",
@@ -391,6 +393,7 @@ impl ProcessEventKind {
| Self::OrderUpdateReject | Self::OrderUpdateReject
| Self::OrderUnsolicitedUpdate | Self::OrderUnsolicitedUpdate
| Self::Trade | Self::Trade
| Self::ManualExecutionObserved
| Self::UniverseUpdated | Self::UniverseUpdated
| Self::UniverseSubscribed | Self::UniverseSubscribed
| Self::UniverseUnsubscribed | Self::UniverseUnsubscribed
@@ -0,0 +1,196 @@
//! Check typed pending intent numbers before JSON could replace NaN/Inf with null.
//! This traverses the original Serialize representation without materializing it.
use serde::{Serialize, Serializer, ser};
#[derive(Clone, Copy)]
struct Finite;
pub(crate) fn validate(value: &impl Serialize) -> Result<(), serde_json::Error> {
value.serialize(Finite)
}
macro_rules! scalar {
($($method:ident: $ty:ty),* $(,)?) => {$(
fn $method(self, _: $ty) -> Result<(), Self::Error> { Ok(()) }
)*};
}
impl Serializer for Finite {
type Ok = ();
type Error = serde_json::Error;
type SerializeSeq = Self;
type SerializeTuple = Self;
type SerializeTupleStruct = Self;
type SerializeTupleVariant = Self;
type SerializeMap = Self;
type SerializeStruct = Self;
type SerializeStructVariant = Self;
scalar!(serialize_bool: bool, serialize_i8: i8, serialize_i16: i16,
serialize_i32: i32, serialize_i64: i64, serialize_i128: i128,
serialize_u8: u8, serialize_u16: u16, serialize_u32: u32,
serialize_u64: u64, serialize_u128: u128, serialize_char: char,
serialize_str: &str, serialize_bytes: &[u8]);
fn serialize_f32(self, value: f32) -> Result<(), Self::Error> {
self.serialize_f64(f64::from(value))
}
fn serialize_f64(self, value: f64) -> Result<(), Self::Error> {
if value.is_finite() {
Ok(())
} else {
Err(ser::Error::custom(
"pending strategy intent contains a non-finite number",
))
}
}
fn serialize_none(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_some<T: ?Sized + Serialize>(self, value: &T) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_unit(self) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_struct(self, _: &'static str) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_unit_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
) -> Result<(), Self::Error> {
Ok(())
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(
self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(
self,
_: &'static str,
_: u32,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(self)
}
fn serialize_seq(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple(self, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_tuple_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_map(self, _: Option<usize>) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct(self, _: &'static str, _: usize) -> Result<Self, Self::Error> {
Ok(self)
}
fn serialize_struct_variant(
self,
_: &'static str,
_: u32,
_: &'static str,
_: usize,
) -> Result<Self, Self::Error> {
Ok(self)
}
}
macro_rules! sequence {
($trait:ident, $method:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn $method<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
sequence!(SerializeSeq, serialize_element);
sequence!(SerializeTuple, serialize_element);
sequence!(SerializeTupleStruct, serialize_field);
sequence!(SerializeTupleVariant, serialize_field);
impl ser::SerializeMap for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, value: &T) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
macro_rules! structure {
($trait:ident) => {
impl ser::$trait for Finite {
type Ok = ();
type Error = serde_json::Error;
fn serialize_field<T: ?Sized + Serialize>(
&mut self,
_: &'static str,
value: &T,
) -> Result<(), Self::Error> {
value.serialize(*self)
}
fn end(self) -> Result<(), Self::Error> {
Ok(())
}
}
};
}
structure!(SerializeStruct);
structure!(SerializeStructVariant);
#[cfg(test)]
mod tests {
use super::*;
use crate::strategy::{OrderIntent, StrategyDecision};
#[test]
fn pending_numbers_cannot_be_silently_serialized_as_optional_nulls() {
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let decision = StrategyDecision {
order_intents: vec![
OrderIntent::LimitTargetPercent {
symbol: "000001.SZ".into(),
target_percent: 0.5,
limit_price: value,
reason: "test".into(),
}
.with_time_in_force(crate::strategy::OrderTimeInForce::Day),
],
..Default::default()
};
assert!(validate(&decision).is_err());
assert!(validate(&vec![Some(value)]).is_err());
}
assert!(validate(&(None::<f64>, vec![0., -0., 0.123456789], "NaN")).is_ok());
}
}
+11
View File
@@ -28,6 +28,17 @@ impl FixedMoney {
self.0 self.0
} }
pub fn to_decimal_string(self) -> String {
let magnitude = self.0.unsigned_abs();
let scale = MONEY_SCALE as u128;
let sign = if self.0 < 0 { "-" } else { "" };
let width = MONEY_SCALE.ilog10() as usize;
format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale)
.trim_end_matches('0')
.trim_end_matches('.')
.to_string()
}
pub fn from_decimal_str(value: &str) -> Result<Self, String> { pub fn from_decimal_str(value: &str) -> Result<Self, String> {
let value = value.trim(); let value = value.trim();
if value.is_empty() { if value.is_empty() {
+4 -4
View File
@@ -26,7 +26,7 @@ fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str)
}) })
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
pub enum FuturesDirection { pub enum FuturesDirection {
Long, Long,
Short, Short,
@@ -62,7 +62,7 @@ impl FuturesDirection {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum FuturesPositionEffect { pub enum FuturesPositionEffect {
Open, Open,
Close, Close,
@@ -81,7 +81,7 @@ impl FuturesPositionEffect {
} }
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy, Serialize)]
pub struct FuturesContractSpec { pub struct FuturesContractSpec {
pub contract_multiplier: f64, pub contract_multiplier: f64,
pub long_margin_rate: f64, pub long_margin_rate: f64,
@@ -190,7 +190,7 @@ impl FuturesTransactionCostModel {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct FuturesOrderIntent { pub struct FuturesOrderIntent {
pub symbol: String, pub symbol: String,
pub direction: FuturesDirection, pub direction: FuturesDirection,
+1 -1
View File
@@ -63,7 +63,7 @@ pub struct HoldingLifecycleEvidence {
pub last_sell_date: Option<NaiveDate>, pub last_sell_date: Option<NaiveDate>,
} }
#[derive(Debug, Clone, Default, PartialEq, Eq)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct AutomaticTradePermission { pub struct AutomaticTradePermission {
pub buy_denial: Option<&'static str>, pub buy_denial: Option<&'static str>,
pub sell_denial: Option<&'static str>, pub sell_denial: Option<&'static str>,
+6 -1
View File
@@ -2,6 +2,7 @@ pub mod broker;
pub mod calendar; pub mod calendar;
pub mod cost; pub mod cost;
pub mod data; pub mod data;
mod numeric_factors;
pub mod daily_patterns; pub mod daily_patterns;
pub mod pattern_context; pub mod pattern_context;
pub mod session_events; pub mod session_events;
@@ -16,15 +17,18 @@ pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
pub mod fixed_point; pub mod fixed_point;
mod finite_serialization;
pub mod futures; pub mod futures;
pub mod instrument; pub mod instrument;
pub mod metrics; pub mod metrics;
pub mod manual_execution;
mod numeric_expr_vm; mod numeric_expr_vm;
pub mod platform_expr_strategy; pub mod platform_expr_strategy;
pub mod platform_runtime_schema; pub mod platform_runtime_schema;
pub mod platform_strategy_spec; pub mod platform_strategy_spec;
pub mod portfolio; pub mod portfolio;
pub mod portfolio_loss; pub mod portfolio_loss;
pub mod position_exposure;
pub mod risk_control; pub mod risk_control;
pub mod rules; pub mod rules;
pub mod scheduler; pub mod scheduler;
@@ -36,6 +40,7 @@ pub mod stock_pool_execution;
pub mod stock_pool_index_policy; pub mod stock_pool_index_policy;
pub mod stock_pool_market_cap; pub mod stock_pool_market_cap;
pub mod stock_pool_state; pub mod stock_pool_state;
pub mod stock_pool_quote_facts;
pub mod signal_contract; pub mod signal_contract;
pub mod strategy_ai; pub mod strategy_ai;
pub mod universe; pub mod universe;
@@ -58,7 +63,7 @@ pub use engine::{
AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, AnalyzerTradeRow, BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError,
BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder, BacktestResult, BacktestTerminalAssetClass, BacktestTerminalAudit, BacktestTerminalOpenOrder,
BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig, BacktestTerminalStatus, DailyEquityPoint, ExecutionQuoteRequest, FuturesValidationConfig,
ProcessEventRetention, backtest_execution_dates, ProcessEventRetention, backtest_execution_dates, backtest_execution_dates_with_rules,
}; };
pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus}; pub use event_bus::{BacktestProcessMod, BacktestProcessModLoader, ProcessEventBus};
pub use events::{ pub use events::{
+657
View File
@@ -0,0 +1,657 @@
//! Confirmed manual fills are external observations, not simulated broker fills.
//! The producer must bind these records to the runtime's durable order/audit facts.
use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::events::OrderSide;
use crate::{DataSet, FixedMoney, PortfolioState};
use rust_decimal::prelude::ToPrimitive;
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v3";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionReplay {
pub schema: String,
pub runtime_id: String,
pub account_id: String,
pub source_contract_sha256: String,
pub content_sha256: String,
pub observation_cutoff: DateTime<Utc>,
pub actions: Vec<ManualExecutionAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub legacy_position_exposure_bps: BTreeMap<NaiveDate, i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionAction {
pub action_id: String,
pub source: ManualExecutionSource,
pub audit_event_ids: Vec<String>,
pub confirmed_at: DateTime<Utc>,
pub confirmation_observed_at: DateTime<Utc>,
pub outcome: ManualActionOutcome,
pub orders: Vec<ManualExecutionOrder>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualActionOutcome {
NoOrdersNeeded,
NotExecuted,
OrdersTerminal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualExecutionSource {
ManualSecurityTrade,
ManualPositionAction,
ManualRebalance,
StockPoolAllocation,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionOrder {
pub order_id: String,
pub broker_order_id: Option<String>,
pub source_adapter: Option<String>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub order_created_at: DateTime<Utc>,
pub terminal_observed_at: DateTime<Utc>,
pub terminal_status: ManualOrderTerminalStatus,
pub fills: Vec<ManualExecutionFill>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualOrderTerminalStatus {
Filled,
Cancelled,
Rejected,
Expired,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ManualExecutionFill {
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub fee_observation_event_id: String,
pub fee_observation_sequence: u64,
pub fee_observed_at: DateTime<Utc>,
pub trade_date: NaiveDate,
pub executed_at: DateTime<Utc>,
pub observed_at: DateTime<Utc>,
pub timestamp_precision: ManualTimestampPrecision,
pub quantity: u32,
#[serde(with = "rust_decimal::serde::str")]
pub price: Decimal,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub commission: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub stamp_tax: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub transfer_fee: Option<Decimal>,
/// Full observed charge, including any venue fees not itemized above.
#[serde(with = "rust_decimal::serde::str")]
pub total_fee: Decimal,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ManualTimestampPrecision {
Second,
Millisecond,
Microsecond,
Nanosecond,
}
impl ManualTimestampPrecision {
fn nanoseconds(self) -> i64 {
match self {
Self::Second => 1_000_000_000,
Self::Millisecond => 1_000_000,
Self::Microsecond => 1_000,
Self::Nanosecond => 1,
}
}
}
impl ManualExecutionFill {
pub fn gross_amount(&self) -> Result<Decimal, String> {
self.price
.checked_mul(Decimal::from(self.quantity))
.ok_or_else(|| "manual fill gross amount overflow".into())
}
pub fn total_fees(&self) -> Result<Decimal, String> {
let known = [self.commission, self.stamp_tax, self.transfer_fee]
.into_iter()
.flatten()
.try_fold(Decimal::ZERO, |sum, fee| {
if fee < Decimal::ZERO {
return Err("manual fill fee component is negative");
}
sum.checked_add(fee).ok_or("manual fill fees overflow")
})?;
if self.total_fee < known {
return Err("manual total fee is below its known components".into());
}
Ok(self.total_fee)
}
}
fn identifier(value: &str) -> Result<(), String> {
if value.is_empty()
|| value.trim() != value
|| value.len() > 256
|| value.chars().any(char::is_control)
{
return Err("manual execution identity is empty, untrimmed or invalid".into());
}
Ok(())
}
impl ManualExecutionReplay {
/// Market/indicator data is needed for securities whose observed fills
/// change the portfolio. A rejected, never-filled order is not data demand.
pub fn required_data_symbols(&self) -> Result<BTreeSet<String>, String> {
self.validate()?;
Ok(self
.actions
.iter()
.flat_map(|action| &action.orders)
.filter(|order| !order.fills.is_empty())
.map(|order| order.symbol.clone())
.collect())
}
pub fn observations(&self) -> Result<Vec<ManualFillObservation<'_>>, String> {
self.validate()?;
let mut observations = Vec::new();
for action in &self.actions {
for order in &action.orders {
for fill in &order.fills {
observations.push(ManualFillObservation {
action,
order,
fill,
});
}
}
}
observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence));
Ok(observations)
}
pub fn content_digest(&self) -> Result<String, String> {
let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?;
value
.as_object_mut()
.ok_or("manual replay is not an object")?
.remove("contentSha256");
let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?;
Ok(format!("{:x}", Sha256::digest(bytes)))
}
pub fn validate(&self) -> Result<(), String> {
if self.schema != MANUAL_REPLAY_SCHEMA
&& self.schema != "fidc.observed-manual-executions/v2"
{
return Err("unsupported manual replay schema".into());
}
if self.schema == "fidc.observed-manual-executions/v2"
&& (!self.position_exposure_events.is_empty()
|| !self.legacy_position_exposure_bps.is_empty())
{
return Err("runtime configuration requires manual replay v3".into());
}
crate::position_exposure::PositionExposureTimeline::from_events(
&self.position_exposure_events,
)?;
if self.position_exposure_events.iter().any(|event| event.effective_at > self.observation_cutoff) {
return Err("observed runtime position event is after the evidence cutoff".into());
}
if self
.legacy_position_exposure_bps
.values()
.any(|value| !(0..=10000).contains(value))
{
return Err("legacy manual exposure is outside 0..10000 bps".into());
}
identifier(&self.runtime_id)?;
identifier(&self.account_id)?;
if self.source_contract_sha256.len() != 64
|| !self
.source_contract_sha256
.bytes()
.all(|v| v.is_ascii_hexdigit())
{
return Err("manual replay source contract hash is invalid".into());
}
if self.content_digest()? != self.content_sha256 {
return Err("manual replay content digest mismatch".into());
}
if self.actions.len() > 100_000 {
return Err("manual replay action limit exceeded; trace was not truncated".into());
}
let shanghai = FixedOffset::east_opt(8 * 3600).unwrap();
let mut actions = BTreeSet::new();
let mut audits = BTreeSet::new();
let mut orders = BTreeSet::new();
let mut broker_orders = BTreeSet::new();
let mut trades = BTreeSet::new();
let mut observation_events = BTreeSet::new();
let mut observation_sequences = BTreeSet::new();
let mut fee_observations = BTreeSet::new();
let mut receipt_ids = BTreeMap::new();
let mut receipt_sequences = BTreeMap::new();
for action in &self.actions {
identifier(&action.action_id)?;
if !actions.insert(action.action_id.as_str())
|| action.confirmed_at > self.observation_cutoff
|| action.confirmation_observed_at < action.confirmed_at
|| action.confirmation_observed_at > self.observation_cutoff
{
return Err("duplicate manual action or confirmation after cutoff".into());
}
if action.audit_event_ids.is_empty() {
return Err("manual action has no immutable audit binding".into());
}
if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
return Err("manual action outcome does not prove its order coverage".into());
}
for id in &action.audit_event_ids {
identifier(id)?;
if !audits.insert(id.as_str()) {
return Err("manual audit event is bound more than once".into());
}
}
for order in &action.orders {
identifier(&order.order_id)?;
if let Some(adapter) = &order.source_adapter {
identifier(adapter)?;
}
identifier(&order.symbol)?;
if let Some(id) = &order.broker_order_id {
identifier(id)?;
if !broker_orders.insert((
order
.source_adapter
.as_deref()
.ok_or("broker identity requires its source adapter")?,
order.order_created_at.with_timezone(&shanghai).date_naive(),
id.as_str(),
)) {
return Err("manual local orders share one broker order identity".into());
}
}
if !order.fills.is_empty() && order.source_adapter.is_none() {
return Err("manual fills require a known source adapter".into());
}
if !order.fills.is_empty()
&& order.source_adapter.as_deref() != Some("paper")
&& order.broker_order_id.is_none()
{
return Err(
"manual broker fills require their original broker order identity".into(),
);
}
if !orders.insert(order.order_id.as_str())
|| order.quantity == 0
|| order.quantity > i32::MAX as u32
{
return Err("duplicate manual order or invalid quantity".into());
}
if order.order_created_at < action.confirmed_at
|| order.terminal_observed_at < order.order_created_at
|| order.terminal_observed_at > self.observation_cutoff
{
return Err(
"manual order confirmation/submission/terminal time is inconsistent".into(),
);
}
let mut filled = 0_u32;
for fill in &order.fills {
identifier(&fill.trade_id)?;
identifier(&fill.observation_event_id)?;
identifier(&fill.fee_observation_event_id)?;
if fill.observation_sequence == 0
|| fill.observation_sequence > i64::MAX as u64
|| !observation_events.insert(fill.observation_event_id.as_str())
|| !observation_sequences.insert(fill.observation_sequence)
{
return Err(
"manual fill requires a unique durable observation event and sequence"
.into(),
);
}
if fill.fee_observation_sequence == 0
|| fill.fee_observation_sequence > i64::MAX as u64
|| fill.fee_observed_at < fill.observed_at
|| fill.fee_observed_at > self.observation_cutoff
|| !fee_observations.insert((
fill.fee_observation_event_id.as_str(),
fill.fee_observation_sequence,
))
{
return Err("manual finalized fees require their own unique observation within the cutoff".into());
}
if (fill.fee_observation_event_id == fill.observation_event_id)
!= (fill.fee_observation_sequence == fill.observation_sequence)
|| (fill.fee_observation_event_id == fill.observation_event_id
&& fill.fee_observed_at != fill.observed_at)
{
return Err("manual fill and fee observation identities disagree".into());
}
if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|| fill.quantity == 0
{
return Err("duplicate manual trade or zero fill quantity".into());
}
for (id, sequence) in [
(&fill.observation_event_id, fill.observation_sequence),
(
&fill.fee_observation_event_id,
fill.fee_observation_sequence,
),
] {
if receipt_ids
.insert(id, (&fill.trade_id, sequence))
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|| receipt_sequences
.insert(sequence, (&fill.trade_id, id))
.is_some_and(|owner| owner != (&fill.trade_id, id))
{
return Err("manual observation identity is reused by a different trade or sequence".into());
}
}
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|| fill.observed_at > self.observation_cutoff
|| fill.observed_at < order.order_created_at
|| fill.observed_at < action.confirmation_observed_at
|| fill.observed_at < fill.executed_at
|| fill.executed_at > order.terminal_observed_at
{
return Err("manual fill execution/observation time is inconsistent".into());
}
if i64::from(fill.executed_at.nanosecond())
% fill.timestamp_precision.nanoseconds()
!= 0
{
return Err(
"broker timestamp contains digits finer than its declared precision"
.into(),
);
}
let upper = fill
.executed_at
.checked_add_signed(chrono::Duration::nanoseconds(
fill.timestamp_precision.nanoseconds(),
))
.ok_or("manual execution timestamp overflow")?;
let earliest = order.order_created_at.max(action.confirmation_observed_at);
if fill.executed_at < earliest && earliest >= upper {
return Err("manual fill predates its order or durable confirmation".into());
}
if fill.price <= Decimal::ZERO {
return Err("manual fill requires a positive price".into());
}
fill.gross_amount()?
.checked_add(fill.total_fees()?)
.ok_or("manual fill cash amount overflow")?;
filled = filled
.checked_add(fill.quantity)
.ok_or("manual cumulative fill quantity overflow")?;
}
if filled > order.quantity
|| (order.terminal_status == ManualOrderTerminalStatus::Filled
&& filled != order.quantity)
|| (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0)
|| (matches!(
order.terminal_status,
ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired
) && filled == order.quantity)
{
return Err("manual terminal status disagrees with cumulative fills".into());
}
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub struct ManualFillObservation<'a> {
pub action: &'a ManualExecutionAction,
pub order: &'a ManualExecutionOrder,
pub fill: &'a ManualExecutionFill,
}
#[derive(Debug, Clone, PartialEq)]
pub struct AppliedManualFill {
pub gross: FixedMoney,
pub fees: FixedMoney,
pub cash_delta: FixedMoney,
pub quantity_after: u32,
}
/// One replay owns its immutable trace and progress. Advancing is atomic even
/// if a later receipt in the same step disagrees with the shadow account.
pub struct ManualReplayCursor {
replay: std::sync::Arc<ManualExecutionReplay>,
indices: Vec<(usize, usize, usize)>,
cursor: usize,
clock: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ManualReplayApplication {
pub action_id: String,
pub order_id: String,
pub trade_id: String,
pub observation_event_id: String,
pub observation_sequence: u64,
pub observed_at: DateTime<Utc>,
pub fee_observation_event_id: String,
pub fee_observed_at: DateTime<Utc>,
pub executed_at: DateTime<Utc>,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub quantity_after: u32,
pub price: String,
pub commission: Option<String>,
pub stamp_tax: Option<String>,
pub transfer_fee: Option<String>,
pub source_total_fee: String,
pub source_gross_amount: String,
pub ledger_gross_amount: String,
pub ledger_fees: String,
pub cash_delta: String,
}
impl ManualReplayCursor {
pub fn new(replay: ManualExecutionReplay) -> Result<Self, String> {
Self::from_shared(std::sync::Arc::new(replay))
}
pub fn from_shared(replay: std::sync::Arc<ManualExecutionReplay>) -> Result<Self, String> {
replay.validate()?;
let mut indices = Vec::new();
for (a, action) in replay.actions.iter().enumerate() {
for (o, order) in action.orders.iter().enumerate() {
for f in 0..order.fills.len() {
indices.push((a, o, f));
}
}
}
indices.sort_by_key(|&(a, o, f)| {
let fill = &replay.actions[a].orders[o].fills[f];
(fill.observed_at, fill.observation_sequence)
});
Ok(Self {
replay,
indices,
cursor: 0,
clock: None,
})
}
pub fn next_observation_at(&self) -> Option<DateTime<Utc>> {
self.indices
.get(self.cursor)
.map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at)
}
pub fn applied_count(&self) -> usize {
self.cursor
}
pub fn advance(
&mut self,
at: DateTime<Utc>,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
let end = self.cursor
+ self.indices[self.cursor..].iter().take_while(|&&(a, o, f)| {
self.replay.actions[a].orders[o].fills[f].observed_at <= at
}).count();
self.advance_through(at, end, portfolio, data, has_pending_orders)
}
/// One receipt at a time lets callbacks observe the intermediate state
/// when multiple fills share a timestamp but have distinct durable sequences.
pub fn advance_next(
&mut self, portfolio: &mut PortfolioState, data: &DataSet, has_pending_orders: bool,
) -> Result<Option<ManualReplayApplication>, String> {
let Some(at) = self.next_observation_at() else { return Ok(None); };
let mut applications = self.advance_through(at, self.cursor + 1, portfolio, data, has_pending_orders)?;
Ok(applications.pop())
}
fn advance_through(
&mut self, at: DateTime<Utc>, end: usize, portfolio: &mut PortfolioState,
data: &DataSet, has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, String> {
if at > self.replay.observation_cutoff {
return Err("manual observation clock exceeds the frozen evidence cutoff".into());
}
if self.clock.is_some_and(|clock| at < clock) {
return Err("manual observation clock moved backwards".into());
}
if end == self.cursor {
self.clock = Some(at);
return Ok(vec![]);
}
let mut next = portfolio.clone();
let mut applications = Vec::with_capacity(end - self.cursor);
for &(a, o, f) in &self.indices[self.cursor..end] {
let action = &self.replay.actions[a];
let order = &action.orders[o];
let fill = &order.fills[f];
let applied = ManualFillObservation {
action,
order,
fill,
}
.apply(&mut next, data, has_pending_orders)?;
applications.push(ManualReplayApplication {
action_id: action.action_id.clone(),
order_id: order.order_id.clone(),
trade_id: fill.trade_id.clone(),
observation_event_id: fill.observation_event_id.clone(),
observation_sequence: fill.observation_sequence,
observed_at: fill.observed_at,
fee_observation_event_id: fill.fee_observation_event_id.clone(),
fee_observed_at: fill.fee_observed_at,
executed_at: fill.executed_at,
symbol: order.symbol.clone(),
side: order.side,
quantity: fill.quantity,
quantity_after: applied.quantity_after,
price: fill.price.to_string(),
commission: fill.commission.map(|fee| fee.to_string()),
stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
source_total_fee: fill.total_fee.to_string(),
source_gross_amount: fill.gross_amount()?.to_string(),
ledger_gross_amount: applied.gross.to_decimal_string(),
ledger_fees: applied.fees.to_decimal_string(),
cash_delta: applied.cash_delta.to_decimal_string(),
});
}
*portfolio = next;
self.cursor = end;
self.clock = Some(at);
Ok(applications)
}
}
impl ManualFillObservation<'_> {
pub(crate) fn apply(
&self,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<AppliedManualFill, String> {
if has_pending_orders {
return Err("manual observation conflicts with pending shadow orders".into());
}
let instrument = data
.instrument(&self.order.symbol)
.ok_or("manual observation instrument is absent from frozen source data")?;
if instrument
.dated_market_absence_reason(self.fill.trade_date)
.is_some()
{
return Err("manual execution contradicts the frozen instrument lifecycle".into());
}
let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?;
let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?;
let price = self
.fill
.price
.to_f64()
.filter(|price| price.is_finite() && *price > 0.)
.ok_or("manual execution price cannot be represented for valuation")?;
// This is the real observed trade price, not a fabricated quote. The
// normal market clock remains responsible for subsequent marks.
let cash_delta = portfolio.apply_observed_manual_fill(
self.fill.trade_date,
&self.order.symbol,
self.order.side,
self.fill.quantity,
price,
price,
gross,
fees,
)?;
Ok(AppliedManualFill {
gross,
fees,
cash_delta,
quantity_after: portfolio
.position(&self.order.symbol)
.map_or(0, |position| position.quantity),
})
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,746 @@
use super::*;
use serde_json::{Value, json};
fn sample() -> ManualExecutionReplay {
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","outcome":"orders_terminal","orders":[{
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
"fills":[fill]
}]}]
})).unwrap();
reseal(&mut input);
input
}
fn reseal(input: &mut ManualExecutionReplay) {
input.content_sha256 = input.content_digest().unwrap();
}
fn delayed_buy_replay() -> ManualExecutionReplay {
let mut input = sample();
let template = input.actions[0].clone();
input.actions.clear();
for (index, side, executed, observed, price, fee) in [
(
0,
OrderSide::Buy,
"2026-09-14T01:30:00Z",
"2026-09-14T01:30:01Z",
"20",
"0.25",
),
(
1,
OrderSide::Buy,
"2026-09-11T06:00:00Z",
"2026-09-14T01:30:02Z",
"10",
"0.75",
),
(
2,
OrderSide::Sell,
"2026-09-14T01:31:00Z",
"2026-09-14T01:31:01Z",
"10",
"0.5",
),
(
3,
OrderSide::Sell,
"2026-09-14T01:32:00Z",
"2026-09-14T01:32:01Z",
"10",
"0.5",
),
] {
let executed: DateTime<Utc> = executed.parse().unwrap();
let observed: DateTime<Utc> = observed.parse().unwrap();
let mut action = template.clone();
action.action_id = format!("action-{index}");
action.audit_event_ids = vec![format!("audit-{index}")];
action.confirmed_at = executed - chrono::Duration::seconds(2);
action.confirmation_observed_at = action.confirmed_at;
let order = &mut action.orders[0];
order.order_id = format!("order-{index}");
order.broker_order_id = Some(format!("broker-{index}"));
order.side = side;
order.order_created_at = executed - chrono::Duration::seconds(1);
order.terminal_observed_at = observed;
let fill = &mut order.fills[0];
fill.trade_id = format!("trade-{index}");
fill.observation_event_id = format!("receipt-{index}");
fill.observation_sequence = index + 1;
fill.fee_observation_event_id = fill.observation_event_id.clone();
fill.fee_observation_sequence = fill.observation_sequence;
fill.trade_date = executed
.with_timezone(&FixedOffset::east_opt(8 * 3600).unwrap())
.date_naive();
fill.executed_at = executed;
fill.observed_at = observed;
fill.fee_observed_at = observed;
fill.price = price.parse().unwrap();
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
fill.total_fee = fee.parse().unwrap();
input.actions.push(action);
}
reseal(&mut input);
input.validate().unwrap();
input
}
#[test]
fn late_buy_retains_the_earliest_opening_and_latest_buy_dates() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:30:02Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(
applications
.iter()
.map(|row| row.trade_id.as_str())
.collect::<Vec<_>>(),
["trade-0", "trade-1"]
);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.opened_date(), NaiveDate::from_ymd_opt(2026, 9, 11));
assert_eq!(
position.last_buy_date(),
NaiveDate::from_ymd_opt(2026, 9, 14)
);
assert_eq!(position.quantity, 200);
let calendar = crate::TradingCalendar::new(
[11, 14, 15, 16, 17, 18]
.map(|day| NaiveDate::from_ymd_opt(2026, 9, day).unwrap())
.into(),
);
let evidence = crate::holding_policy::HoldingLifecycleEvidence {
has_position: true,
opened_date: position.opened_date(),
last_buy_date: position.last_buy_date(),
last_sell_date: None,
};
let mut policy = crate::holding_policy::AutomaticTradeProtection {
max_holding_days: 1,
..Default::default()
};
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
policy.buy_protection_days = 3;
for day in [14, 15, 16, 17] {
let permission = policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, day).unwrap(),
&evidence,
&calendar,
)
.unwrap();
assert_eq!(permission.sell_denial, Some("buy_fill_protection"));
assert!(!permission.max_holding_exit);
}
assert!(
policy
.evaluate(
"000001.SZ",
NaiveDate::from_ymd_opt(2026, 9, 18).unwrap(),
&evidence,
&calendar
)
.unwrap()
.max_holding_exit
);
}
#[test]
fn late_buy_fifo_depletion_preserves_costs_and_cannot_unlock_today_lots() {
let mut cursor = ManualReplayCursor::new(delayed_buy_replay()).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut portfolio = PortfolioState::new(10000.);
let applications = cursor
.advance(
"2026-09-14T01:31:01Z".parse().unwrap(),
&mut portfolio,
&data,
false,
)
.unwrap();
assert_eq!(applications.len(), 3);
let position = portfolio.position("000001.SZ").unwrap();
assert_eq!(position.quantity, 100);
assert_eq!(position.unrealized_pnl(), -1000.25);
assert_eq!(
position.sellable_qty(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap()),
0
);
assert_eq!(position.realized_pnl(), -0.75);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.external_cash_flow_total(), 0.);
assert!(
cursor
.advance(
"2026-09-14T01:32:01Z".parse().unwrap(),
&mut portfolio,
&data,
false
)
.unwrap_err()
.contains("T+1")
);
assert_eq!(cursor.applied_count(), 3);
assert_eq!(portfolio.cash(), 7998.5);
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
}
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
let mut input = input.clone();
reseal(&mut input);
input.validate()
}
#[test]
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
let input = sample();
input.validate().unwrap();
let fill = &input.actions[0].orders[0].fills[0];
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
assert_eq!(
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
"10.1234567891"
);
}
#[test]
fn data_scope_only_contains_actual_filled_securities_and_validates_the_source() {
let mut input = sample();
let mut rejected = input.actions[0].orders[0].clone();
rejected.order_id = "rejected-order".into();
rejected.broker_order_id = None;
rejected.source_adapter = None;
rejected.symbol = "510300.SH".into();
rejected.terminal_status = ManualOrderTerminalStatus::Rejected;
rejected.fills.clear();
input.actions[0].orders.push(rejected);
reseal(&mut input);
assert_eq!(
input.required_data_symbols().unwrap(),
BTreeSet::from(["000001.SZ".into()])
);
input.actions[0].orders[0].symbol = "600000.SH".into();
assert!(input.required_data_symbols().is_err());
}
#[test]
fn v2_facts_keep_their_encoding_but_cannot_silently_carry_new_runtime_settings() {
let mut input = sample();
input.schema = "fidc.observed-manual-executions/v2".into();
reseal(&mut input);
input.validate().unwrap();
let old = serde_json::to_value(&input).unwrap();
assert!(old.get("positionExposureEvents").is_none());
assert!(old.get("legacyPositionExposureBps").is_none());
input
.legacy_position_exposure_bps
.insert(NaiveDate::from_ymd_opt(2026, 9, 14).unwrap(), 5000);
reseal(&mut input);
assert!(input.validate().is_err());
input.schema = MANUAL_REPLAY_SCHEMA.into();
reseal(&mut input);
input.validate().unwrap();
}
#[test]
fn runtime_position_events_cannot_claim_observations_after_the_source_cutoff() {
let mut input = sample();
input.position_exposure_events.push(serde_json::from_value(json!({
"eventId": "position-event", "sequence": 1, "effectiveAt": input.observation_cutoff,
"action": "scale", "requestedBps": 5000
})).unwrap());
semantic_result(&input).unwrap();
input.position_exposure_events[0].effective_at += chrono::Duration::nanoseconds(1);
assert!(semantic_result(&input).unwrap_err().contains("after the evidence cutoff"));
}
#[test]
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
let original = serde_json::to_value(sample()).unwrap();
for field in ["price", "totalFee"] {
let mut missing = original.clone();
missing["actions"][0]["orders"][0]["fills"][0]
.as_object_mut()
.unwrap()
.remove(field);
assert!(
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
"{field}"
);
let mut numeric = original.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
assert!(
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
"numeric {field}"
);
}
for mutate in [
("schema", json!("unknown")),
("sourceContractSha256", json!("broken")),
("accountId", json!(" ")),
] {
let mut value = original.clone();
value[mutate.0] = mutate.1;
assert!(
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
.is_err()
);
}
}
#[test]
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
let original = sample();
let mut invalid = original.clone();
invalid.actions[0].orders[0].quantity = 200;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].audit_event_ids.clear();
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions.push(invalid.actions[0].clone());
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
invalid.actions[0].orders[0].fills.push(duplicate);
assert!(semantic_result(&invalid).is_err());
let mut invalid = original.clone();
invalid.actions[0].orders[0].broker_order_id = None;
assert!(semantic_result(&invalid).is_err());
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
reseal(&mut invalid);
invalid.validate().unwrap();
}
#[test]
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
let mut input = sample();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
input.actions[0].orders[0].terminal_observed_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
input.actions[0].orders[0].fills[0].fee_observed_at =
input.actions[0].orders[0].fills[0].observed_at;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].order_created_at = "2026-09-14T01:30:01Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut input = sample();
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].fills[0].executed_at =
"2026-09-14T01:30:00.800001Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
}
#[test]
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
let mut input = sample();
input.actions[0].orders.clear();
assert!(semantic_result(&input).is_err());
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
reseal(&mut input);
input.validate().unwrap();
let mut value = serde_json::to_value(input).unwrap();
value["actions"][0]["outcome"] = json!("result_unknown");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn raw_timezone_and_cutoff_are_required() {
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
let mut input = sample();
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
assert!(semantic_result(&input).is_err());
let mut value = serde_json::to_value(sample()).unwrap();
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
assert_eq!(
fill.total_fees().unwrap(),
"0.1200001".parse::<Decimal>().unwrap()
);
assert!(semantic_result(&input).is_ok());
let value = serde_json::to_value(&input).unwrap();
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
assert_eq!(
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
"0.1200001"
);
for field in ["commission", "stampTax", "transferFee"] {
let mut numeric = value.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
}
}
#[test]
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
let mut input = sample();
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
assert!(semantic_result(&input).is_ok());
assert_eq!(
input.actions[0].orders[0].fills[0]
.total_fees()
.unwrap()
.to_string(),
"0.15"
);
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
assert!(semantic_result(&input).is_err());
}
#[test]
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
let original = fill.observed_at;
fill.fee_observation_event_id = "fee-receipt-1".into();
fill.fee_observation_sequence = 2;
fill.fee_observed_at = original + chrono::Duration::hours(1);
let fee_time = fill.fee_observed_at;
reseal(&mut input);
let mut cursor = ManualReplayCursor::new(input).unwrap();
assert_eq!(cursor.next_observation_at(), Some(original));
let mut portfolio = PortfolioState::new(10_000.);
let result = cursor
.advance(original, &mut portfolio, &data, false)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].observed_at, original);
assert_eq!(result[0].fee_observed_at, fee_time);
assert_eq!(result[0].source_total_fee, "0.1200001");
assert!(
cursor
.advance(fee_time, &mut portfolio, &data, false)
.unwrap()
.is_empty()
);
}
#[test]
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
let input = sample();
let original = input.content_sha256.clone();
let mut changed = input.clone();
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
assert_ne!(changed.content_digest().unwrap(), original);
assert_eq!(
changed.validate().unwrap_err(),
"manual replay content digest mismatch"
);
let mut changed = input;
changed.account_id = "another-account".into();
assert_ne!(changed.content_digest().unwrap(), original);
assert!(changed.validate().is_err());
}
fn identity_data(listed: NaiveDate) -> DataSet {
DataSet::from_components(
vec![crate::Instrument {
symbol: "000001.SZ".into(),
name: "test".into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(listed),
delisted_at: None,
status: "active".into(),
}],
vec![],
vec![],
vec![],
vec![crate::BenchmarkSnapshot {
date: listed,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 0,
}],
)
.unwrap()
}
#[test]
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
let input = sample();
let observations = input.observations().unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
let applied = observations[0].apply(&mut account, &data, false).unwrap();
assert_eq!(
applied.gross,
FixedMoney::from_decimal_str("1012.345679").unwrap()
);
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
assert_eq!(account.cash(), 8987.534321);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
assert_eq!(
account
.position("000001.SZ")
.unwrap()
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
0
);
assert_eq!(account.external_cash_flow_total(), 0.);
assert_eq!(account.starting_cash(), 10_000.);
}
#[test]
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let observations = input.observations().unwrap();
let mut poor = PortfolioState::new(10.);
assert!(observations[0].apply(&mut poor, &data, false).is_err());
assert_eq!(poor.cash(), 10.);
assert!(poor.positions().is_empty());
let mut account = PortfolioState::new(10_000.);
assert!(observations[0].apply(&mut account, &data, true).is_err());
assert_eq!(account.cash(), 10_000.);
assert!(account.positions().is_empty());
observations[0].apply(&mut account, &data, false).unwrap();
let before = account.cash();
let mut sell = input.clone();
sell.actions[0].orders[0].side = OrderSide::Sell;
reseal(&mut sell);
assert!(
sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap_err()
.contains("T+1")
);
assert_eq!(account.cash(), before);
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
assert!(
observations[0]
.apply(&mut account, &unlisted, false)
.unwrap_err()
.contains("lifecycle")
);
assert_eq!(account.cash(), before);
}
#[test]
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let input = sample();
let mut account = PortfolioState::new(10_000.);
input.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
let mut sell = input.clone();
let order = &mut sell.actions[0].orders[0];
order.side = OrderSide::Sell;
order.order_created_at += chrono::Duration::days(1);
order.terminal_observed_at += chrono::Duration::days(1);
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
order.fills[0].executed_at += chrono::Duration::days(1);
order.fills[0].observed_at += chrono::Duration::days(1);
order.fills[0].fee_observed_at += chrono::Duration::days(1);
sell.observation_cutoff += chrono::Duration::days(1);
reseal(&mut sell);
let applied = sell.observations().unwrap()[0]
.apply(&mut account, &data, false)
.unwrap();
assert_eq!(applied.quantity_after, 0);
assert_eq!(account.cash(), 9999.76);
assert_eq!(account.external_cash_flow_total(), 0.);
}
#[test]
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
let mut input = sample();
let mut second = input.actions[0].orders[0].fills[0].clone();
second.trade_id = "trade-2".into();
second.observation_event_id = "received-2".into();
second.observation_sequence = 2;
second.fee_observation_event_id = "received-2".into();
second.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.insert(0, second);
reseal(&mut input);
assert_eq!(
input
.observations()
.unwrap()
.iter()
.map(|row| row.fill.observation_sequence)
.collect::<Vec<_>>(),
vec![1, 2]
);
let mut invalid = input.clone();
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
let mut invalid = input;
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
assert!(
semantic_result(&invalid)
.unwrap_err()
.contains("observation")
);
}
#[test]
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
let mut input = sample();
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
semantic_result(&input).unwrap();
input.actions[0].orders[0].quantity = 100;
assert!(
semantic_result(&input)
.unwrap_err()
.contains("terminal status")
);
}
#[test]
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
let input = sample();
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(10_000.);
assert_eq!(replay.next_observation_at(), Some(at));
assert!(
replay
.advance(
at - chrono::Duration::milliseconds(1),
&mut account,
&data,
false
)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), 10_000.);
let records = replay.advance(at, &mut account, &data, false).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].cash_delta, "-1012.465679");
assert_eq!(replay.applied_count(), 1);
assert_eq!(replay.next_observation_at(), None);
let cash = account.cash();
assert!(
replay
.advance(at, &mut account, &data, false)
.unwrap()
.is_empty()
);
assert_eq!(account.cash(), cash);
assert!(
replay
.advance(
at - chrono::Duration::seconds(1),
&mut account,
&data,
false
)
.unwrap_err()
.contains("backwards")
);
}
#[test]
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
let mut input = sample();
let mut next = input.actions[0].orders[0].fills[0].clone();
next.trade_id = "trade-2".into();
next.observation_event_id = "received-2".into();
next.observation_sequence = 2;
next.fee_observation_event_id = "received-2".into();
next.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.push(next);
reseal(&mut input);
let at = input.actions[0].orders[0].fills[0].observed_at;
let mut replay = ManualReplayCursor::new(input).unwrap();
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut account = PortfolioState::new(1_500.);
assert!(replay.advance(at, &mut account, &data, false).is_err());
assert_eq!(account.cash(), 1_500.);
assert!(account.positions().is_empty());
assert_eq!(replay.applied_count(), 0);
assert_eq!(replay.next_observation_at(), Some(at));
}
#[test]
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
for text in [
"0",
"100",
"-100",
"0.000001",
"-0.000001",
"12345678901234567890123456.123456",
] {
assert_eq!(
FixedMoney::from_decimal_str(text)
.unwrap()
.to_decimal_string(),
text
);
}
let min = FixedMoney::from_raw(i128::MIN);
assert!(min.to_decimal_string().starts_with('-'));
}
+18 -1
View File
@@ -93,6 +93,15 @@ pub fn compute_backtest_metrics(
account_events: &[AccountEvent], account_events: &[AccountEvent],
initial_cash: f64, initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>, risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> {
compute_backtest_metrics_with_manual(equity_curve, fills, &[], daily_holdings, account_events, initial_cash, risk_free_contract)
}
pub fn compute_backtest_metrics_with_manual(
equity_curve: &[DailyEquityPoint], fills: &[FillEvent],
manual_executions: &[crate::manual_execution::ManualReplayApplication],
daily_holdings: &[HoldingSummary], account_events: &[AccountEvent], initial_cash: f64,
risk_free_contract: Option<&RiskFreeRateContract>,
) -> Result<BacktestMetrics, String> { ) -> Result<BacktestMetrics, String> {
let Some(first_point) = equity_curve.first() else { let Some(first_point) = equity_curve.first() else {
return Ok(BacktestMetrics { return Ok(BacktestMetrics {
@@ -229,12 +238,20 @@ pub fn compute_backtest_metrics(
); );
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
let turnover_by_date = fills let mut turnover_by_date = fills
.iter() .iter()
.fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| { .fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
*acc.entry(fill.date).or_default() += fill.gross_amount.abs(); *acc.entry(fill.date).or_default() += fill.gross_amount.abs();
acc acc
}); });
for execution in manual_executions {
use rust_decimal::prelude::ToPrimitive;
let gross = execution.ledger_gross_amount.parse::<rust_decimal::Decimal>()
.ok().and_then(|value| value.to_f64()).filter(|value| value.is_finite() && *value >= 0.)
.ok_or("manual turnover requires its validated ledger gross amount")?;
let date = execution.observed_at.with_timezone(&chrono::FixedOffset::east_opt(8 * 3600).unwrap()).date_naive();
*turnover_by_date.entry(date).or_default() += gross;
}
let equity_by_date = equity_curve let equity_by_date = equity_curve
.iter() .iter()
.map(|point| (point.date, point.total_equity)) .map(|point| (point.date, point.total_equity))
+415
View File
@@ -0,0 +1,415 @@
use std::borrow::Cow;
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};
/// Sorted numeric fields stored contiguously, without a tree node per snapshot.
#[derive(Clone, Default, PartialEq)]
pub struct NumericFactorMap {
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 {
pub const fn new() -> Self {
Self {
entries: Vec::new(),
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn clear(&mut self) {
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_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_str().cmp(key))
.ok()
.map(|index| &mut self.entries[index].1)
}
pub fn contains_key(&self, key: &str) -> bool {
self.get(key).is_some()
}
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_str() < key.as_str())
{
self.entries.push((key, value));
return None;
}
match self
.entries
.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) => {
self.entries.insert(index, (key, value));
None
}
}
}
pub fn remove(&mut self, key: &str) -> Option<f64> {
self.entries
.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(&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 = &CompactString> + ExactSizeIterator {
self.entries.iter().map(|(key, _)| key)
}
pub fn values(&self) -> impl DoubleEndedIterator<Item = &f64> + ExactSizeIterator {
self.entries.iter().map(|(_, value)| value)
}
}
impl fmt::Debug for NumericFactorMap {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self).finish()
}
}
impl Index<&str> for NumericFactorMap {
type Output = f64;
fn index(&self, key: &str) -> &Self::Output {
self.get(key).expect("numeric factor key not found")
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (CompactString, f64)>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a CompactString, &'a f64);
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (k, v))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for Iter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back().map(|(k, v)| (k, v))
}
}
impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a NumericFactorMap {
type Item = (&'a CompactString, &'a f64);
type IntoIter = Iter<'a>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
impl IntoIterator for NumericFactorMap {
type Item = (CompactString, f64);
type IntoIter = std::vec::IntoIter<Self::Item>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}
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) {
entries.sort_by(|left, right| left.0.cmp(&right.0));
}
entries.dedup_by(|later, earlier| {
if later.0 == earlier.0 {
earlier.1 = later.1;
true
} else {
false
}
});
Self { entries }
}
}
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;
}
if self.is_empty() {
*self = incoming;
return;
}
if self.entries.last().unwrap().0 < incoming.entries[0].0 {
self.entries.append(&mut incoming.entries);
return;
}
// Merge sorted sets in linear time; wide factor batches must not shift
// the existing vector once per field. Existing keys keep their identity.
let mut merged = Vec::with_capacity(self.len() + incoming.len());
let mut old = std::mem::take(&mut self.entries).into_iter().peekable();
let mut new = incoming.entries.into_iter().peekable();
while let (Some(left), Some(right)) = (old.peek(), new.peek()) {
match left.0.cmp(&right.0) {
std::cmp::Ordering::Less => merged.push(old.next().unwrap()),
std::cmp::Ordering::Greater => merged.push(new.next().unwrap()),
std::cmp::Ordering::Equal => {
let (key, _) = old.next().unwrap();
merged.push((key, new.next().unwrap().1));
}
}
}
merged.extend(old);
merged.extend(new);
self.entries = merged;
}
}
impl<const N: usize> From<[(Cow<'static, str>, f64); N]> for NumericFactorMap {
fn from(entries: [(Cow<'static, str>, f64); N]) -> Self {
entries.into_iter().collect()
}
}
impl From<BTreeMap<Cow<'static, str>, f64>> for NumericFactorMap {
fn from(entries: BTreeMap<Cow<'static, str>, f64>) -> Self {
entries.into_iter().collect()
}
}
impl Serialize for NumericFactorMap {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(self.len()))?;
for (key, value) in self {
map.serialize_entry(key, value)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for NumericFactorMap {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct Fields;
impl<'de> Visitor<'de> for Fields {
type Value = NumericFactorMap;
fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("a numeric factor map")
}
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::<CompactString, f64>()? {
entries.push((key, value));
}
Ok(entries.into_iter().collect())
}
}
deserializer.deserialize_map(Fields)
}
}
#[cfg(test)]
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();
let mut tree = BTreeMap::new();
let mut seed = 71_u64;
for index in 0..10000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
let key: Cow<'static, str> = Cow::Owned(format!("factor_{:04}", (seed >> 32) % 1000));
let value = (index as f64 - 5000.0) / 7.0;
if index % 11 == 0 {
assert_eq!(flat.remove(key.as_ref()), tree.remove(key.as_ref()));
} else {
assert_eq!(flat.insert(key.clone(), value), tree.insert(key, value));
}
}
assert_eq!(
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().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>(),
std::mem::size_of_val(&tree)
);
}
#[test]
fn bulk_load_is_sorted_and_keeps_last_value_for_each_field() {
let input = vec![
(Cow::Borrowed("z"), 2.0),
(Cow::Borrowed("a"), 1.0),
(Cow::Borrowed("z"), 3.0),
(Cow::Borrowed("z"), 4.0),
];
let flat: NumericFactorMap = input.clone().into_iter().collect();
let tree: BTreeMap<_, _> = input.into_iter().collect();
assert_eq!(
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);
}
#[test]
fn serialization_keeps_the_map_contract_and_precise_numbers() {
let input = [
(Cow::Borrowed("zero"), -0.0),
(Cow::Borrowed("tiny"), 1.0000000000000002),
(Cow::Borrowed("large"), 9.123456789123456e20),
];
let flat = NumericFactorMap::from(input.clone());
let tree = BTreeMap::from(input);
let json = serde_json::to_string(&flat).unwrap();
assert_eq!(json, serde_json::to_string(&tree).unwrap());
let decoded: NumericFactorMap = serde_json::from_str(&json).unwrap();
for (key, value) in &flat {
assert_eq!(value.to_bits(), decoded[key.as_ref()].to_bits());
}
let duplicate: NumericFactorMap = serde_json::from_str(r#"{"x":1,"x":2}"#).unwrap();
assert_eq!(duplicate["x"], 2.0);
}
#[test]
fn clone_does_not_share_mutable_values() {
let original = NumericFactorMap::from([(Cow::Borrowed("signal"), 1.0)]);
let mut copy = original.clone();
*copy.get_mut("signal").unwrap() = 0.0;
copy.insert(Cow::Borrowed("other"), 2.0);
assert_eq!(original["signal"], 1.0);
assert!(!original.contains_key("other"));
}
#[test]
fn wide_batch_merge_matches_tree_and_preserves_old_key_ownership() {
let entries = (0..4096)
.map(|index| (Cow::Owned(format!("f_{index:05}")), index as f64))
.collect::<Vec<_>>();
let mut flat: NumericFactorMap = entries.clone().into_iter().collect();
let mut tree = BTreeMap::from_iter(entries);
flat.insert(Cow::Borrowed("shared"), -0.0);
tree.insert(Cow::Borrowed("shared"), -0.0);
let incoming = (2048..8192)
.rev()
.map(|index| (Cow::Owned(format!("f_{index:05}")), -(index as f64)))
.chain([(Cow::Owned("shared".to_owned()), 1.0)])
.collect::<Vec<_>>();
flat.extend(incoming.clone());
tree.extend(incoming);
assert_eq!(
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.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::<(CompactString, f64)>());
assert_eq!(flat.len(), tree.len() + 1);
}
}
+41 -3
View File
@@ -212,6 +212,11 @@ pub fn build_dataset_context(
} }
pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> { 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(); let mut specs = Vec::new();
match value { match value {
Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?), Value::String(text) => specs.extend(crate::daily_patterns::expression_specs(text)?),
@@ -221,7 +226,15 @@ pub fn specs_in_value(value: &Value) -> Result<Vec<PatternSpec>, String> {
} }
} }
Value::Object(items) => { Value::Object(items) => {
for v in items.values() { let typed_pool = items.get("stockPool").or_else(|| items.get("stock_pool"))
.is_some_and(Value::is_object);
for (key, v) in items {
// The executable pool already supplies decoded expressions.
// Its display/source serialization escapes those expressions
// one more time and is not another executable program.
if typed_pool && matches!(key.as_str(), "sourceCode" | "source_code") {
continue;
}
specs.extend(specs_in_value(v)?); specs.extend(specs_in_value(v)?);
} }
} }
@@ -248,6 +261,31 @@ mod tests {
use super::*; use super::*;
use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument}; use crate::{BenchmarkSnapshot, DailyFactorSnapshot, DailyMarketSnapshot, Instrument};
use serde_json::json; use serde_json::json;
#[test]
fn structured_pool_conditions_are_not_rescanned_inside_serialized_source_code() {
let pattern = json!({"template":"expression","parameters":{"history_window":20},
"expression":{"kind":"operator","name":"GT","args":[{"kind":"field","name":"amount"},{"kind":"number","value":0}]}});
let expr = format!("pattern_signal({})", serde_json::to_string(&pattern.to_string()).unwrap());
let pool = json!({"schema_version":1,"pool_id":"fixture","version_id":"v1","members":[],
"allocation_policy":{},"timing_policy":{},"stop_take_policy":{},"out_of_pool_policy":"hold",
"exit_signals":[{"role":"risk_exit","when_expr":expr,"remaining_position_bps":5000,"reason":"fixture"}]});
let source = format!("stock_pool.config({pool})");
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);
}
#[test] #[test]
fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() { fn normalized_rule_does_not_turn_an_omitted_window_into_explicit_null() {
let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap(); let expression:Expr=serde_json::from_value(json!({"kind":"operator","name":"GT","args":[{"kind":"field","name":"close"},{"kind":"number","value":1}]})).unwrap();
@@ -298,7 +336,7 @@ mod tests {
][n][i]; ][n][i];
market.push(DailyMarketSnapshot { market.push(DailyMarketSnapshot {
date: *d, date: *d,
symbol: s.to_string(), symbol: (*s).into(),
timestamp: None, timestamp: None,
day_open: c, day_open: c,
open: c, open: c,
@@ -321,7 +359,7 @@ mod tests {
}); });
factors.push(DailyFactorSnapshot { factors.push(DailyFactorSnapshot {
date: *d, date: *d,
symbol: s.to_string(), symbol: (*s).into(),
market_cap_bn: 1., market_cap_bn: 1.,
free_float_cap_bn: 1., free_float_cap_bn: 1.,
pe_ttm: 10., pe_ttm: 10.,
File diff suppressed because it is too large Load Diff
+31 -4
View File
@@ -182,6 +182,15 @@ impl PlatformExprStrategy {
scope.push(symbol) scope.push(symbol)
} }
} }
let allocation_weights = self
.config
.runtime_position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
.or_else(|| {
self.config
.position_exposure_timeline
.allocation_weights_at(portfolio_loss_decision_at(ctx))
});
let members = scope let members = scope
.iter() .iter()
.enumerate() .enumerate()
@@ -198,18 +207,36 @@ impl PlatformExprStrategy {
take_profit: constraints.default_take_profit, take_profit: constraints.default_take_profit,
}); });
member.requested_order = index as i32; member.requested_order = index as i32;
if let Some(weights) = allocation_weights {
member.target_weight_bps = Some(*weights.get(symbol).unwrap_or(&0));
}
member member
}) })
.collect(); .collect();
let (base_ratio, reserve_cash) = let (base_ratio, reserve_cash) =
pool::stock_pool_funding_from_configuration(&program.allocation_policy) pool::stock_pool_funding_from_configuration(&program.allocation_policy)
.map_err(BacktestError::Execution)?; .map_err(BacktestError::Execution)?;
let base_exposure = self
.config
.position_exposure_timeline
.exposure_at(
portfolio_loss_decision_at(ctx),
ctx.execution_date,
&self.config.position_exposure_schedule,
f64::from(base_ratio) / 10000.,
)
.unwrap_or(f64::from(base_ratio) / 10000.);
let ratio = self let ratio = self
.config .config
.position_exposure_schedule .runtime_position_exposure_timeline
.range(..=ctx.decision_date) .exposure_at(
.next_back() portfolio_loss_decision_at(ctx),
.map(|(_, value)| (*value * 10000.).round() as i64) ctx.execution_date,
&self.config.runtime_position_exposure_schedule,
base_exposure,
)
.or(Some(base_exposure))
.map(|value| (value * 10000.).round() as i64)
.unwrap_or(i64::from(base_ratio)); .unwrap_or(i64::from(base_ratio));
let invest_ratio_bps = i32::try_from(ratio) let invest_ratio_bps = i32::try_from(ratio)
.ok() .ok()
@@ -949,6 +949,8 @@ pub struct StrategyExpressionRiskConfig {
pub exposure_expr: Option<String>, pub exposure_expr: Option<String>,
#[serde(default, alias = "position_exposure_schedule")] #[serde(default, alias = "position_exposure_schedule")]
pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>, pub position_exposure_schedule: Vec<StrategyPositionExposureSchedulePoint>,
#[serde(default, alias = "position_exposure_events")]
pub position_exposure_events: Vec<crate::position_exposure::PositionExposureEvent>,
#[serde(default)] #[serde(default)]
pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>, pub portfolio_drawdown_control: Option<StrategyPortfolioDrawdownControlConfig>,
#[serde(default)] #[serde(default)]
@@ -2228,6 +2230,7 @@ pub fn platform_expr_config_from_spec(
expr.clone() expr.clone()
}; };
} }
cfg.position_exposure_timeline = crate::position_exposure::PositionExposureTimeline::from_events(&risk.position_exposure_events)?;
for point in &risk.position_exposure_schedule { for point in &risk.position_exposure_schedule {
let effective_date = NaiveDate::parse_from_str( let effective_date = NaiveDate::parse_from_str(
point.effective_date.trim(), point.effective_date.trim(),
+154 -23
View File
@@ -138,18 +138,30 @@ impl Position {
if quantity == 0 { if quantity == 0 {
return; return;
} }
let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount");
self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount);
}
fn buy_with_fixed_gross(
&mut self,
date: NaiveDate,
quantity: u32,
execution_price: f64,
mark_price: f64,
gross_amount: FixedMoney,
) {
let previous_quantity = self.quantity; let previous_quantity = self.quantity;
self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date))); self.last_buy_date = Some(
self.last_buy_date
.map_or(date, |previous| previous.max(date)),
);
if previous_quantity == 0 { if previous_quantity == 0 {
self.opened_date = Some(date); self.opened_date = Some(date);
} else if let Some(opened) = self.opened_date {
self.opened_date = Some(opened.min(date));
} }
let previous_average_price = self.average_price; let previous_average_price = self.average_price;
let previous_average_cost = self.average_cost; let previous_average_cost = self.average_cost;
let gross_amount = fixed_money_or_panic(
execution_price * quantity as f64,
"position buy gross amount",
);
self.lots.push(PositionLot { self.lots.push(PositionLot {
acquired_date: date, acquired_date: date,
quantity, quantity,
@@ -200,6 +212,20 @@ impl Position {
quantity: u32, quantity: u32,
execution_price: f64, execution_price: f64,
mark_price: f64, mark_price: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol));
}
let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?;
self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds)
}
fn sell_with_fixed_gross(
&mut self,
quantity: u32,
execution_price: f64,
mark_price: f64,
total_proceeds: FixedMoney,
) -> Result<f64, String> { ) -> Result<f64, String> {
if quantity > self.quantity { if quantity > self.quantity {
return Err(format!( return Err(format!(
@@ -208,10 +234,17 @@ impl Position {
)); ));
} }
let total_proceeds = fixed_money( // A delayed receipt or a successor conversion can append an older
execution_price * quantity as f64, // acquisition after a newer lot. Deplete by actual acquisition date;
"position sell gross amount", // stable ordering preserves same-day receipts and their attached fees.
)?; if quantity > 0
&& self
.lots
.windows(2)
.any(|pair| pair[0].acquired_date > pair[1].acquired_date)
{
self.lots.sort_by_key(|lot| lot.acquired_date);
}
let mut remaining = quantity; let mut remaining = quantity;
let mut remaining_proceeds = total_proceeds; let mut remaining_proceeds = total_proceeds;
let mut realized = FixedMoney::ZERO; let mut realized = FixedMoney::ZERO;
@@ -796,6 +829,106 @@ impl PortfolioState {
Ok(()) Ok(())
} }
/// Apply one fully observed external fill atomically. Its money is already
/// quantized from the original decimal amounts, not from a float product.
pub(crate) fn apply_observed_manual_fill(
&mut self,
trade_date: NaiveDate,
symbol: &str,
side: crate::events::OrderSide,
quantity: u32,
price: f64,
mark_price: f64,
gross: FixedMoney,
fees: FixedMoney,
) -> Result<FixedMoney, String> {
use crate::events::OrderSide;
if symbol.trim().is_empty()
|| quantity == 0
|| quantity > i32::MAX as u32
|| !price.is_finite()
|| price <= 0.
|| !mark_price.is_finite()
|| mark_price <= 0.
|| gross <= FixedMoney::ZERO
|| fees < FixedMoney::ZERO
{
return Err("invalid observed manual fill".into());
}
let mut position = self
.positions
.get(symbol)
.cloned()
.unwrap_or_else(|| Position::new(symbol));
let delta = match side {
OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg),
OrderSide::Sell => gross.checked_sub(fees),
}
.ok_or("manual fill cash delta overflow")?;
let next_cash = self
.cash
.checked_add(delta)
.filter(|cash| *cash >= FixedMoney::ZERO)
.ok_or("manual fill disagrees with shadow available cash")?;
let next_cost = position
.day_trade_cost
.checked_add(fees)
.ok_or("manual trade cost overflow")?;
match side {
OrderSide::Buy => {
let total_quantity = position
.quantity
.checked_add(quantity)
.ok_or("manual position quantity overflow")?;
FixedMoney::from_f64(mark_price * f64::from(total_quantity))
.ok_or("manual marked position value overflow")?;
position
.day_buy_quantity
.checked_add(quantity)
.ok_or("manual daily buy quantity overflow")?;
position
.day_trade_quantity_delta
.checked_add(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position
.day_buy_value
.checked_add(gross)
.ok_or("manual daily buy value overflow")?;
let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?;
position
.total_cost_basis()
.checked_add(total_basis)
.ok_or("manual aggregate position basis overflow")?;
position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross);
position
.lots
.last_mut()
.ok_or("manual buy produced no lot")?
.cost_basis = total_basis;
position.average_cost += fees.to_f64() / f64::from(position.quantity);
}
OrderSide::Sell => {
if quantity > position.sellable_qty(trade_date) {
return Err("manual fill disagrees with shadow sellable holdings or T+1".into());
}
position
.day_sell_quantity
.checked_add(quantity)
.ok_or("manual daily sell quantity overflow")?;
position
.day_trade_quantity_delta
.checked_sub(quantity as i32)
.ok_or("manual daily quantity delta overflow")?;
position.sell_with_fixed_gross(quantity, price, mark_price, gross)?;
}
}
position.day_trade_cost = next_cost;
position.refresh_day_pnl();
self.positions.insert(symbol.to_string(), position);
self.cash = next_cash;
Ok(delta)
}
pub fn prune_flat_positions(&mut self) { pub fn prune_flat_positions(&mut self) {
let mut sold_symbols = Vec::new(); let mut sold_symbols = Vec::new();
self.positions.retain(|symbol, position| { self.positions.retain(|symbol, position| {
@@ -1438,8 +1571,6 @@ mod tests {
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField, PriceField,
}; };
use std::collections::BTreeMap;
#[test] #[test]
fn cash_ledger_accumulates_micro_yuan_exactly() { fn cash_ledger_accumulates_micro_yuan_exactly() {
let mut portfolio = PortfolioState::new(1_000_000.0); let mut portfolio = PortfolioState::new(1_000_000.0);
@@ -1641,7 +1772,7 @@ mod tests {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: prev_date, date: prev_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -1664,7 +1795,7 @@ mod tests {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -1688,18 +1819,18 @@ mod tests {
], ],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 50.0, market_cap_bn: 50.0,
free_float_cap_bn: 45.0, free_float_cap_bn: 45.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1754,7 +1885,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: None, timestamp: None,
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -1777,18 +1908,18 @@ mod tests {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 50.0, market_cap_bn: 50.0,
free_float_cap_bn: 45.0, free_float_cap_bn: 45.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1840,7 +1971,7 @@ mod tests {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date: prev_date, date: prev_date,
symbol: "601028.SH".to_string(), symbol: "601028.SH".into(),
timestamp: None, timestamp: None,
day_open: 10.2, day_open: 10.2,
open: 10.2, open: 10.2,
@@ -1923,7 +2054,7 @@ mod tests {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: buy_date, date: buy_date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: 2.99, day_open: 2.99,
open: 2.99, open: 2.99,
@@ -1946,7 +2077,7 @@ mod tests {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: next_date, date: next_date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: 3.03, day_open: 3.03,
open: 3.03, open: 3.03,
+536
View File
@@ -0,0 +1,536 @@
//! Dated manual adjustments are ordered facts; restoring is not a 100% target.
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum PositionExposureAction {
Scale {
#[serde(rename = "requestedBps", alias = "requested_bps")]
requested_bps: i32,
},
Set {
#[serde(rename = "targetExposureBps", alias = "target_exposure_bps")]
target_exposure_bps: i32,
},
Restore,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PositionExposureEvent {
#[serde(alias = "event_id")]
pub event_id: String,
pub sequence: u64,
#[serde(alias = "effective_at")]
pub effective_at: DateTime<Utc>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
alias = "allocation_weights_bps"
)]
pub allocation_weights_bps: Option<BTreeMap<String, i32>>,
#[serde(flatten)]
pub action: PositionExposureAction,
}
#[derive(Debug, Clone, Default)]
pub struct PositionExposureTimeline {
events: BTreeMap<(DateTime<Utc>, u64), (PositionExposureAction, Option<BTreeMap<String, i32>>)>,
}
impl PositionExposureTimeline {
pub fn from_events(events: &[PositionExposureEvent]) -> Result<Self, String> {
let mut result = Self::default();
let mut ids = BTreeSet::new();
let mut sequences = BTreeSet::new();
for event in events {
if event.event_id.trim().is_empty() || !ids.insert(event.event_id.as_str()) {
return Err("position exposure event id is missing or duplicated".into());
}
if event.sequence == 0 || !sequences.insert(event.sequence) {
return Err("position exposure event sequence must be positive and unique".into());
}
if let PositionExposureAction::Scale { requested_bps } = event.action
&& !(0..=10000).contains(&requested_bps)
{
return Err("position exposure scale must be between 0 and 10000 bps".into());
}
if let PositionExposureAction::Set {
target_exposure_bps,
} = event.action
&& !(0..=10_000).contains(&target_exposure_bps)
{
return Err("position exposure target must be between 0 and 10000 bps".into());
}
if let Some(weights) = &event.allocation_weights_bps {
let target = match event.action {
PositionExposureAction::Set {
target_exposure_bps,
} => target_exposure_bps,
PositionExposureAction::Scale { requested_bps } => requested_bps,
PositionExposureAction::Restore => {
return Err(
"restoring strategy allocation cannot carry manual weights".into()
);
}
};
validate_allocation_weights(weights, target)?;
}
result.events.insert(
(event.effective_at, event.sequence),
(event.action.clone(), event.allocation_weights_bps.clone()),
);
}
Ok(result)
}
/// Legacy day-level contracts remain day-level; never invent intraday times.
pub fn exposure_at(
&self,
at: DateTime<Utc>,
execution_date: NaiveDate,
legacy: &BTreeMap<NaiveDate, f64>,
strategy_exposure: f64,
) -> Option<f64> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, (action, _))| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => {
Some(strategy_exposure * f64::from(*requested_bps) / 10000.)
}
Some(PositionExposureAction::Set {
target_exposure_bps,
}) => Some(f64::from(*target_exposure_bps) / 10_000.),
Some(PositionExposureAction::Restore) => None,
None => legacy
.range(..=execution_date)
.next_back()
.map(|(_, value)| *value),
}
}
pub fn scale_at(&self, at: DateTime<Utc>) -> Option<i32> {
match self
.events
.range(..=(at, u64::MAX))
.next_back()
.map(|(_, (action, _))| action)
{
Some(PositionExposureAction::Scale { requested_bps }) => Some(*requested_bps),
_ => None,
}
}
pub fn allocation_weights_at(&self, at: DateTime<Utc>) -> Option<&BTreeMap<String, i32>> {
self.events
.range(..=(at, u64::MAX))
.next_back()
.and_then(|(_, (_, weights))| weights.as_ref())
}
}
pub fn validate_allocation_weights(
weights: &BTreeMap<String, i32>,
exposure_bps: i32,
) -> Result<(), String> {
if !(0..=10000).contains(&exposure_bps) || weights.len() > 10000 {
return Err("invalid allocation exposure or weight count".into());
}
for (symbol, weight) in weights {
if !(0..=10000).contains(weight)
|| !symbol.rsplit_once('.').is_some_and(|(code, exchange)| {
code.len() == 6
&& code.bytes().all(|byte| byte.is_ascii_digit())
&& matches!(exchange, "SH" | "SZ" | "BJ")
})
{
return Err(
"allocation weights require canonical stock/ETF symbols and 0..10000 bps".into(),
);
}
}
if (weights.is_empty() && exposure_bps != 0)
|| (!weights.is_empty() && weights.values().sum::<i32>() != 10000)
{
return Err("manual allocation weights must total 10000 bps; only a zero exposure may have no weights".into());
}
Ok(())
}
/// Scale new buys and desired targets without weakening sell/reduction or
/// cancellation instructions. Prices, subscriptions and cash flows are intact.
pub fn scale_explicit_intent(
mut intent: crate::OrderIntent,
bps: i32,
open_orders: &[crate::OpenOrderView],
) -> Result<Option<crate::OrderIntent>, String> {
use crate::OrderIntent as I;
if !(0..=10000).contains(&bps) {
return Err("position scale out of range".into());
}
if bps == 10000 {
return Ok(Some(intent));
}
if let I::WithTimeInForce {
intent: inner,
time_in_force,
} = intent
{
return Ok(
scale_explicit_intent(*inner, bps, open_orders)?.map(|intent| I::WithTimeInForce {
intent: Box::new(intent),
time_in_force,
}),
);
}
let integer = |value: i32| ((i64::from(value) * i64::from(bps)) / 10000) as i32;
let amount = |value: f64, target: bool| -> Result<f64, String> {
if !value.is_finite() || (target && value < 0.) {
return Err("position override received an invalid original amount".into());
}
Ok(if value > 0. {
value * f64::from(bps) / 10000.
} else {
value
})
};
match &mut intent {
I::Shares { quantity, .. }
| I::LimitShares { quantity, .. }
| I::Lots { lots: quantity, .. }
| I::LimitLots { lots: quantity, .. } => {
if *quantity > 0 {
*quantity = integer(*quantity);
if *quantity == 0 {
return Ok(None);
}
}
}
I::TargetShares {
target_quantity, ..
}
| I::LimitTargetShares {
target_quantity, ..
} => {
if *target_quantity < 0 {
return Err("position override received a negative target quantity".into());
}
*target_quantity = integer(*target_quantity);
}
I::Value { value, .. }
| I::LimitValue { value, .. }
| I::AlgoValue { value, .. }
| I::Percent { percent: value, .. }
| I::LimitPercent { percent: value, .. }
| I::AlgoPercent { percent: value, .. } => {
*value = amount(*value, false)?;
if *value == 0. {
return Ok(None);
}
}
I::TargetValue { target_value, .. }
| I::LimitTargetValue { target_value, .. }
| I::TimedTargetValue { target_value, .. }
| I::TargetPercent {
target_percent: target_value,
..
}
| I::LimitTargetPercent {
target_percent: target_value,
..
} => {
*target_value = amount(*target_value, true)?;
}
I::TargetPortfolioSmart { target_weights, .. } => {
for value in target_weights.values_mut() {
*value = amount(*value, true)?;
}
}
I::ModifyOrder {
order_id,
new_total_quantity: Some(quantity),
..
} => {
let order = open_orders
.iter()
.find(|order| order.order_id == *order_id)
.ok_or("position override cannot resolve the order being modified")?;
if order.side == crate::OrderSide::Buy && *quantity > order.requested_quantity {
let extra = u64::from(*quantity - order.requested_quantity) * bps as u64 / 10000;
*quantity = order.requested_quantity + extra as u32;
}
}
I::Futures { .. } | I::StockPool { .. } => {
return Err("manual equity scaling cannot transform this intent kind".into());
}
I::ModifyOrder { .. }
| I::CancelOrder { .. }
| I::CancelSymbol { .. }
| I::CancelAll { .. }
| I::UpdateUniverse { .. }
| I::Subscribe { .. }
| I::Unsubscribe { .. }
| I::DepositWithdraw { .. }
| I::FinanceRepay { .. }
| I::SetManagementFeeRate { .. } => {}
I::WithTimeInForce { .. } => unreachable!("wrapper handled first"),
}
Ok(Some(intent))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn scalar_preserves_strategy_risk_off_and_restore_keeps_original_exposure() {
let at = DateTime::parse_from_rfc3339("2026-01-05T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
let event = PositionExposureEvent {
event_id: "scale".into(),
sequence: 1,
effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Scale {
requested_bps: 5000,
},
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.),
Some(0.)
);
assert_eq!(
timeline.exposure_at(at, at.date_naive(), &BTreeMap::new(), 0.2),
Some(0.1)
);
let restored = PositionExposureEvent {
event_id: "restore".into(),
sequence: 2,
effective_at: at,
allocation_weights_bps: None,
action: PositionExposureAction::Restore,
};
let timeline = PositionExposureTimeline::from_events(&[event, restored]).unwrap();
assert_eq!(
timeline
.exposure_at(
at,
at.date_naive(),
&BTreeMap::from([(at.date_naive(), 1.)]),
0.2
)
.unwrap_or(0.2),
0.2
);
}
#[test]
fn allocation_is_dated_and_any_later_scalar_or_restore_clears_it() {
let at = DateTime::parse_from_rfc3339("2026-09-14T10:00:00+08:00")
.unwrap()
.with_timezone(&Utc);
let weights = BTreeMap::from([("000001.SZ".into(), 3000), ("510300.SH".into(), 7000)]);
let event = PositionExposureEvent {
event_id: "allocation".into(),
sequence: 1,
effective_at: at,
action: PositionExposureAction::Set {
target_exposure_bps: 8000,
},
allocation_weights_bps: Some(weights.clone()),
};
let timeline = PositionExposureTimeline::from_events(&[event.clone()]).unwrap();
assert!(
timeline
.allocation_weights_at(at - chrono::Duration::seconds(1))
.is_none()
);
assert_eq!(timeline.allocation_weights_at(at), Some(&weights));
for action in [
PositionExposureAction::Set {
target_exposure_bps: 5000,
},
PositionExposureAction::Restore,
] {
let next = PositionExposureEvent {
event_id: "new".into(),
sequence: 2,
effective_at: at + chrono::Duration::seconds(1),
action,
allocation_weights_bps: None,
};
assert!(
PositionExposureTimeline::from_events(&[event.clone(), next])
.unwrap()
.allocation_weights_at(at + chrono::Duration::seconds(1))
.is_none()
);
}
assert!(
validate_allocation_weights(&BTreeMap::from([("000001.SZ".into(), 9000)]), 5000)
.is_err()
);
assert!(validate_allocation_weights(&BTreeMap::new(), 1).is_err());
assert!(validate_allocation_weights(&BTreeMap::new(), 0).is_ok());
}
#[test]
fn explicit_equity_targets_and_buys_scale_but_sales_cashflows_and_prices_do_not() {
use crate::OrderIntent as I;
let symbol = "000001.SZ".to_string();
let reason = "fixture".to_string();
for bps in [0, 3000, 5000, 10000] {
let ratio = f64::from(bps) / 10000.;
let buy = I::LimitShares {
symbol: symbol.clone(),
quantity: 1000,
limit_price: 12.345,
reason: reason.clone(),
};
let scaled = scale_explicit_intent(buy, bps, &[]).unwrap();
if bps == 0 {
assert!(scaled.is_none())
} else if let Some(I::LimitShares {
quantity,
limit_price,
..
}) = scaled
{
assert_eq!(quantity, (1000. * ratio) as i32);
assert_eq!(limit_price, 12.345);
} else {
panic!("wrong intent")
}
let sell = I::Shares {
symbol: symbol.clone(),
quantity: -1000,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(sell, bps, &[]).unwrap(),
Some(I::Shares {
quantity: -1000,
..
})
));
let clear = I::TargetShares {
symbol: symbol.clone(),
target_quantity: 0,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(clear, bps, &[]).unwrap(),
Some(I::TargetShares {
target_quantity: 0,
..
})
));
let target = I::TargetPercent {
symbol: symbol.clone(),
target_percent: 0.2,
reason: reason.clone(),
};
if let Some(I::TargetPercent { target_percent, .. }) =
scale_explicit_intent(target, bps, &[]).unwrap()
{
assert!((target_percent - 0.2 * ratio).abs() < 1e-12)
} else {
panic!("wrong target")
}
let deposit = I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
reason: reason.clone(),
};
assert!(matches!(
scale_explicit_intent(deposit, bps, &[]).unwrap(),
Some(I::DepositWithdraw {
amount: 123.456,
receiving_days: 2,
..
})
));
}
assert!(
scale_explicit_intent(
I::TargetValue {
symbol,
target_value: f64::NAN,
reason
},
0,
&[]
)
.is_err()
);
}
#[test]
fn same_day_adjustments_restore_and_future_events_keep_their_own_times() {
let events: Vec<PositionExposureEvent> = serde_json::from_value(json!([
{"eventId":"first","sequence":1,"effectiveAt":"2026-09-10T10:00:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"second","sequence":2,"effectiveAt":"2026-09-10T13:00:00+08:00","action":"set","targetExposureBps":5000},
{"eventId":"restore","sequence":3,"effectiveAt":"2026-09-10T14:00:00+08:00","action":"restore"},
{"eventId":"future","sequence":4,"effectiveAt":"2026-09-11T10:00:00+08:00","action":"set","targetExposureBps":1000}
])).unwrap();
let timeline = PositionExposureTimeline::from_events(&events).unwrap();
let date = NaiveDate::from_ymd_opt(2026, 9, 10).unwrap();
let legacy = BTreeMap::from([(date.pred_opt().unwrap(), 0.8)]);
for (time, expected) in [
("09:30:00", Some(0.8)),
("10:00:00", Some(0.)),
("12:59:59", Some(0.)),
("13:00:00", Some(0.5)),
("14:00:00", None),
("15:00:00", None),
] {
let at = DateTime::parse_from_rfc3339(&format!("2026-09-10T{time}+08:00"))
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(at, date, &legacy, 0.2),
expected,
"{time}"
);
}
let next_open = DateTime::parse_from_rfc3339("2026-09-11T09:30:00+08:00")
.unwrap()
.with_timezone(&Utc);
assert_eq!(
timeline.exposure_at(next_open, date.succ_opt().unwrap(), &legacy, 0.2),
None
);
}
#[test]
fn rejects_unknown_actions_duplicate_identity_and_invalid_bps() {
let valid = json!({"eventId":"one","sequence":1,"effectiveAt":"2026-09-10T09:30:00+08:00","action":"set","targetExposureBps":5000});
for (key, value) in [
("action", json!("other")),
("effectiveAt", json!("2026-09-10 09:30:00")),
("targetExposureBps", json!(null)),
] {
let mut invalid = valid.clone();
invalid[key] = value;
assert!(serde_json::from_value::<PositionExposureEvent>(invalid).is_err());
}
let event: PositionExposureEvent = serde_json::from_value(valid).unwrap();
assert!(PositionExposureTimeline::from_events(&[event.clone(), event.clone()]).is_err());
let mut invalid = event.clone();
invalid.action = PositionExposureAction::Set {
target_exposure_bps: 10001,
};
assert!(PositionExposureTimeline::from_events(&[invalid]).is_err());
let mut duplicate = event.clone();
duplicate.event_id = "two".into();
assert!(PositionExposureTimeline::from_events(&[event, duplicate]).is_err());
}
}
+92 -29
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)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct TradingConstraintConfig { pub struct TradingConstraintConfig {
/// Shared execution limits. These fields intentionally use the same /// 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 { fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
let fields = missing_risk_state_fields(code); let fields = missing_risk_state_fields(code);
if fields.is_empty() { if fields.is_empty() {
return config.static_rules.reject_st_selection return config.static_rules.selection_state_checks_enabled();
|| 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;
} }
missing_field_rejected(&fields, config, RiskCheckScope::Selection) 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, RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
}, },
_ => match scope { _ => match scope {
RiskCheckScope::Selection => { RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
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::Buy => { RiskCheckScope::Buy => {
config.static_rules.reject_st_buy config.static_rules.reject_st_buy
|| config.static_rules.reject_star_st_buy || config.static_rules.reject_star_st_buy
@@ -869,7 +869,7 @@ mod tests {
fn candidate(date: NaiveDate) -> CandidateEligibility { fn candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: "002633.SZ".to_string(), symbol: "002633.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -885,7 +885,7 @@ mod tests {
fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot { fn market(date: NaiveDate, last_price: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "002633.SZ".to_string(), symbol: "002633.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: last_price, day_open: last_price,
open: last_price, open: last_price,
@@ -914,6 +914,69 @@ mod tests {
position 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] #[test]
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() { fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
let day = d(2025, 2, 6); let day = d(2025, 2, 6);
@@ -944,7 +1007,7 @@ mod tests {
let mut candidate=candidate(day); let mut candidate=candidate(day);
let mut snapshot=market(day,1.2,0.5); let mut snapshot=market(day,1.2,0.5);
snapshot.lower_limit=0.01;snapshot.upper_limit=10.; snapshot.lower_limit=0.01;snapshot.upper_limit=10.;
let instrument=Instrument{symbol:candidate.symbol.clone(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()}; let instrument=Instrument{symbol:candidate.symbol.to_string(),name:"fixture fund".into(),board:"ETF".into(),round_lot:100,listed_at:Some(d(2024,1,2)),delisted_at:None,status:"active".into()};
let config=FidcRiskControlConfig::default(); let config=FidcRiskControlConfig::default();
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None); assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.9,&config),None);
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price")); assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(day,&candidate,&snapshot,Some(&instrument),0.,&config),Some("invalid execution price"));
@@ -1047,7 +1110,7 @@ mod tests {
config config
.static_rules .static_rules
.blacklisted_symbols .blacklisted_symbols
.insert(candidate.symbol.clone()); .insert(candidate.symbol.to_string());
let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config( let selection_reason = ChinaAShareRiskControl::selection_rejection_reason_with_config(
date, &candidate, &market, None, &config, date, &candidate, &market, None, &config,
@@ -1092,7 +1155,7 @@ mod tests {
let mut candidate = candidate(date); let mut candidate = candidate(date);
let config = FidcRiskControlConfig::default(); let config = FidcRiskControlConfig::default();
for symbol in ["688001.SH", "689001.SH", "000001.SZ"] { for symbol in ["688001.SH", "689001.SH", "000001.SZ"] {
candidate.symbol = symbol.to_string(); candidate.symbol = symbol.into();
for is_kcb in [false, true] { for is_kcb in [false, true] {
candidate.is_kcb = is_kcb; candidate.is_kcb = is_kcb;
let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config(
@@ -1200,7 +1263,7 @@ mod tests {
fn configurable_bjse_filter_can_be_disabled() { fn configurable_bjse_filter_can_be_disabled() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "430047.BJ".to_string(); candidate.symbol = "430047.BJ".into();
candidate.allow_sell = true; candidate.allow_sell = true;
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
let default_selection = let default_selection =
@@ -1232,7 +1295,7 @@ mod tests {
fn concrete_kcb_reason_wins_over_generic_missing_risk_state() { fn concrete_kcb_reason_wins_over_generic_missing_risk_state() {
let date = d(2025, 1, 2); let date = d(2025, 1, 2);
let mut candidate = candidate(date); let mut candidate = candidate(date);
candidate.symbol = "688506.SH".to_string(); candidate.symbol = "688506.SH".into();
candidate.is_kcb = true; candidate.is_kcb = true;
candidate.risk_level_code = Some("missing_risk_state".to_string()); candidate.risk_level_code = Some("missing_risk_state".to_string());
let market = market(date, 6.27, 5.63); let market = market(date, 6.27, 5.63);
+1 -1
View File
@@ -233,7 +233,7 @@ impl<'a> Scheduler<'a> {
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> { pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
match stage { match stage {
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")), ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 25, 0).expect("valid time")),
ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::Bar => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::Minute => None, ScheduleStage::Minute => None,
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")), ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
+191
View File
@@ -0,0 +1,191 @@
use std::ops::Index;
use super::prefix_sums;
#[derive(Debug, Clone)]
pub(super) enum ReferenceMatchedValues {
Identical,
Owned(Vec<f64>),
}
impl ReferenceMatchedValues {
pub(super) fn push(&mut self, value: f64, reference: &[f64], capacity: usize) {
let previous_len = reference.len().checked_sub(1).expect("reference row is missing");
match self {
Self::Identical if value.to_bits() == reference[previous_len].to_bits() => {}
Self::Identical => {
let mut values = Vec::with_capacity(capacity);
values.extend_from_slice(&reference[..previous_len]);
values.push(value);
*self = Self::Owned(values);
}
Self::Owned(values) => {
debug_assert_eq!(values.len(), previous_len);
values.push(value);
}
}
}
pub(super) fn values<'a>(&'a self, reference: &'a [f64]) -> &'a [f64] {
match self {
Self::Identical => reference,
Self::Owned(values) => {
debug_assert_eq!(values.len(), reference.len());
values
}
}
}
pub(super) fn set(&mut self, index: usize, value: f64, reference: &[f64]) {
assert!(index < reference.len(), "series index out of bounds");
match self {
Self::Owned(values) => values[index] = value,
Self::Identical if value.to_bits() == reference[index].to_bits() => {}
Self::Identical => {
let mut values = reference.to_vec();
values[index] = value;
*self = Self::Owned(values);
}
}
}
pub(super) fn prefix(&self) -> Self {
match self {
Self::Identical => Self::Identical,
Self::Owned(values) => Self::Owned(prefix_sums(values)),
}
}
}
#[derive(Debug, Clone)]
pub(super) struct RepeatedValues<T> {
repeated: T,
values: Option<Vec<T>>,
len: usize,
}
impl<T: Default + Clone + Eq> RepeatedValues<T> {
pub(super) fn new() -> Self {
Self { repeated: T::default(), values: None, len: 0 }
}
pub(super) fn push(&mut self, value: &T, capacity: usize) {
if let Some(values) = &mut self.values {
values.push(value.clone());
} else if self.len == 0 {
self.repeated = value.clone();
} else if *value != self.repeated {
let mut values = Vec::with_capacity(capacity);
values.resize(self.len, std::mem::take(&mut self.repeated));
values.push(value.clone());
self.values = Some(values);
}
self.len += 1;
}
pub(super) fn set(&mut self, index: usize, value: T) {
assert!(index < self.len, "series index out of bounds");
if let Some(values) = &mut self.values {
values[index] = value;
} else if value != self.repeated {
let mut values = vec![std::mem::take(&mut self.repeated); self.len];
values[index] = value;
self.values = Some(values);
}
}
}
impl<T> Index<usize> for RepeatedValues<T> {
type Output = T;
fn index(&self, index: usize) -> &T {
assert!(index < self.len, "series index out of bounds");
match &self.values {
Some(values) => &values[index],
None => &self.repeated,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bits(values: &[f64]) -> Vec<u64> {
values.iter().map(|value| value.to_bits()).collect()
}
#[test]
fn identical_prices_share_only_after_exact_bit_comparison() {
let reference = [10., -0., f64::from_bits(0x7ff8_0000_0000_0042), f64::INFINITY];
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in reference.iter().copied().enumerate() {
column.push(value, &reference[..=index], reference.len());
}
assert!(matches!(column, ReferenceMatchedValues::Identical));
assert_eq!(column.values(&reference).as_ptr(), reference.as_ptr());
let prefix = prefix_sums(&reference);
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix));
let original = column.clone();
column.set(1, 0., &reference);
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(column.values(&reference)[1].to_bits(), 0_f64.to_bits());
assert_eq!(bits(original.values(&reference)), bits(&reference));
assert_eq!(bits(column.prefix().values(&prefix)), bits(&prefix_sums(column.values(&reference))));
}
#[test]
fn differing_prices_preserve_zero_nan_payloads_and_prior_rows() {
let reference = [10., 11., f64::from_bits(0x7ff8_0000_0000_0042), 13.];
for actual in [
[10., 0., reference[2], 13.],
[10., 11., f64::from_bits(0x7ff8_0000_0000_0043), 13.],
] {
let mut column = ReferenceMatchedValues::Identical;
for (index, value) in actual.iter().copied().enumerate() {
column.push(value, &reference[..=index], actual.len());
}
assert!(matches!(column, ReferenceMatchedValues::Owned(_)));
assert_eq!(bits(column.values(&reference)), bits(&actual));
}
}
#[test]
fn repeated_values_preserve_nonzero_values_and_copy_on_change() {
let mut column = RepeatedValues::new();
for _ in 0..128 { column.push(&7_u64, 128); }
assert!(column.values.is_none());
assert_eq!(column[127], 7);
column.set(0, 7);
assert!(column.values.is_none());
let mut changed = column.clone();
changed.set(64, 9);
assert_eq!(changed[64], 9);
assert_eq!(changed[63], 7);
assert_eq!(column[64], 7);
}
#[test]
fn optional_values_keep_none_distinct_from_empty_and_repeated_text() {
for repeated in [None, Some(String::new()), Some("continuous".to_string())] {
let mut column = RepeatedValues::new();
for _ in 0..12 { column.push(&repeated, 16); }
assert!(column.values.is_none());
assert_eq!(column[0], repeated);
column.push(&Some("closing".to_string()), 16);
assert_eq!(column[11], repeated);
assert_eq!(column[12].as_deref(), Some("closing"));
column.set(5, None);
assert_eq!(column[5], None);
assert_eq!(column[4], repeated);
}
}
#[test]
#[should_panic(expected = "series index out of bounds")]
fn repeated_values_reject_out_of_range_access() {
let column = RepeatedValues::<u64>::new();
let _ = column[0];
}
}
+2 -2
View File
@@ -479,7 +479,7 @@ pub struct StockPoolSelection {
pub generation: Option<String>, pub generation: Option<String>,
} }
#[derive(Debug, Clone, Default, PartialEq)] #[derive(Debug, Clone, Default, PartialEq, Serialize)]
pub struct StockPoolDecisionConstraints { pub struct StockPoolDecisionConstraints {
pub execution_date: Option<NaiveDate>, pub execution_date: Option<NaiveDate>,
pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>, pub frozen_positions: BTreeMap<String, FrozenStockPoolPosition>,
@@ -545,7 +545,7 @@ pub struct StockPoolPlan {
/// A signal-time contract. Only the broker/execution adapter supplies later /// A signal-time contract. Only the broker/execution adapter supplies later
/// prices, actual cash and holdings; strategy code never sees those inputs. /// prices, actual cash and holdings; strategy code never sees those inputs.
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct FrozenStockPoolIntent { pub struct FrozenStockPoolIntent {
pub pool_id: String, pub pool_id: String,
pub signal_date: NaiveDate, pub signal_date: NaiveDate,
@@ -0,0 +1,148 @@
//! Condition facts are distinct from the quote's per-observation fill capacity.
//! Only a complete, declared raw-minute prefix can prove a session total.
use std::collections::BTreeMap;
use chrono::{NaiveDate, NaiveDateTime, NaiveTime, Timelike};
use rust_decimal::Decimal;
use crate::data::IntradayExecutionQuote;
use crate::stock_pool_execution::{parse_stock_pool_condition, StockPoolExecutionRule};
pub fn requires_session_totals(rule: &StockPoolExecutionRule) -> bool {
[rule.buy_condition.as_str(), if rule.sell_trigger_mode == "condition" { rule.sell_condition.as_str() } else { "" }].into_iter().any(|condition| {
parse_stock_pool_condition(condition).is_some_and(|(_, field, _, _)| matches!(field.as_str(), "volume" | "amount"))
})
}
/// The cash-equity minute feed includes the opening observation and a separate
/// post-close segment. Trading eligibility remains owned by the dated rules.
fn next_minute(time: NaiveTime) -> Option<NaiveTime> {
let minute = time.hour() * 60 + time.minute();
let next = match minute {
570..=689 | 781..=899 | 906..=929 => minute + 1,
690 => 781,
900 => 906,
_ => return None,
};
NaiveTime::from_hms_opt(next / 60, next % 60, 0)
}
#[derive(Debug, Default)]
pub(crate) struct SessionTotalsCache {
pub date: Option<NaiveDate>,
pub symbols: BTreeMap<String, MinutePrefix>,
}
#[derive(Debug)]
pub(crate) struct MinutePrefix {
values: BTreeMap<NaiveTime, (Decimal, Decimal)>,
failure: String,
}
impl MinutePrefix {
pub fn build(date: NaiveDate, symbol: &str, quotes: &[IntradayExecutionQuote]) -> Self {
let mut values = BTreeMap::new();
let mut expected = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let mut volume = 0_u64;
let mut amount = Decimal::ZERO;
let mut failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
for quote in quotes {
let time = quote.timestamp.time();
if quote.date != date || quote.timestamp.date() != date || quote.symbol != symbol {
failure = format!("stock_pool_session_prefix_identity_invalid:{symbol}:{date}");
break;
}
if time != expected {
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:expected={expected}:observed={time}");
break;
}
if quote.observation_kind != crate::data::QuoteObservationKind::MinuteBar {
failure = format!("stock_pool_session_prefix_basis_unverified:{symbol}:{date}:{time}");
break;
}
let Some(next_volume) = volume.checked_add(quote.volume_delta) else {
failure = format!("stock_pool_session_volume_overflow:{symbol}:{date}:{time}");
break;
};
let delta = if quote.amount_delta.is_finite() && quote.amount_delta >= 0.0 {
quote.amount_delta.to_string().parse::<Decimal>().ok()
} else { None };
let Some(next_amount) = delta.and_then(|delta| amount.checked_add(delta)) else {
failure = format!("stock_pool_session_amount_invalid:{symbol}:{date}:{time}");
break;
};
volume = next_volume;
amount = next_amount;
values.insert(time, (Decimal::from(volume), amount));
let Some(next) = next_minute(time) else { break };
expected = next;
failure = format!("stock_pool_session_prefix_missing:{symbol}:{date}:{expected}");
}
Self { values, failure }
}
pub fn at(&self, at: NaiveDateTime) -> Result<(Decimal, Decimal), String> {
let time = at.time().with_second(0).unwrap().with_nanosecond(0).unwrap();
self.values.get(&time).copied().ok_or_else(|| self.failure.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn quote(hour: u32, minute: u32, volume: u64, amount: f64) -> IntradayExecutionQuote {
let date = NaiveDate::from_ymd_opt(2026, 9, 11).unwrap();
IntradayExecutionQuote { observation_kind: crate::data::QuoteObservationKind::MinuteBar, date, symbol: "000001.SZ".into(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(),
last_price: 10., bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: amount, trading_phase: Some("minute_execution_prices:raw-minute".into()) }
}
#[test]
fn totals_use_only_the_complete_observed_prefix_and_keep_decimal_amounts() {
let mut rows = vec![quote(9,30,100,10.01), quote(9,31,0,0.), quote(9,32,200,20.02)];
let prefix = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(prefix.at(rows[1].timestamp).unwrap(), (100.into(), Decimal::new(1001,2)));
assert_eq!(prefix.at(rows[2].timestamp).unwrap(), (300.into(), Decimal::new(3003,2)));
rows[2].volume_delta = 999999;
rows[2].amount_delta = f64::NAN;
let changed = MinutePrefix::build(rows[0].date, "000001.SZ", &rows);
assert_eq!(changed.at(rows[1].timestamp).unwrap(), prefix.at(rows[1].timestamp).unwrap());
assert!(changed.at(rows[2].timestamp).unwrap_err().contains("amount_invalid"));
}
#[test]
fn sparse_unverified_and_overflowing_quotes_cannot_be_called_session_totals() {
let first = quote(9,30,100,1000.);
for rows in [vec![quote(9,31,100,1000.)], vec![first.clone(), quote(9,32,100,1000.)]] {
let prefix = MinutePrefix::build(first.date, "000001.SZ", &rows);
assert!(prefix.at(rows.last().unwrap().timestamp).unwrap_err().contains("prefix_missing"));
}
let mut unknown = first.clone(); unknown.observation_kind = Default::default();
assert!(MinutePrefix::build(first.date, "000001.SZ", &[unknown]).at(first.timestamp).unwrap_err().contains("basis_unverified"));
let rows = [quote(9,30,u64::MAX,0.), quote(9,31,1,0.)];
assert!(MinutePrefix::build(first.date, "000001.SZ", &rows).at(rows[1].timestamp).unwrap_err().contains("volume_overflow"));
}
#[test]
fn lunch_and_post_close_gaps_follow_the_minute_feed_segments() {
let mut rows = Vec::new(); let mut time = NaiveTime::from_hms_opt(9,30,0).unwrap();
loop {
rows.push(quote(time.hour(), time.minute(), 1, 0.01));
let Some(next) = next_minute(time) else { break }; time=next;
}
let prefix=MinutePrefix::build(rows[0].date,"000001.SZ",&rows);
assert_eq!(prefix.at(rows.last().unwrap().timestamp).unwrap(), (Decimal::from(rows.len()), Decimal::new(rows.len() as i64,2)));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==12));
assert!(!rows.iter().any(|row| row.timestamp.time()==NaiveTime::from_hms_opt(13,0,0).unwrap()));
assert!(!rows.iter().any(|row| row.timestamp.time().hour()==15 && (1..6).contains(&row.timestamp.time().minute())));
}
#[test]
#[ignore = "requires FIDC_SESSION_PREFIX_SOURCE_JSON from the frozen Source minute response"]
fn real_source_session_prefix_matches_observed_checkpoints() {
let path=std::env::var("FIDC_SESSION_PREFIX_SOURCE_JSON").expect("explicit Source evidence path");
let rows:Vec<IntradayExecutionQuote>=serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
let date=NaiveDate::from_ymd_opt(2026,9,8).unwrap();
assert_eq!(rows.len(),242);
let prefix=MinutePrefix::build(date,"000063.SZ",&rows);
for (hour,minute,volume,amount) in [(9,30,512700,17103672),(9,31,2296631,76576756),(9,32,2983531,99471024),(11,30,27868847,928167630),(13,1,28495518,948994890),(15,0,45625008,1518115100)] {
assert_eq!(prefix.at(date.and_hms_opt(hour,minute,0).unwrap()).unwrap(),(Decimal::from(volume),Decimal::from(amount)));
}
assert!(prefix.at(date.and_hms_opt(15,30,0).unwrap()).unwrap_err().contains("prefix_missing"),"one final aggregate is not a verified intraday prefix");
}
}
+100 -10
View File
@@ -20,6 +20,18 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
pub trait Strategy { pub trait Strategy {
fn name(&self) -> &str; fn name(&self) -> &str;
fn bind_runtime_position_configuration(
&mut self,
events: &[crate::position_exposure::PositionExposureEvent],
legacy: &BTreeMap<NaiveDate, i32>,
) -> Result<(), BacktestError> {
if !events.is_empty() || !legacy.is_empty() {
return Err(BacktestError::Execution(
"strategy does not implement runtime position configuration".into(),
));
}
Ok(())
}
fn initial_subscriptions(&self) -> BTreeSet<String> { fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::new() BTreeSet::new()
} }
@@ -40,6 +52,12 @@ pub trait Strategy {
) -> Result<(), BacktestError> { ) -> Result<(), BacktestError> {
Ok(()) Ok(())
} }
/// External, already executed manual activity. It is not a new strategy
/// order and must not be run through order generation or transaction costs.
fn on_observed_manual_execution(
&mut self,
_execution: &crate::manual_execution::ManualReplayApplication,
) -> Result<(), BacktestError> { Ok(()) }
fn schedule_rules(&self) -> Vec<ScheduleRule> { fn schedule_rules(&self) -> Vec<ScheduleRule> {
Vec::new() Vec::new()
} }
@@ -102,6 +120,7 @@ pub struct OpenOrderView {
pub avg_price: f64, pub avg_price: f64,
pub transaction_cost: f64, pub transaction_cost: f64,
pub limit_price: f64, pub limit_price: f64,
pub reserved_cash: Option<f64>,
pub reason: String, pub reason: String,
} }
@@ -497,6 +516,7 @@ impl StrategyContext<'_> {
.iter() .iter()
.filter(|order| order.side == OrderSide::Buy) .filter(|order| order.side == OrderSide::Buy)
.map(|order| { .map(|order| {
if let Some(reserved) = order.reserved_cash { return reserved; }
let price = if order.limit_price.is_finite() { let price = if order.limit_price.is_finite() {
order.limit_price.max(0.0) order.limit_price.max(0.0)
} else { } else {
@@ -975,7 +995,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
} }
} }
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default, serde::Serialize)]
pub struct StrategyDecision { pub struct StrategyDecision {
pub buy_denials: BTreeMap<String, String>, pub buy_denials: BTreeMap<String, String>,
pub rebalance: bool, pub rebalance: bool,
@@ -988,6 +1008,15 @@ pub struct StrategyDecision {
} }
impl 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> { pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
let mut symbols = BTreeSet::new(); let mut symbols = BTreeSet::new();
if self.rebalance { if self.rebalance {
@@ -1001,9 +1030,24 @@ impl StrategyDecision {
} }
pub fn merge_from(&mut self, mut other: 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.buy_denials.append(&mut other.buy_denials);
self.rebalance |= other.rebalance; 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.target_weights.append(&mut other.target_weights);
}
self.exit_symbols.append(&mut other.exit_symbols); self.exit_symbols.append(&mut other.exit_symbols);
self.order_intents.append(&mut other.order_intents); self.order_intents.append(&mut other.order_intents);
self.notes.append(&mut other.notes); self.notes.append(&mut other.notes);
@@ -1023,13 +1067,59 @@ impl StrategyDecision {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg(test)]
mod decision_merge_tests {
use super::*;
#[test]
fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() {
let mut earlier = StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]),
exit_symbols: BTreeSet::from(["risk_exit".into()]),
order_intents: vec![OrderIntent::Shares {
symbol: "explicit".into(),
quantity: 100,
reason: "explicit action".into(),
}],
..Default::default()
};
earlier.merge_from(StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("C".into(), 1.)]),
..Default::default()
});
assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)]));
assert!(earlier.rebalance);
assert!(earlier.exit_symbols.contains("risk_exit"));
assert_eq!(earlier.order_intents.len(), 1);
}
#[test]
fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() {
let mut decision = StrategyDecision {
rebalance: true,
target_weights: BTreeMap::from([("A".into(), 1.)]),
..Default::default()
};
decision.merge_from(StrategyDecision::default());
assert_eq!(decision.target_weights.len(), 1);
decision.merge_from(StrategyDecision {
rebalance: true,
..Default::default()
});
assert!(decision.target_weights.is_empty());
assert!(decision.rebalance);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum AlgoOrderStyle { pub enum AlgoOrderStyle {
Vwap, Vwap,
Twap, Twap,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum OrderTimeInForce { pub enum OrderTimeInForce {
Day, Day,
Ioc, Ioc,
@@ -1058,7 +1148,7 @@ impl OrderTimeInForce {
} }
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub enum TargetPortfolioOrderPricing { pub enum TargetPortfolioOrderPricing {
LimitPrices(BTreeMap<String, f64>), LimitPrices(BTreeMap<String, f64>),
AlgoOrder { AlgoOrder {
@@ -1068,7 +1158,7 @@ pub enum TargetPortfolioOrderPricing {
}, },
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub enum OrderIntent { pub enum OrderIntent {
StockPool { StockPool {
contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>, contract: Box<crate::stock_pool_execution::FrozenStockPoolIntent>,
@@ -3184,7 +3274,7 @@ mod tests {
.enumerate() .enumerate()
.map(|(index, date)| DailyMarketSnapshot { .map(|(index, date)| DailyMarketSnapshot {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0 + index as f64, day_open: 10.0 + index as f64,
open: 10.0 + index as f64, open: 10.0 + index as f64,
@@ -3210,21 +3300,21 @@ mod tests {
.iter() .iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 9.0, free_float_cap_bn: 9.0,
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let candidate_rows = dates let candidate_rows = dates
.iter() .iter()
.map(|date| CandidateEligibility { .map(|date| CandidateEligibility {
date: *date, date: *date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
+4 -4
View File
@@ -83,7 +83,7 @@ impl SelectionContext<'_> {
for factor in self.data.factor_snapshot_rows_on(self.decision_date) { for factor in self.data.factor_snapshot_rows_on(self.decision_date) {
if self if self
.dynamic_universe .dynamic_universe
.is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(&factor.symbol)) .is_some_and(|symbols| !symbols.is_empty() && !symbols.contains(factor.symbol.as_str()))
{ {
continue; continue;
} }
@@ -307,7 +307,7 @@ mod tests {
fn market(symbol: &str, price: f64) -> DailyMarketSnapshot { fn market(symbol: &str, price: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some("2025-01-02 10:00:00".to_string()), timestamp: Some("2025-01-02 10:00:00".to_string()),
day_open: price, day_open: price,
open: price, open: price,
@@ -333,7 +333,7 @@ mod tests {
fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot { fn factor(symbol: &str, market_cap_bn: f64) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn: market_cap_bn, free_float_cap_bn: market_cap_bn,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -347,7 +347,7 @@ mod tests {
fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility { fn candidate(symbol: &str, is_st: bool, is_kcb: bool) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(), date: d(),
symbol: symbol.to_string(), symbol: symbol.into(),
is_st, is_st,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -105,6 +105,158 @@ fn action(quantity: &str, when: &str) -> PlatformTradeAction {
reason: "configured_strategy_action".into(), reason: "configured_strategy_action".into(),
} }
} }
#[test]
fn observed_manual_trades_then_split_keep_real_fill_protection_and_lock_dates() {
for sell_during_lock in [false, true] {
let sale = if sell_during_lock {
("manual-sell", "Sell", "2026-09-16T01:31:00Z", "2026-09-16T01:31:01Z", "5", "0.5", 200)
} else {
("manual-sell", "Sell", "2026-09-14T01:31:00Z", "2026-09-14T01:31:01Z", "10", "0.5", 100)
};
let actions = [
("new-buy", "Buy", "2026-09-14T01:30:00Z", "2026-09-14T01:30:01Z", "10", "0.25", 100),
("late-buy", "Buy", "2026-09-11T06:00:00Z", "2026-09-14T01:30:02Z", "10", "0.75", 100),
sale,
].into_iter().enumerate().map(|(index, (id, side, executed, observed, price, fee, quantity))| {
let executed: chrono::DateTime<chrono::Utc> = executed.parse().unwrap();
let observed: chrono::DateTime<chrono::Utc> = observed.parse().unwrap();
let created = executed - chrono::Duration::seconds(1);
serde_json::json!({"actionId":id,"source":"manual_security_trade","auditEventIds":[format!("audit-{id}")],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[{
"orderId":id,"brokerOrderId":id,"sourceAdapter":"paper","symbol":"000001.SZ","side":side,"quantity":quantity,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[{
"tradeId":id,"observationEventId":id,"observationSequence":index+1,
"tradeDate":executed.date_naive(),"executedAt":executed,"observedAt":observed,
"feeObservationEventId":id,"feeObservationSequence":index+1,"feeObservedAt":observed,
"timestampPrecision":"second","quantity":quantity,"price":price,"totalFee":fee
}]
}]})
}).collect::<Vec<_>>();
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a",
"sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-09-18T08:00:00Z","actions":actions,
})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let mut parts = data().snapshot_components();
for row in &mut parts.market {
if row.date >= d(15) {
row.day_open = 5.;
row.open = 5.;
row.high = 5.;
row.low = 5.;
row.close = 5.;
row.last_price = 5.;
row.bid1 = 5.;
row.ask1 = 5.;
row.prev_close = 5.;
row.upper_limit = 5.5;
row.lower_limit = 4.5;
}
}
parts.corporate_actions.push(fidc_core::CorporateAction {
date: d(15),
symbol: "000001.SZ".into(),
payable_date: None,
share_cash: 0.,
share_bonus: 1.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: None,
successor_ratio: None,
successor_cash: None,
});
let data = DataSet::from_components_with_actions(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
)
.unwrap();
let mut config = PlatformExprStrategyConfig::generic();
config.signal_symbol = "000001.SZ".into();
config.benchmark_symbol = "000300.SH".into();
config.rotation_enabled = false;
config.matching_type = MatchingType::CurrentBarClose;
config.volume_capacity_mode =
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
config.automatic_trade_protection = AutomaticTradeProtection {
buy_protection_days: 3,
sell_cooldown_days: 3,
max_holding_days: 1,
locks: vec![AutomaticTradeLock {
symbol: "000001.SZ".into(),
start_date: d(16),
end_date: Some(d(17)),
}],
};
config.explicit_actions = vec![action("-200", "decision_date >= \"2026-09-14\"")];
let result = BacktestEngine::new(
data,
PlatformExprStrategy::new(config),
BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
.with_matching_type(MatchingType::CurrentBarClose)
.with_volume_capacity_mode(
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
),
BacktestConfig {
initial_cash: 10000.,
benchmark_code: "000300.SH".into(),
start_date: Some(d(11)),
end_date: Some(d(18)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
assert_eq!(result.manual_executions.len(), 3);
assert_eq!(result.manual_executions[2].quantity_after, if sell_during_lock { 200 } else { 100 });
assert_eq!(result.fills.len(), 1, "{:?}", result.fills);
assert_eq!(
(
result.fills[0].date,
result.fills[0].side,
result.fills[0].quantity,
result.fills[0].price
),
(d(18), OrderSide::Sell, 200, 5.)
);
assert!(result.fills[0].reason.contains("max_holding_days_exit"));
for day in [14, 15] {
for rule in ["buy_fill_protection", "sell_fill_cooldown"] {
if rule == "sell_fill_cooldown" && sell_during_lock { continue; }
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == rule && !audit.accepted), "day={day} rule={rule}");
}
}
for day in [16, 17] {
assert!(result.risk_decisions.iter().any(|audit| audit.date == d(day)
&& audit.symbol == "000001.SZ" && audit.rule_code == "automatic_trade_locked" && !audit.accepted));
}
assert!(
result
.daily_holdings
.iter()
.any(|row| row.date == d(15) && row.quantity == if sell_during_lock { 400 } else { 200 })
);
assert!(result.holdings_summary.is_empty());
assert!(
result
.equity_curve
.iter()
.all(|point| point.external_cash_flow == 0.)
);
}
}
fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult { fn run(policy: AutomaticTradeProtection) -> fidc_core::BacktestResult {
let mut config = PlatformExprStrategyConfig::generic(); let mut config = PlatformExprStrategyConfig::generic();
config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit; config.volume_capacity_mode = fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit;
+3 -3
View File
@@ -15,7 +15,7 @@ fn d(year: i32, month: u32, day: u32) -> NaiveDate {
fn candidate() -> CandidateEligibility { fn candidate() -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(2024, 1, 3), date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -31,7 +31,7 @@ fn candidate() -> CandidateEligibility {
fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot { fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(2024, 1, 3), date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2024-01-03 10:18:00".to_string()), timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open, day_open: open,
open, open,
@@ -256,7 +256,7 @@ fn china_rule_hooks_allow_sell_when_last_price_is_above_lower_limit() {
let snapshot = DailyMarketSnapshot { let snapshot = DailyMarketSnapshot {
date: d(2024, 4, 7), date: d(2024, 4, 7),
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2024-04-07 10:18:00".to_string()), timestamp: Some("2024-04-07 10:18:00".to_string()),
day_open: 2.53, day_open: 2.53,
open: 2.53, open: 2.53,
+167 -16
View File
@@ -113,7 +113,7 @@ impl Strategy for BuyAndHoldStrategy {
fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot { fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -139,21 +139,21 @@ fn stock_market_snapshot(date: NaiveDate) -> DailyMarketSnapshot {
fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot { fn stock_factor_snapshot(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
} }
} }
fn stock_candidate(date: NaiveDate) -> CandidateEligibility { fn stock_candidate(date: NaiveDate) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -177,6 +177,157 @@ fn benchmark_snapshot(date: NaiveDate) -> BenchmarkSnapshot {
} }
} }
#[test]
fn successor_conversion_depletes_older_source_lots_before_newer_successor_buys() {
struct ConvertedSale {
dates: [NaiveDate; 3],
seen: std::rc::Rc<std::cell::RefCell<Option<(Option<NaiveDate>, Option<NaiveDate>)>>>,
}
impl Strategy for ConvertedSale {
fn name(&self) -> &str {
"successor FIFO"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
let (symbol, quantity) = if ctx.execution_date == self.dates[0] {
("000001.SZ", 100)
} else if ctx.execution_date == self.dates[1] {
("000002.SZ", 100)
} else {
let holding = ctx.portfolio.position("000002.SZ").unwrap();
*self.seen.borrow_mut() = Some((holding.opened_date(), holding.last_buy_date()));
("000002.SZ", -200)
};
Ok(StrategyDecision {
order_intents: vec![fidc_core::OrderIntent::Shares {
symbol: symbol.into(),
quantity,
reason: "dated lot test".into(),
}],
..Default::default()
})
}
}
let dates = [d(2026, 9, 11), d(2026, 9, 14), d(2026, 9, 15)];
let symbols = ["000001.SZ", "000002.SZ"];
let mut market = Vec::new();
let mut factors = Vec::new();
let mut candidates = Vec::new();
for date in dates {
for symbol in symbols {
let price = if symbol == symbols[0] {
10.
} else if date == dates[2] {
6.
} else {
20.
};
let mut quote = stock_market_snapshot(date);
quote.symbol = symbol.into();
quote.day_open = price;
quote.open = price;
quote.high = price;
quote.low = price;
quote.close = price;
quote.last_price = price;
quote.bid1 = price;
quote.ask1 = price;
quote.prev_close = price;
quote.upper_limit = price * 1.1;
quote.lower_limit = price * 0.9;
market.push(quote);
let mut factor = stock_factor_snapshot(date);
factor.symbol = symbol.into();
factors.push(factor);
let mut candidate = stock_candidate(date);
candidate.symbol = symbol.into();
candidates.push(candidate);
}
}
let data = DataSet::from_components_with_actions(
symbols
.into_iter()
.map(|symbol| Instrument {
symbol: symbol.into(),
name: symbol.into(),
board: "SZ".into(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".into(),
})
.collect(),
market,
factors,
candidates,
dates.map(benchmark_snapshot).into(),
vec![CorporateAction {
date: dates[2],
symbol: symbols[0].into(),
payable_date: None,
share_cash: 0.,
share_bonus: 0.,
share_gift: 0.,
issue_quantity: 0.,
issue_price: 0.,
reform: false,
adjust_factor: None,
successor_symbol: Some(symbols[1].into()),
successor_ratio: Some(2.),
successor_cash: Some(0.),
}],
)
.unwrap();
let seen = std::rc::Rc::new(std::cell::RefCell::new(None));
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_matching_type(fidc_core::MatchingType::NextBarOpen)
.with_volume_limit(false)
.with_liquidity_limit(false);
let result = BacktestEngine::new(
data,
ConvertedSale {
dates,
seen: seen.clone(),
},
broker,
BacktestConfig {
initial_cash: 10000.,
benchmark_code: "000300.SH".into(),
start_date: Some(dates[0]),
end_date: Some(dates[2]),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.run()
.unwrap();
assert_eq!(*seen.borrow(), Some((Some(dates[0]), Some(dates[1]))));
assert_eq!(result.fills.len(), 3);
assert_eq!(result.fills[2].quantity, 200);
assert_eq!(result.fills[2].symbol, symbols[1]);
let remaining = result
.holdings_summary
.iter()
.find(|row| row.symbol == symbols[1])
.unwrap();
assert_eq!(remaining.quantity, 100);
assert_eq!(remaining.realized_pnl, 200.);
assert!(
result
.position_events
.iter()
.any(|event| event.symbol == symbols[0]
&& event.quantity_after == 0
&& event.reason.starts_with("successor_conversion"))
);
}
#[test] #[test]
fn engine_reinvests_dividend_receivable_in_round_lots() { fn engine_reinvests_dividend_receivable_in_round_lots() {
let buy_date = d(2025, 1, 1); let buy_date = d(2025, 1, 1);
@@ -195,7 +346,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-01 10:18:00".to_string()), timestamp: Some("2025-01-01 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -218,7 +369,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -241,7 +392,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -266,42 +417,42 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: buy_date, date: buy_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -314,7 +465,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
CandidateEligibility { CandidateEligibility {
date: ex_date, date: ex_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -327,7 +478,7 @@ fn engine_reinvests_dividend_receivable_in_round_lots() {
}, },
CandidateEligibility { CandidateEligibility {
date: payable_date, date: payable_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -5,7 +5,7 @@ use fidc_core::{
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
StrategyDecision, StrategyDecision,
}; };
use std::collections::{BTreeMap, BTreeSet}; use std::collections::BTreeSet;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -105,7 +105,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
fixture_instruments(), fixture_instruments(),
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 15:00:00")), timestamp: Some(format!("{date} 15:00:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -128,7 +128,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -139,7 +139,7 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -163,7 +163,48 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
} }
#[test] #[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 date = d(2026, 1, 5);
let data = single_day_quote_plan_data(date); let data = single_day_quote_plan_data(date);
let broker = BrokerSimulator::new_with_execution_price( 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), symbol_plan_calls: Arc::clone(&symbol_plan_calls),
}; };
let captured_loader_calls = Arc::clone(&loader_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) let mut engine = BacktestEngine::new(data, strategy, broker, config)
.with_execution_quote_loader(move |request| { .with_execution_quote_loader(move |request| {
*captured_loader_calls.lock().expect("loader counter mutex") += 1; *captured_loader_calls.lock().expect("loader counter mutex") += 1;
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(t(10, 17, 59)), 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()), trading_phase: Some("continuous".to_string()),
}) })
.collect()) .collect())
}) });
.with_preplanned_decision_quote_symbols_by_date(preplanned);
engine.run().expect("backtest should run"); engine.run().expect("backtest should run");
assert_eq!( assert_eq!(
*symbol_plan_calls.lock().expect("symbol plan counter mutex"), *symbol_plan_calls.lock().expect("symbol plan counter mutex"),
0, 1,
"the strategy plan must not be recomputed after a complete plan is supplied" "quote planning must use the actual run context"
); );
assert_eq!( assert_eq!(
*loader_calls.lock().expect("loader counter mutex"), *loader_calls.lock().expect("loader counter mutex"),
1, 0,
"the supplied symbols must still pass through the normal quote loader" "an empty runtime scope must not fetch unrequested symbols"
); );
} }
@@ -269,7 +305,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -292,7 +328,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -317,7 +353,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -328,7 +364,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -341,7 +377,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -354,7 +390,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -411,7 +447,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(t(10, 39, 59)), timestamp: request.date.and_time(t(10, 39, 59)),
@@ -439,7 +475,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -462,7 +498,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -487,7 +523,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -498,7 +534,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -511,7 +547,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -524,7 +560,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -556,7 +592,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
], ],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)), 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, amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)), timestamp: second.and_time(t(10, 39, 59)),
@@ -674,7 +710,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-05 15:00:00".to_string()), timestamp: Some("2026-01-05 15:00:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -697,7 +733,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2026-01-06 15:00:00".to_string()), timestamp: Some("2026-01-06 15:00:00".to_string()),
day_open: 10.5, day_open: 10.5,
open: 10.5, open: 10.5,
@@ -722,7 +758,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -733,7 +769,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -746,7 +782,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: first, date: first,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -759,7 +795,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
}, },
CandidateEligibility { CandidateEligibility {
date: second, date: second,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -826,7 +862,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
Ok(request Ok(request
.symbols .symbols
.into_iter() .into_iter()
.map(|symbol| IntradayExecutionQuote { .map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date, date: request.date,
symbol, symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1), timestamp: request.date.and_time(start_time) - Duration::seconds(1),
+28 -28
View File
@@ -71,7 +71,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -94,7 +94,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0, day_open: 5.0,
open: 5.0, open: 5.0,
@@ -117,7 +117,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.05, day_open: 5.05,
open: 5.05, open: 5.05,
@@ -140,7 +140,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-06 10:18:00".to_string()), timestamp: Some("2025-01-06 10:18:00".to_string()),
day_open: 5.1, day_open: 5.1,
open: 5.1, open: 5.1,
@@ -165,53 +165,53 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.0, market_cap_bn: 30.0,
free_float_cap_bn: 28.0, free_float_cap_bn: 28.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.5, market_cap_bn: 30.5,
free_float_cap_bn: 28.5, free_float_cap_bn: 28.5,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 31.0, market_cap_bn: 31.0,
free_float_cap_bn: 29.0, free_float_cap_bn: 29.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -224,7 +224,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -237,7 +237,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: delist_date, date: delist_date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -250,7 +250,7 @@ fn engine_keeps_unresolved_delisted_position_without_fabricating_a_fill() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -364,7 +364,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -387,7 +387,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 20.0, day_open: 20.0,
open: 20.0, open: 20.0,
@@ -410,7 +410,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 21.0, day_open: 21.0,
open: 21.0, open: 21.0,
@@ -435,42 +435,42 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 30.0, market_cap_bn: 30.0,
free_float_cap_bn: 28.0, free_float_cap_bn: 28.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 31.0, market_cap_bn: 31.0,
free_float_cap_bn: 29.0, free_float_cap_bn: 29.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -483,7 +483,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -496,7 +496,7 @@ fn engine_applies_successor_conversion_before_unresolved_delisting_audit() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
+188 -103
View File
@@ -49,7 +49,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -72,18 +72,18 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 100.0, market_cap_bn: 100.0,
free_float_cap_bn: 80.0, free_float_cap_bn: 80.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -109,7 +109,7 @@ fn single_day_anchor_data(date: NaiveDate) -> DataSet {
fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot { fn market_row(date: NaiveDate, symbol: &str, open: f64, close: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: open, day_open: open,
open, open,
@@ -139,7 +139,7 @@ fn factor_row(
) -> DailyFactorSnapshot { ) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn: 100.0, market_cap_bn: 100.0,
free_float_cap_bn: 80.0, free_float_cap_bn: 80.0,
pe_ttm: 10.0, pe_ttm: 10.0,
@@ -153,7 +153,7 @@ fn factor_row(
fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility { fn candidate_row(date: NaiveDate, symbol: &str) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date, date,
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -219,7 +219,7 @@ fn two_day_futures_data() -> DataSet {
("stock_connect_north_bound".into(), 1.0), ("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0), ("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 99.0), ("fundamental_net_profit".into(), 99.0),
]), ]).into(),
), ),
factor_row( factor_row(
d2, d2,
@@ -232,7 +232,7 @@ fn two_day_futures_data() -> DataSet {
("stock_connect_north_bound".into(), 1.0), ("stock_connect_north_bound".into(), 1.0),
("industry_citics_l1".into(), 10.0), ("industry_citics_l1".into(), 10.0),
("fundamental_net_profit".into(), 101.0), ("fundamental_net_profit".into(), 101.0),
]), ]).into(),
), ),
], ],
vec![ vec![
@@ -1094,7 +1094,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -1117,7 +1117,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.1, day_open: 10.1,
open: 10.1, open: 10.1,
@@ -1142,31 +1142,31 @@ fn engine_runs_strategy_hooks_in_daily_order() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 21.0, market_cap_bn: 21.0,
free_float_cap_bn: 19.0, free_float_cap_bn: 19.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1179,7 +1179,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1337,7 +1337,7 @@ fn engine_executes_open_auction_decisions_before_on_day() {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 09:25:00".to_string()), timestamp: Some("2025-01-02 09:25:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -1360,18 +1360,18 @@ fn engine_executes_open_auction_decisions_before_on_day() {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1438,7 +1438,7 @@ fn engine_executes_futures_order_intents_against_future_account() {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -1461,18 +1461,18 @@ fn engine_executes_futures_order_intents_against_future_account() {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 100.0, market_cap_bn: 100.0,
free_float_cap_bn: 80.0, free_float_cap_bn: 80.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() {
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6); 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] #[test]
fn platform_runtime_actions_execute_generic_futures_open_and_close() { fn platform_runtime_actions_execute_generic_futures_open_and_close() {
let mut cfg = PlatformExprStrategyConfig::generic(); let mut cfg = PlatformExprStrategyConfig::generic();
@@ -1935,7 +2019,7 @@ fn engine_rejects_futures_orders_when_trading_phase_is_closed() {
}, },
], ],
vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market], vec![market_row(date, "000001.SZ", 10.0, 10.0), future_market],
vec![factor_row(date, "000001.SZ", BTreeMap::new())], vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
vec![candidate_row(date, "000001.SZ")], vec![candidate_row(date, "000001.SZ")],
vec![benchmark_row(date)], vec![benchmark_row(date)],
Vec::new(), Vec::new(),
@@ -2011,7 +2095,7 @@ fn engine_sweeps_futures_order_book_depth_when_available() {
market_row(date, "000001.SZ", 10.0, 10.0), market_row(date, "000001.SZ", 10.0, 10.0),
market_row(date, "IF2501", 4000.0, 4000.0), market_row(date, "IF2501", 4000.0, 4000.0),
], ],
vec![factor_row(date, "000001.SZ", BTreeMap::new())], vec![factor_row(date, "000001.SZ", BTreeMap::new().into())],
vec![candidate_row(date, "000001.SZ")], vec![candidate_row(date, "000001.SZ")],
vec![benchmark_row(date)], vec![benchmark_row(date)],
Vec::new(), Vec::new(),
@@ -2154,7 +2238,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}).collect(), }).collect(),
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -2177,18 +2261,18 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}, market_row(date, "000002.SZ", 20.0, 20.4)], }, market_row(date, "000002.SZ", 20.0, 20.4)],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -2209,7 +2293,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}], }],
Vec::new(), Vec::new(),
vec![ vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), 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, amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), 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, amount_delta: 20_400.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0), 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 date = d(2025, 1, 2);
let mut data = single_day_anchor_data(date); let mut data = single_day_anchor_data(date);
data.add_execution_quotes(vec![ data.add_execution_quotes(vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0), 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, amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0), timestamp: dt(2025, 1, 2, 10, 19, 0),
@@ -2446,7 +2530,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.map( .map(
|(date, open, close, prev_close, volume)| DailyMarketSnapshot { |(date, open, close, prev_close, volume)| DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: open, day_open: open,
open, open,
@@ -2473,14 +2557,14 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.into_iter() .into_iter()
.map(|date| DailyFactorSnapshot { .map(|date| DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let candidates = [ let candidates = [
@@ -2491,7 +2575,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
.into_iter() .into_iter()
.map(|(date, is_paused, is_st)| CandidateEligibility { .map(|(date, is_paused, is_st)| CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st, is_st,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -2519,7 +2603,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let quotes = vec![ let quotes = vec![
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 3, 14, 30, 0), 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, amount_delta: 10_150.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 18, 0), 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, amount_delta: 10_250.0,
trading_phase: Some("continuous".to_string()), trading_phase: Some("continuous".to_string()),
}, },
IntradayExecutionQuote { IntradayExecutionQuote { observation_kind: Default::default(),
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 19, 0), timestamp: dt(2025, 1, 6, 10, 19, 0),
@@ -2619,7 +2703,7 @@ fn strategy_context_exposes_final_order_runtime_view() {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -2642,18 +2726,18 @@ fn strategy_context_exposes_final_order_runtime_view() {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -2748,6 +2832,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() {
avg_price: 0.0, avg_price: 0.0,
transaction_cost: 0.0, transaction_cost: 0.0,
limit_price: 12.0, limit_price: 12.0,
reserved_cash: None,
reason: "pending_buy".to_string(), reason: "pending_buy".to_string(),
}]; }];
let subscriptions = BTreeSet::new(); let subscriptions = BTreeSet::new();
@@ -2838,7 +2923,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -2861,7 +2946,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -2886,31 +2971,31 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -2923,7 +3008,7 @@ fn engine_applies_account_cash_flow_and_financing_intents() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3035,7 +3120,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -3058,7 +3143,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-03 10:18:00".to_string()), timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 9.7, day_open: 9.7,
open: 9.7, open: 9.7,
@@ -3083,31 +3168,31 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 21.0, market_cap_bn: 21.0,
free_float_cap_bn: 19.0, free_float_cap_bn: 19.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3120,7 +3205,7 @@ fn engine_expires_pending_day_limit_orders_at_market_close() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3211,7 +3296,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-30 09:25:00".to_string()), timestamp: Some("2025-01-30 09:25:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -3234,7 +3319,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-31 09:25:00".to_string()), timestamp: Some("2025-01-31 09:25:00".to_string()),
day_open: 10.1, day_open: 10.1,
open: 10.1, open: 10.1,
@@ -3257,7 +3342,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-02-03 09:25:00".to_string()), timestamp: Some("2025-02-03 09:25:00".to_string()),
day_open: 10.2, day_open: 10.2,
open: 10.2, open: 10.2,
@@ -3282,42 +3367,42 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 21.0, market_cap_bn: 21.0,
free_float_cap_bn: 19.0, free_float_cap_bn: 19.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 22.0, market_cap_bn: 22.0,
free_float_cap_bn: 20.0, free_float_cap_bn: 20.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3330,7 +3415,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3343,7 +3428,7 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
}, },
CandidateEligibility { CandidateEligibility {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3469,7 +3554,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
vec![ vec![
DailyMarketSnapshot { DailyMarketSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-30 09:25:00".to_string()), timestamp: Some("2025-01-30 09:25:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -3492,7 +3577,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-31 09:25:00".to_string()), timestamp: Some("2025-01-31 09:25:00".to_string()),
day_open: 10.1, day_open: 10.1,
open: 10.1, open: 10.1,
@@ -3515,7 +3600,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-02-03 09:25:00".to_string()), timestamp: Some("2025-02-03 09:25:00".to_string()),
day_open: 10.2, day_open: 10.2,
open: 10.2, open: 10.2,
@@ -3540,42 +3625,42 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
vec![ vec![
DailyFactorSnapshot { DailyFactorSnapshot {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 21.0, market_cap_bn: 21.0,
free_float_cap_bn: 19.0, free_float_cap_bn: 19.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 22.0, market_cap_bn: 22.0,
free_float_cap_bn: 20.0, free_float_cap_bn: 20.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
], ],
vec![ vec![
CandidateEligibility { CandidateEligibility {
date: date1, date: date1,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3588,7 +3673,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
}, },
CandidateEligibility { CandidateEligibility {
date: date2, date: date2,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3601,7 +3686,7 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
}, },
CandidateEligibility { CandidateEligibility {
date: date3, date: date3,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3855,7 +3940,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[ [
DailyMarketSnapshot { DailyMarketSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -3878,7 +3963,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
}, },
DailyMarketSnapshot { DailyMarketSnapshot {
date: *date, date: *date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
timestamp: Some(format!("{date} 10:18:00")), timestamp: Some(format!("{date} 10:18:00")),
day_open: 20.0, day_open: 20.0,
open: 20.0, open: 20.0,
@@ -3908,25 +3993,25 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[ [
DailyFactorSnapshot { DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 10.0, market_cap_bn: 10.0,
free_float_cap_bn: 8.0, free_float_cap_bn: 8.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
DailyFactorSnapshot { DailyFactorSnapshot {
date: *date, date: *date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
market_cap_bn: 12.0, market_cap_bn: 12.0,
free_float_cap_bn: 10.0, free_float_cap_bn: 10.0,
pe_ttm: 12.0, pe_ttm: 12.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}, },
] ]
}) })
@@ -3937,7 +4022,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
[ [
CandidateEligibility { CandidateEligibility {
date: *date, date: *date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -3950,7 +4035,7 @@ fn engine_applies_dynamic_universe_and_subscription_directives() {
}, },
CandidateEligibility { CandidateEligibility {
date: *date, date: *date,
symbol: "000002.SZ".to_string(), symbol: "000002.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -4041,7 +4126,7 @@ fn engine_exposes_current_process_context_to_strategies() {
}], }],
vec![DailyMarketSnapshot { vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
timestamp: Some("2025-01-02 10:18:00".to_string()), timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -4064,18 +4149,18 @@ fn engine_exposes_current_process_context_to_strategies() {
}], }],
vec![DailyFactorSnapshot { vec![DailyFactorSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
market_cap_bn: 20.0, market_cap_bn: 20.0,
free_float_cap_bn: 18.0, free_float_cap_bn: 18.0,
pe_ttm: 10.0, pe_ttm: 10.0,
turnover_ratio: Some(1.0), turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
adjustment_factor_backward1: None, adjustment_factor_backward1: None,
extra_factors: BTreeMap::new(), extra_factors: Default::default(),
}], }],
vec![CandidateEligibility { vec![CandidateEligibility {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -4191,8 +4276,8 @@ fn engine_rejects_an_unexplained_missing_holding_close() {
market_row(date2, "000001.SZ", 20.0, 20.2), market_row(date2, "000001.SZ", 20.0, 20.2),
], ],
vec![ vec![
factor_row(date1, "601028.SH", BTreeMap::new()), factor_row(date1, "601028.SH", BTreeMap::new().into()),
factor_row(date2, "000001.SZ", BTreeMap::new()), factor_row(date2, "000001.SZ", BTreeMap::new().into()),
], ],
vec![ vec![
candidate_row(date1, "601028.SH"), candidate_row(date1, "601028.SH"),
@@ -4269,8 +4354,8 @@ fn platform_strategy_cannot_hide_missing_valuation_by_skipping_stop_take() {
market_row(date2, "000001.SZ", 20.0, 20.2), market_row(date2, "000001.SZ", 20.0, 20.2),
], ],
vec![ vec![
factor_row(date1, "601028.SH", BTreeMap::new()), factor_row(date1, "601028.SH", BTreeMap::new().into()),
factor_row(date2, "000001.SZ", BTreeMap::new()), factor_row(date2, "000001.SZ", BTreeMap::new().into()),
], ],
vec![ vec![
candidate_row(date1, "601028.SH"), candidate_row(date1, "601028.SH"),
File diff suppressed because it is too large Load Diff
@@ -17,7 +17,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
.iter() .iter()
.map(|date| DailyMarketSnapshot { .map(|date| DailyMarketSnapshot {
date: *date, date: *date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.into(),
timestamp: None, timestamp: None,
day_open: 10.0, day_open: 10.0,
open: 10.0, open: 10.0,
@@ -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"); let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
for offset in 0..bars_per_day { for offset in 0..bars_per_day {
let timestamp = session_start + Duration::minutes(offset as i64); let timestamp = session_start + Duration::minutes(offset as i64);
quotes.push(IntradayExecutionQuote { quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date: *date, date: *date,
symbol: SYMBOL.to_string(), symbol: SYMBOL.to_string(),
timestamp, timestamp,
@@ -7,6 +7,7 @@ use fidc_core::{
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value, PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
}; };
use rust_decimal::Decimal; use rust_decimal::Decimal;
use fidc_core::IntradayExecutionQuote;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate { fn day(n: u32) -> NaiveDate {
@@ -66,7 +67,7 @@ fn data_with_fund_rules(
}; };
market.push(DailyMarketSnapshot { market.push(DailyMarketSnapshot {
date, date,
symbol: code(n), symbol: code(n).into(),
timestamp: None, timestamp: None,
day_open: price, day_open: price,
open: price, open: price,
@@ -89,7 +90,7 @@ fn data_with_fund_rules(
}); });
candidates.push(CandidateEligibility { candidates.push(CandidateEligibility {
date, date,
symbol: code(n), symbol: code(n).into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing: false, is_new_listing: false,
@@ -131,7 +132,7 @@ fn data_with_fund_rules(
.flat_map(|date| { .flat_map(|date| {
(1..=2).map(move |n| DailyFactorSnapshot { (1..=2).map(move |n| DailyFactorSnapshot {
date, date,
symbol: code(n), symbol: code(n).into(),
market_cap_bn: 10., market_cap_bn: 10.,
free_float_cap_bn: 10., free_float_cap_bn: 10.,
pe_ttm: 10., pe_ttm: 10.,
@@ -142,8 +143,8 @@ fn data_with_fund_rules(
}) })
}) })
.collect(); .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.clone(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(), 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, 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, volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
trading_phase: Some("synthetic_observation_fixture".into()), trading_phase: Some("synthetic_observation_fixture".into()),
@@ -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] #[test]
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() { fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
let data = data_with_suspension(1_000_000, Some(day(6))); 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); 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] #[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() { fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false); let intent = contract(day(2), 1, false);
@@ -561,6 +739,181 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
} }
} }
#[test]
fn pool_position_adjustments_use_execution_clock_and_restore_original_twenty_percent() {
for timed in [false,true] {
let program=StockPoolProgram { schema_version:1,pool_id:"position-clock".into(),version_id:"v1".into(),
members:contract(day(2),1,false).members,exit_signals:vec![],
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
timing_policy:serde_json::json!({"auto_execute":true,"pricing_mode":"first_tick"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into() };
let risk=if timed {serde_json::json!({"positionExposureEvents":[
{"eventId":"zero","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":0},
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
]})}else{serde_json::json!({"positionExposureSchedule":[{"effectiveDate":"2026-01-05","targetExposureBps":1000}]})};
let mut config=platform_expr_config_from_value("position-clock",&code(1),&serde_json::json!({
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"universe":{"include":[code(1),code(2)]},
"runtimeExpressions":{"risk":risk}
})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1.0e12".into();
config.stock_filter_expr="true".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr="0".into();
config.matching_type=MatchingType::NextBarOpen;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false),BacktestConfig {
// The raw engine retains its first signal day as a cash baseline;
// Jan 2's signal executes Jan 5, across the fixture weekend.
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(6)),
decision_lag_trading_days:1,execution_price_field:PriceField::Open,
}).run().unwrap();
assert_eq!(result.fills.len(),1,"timed={timed}, fills={:?}",result.fills);
assert_eq!(result.fills[0].symbol,code(1));
assert_eq!(result.fills[0].quantity,if timed {300}else{100});
assert_eq!(result.fills[0].date,if timed {day(6)}else{day(5)});
}
}
#[test]
fn runtime_allocation_is_separate_from_the_frozen_pool_and_restores_its_weights() {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "manual-allocation".into(),
version_id: "v1".into(),
members: contract(day(2), 2, false).members,
exit_signals: vec![],
allocation_policy: serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":true}}),
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
stop_take_policy: serde_json::json!({}),
out_of_pool_policy: "hold".into(),
};
let mut cfg = platform_expr_config_from_value(
"manual-allocation",
"000300.SH",
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}}),
)
.unwrap();
cfg.market_cap_field = "close".into();
cfg.market_cap_lower_expr = "0".into();
cfg.market_cap_upper_expr = "1e12".into();
cfg.stock_filter_expr = "true".into();
cfg.selection_limit_expr = "2".into();
cfg.selection_candidate_limit_expr = "2".into();
cfg.rank_expr = "0".into();
cfg.matching_type = MatchingType::NextBarOpen;
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
"observationCutoff":"2026-01-06T08:00:00Z","actions":[],"positionExposureEvents":[
{"eventId":"weights","sequence":1,"effectiveAt":"2026-01-05T09:30:00+08:00","action":"set","targetExposureBps":8000,"allocationWeightsBps":{"000001.SZ":3000,"000002.SZ":7000}},
{"eventId":"restore","sequence":2,"effectiveAt":"2026-01-06T09:30:00+08:00","action":"restore"}
]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(cfg),
broker(false),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
let quantities = |date| {
result
.daily_holdings
.iter()
.filter(|row| row.date == date)
.map(|row| (row.symbol.clone(), row.quantity))
.collect::<BTreeMap<_, _>>()
};
assert_eq!(
quantities(day(5)),
BTreeMap::from([(code(1), 300), (code(2), 1600)]),
"{:?}",
result.fills
);
assert_eq!(
quantities(day(6)),
BTreeMap::from([(code(1), 700), (code(2), 1500)]),
"{:?}",
result.fills
);
assert!(
result.manual_executions.is_empty(),
"parameter events are not fabricated fills"
);
}
#[test]
fn outside_manual_holding_data_does_not_become_a_pool_candidate() {
let program = StockPoolProgram {
schema_version: 1,
pool_id: "manual-data-scope".into(),
version_id: "v1".into(),
members: vec![contract(day(2), 1, false).members.remove(0)],
exit_signals: vec![],
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":2000}),
timing_policy: serde_json::json!({"pricing_mode":"first_tick"}),
stop_take_policy: serde_json::json!({}),
out_of_pool_policy: "hold".into(),
};
let mut cfg = platform_expr_config_from_value(
"manual-data-scope",
"000300.SH",
&serde_json::json!({"stockPool":program,"universe":{"include":[code(1)]}}),
)
.unwrap();
cfg.market_cap_field = "close".into();
cfg.market_cap_lower_expr = "0".into();
cfg.market_cap_upper_expr = "1e12".into();
cfg.stock_filter_expr = "true".into();
cfg.selection_limit_expr = "1".into();
cfg.selection_candidate_limit_expr = "2".into();
cfg.rank_expr = "0".into();
cfg.matching_type = MatchingType::NextBarOpen;
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05","executedAt":"2026-01-05T01:31:00Z","observedAt":"2026-01-05T01:31:01Z",
"feeObservationEventId":"receipt","feeObservationSequence":1,"feeObservedAt":"2026-01-05T01:31:01Z","timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
let order = serde_json::json!({"orderId":"external-order","sourceAdapter":"paper","symbol":code(2),"side":"Buy","quantity":100,"orderCreatedAt":"2026-01-05T01:30:00Z","terminalObservedAt":"2026-01-05T01:31:01Z","terminalStatus":"filled","fills":[fill]});
let mut replay:fidc_core::manual_execution::ManualExecutionReplay=serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"","observationCutoff":"2026-01-06T08:00:00Z",
"actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],"confirmedAt":"2026-01-05T01:29:59Z","confirmationObservedAt":"2026-01-05T01:29:59Z","outcome":"orders_terminal","orders":[order]}]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let result = BacktestEngine::new(
data(false),
PlatformExprStrategy::new(cfg),
broker(false),
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(6)),
decision_lag_trading_days: 1,
execution_price_field: PriceField::Open,
},
)
.with_observed_manual_executions(replay)
.unwrap()
.run()
.unwrap();
assert!(
result.fills.iter().all(|fill| fill.symbol != code(2)),
"extra data cannot authorize an extra candidate"
);
assert_eq!(result.manual_executions.len(), 1);
assert_eq!(
result
.holdings_summary
.iter()
.find(|row| row.symbol == code(2))
.unwrap()
.quantity,
100
);
}
#[test] #[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() { fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [ for (ordinary, risk, quote, sold) in [
@@ -789,6 +1142,411 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op
assert!(result.terminal_audit.is_clean()); assert!(result.terminal_audit.is_clean());
} }
#[test]
fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
use fidc_core::strategy::{Strategy,StrategyContext};
use std::{cell::RefCell,rc::Rc};
struct ObservedPool { inner:EtfPoolSignal, observations:Rc<RefCell<Vec<(chrono::NaiveDateTime,u32,usize)>>> }
impl Strategy for ObservedPool {
fn name(&self)->&str {"ETF actual opening clock"}
fn initial_subscriptions(&self)->BTreeSet<String> {BTreeSet::from([code(1)])}
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {self.inner.decision_quote_times()}
fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)}
fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {self.inner.on_day(ctx)}
fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result<StrategyDecision,fidc_core::BacktestError> {
if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp,
ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));}
Ok(StrategyDecision::default())
}
}
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
let mut data=etf_fallback_fixture(time);
let quote=data.execution_quotes_on(day(5),&code(1))[0].clone();
data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| {
let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row
}).collect());
let observations=Rc::new(RefCell::new(Vec::new()));
let broker=broker(false).with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,BacktestConfig {
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
}).with_execution_quote_loader(|_|Ok(vec![])).run().unwrap();
let observations=observations.borrow();
assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0));
assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1));
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
}
#[test]
fn late_opening_rule_sees_the_etf_open_fill_after_earlier_quote_callbacks() {
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
use fidc_core::strategy::{Strategy, StrategyContext};
use std::{cell::RefCell, rc::Rc};
struct ObservedPool {
inner: EtfPoolSignal,
observations: Rc<RefCell<Vec<(String, chrono::NaiveDateTime, u32, usize)>>>,
}
impl ObservedPool {
fn record(&self, label: &str, ctx: &StrategyContext<'_>) {
if ctx.execution_date == day(5) {
self.observations.borrow_mut().push((
label.into(),
ctx.current_datetime().unwrap(),
ctx.portfolio
.position(&code(2))
.map_or(0, |position| position.quantity),
ctx.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.count(),
));
}
}
}
impl Strategy for ObservedPool {
fn name(&self) -> &str {
"late opening with ETF fill"
}
fn initial_subscriptions(&self) -> BTreeSet<String> {
BTreeSet::from([code(1)])
}
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
self.inner.decision_quote_times()
}
fn decision_quote_symbols(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
self.inner.decision_quote_symbols(ctx)
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("open", ScheduleStage::OpenAuction)
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
]
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.record("opening", ctx);
Ok(Default::default())
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.inner.on_day(ctx)
}
fn on_minute(
&mut self,
ctx: &StrategyContext<'_>,
_: &IntradayExecutionQuote,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.record("quote", ctx);
Ok(Default::default())
}
}
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let mut data = etf_fallback_fixture(time);
let quote = data.execution_quotes_on(day(5), &code(1))[0].clone();
data.add_execution_quotes(
[(9, 15), (9, 30), (9, 32)]
.into_iter()
.map(|(hour, minute)| {
let mut row = quote.clone();
row.timestamp = day(5).and_hms_opt(hour, minute, 0).unwrap();
row
})
.collect(),
);
let observations = Rc::new(RefCell::new(Vec::new()));
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data,
ObservedPool {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
observations: observations.clone(),
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.run()
.unwrap();
let seen = observations.borrow();
assert_eq!(
seen[..4],
[
("quote".into(), day(5).and_hms_opt(9, 15, 0).unwrap(), 0, 0),
(
"quote".into(),
day(5).and_hms_opt(9, 30, 0).unwrap(),
3700,
1
),
(
"opening".into(),
day(5).and_hms_opt(9, 31, 0).unwrap(),
3700,
1
),
(
"quote".into(),
day(5).and_hms_opt(9, 32, 0).unwrap(),
3700,
1
),
]
);
let etf = result
.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.collect::<Vec<_>>();
assert_eq!(etf.len(), 1);
assert_eq!(
(etf[0].quantity, etf[0].price, etf[0].execution_timestamp),
(3700, 4., Some(day(5).and_hms_opt(9, 30, 0).unwrap()))
);
}
#[test]
fn no_signal_day_executes_the_etf_open_before_later_deferred_stock_orders() {
use fidc_core::scheduler::{ScheduleRule, ScheduleStage, ScheduleTimeRule};
use fidc_core::strategy::{Strategy, StrategyContext};
struct DeferredStockAndEtf {
inner: EtfPoolSignal,
quantity: i32,
}
impl Strategy for DeferredStockAndEtf {
fn name(&self) -> &str {
"no signal ETF and deferred stock"
}
fn requires_minute_callbacks(&self) -> bool {
false
}
fn decision_quote_times(&self) -> Vec<chrono::NaiveTime> {
self.inner.decision_quote_times()
}
fn decision_quote_symbols(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
self.inner.decision_quote_symbols(ctx)
}
fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![
ScheduleRule::daily("deferred-stock", ScheduleStage::AfterTrading)
.with_time_rule(ScheduleTimeRule::physical_time(16, 0)),
]
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.inner.on_day(ctx)
}
fn on_scheduled(
&mut self,
ctx: &StrategyContext<'_>,
_: &ScheduleRule,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(if ctx.execution_date == day(2) && self.quantity != 0 {
StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: code(1),
quantity: self.quantity,
reason: "after-close stock order".into(),
}],
..Default::default()
}
} else {
StrategyDecision::default()
})
}
}
let time = chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap();
let mut parts = etf_fallback_fixture(time).snapshot_components();
parts.factors.retain(|row| row.date != day(5));
let data = DataSet::from_components_with_actions_and_quotes(
parts.instruments,
parts.market,
parts.factors,
parts.candidates,
parts.benchmarks,
parts.corporate_actions,
parts.execution_quotes,
)
.unwrap();
for quantity in [100, -100, 0] {
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data.clone(),
DeferredStockAndEtf {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
quantity,
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.run()
.unwrap();
let fills = result
.fills
.iter()
.filter(|fill| fill.date == day(5))
.collect::<Vec<_>>();
assert_eq!(fills.len(), if quantity < 0 { 2 } else { 1 }, "{fills:?}");
assert_eq!(
(
fills[0].symbol.clone(),
fills[0].quantity,
fills[0].price,
fills[0].execution_timestamp
),
(code(2), 3700, 4., day(5).and_hms_opt(9, 30, 0))
);
if quantity < 0 {
assert_eq!(
(
fills[1].symbol.clone(),
fills[1].side,
fills[1].quantity,
fills[1].execution_timestamp
),
(
code(1),
fidc_core::OrderSide::Sell,
100,
Some(day(5).and_time(time))
)
);
} else if quantity > 0 {
// The later stock buy cannot spend money that the 09:30 ETF fill
// already consumed. It is rejected, not allowed to shrink that fill.
assert!(
result.order_events.iter().any(|order| order.date == day(5)
&& order.symbol == code(1)
&& order.status == fidc_core::OrderStatus::Rejected
&& order.reason.contains("cash")),
"{:?}",
result.order_events
);
}
if quantity != 0 {
assert!(
result.equity_curve.iter().any(
|point| point.date == day(5) && point.diagnostics.contains("no_new_signal")
)
);
}
assert!(result.terminal_audit.is_clean());
}
for minute in [15, 31] {
let observed = format!("2026-01-05T01:{minute}:00Z");
let created = format!("2026-01-05T01:{}:00Z", minute - 1);
let fill = serde_json::json!({"tradeId":"fill","observationEventId":"receipt","observationSequence":1,"tradeDate":"2026-01-05",
"executedAt":observed,"observedAt":observed,"feeObservationEventId":"receipt","feeObservationSequence":1,
"feeObservedAt":observed,"timestampPrecision":"second","quantity":100,"price":"10","totalFee":"0"});
let order = serde_json::json!({"orderId":"manual-order","sourceAdapter":"paper","symbol":code(1),"side":"Sell","quantity":100,
"orderCreatedAt":created,"terminalObservedAt":observed,"terminalStatus":"filled","fills":[fill]});
let mut replay: fidc_core::manual_execution::ManualExecutionReplay = serde_json::from_value(serde_json::json!({
"schema":fidc_core::manual_execution::MANUAL_REPLAY_SCHEMA,"runtimeId":"r","accountId":"a","sourceContractSha256":"a".repeat(64),"contentSha256":"",
"observationCutoff":"2026-01-05T08:00:00Z","actions":[{"actionId":"manual","source":"manual_security_trade","auditEventIds":["audit"],
"confirmedAt":created,"confirmationObservedAt":created,"outcome":"orders_terminal","orders":[order]}]})).unwrap();
replay.content_sha256 = replay.content_digest().unwrap();
let broker = broker(false)
.with_matching_type(MatchingType::MinuteLast)
.with_execution_price_field(PriceField::Last)
.with_intraday_execution_start_time(time)
.with_historical_etf_open_fallback(true);
let result = BacktestEngine::new(
data.clone(),
DeferredStockAndEtf {
inner: EtfPoolSignal {
at: time,
condition: String::new(),
},
quantity: 0,
},
broker,
BacktestConfig {
initial_cash: 30000.,
benchmark_code: "000300.SH".into(),
start_date: Some(day(2)),
end_date: Some(day(5)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Last,
},
)
.with_execution_quote_loader(|_| Ok(vec![]))
.with_observed_manual_executions(replay)
.unwrap()
.run();
if minute < 30 {
assert!(
result
.unwrap_err()
.to_string()
.contains("manual observation conflicts with pending shadow orders")
);
} else {
let result = result.unwrap();
assert_eq!(result.manual_executions.len(), 1);
assert_eq!(
result
.fills
.iter()
.filter(|fill| fill.symbol == code(2))
.count(),
1
);
assert_eq!(
result
.daily_holdings
.iter()
.find(|position| position.date == day(5) && position.symbol == code(1))
.unwrap()
.quantity,
1400
);
}
}
}
#[test] #[test]
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() { 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(); let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
+3 -3
View File
@@ -35,7 +35,7 @@ fn market(
) -> DailyMarketSnapshot { ) -> DailyMarketSnapshot {
DailyMarketSnapshot { DailyMarketSnapshot {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
timestamp: None, timestamp: None,
day_open: open, day_open: open,
open, open,
@@ -66,7 +66,7 @@ fn factor(
) -> DailyFactorSnapshot { ) -> DailyFactorSnapshot {
DailyFactorSnapshot { DailyFactorSnapshot {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
market_cap_bn, market_cap_bn,
free_float_cap_bn, free_float_cap_bn,
pe_ttm: 18.0, pe_ttm: 18.0,
@@ -87,7 +87,7 @@ fn candidate(
) -> CandidateEligibility { ) -> CandidateEligibility {
CandidateEligibility { CandidateEligibility {
date: d(date), date: d(date),
symbol: symbol.to_string(), symbol: symbol.into(),
is_st: false, is_st: false,
is_star_st: false, is_star_st: false,
is_new_listing, is_new_listing,
@@ -0,0 +1,55 @@
# 回报上下文、盘前意图与尚未提交的目标
2026-09-14。本轮已配套发布177annotated tag `v2026.9.14.5`。Engine81acc54 / Service e81bf47 / Trading94f99d2;完整股票池Goal继续,不据本阶段关闭。
## 已复现问题
1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。
2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。
3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。
4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。
5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。
## 本轮处理
- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。
- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。
- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。
- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。
- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。
- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。
## 回归证据
- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。
- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。
- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。
- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
精确只读快照在Linux通过Core834及Trading613。旧二进制先独立归档,构建保持1GiB磁盘余量;本轮未再次删除缓存或业务文件。
## 发布与真实历史复验
已推送annotated tag `v2026.9.14.5`对应Engine `81acc5422878abc855fca72b35766ffad6159200`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `94f99d20f49f6cd1810996706cb94f610c302385`。回测API/Runner于06:15:53 CST切换,五交易单元06:21:09切换,06:22实际运行文件和业务事实复核通过。
三组冻结合同共六次独立原生A/B,完整Canonical及equity/orders/trades/holdings逐行一致;再通过生产HTTP各提交一次,结果分别匹配原生候选,旧记录未改写:
| 案例 | 生产回测ID | 成交 / 持仓 | 期末权益 |
| --- | --- | --- | ---: |
| 手选优先四证券 | btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20 | 10 / 4 | 9706248.648662 |
| 自动优先四证券 | btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237 | 10 / 4 | 9706248.648662 |
| 许总24只原v3 | btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4 | 51 / 21 | 9685563.876924999 |
重复目标委托0。三条新记录各5个交易日事件落库,持久事件27/18/32条,唯一键计数分别相同;旧流式样本仍27条/5日。仍为原合同下的日终容量审计,不外推实时盘口成交能力;首次Source准备和后续快速返回也不作为性能优化证明。
API SHA `dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc`Runner SHA `b1d93215deb275fbec6217c6b9afbf717d5649600716bf4f3a1bf5d1cfa69731`,运行实现身份 `fed10e9fa61836aa271921f5d58490054210d83da935cad5de11cfacab45c13e`。API发布目录`/srv/fidc/canonical/run/backtest-api/releases/callback-81acc54-7w1zx9fb`,回退目录`/srv/fidc/canonical/run/build/callback-rollback-j7oje2tz`;交易回退`holding-protection-rollback-czuric4r`
六服务实际SHA与manifest吻合,新增ERROR0。3Paper/0Live、配置、旧活动单、3个未确认Paper预览、迁移、影子配置0及disabled未变;发布后Paper/Live新订单0,未发送真实通知、委托或撤单。Source d5/PID1700096与UI6a2/PID3089476未重启,研究/信号暂停不变。177维护中的Engine9a54156完整保留,实际构建使用81acc54/e81bf47及81acc54/94f99d2的只读Git快照。
原始回放/HTTP证据`/srv/fidc/canonical/run/research/stock-pool-callback-20260914/`;发布和最终审计`/tmp/fidc-callback-{candidate,api-release,trading-release,final-audit}-20260914.json`;非敏感汇总在`docs/evidence/callback-target-20260914/acceptance.json`
## 继续范围
显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。
@@ -0,0 +1,191 @@
{
"verified_at": "2026-09-13T22:22:38.597836+00:00",
"tag": "v2026.9.14.5",
"processes": {
"fidc-backtest-service-highmem177.service": {
"pid": 3692551,
"sha256": "dea170902d77734d0a77c4da7dad71a70b33f76467e0608675dfbcc9d35d67fc",
"journal_since": "2026-09-13T22:15:53.225719+00:00",
"journal_lines": 54,
"error_lines": 0
},
"fidc-trading-control-highmem177.service": {
"pid": 3697497,
"sha256": "a8f62ba74caf7ce2f5ba9cc6f67f41c844dee3747852611051c8dfb7b36295a3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-market-data-highmem177.service": {
"pid": 3697498,
"sha256": "89395ab4c9e11274f171f4386f88949ce616fe45d15aae9a829216d53ed11db7",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-strategy-runtime-highmem177.service": {
"pid": 3697688,
"sha256": "041a0103d2c46c55221d169965ece9fdacee3905abc0d46edd9c8a2a86f6cd54",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-paper-trading-highmem177.service": {
"pid": 3697792,
"sha256": "8460008f712810f7d3876b9f2274aef88f82d46cd33e1593dbd0361f6d158b75",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
},
"fidc-live-trading-highmem177.service": {
"pid": 3697762,
"sha256": "e89d6ba68655a5d4a164793ad67a9003cf2c49733063018497339930feafaac3",
"journal_since": "2026-09-13T22:21:09.122548+00:00",
"journal_lines": 6,
"error_lines": 0
}
},
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
"source_stale": false,
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
},
"source_checkouts": {
"fidc-backtest-engine": {
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
"runtime_commit": "81acc5422878abc855fca72b35766ffad6159200",
"tracked_dirty": false
},
"fidc-backtest-service": {
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
"tracked_dirty": false
},
"fidc-trading-platform": {
"head": "94f99d20f49f6cd1810996706cb94f610c302385",
"runtime_commit": "94f99d20f49f6cd1810996706cb94f610c302385",
"tracked_dirty": false
},
"omniquant": {
"head": "6a2b2604b40505fa754453307c517fef60743426",
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
"tracked_dirty": false
}
},
"ui_unchanged": {
"commit": "6a2b2604b40505fa754453307c517fef60743426",
"pid": 3089476
},
"http_cases": [
{
"name": "manual_first",
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"status": "succeeded",
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "automatic_first",
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"status": "succeeded",
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "stock24",
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"status": "succeeded",
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
"trade_count": 51,
"holding_count": 21,
"final_equity": 9685563.876924999,
"old_result_unchanged": true
}
],
"durable_events": [
{
"run_id": "btr_req_44f1bb067559946ef22941a0c425ed53e47515e04b399e20",
"count": 27,
"unique_keys": 27,
"days": 5
},
{
"run_id": "btr_req_078f129ed46b55ba72b47605983a00ae3eef14b1995e1237",
"count": 18,
"unique_keys": 18,
"days": 5
},
{
"run_id": "btr_req_ef37b8ea403f489e4798d2878e0ef85966ace550f6b4f3c4",
"count": 32,
"unique_keys": 32,
"days": 5
},
{
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
"count": 27,
"unique_keys": 27,
"days": 5
}
],
"trading_state": {
"paper": {
"configuration": {
"count": 3,
"hash": "93f3224edef59c381164e0236529dacc"
},
"active": {
"claims": 0,
"orders": 0
}
},
"live": {
"configuration": {
"count": 0,
"hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"active": {
"claims": 0,
"orders": 1,
"today_orders": 0,
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
"route_mode": "disabled"
}
}
},
"manual_facts_unchanged": {
"paper": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 3,
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
"new_orders": 0
},
"live": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 0,
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
"new_orders": 0
}
},
"broker_submission": false,
"linux_core_tests": {
"passed": 834,
"failed": 0,
"ignored": 9,
"log": "/srv/fidc/canonical/run/fidc-private/evidence/callback-candidate-6qumwky7/linux-core-tests.log"
},
"scope": "Callback and pending-target release verification; historical simulation only. Full manual shadow replay remains incomplete.",
"native_replays": 6
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
{
"verified_at": "2026-09-13T20:22:24.750379+00:00",
"tag": "v2026.9.14.4",
"processes": {
"fidc-backtest-service-highmem177.service": {
"pid": 3612875,
"sha256": "4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2",
"journal_since": "2026-09-13T20:14:17.444770+00:00",
"journal_lines": 54,
"error_lines": 0
},
"fidc-trading-control-highmem177.service": {
"pid": 3617963,
"sha256": "cd587928591fef952f2e98b47aa338a1702def5edf016a7da9751296667f6674",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-market-data-highmem177.service": {
"pid": 3617964,
"sha256": "2efb1d3ad7d510cf85e6047dd6d1981d0a30d768ff3d33c842adc52211002bdc",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-strategy-runtime-highmem177.service": {
"pid": 3618140,
"sha256": "3d7f3f2e8756e7f3439075344fe9c8bc0b55df33e7e6f251282712274c00339d",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 5,
"error_lines": 0
},
"fidc-paper-trading-highmem177.service": {
"pid": 3618260,
"sha256": "0383b1d6cc7b3c48c6902dd7fd4760a38698c1916e0eaff63be26fe3f6b1a2ab",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 6,
"error_lines": 0
},
"fidc-live-trading-highmem177.service": {
"pid": 3618246,
"sha256": "583f52e204aeb416574ee17daa20cebed49e0659a194c81e8072a9249824e48e",
"journal_since": "2026-09-13T20:19:57.821843+00:00",
"journal_lines": 6,
"error_lines": 0
}
},
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
"source_stale": false,
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
},
"source_checkouts": {
"fidc-backtest-engine": {
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
"runtime_commit": "237ee15a518a668297959509daffc4b88995f310",
"tracked_dirty": false
},
"fidc-backtest-service": {
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
"tracked_dirty": false
},
"fidc-trading-platform": {
"head": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
"runtime_commit": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
"tracked_dirty": false
},
"omniquant": {
"head": "6a2b2604b40505fa754453307c517fef60743426",
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
"tracked_dirty": false
}
},
"ui_unchanged": {
"commit": "6a2b2604b40505fa754453307c517fef60743426",
"pid": 3089476
},
"http_cases": [
{
"name": "manual_first",
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
"status": "succeeded",
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "automatic_first",
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
"status": "succeeded",
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
"trade_count": 10,
"holding_count": 4,
"final_equity": 9706248.648662,
"old_result_unchanged": true
},
{
"name": "stock24",
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
"status": "succeeded",
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
"trade_count": 51,
"holding_count": 21,
"final_equity": 9685563.876924999,
"old_result_unchanged": true
}
],
"durable_events": [
{
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
"count": 27,
"unique_keys": 27,
"days": 5
},
{
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
"count": 18,
"unique_keys": 18,
"days": 5
},
{
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
"count": 32,
"unique_keys": 32,
"days": 5
},
{
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
"count": 27,
"unique_keys": 27,
"days": 5
}
],
"trading_state": {
"paper": {
"configuration": {
"count": 3,
"hash": "93f3224edef59c381164e0236529dacc"
},
"active": {
"claims": 0,
"orders": 0
}
},
"live": {
"configuration": {
"count": 0,
"hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"active": {
"claims": 0,
"orders": 1,
"today_orders": 0,
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
"route_mode": "disabled"
}
}
},
"manual_facts_unchanged": {
"paper": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 3,
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
"new_orders": 0
},
"live": {
"shadow_configurations": 0,
"shadow_runs": 0,
"manual_count": 0,
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
"new_orders": 0
}
},
"broker_submission": false,
"linux_core_tests": {
"passed": 822,
"failed": 0,
"ignored": 9,
"log": "/srv/fidc/canonical/run/fidc-private/evidence/clock-candidate-gqx8g70l/linux-core-tests.log"
},
"cleanup": {
"apply": true,
"deleted": [
{
"path": "/srv/fidc/canonical/build/holding-protection-stage-wywd2682/fidc-trading-platform/debug/incremental",
"kind": "incremental_compiler_state",
"bytes": 9553190912,
"device": 2101,
"inode": 39877787,
"mtime_ns": 1789318996850462500,
"links": 176,
"size_bytes": 12288
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/incremental",
"kind": "incremental_compiler_state",
"bytes": 2103459840,
"device": 2101,
"inode": 29904450,
"mtime_ns": 1789318494701447400,
"links": 46,
"size_bytes": 4096
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_backtest_service-46310ff8aeeb4040",
"kind": "superseded_test_binary",
"bytes": 398401536,
"device": 2101,
"inode": 29934330,
"mtime_ns": 1789117975169420000,
"links": 1,
"size_bytes": 399242488
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-18c9b2429fdf6026",
"kind": "superseded_test_binary",
"bytes": 207015936,
"device": 2101,
"inode": 29918792,
"mtime_ns": 1789166943885717800,
"links": 1,
"size_bytes": 207144208
},
{
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-42f704a330411730",
"kind": "superseded_test_binary",
"bytes": 191205376,
"device": 2101,
"inode": 29933759,
"mtime_ns": 1789117542401109800,
"links": 1,
"size_bytes": 191333360
}
],
"reclaimed_allocated_bytes": 12453273600,
"before": {
"total": 1749269057536,
"used": 1659299954688,
"free": 1035599872
},
"after": {
"total": 1749269057536,
"used": 1647166930944,
"free": 13168623616
},
"observed_free_change": 12133023744
},
"scope": "Intraday clock release verification; historical simulation only, not a performance or real broker liquidity acceptance.",
"native_replays": 6
}
@@ -0,0 +1,40 @@
{
"source": "/srv/fidc/programs/runtime-snapshots/opening-clock-20260914-UUx5ru",
"build": "/srv/fidc/canonical/build/opening-clock-20260914-UUx5ru",
"commits": {
"fidc-backtest-engine": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
"fidc-backtest-service": "49f280075e6b9dce2ef149fc190cfe663411b905",
"fidc-trading-platform": "ae83fd30f56a5420a235022b0a169aaf0bae55cf"
},
"source_unchanged": true,
"results": [
{
"repository": "fidc-backtest-engine",
"exit_code": 0,
"elapsed_seconds": 56.007,
"passed": 885,
"failed": 0,
"ignored": 9,
"log_sha256": "9a887a23fc656d0f448502ecb2fca5fcfd82db15346f567e6a8b0bcaa88180d7"
},
{
"repository": "fidc-trading-platform",
"exit_code": 0,
"elapsed_seconds": 90.054,
"passed": 625,
"failed": 0,
"ignored": 63,
"log_sha256": "048a7690ce874bccc58e3111decd183c3f88f649397c59cdfbaa3e4533662589"
},
{
"repository": "fidc-backtest-service",
"exit_code": 0,
"elapsed_seconds": 118.011,
"passed": 589,
"failed": 0,
"ignored": 16,
"log_sha256": "ec4f51b30dd69d9c86812cbb1b1e310c277fdfcf29465978b8d87eb83f40204c"
}
],
"available_bytes": 97373085696
}
@@ -0,0 +1,60 @@
{
"observed_date": "2026-09-14",
"status": "tested_not_deployed",
"source_snapshot_manifest_sha256": "bf01cd040bdcbeaa0c81a93153eb8e505dd7dcb8c761731f7f3ed81b1b2b6b67",
"immutable_source_file_count": 959,
"test_scope": "fidc-opening-clock-check-20260914-UUx5ru.scope",
"test_cpu_quota_percent": 400,
"test_memory_max_gib": 12,
"source_runtime_commit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"source_runtime_pid": 1700096,
"source_runtime_changed": false,
"runtime_services": [
{
"service": "omniquant-highmem177.service",
"pid": 987464,
"state": "active",
"version": "v2026.9.14.8"
},
{
"service": "fidc-backtest-service-highmem177.service",
"pid": 4108679,
"state": "active",
"exe_sha256": "5933b7423c6722e5fe644a604c915a27ceb89900f690b092d1689bfe0fe389b7"
},
{
"service": "fidc-paper-trading-highmem177.service",
"pid": 3765829,
"state": "active",
"exe_sha256": "3ebd7ce815e325ff0bb38632ef5b5e5cd5aff7a4e0601ba0229feb3201cdefe3"
},
{
"service": "fidc-live-trading-highmem177.service",
"pid": 3765783,
"state": "active",
"exe_sha256": "1f4b2d697accdbb76d89cd7cb9c3ae4dad942d86e3dde2e7d211eee5879b9144"
},
{
"service": "fidc-strategy-runtime-highmem177.service",
"pid": 3765705,
"state": "active",
"exe_sha256": "5a160ba9588f0ba9275f7d33152667be176cece310fc3026d4c043d897d44b4c"
},
{
"service": "fidc-trading-control-highmem177.service",
"pid": 3765532,
"state": "active",
"exe_sha256": "9feade5eed04007595f6ef88e076a3f501631b06cb6dc9d9b6d7f5be379b0a20"
}
],
"engine_development_checkout": "13c89e8d59f21df6280d722f369d0ec8d9a6457e",
"preserved_service_development_checkout": "5991e7733d9e2a770f740e971924b12cc5f7d29d",
"production_order_or_cancel_requests_by_this_verification": 0,
"production_task_configuration_writes_by_this_verification": 0,
"remaining": [
"Source freeze authorization and physical partition validation",
"Actual Source and Runner shadow joint acceptance",
"Unaccepted Arrow performance candidate gate",
"Remaining corporate-action, holding-protection and full parameter matrix"
]
}
@@ -0,0 +1,588 @@
{
"schema": "fidc.selection-risk-plan-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
"wallSeconds": 30.986483575077727,
"engineSeconds": 8.79,
"dataSeconds": 8.445,
"validationSeconds": 12.244,
"resultSeconds": 1.292,
"maxRssKiB": 7090392,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-2",
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
"wallSeconds": 13.274638780159876,
"engineSeconds": 6.739,
"dataSeconds": 5.19,
"validationSeconds": 0.209,
"resultSeconds": 1.003,
"maxRssKiB": 7092040,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "control-3",
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
"wallSeconds": 13.043757867999375,
"engineSeconds": 6.732,
"dataSeconds": 5.159,
"validationSeconds": 0.005,
"resultSeconds": 1,
"maxRssKiB": 7089984,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-1",
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
"wallSeconds": 12.976857921108603,
"engineSeconds": 6.682,
"dataSeconds": 5.132,
"validationSeconds": 0.004,
"resultSeconds": 1.021,
"maxRssKiB": 7091752,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-2",
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
"wallSeconds": 12.927155625075102,
"engineSeconds": 6.64,
"dataSeconds": 5.128,
"validationSeconds": 0.005,
"resultSeconds": 1.01,
"maxRssKiB": 7092320,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "candidate-3",
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
"wallSeconds": 12.926160736009479,
"engineSeconds": 6.664,
"dataSeconds": 5.113,
"validationSeconds": 0.006,
"resultSeconds": 1.006,
"maxRssKiB": 7091128,
"fills": 21393,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-control",
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
"wallSeconds": 15.628366323187947,
"engineSeconds": 8.199,
"dataSeconds": 5.234,
"validationSeconds": 0.694,
"resultSeconds": 1.33,
"maxRssKiB": 7108340,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "trend-40-candidate",
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
"wallSeconds": 14.82603678200394,
"engineSeconds": 8.087,
"dataSeconds": 5.276,
"validationSeconds": 0.004,
"resultSeconds": 1.322,
"maxRssKiB": 7108656,
"fills": 29776,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-control",
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
"wallSeconds": 13.775856785941869,
"engineSeconds": 7.374,
"dataSeconds": 4.893,
"validationSeconds": 0.005,
"resultSeconds": 1.358,
"maxRssKiB": 7119352,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "pullback-40-candidate",
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
"wallSeconds": 13.927610703045502,
"engineSeconds": 7.239,
"dataSeconds": 5.137,
"validationSeconds": 0.003,
"resultSeconds": 1.368,
"maxRssKiB": 7119784,
"fills": 31862,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
"wallSeconds": 18.176081838086247,
"engineSeconds": 11.154,
"dataSeconds": 4.585,
"validationSeconds": 0.004,
"resultSeconds": 2.268,
"maxRssKiB": 7158556,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
"wallSeconds": 18.627057212870568,
"engineSeconds": 11.013,
"dataSeconds": 5.2,
"validationSeconds": 0.005,
"resultSeconds": 2.267,
"maxRssKiB": 7152664,
"fills": 51300,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"sharedInputsUnchanged": true
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
"bounds": [
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
"No Source/trading service was changed and no paused research/signal task resumed.",
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
]
}
@@ -0,0 +1,648 @@
{
"schema": "fidc.series-column-storage-acceptance/v1",
"rows": [
{
"name": "control-1",
"receiptSha256": "fa8f2265f1d3ed2cdeef02f62840fec00a266ace3cf9399cb81d33110f3b115c",
"wallSeconds": 23.878584733000025,
"engineSeconds": 6.612,
"dataSeconds": 5.213,
"datasetConstructSeconds": 1.901,
"loopSeconds": 1.734,
"validationSeconds": 10.871,
"resultSeconds": 1.007,
"maxRssKiB": 7137676,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-1",
"receiptSha256": "edd7e575013932ae58603e9fa810626573e1b0eaa00d01426d736cb4740aaf86",
"wallSeconds": 24.12557172914967,
"engineSeconds": 6.674,
"dataSeconds": 4.818,
"datasetConstructSeconds": 1.589,
"loopSeconds": 1.646,
"validationSeconds": 11.47,
"resultSeconds": 1.011,
"maxRssKiB": 6463660,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-control-2",
"receiptSha256": "6a5b37c2881c7a4177ce69cef2440c3dd7a0846c6ca9a4811c68f173b16f7183",
"wallSeconds": 18.18023332185112,
"engineSeconds": 9.509,
"dataSeconds": 7.272,
"datasetConstructSeconds": 2.824,
"loopSeconds": 2.822,
"validationSeconds": 0.005,
"resultSeconds": 1.206,
"maxRssKiB": 7138628,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "rotation-candidate-2",
"receiptSha256": "f50ca6af6496213d558af646862e5a86334077bb9080e4e7c6c9602a0cabd789",
"wallSeconds": 15.078223099000752,
"engineSeconds": 8.171,
"dataSeconds": 5.543,
"datasetConstructSeconds": 2.094,
"loopSeconds": 2.066,
"validationSeconds": 0.004,
"resultSeconds": 1.177,
"maxRssKiB": 6454624,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "candidate-3",
"receiptSha256": "c8bce9a8eeda769f00e72118c9ea955d323fdad902dc271590d6e377b5a99196",
"wallSeconds": 13.124768079956993,
"engineSeconds": 6.649,
"dataSeconds": 5.103,
"datasetConstructSeconds": 1.853,
"loopSeconds": 1.676,
"validationSeconds": 0.208,
"resultSeconds": 1.017,
"maxRssKiB": 6457728,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "control-3",
"receiptSha256": "1f999a89aadab1baf4a70cda96a314ab237302722cdce895bb5fe26dea4306ec",
"wallSeconds": 12.725315875839442,
"engineSeconds": 6.583,
"dataSeconds": 4.955,
"datasetConstructSeconds": 1.713,
"loopSeconds": 1.675,
"validationSeconds": 0.004,
"resultSeconds": 1.013,
"maxRssKiB": 7140772,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 93895,
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
"sections": {
"accountEvents": {
"rowCount": 21555,
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
},
"fillEvents": {
"rowCount": 21393,
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
},
"holdingSnapshots": {
"rowCount": 28353,
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
},
"orderEvents": {
"rowCount": 21491,
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
},
"riskAudits": {
"rowCount": 78,
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
}
}
},
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
"verifiedFactBlocks": 290,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-control",
"receiptSha256": "55b1976d15ba531477ff92f07f953a0a8a9b7cab96f01fb0ce5546f9aeda7406",
"wallSeconds": 14.779385674046353,
"engineSeconds": 7.867,
"dataSeconds": 5.418,
"datasetConstructSeconds": 1.921,
"loopSeconds": 1.952,
"validationSeconds": 0.005,
"resultSeconds": 1.35,
"maxRssKiB": 7157844,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "trend-40-candidate",
"receiptSha256": "86a3f452168d059f034e91f7317890af49d9763b75745dd3d39a948b00c1d72d",
"wallSeconds": 15.011793529149145,
"engineSeconds": 8.011,
"dataSeconds": 5.168,
"datasetConstructSeconds": 1.851,
"loopSeconds": 1.739,
"validationSeconds": 0.376,
"resultSeconds": 1.315,
"maxRssKiB": 6470768,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 128192,
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
"sections": {
"accountEvents": {
"rowCount": 29968,
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
},
"fillEvents": {
"rowCount": 29776,
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
},
"holdingSnapshots": {
"rowCount": 37367,
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
},
"orderEvents": {
"rowCount": 29932,
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
},
"riskAudits": {
"rowCount": 124,
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
}
}
},
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
"verifiedFactBlocks": 293,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-control",
"receiptSha256": "f5e6c9cb90db0dc7095e388b4ee939d4c6609824f3c065e0a08144a80f3d9395",
"wallSeconds": 14.010676869889721,
"engineSeconds": 7.298,
"dataSeconds": 5.172,
"datasetConstructSeconds": 1.893,
"loopSeconds": 1.7,
"validationSeconds": 0.005,
"resultSeconds": 1.388,
"maxRssKiB": 7167408,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "pullback-40-candidate",
"receiptSha256": "f8caf67a6010d882a064678cf5c57f48c53c85b7c82dde708c029ee52e9e56b2",
"wallSeconds": 13.87669027899392,
"engineSeconds": 7.219,
"dataSeconds": 5.117,
"datasetConstructSeconds": 1.837,
"loopSeconds": 1.691,
"validationSeconds": 0.005,
"resultSeconds": 1.362,
"maxRssKiB": 6478492,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 135630,
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
"sections": {
"accountEvents": {
"rowCount": 32010,
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
},
"fillEvents": {
"rowCount": 31862,
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
},
"holdingSnapshots": {
"rowCount": 38679,
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
},
"orderEvents": {
"rowCount": 31966,
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
},
"riskAudits": {
"rowCount": 88,
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
}
}
},
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
"verifiedFactBlocks": 281,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-control",
"receiptSha256": "0f589d11fec4a37f80635446fa445b7c7a9ae58e202e5b0c2533aa257fd94c81",
"wallSeconds": 18.577259425073862,
"engineSeconds": 10.96,
"dataSeconds": 5.163,
"datasetConstructSeconds": 1.891,
"loopSeconds": 1.698,
"validationSeconds": 0.004,
"resultSeconds": 2.282,
"maxRssKiB": 7210220,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
},
{
"name": "volume-momentum-80-candidate",
"receiptSha256": "5ae74a1b311479a39c9863c2fba487951631353f9ae2a523d919f2d0a8f592e7",
"wallSeconds": 18.476340716006234,
"engineSeconds": 10.989,
"dataSeconds": 5.051,
"datasetConstructSeconds": 1.844,
"loopSeconds": 1.645,
"validationSeconds": 0.005,
"resultSeconds": 2.284,
"maxRssKiB": 6527236,
"canonical": {
"schemaVersion": "fidc-canonical-backtest-result/v2",
"algorithm": "sha256",
"ordering": "engine_fact_order_v2",
"totalRows": 234267,
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
"sections": {
"accountEvents": {
"rowCount": 51696,
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
},
"equityFacts": {
"rowCount": 1025,
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
},
"fillEvents": {
"rowCount": 51300,
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
},
"holdingSnapshots": {
"rowCount": 78078,
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
},
"orderEvents": {
"rowCount": 51783,
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
},
"riskAudits": {
"rowCount": 385,
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
}
}
},
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
"verifiedFactBlocks": 309,
"source": {
"commit": "d5b682c6d097",
"pid": 1700096,
"source_stale": false
}
}
],
"sharedInputFiles": 9257,
"sharedInputBytes": 12596608049,
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
"verifiedFactBlocks": 3506,
"status": "candidate-not-deployed",
"scope": "exact in-memory column reuse; separate DayOpen correctness fix included in both control and candidate",
"controlRunnerSha256": "8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d",
"candidateRunnerSha256": "40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43",
"candidateApiSha256": "c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e",
"engineCommit": "996b909608589fb1987f33c0cfb4c62099f69617",
"serviceCommit": "443ed421c2c9c854a01fab69ce58957690504570",
"boundaries": [
"The DayOpen prefix correction is present in both storage A/B binaries.",
"No file format, cache schema, input values or execution policy changed for the storage comparison.",
"Original shared inputs were hashed and remained unchanged; results were recalculated into private artifacts.",
"The last pair ran candidate before control. It did not establish a general latency improvement.",
"Source remains frozen and paused research/signal tasks were not resumed.",
"The independent same-day intraday clock counterexample remains unresolved."
]
}
@@ -0,0 +1,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`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
+35
View File
@@ -0,0 +1,35 @@
# 迟到成交、批次成本与持有保护
2026-09-14。阶段修复;完整股票池目标仍未完成,未据此发布生产。
## 根因与修复
旧持仓账本按收到买入回报的顺序追加批次,卖出直接扣列表首批。当较早成交的回报晚到,或旧证券换股并入已有新证券持仓时,列表先后不再等于取得日期。T+1 校验计算了合法老批次数量,却实际扣掉新批次;剩余旧批次可能再次被当作可卖。FIFO 成本与已实现/未实现盈亏随之错配。
另一问题是已有持仓收到更早买入事实时,`opened_date` 没有更新,最长持有期从较晚日期起算。
修复在账本扣减入口按真实取得日期稳定执行 FIFO;同日回报及其费用保持原关联,不重排收到的外部事件,不补单、不回写历史结果。正常日期顺序不排序,零股操作不排序。连续持仓的已知开仓日取较早日期,最近买入日仍取较晚日期;缺失的原始建仓日期不靠新买入猜测填充。移动均价展示合同与固定精度现金/费用不变。
## 负向证据
基线 `b2eaaa0d269d4aee5e2e500cd0f2b2edbda648b8` 上新增两个测试实际失败:
- 9月14日新买100股先被观察,9月11日老买100股随后才被观察。旧 `opened_date` 仍为9月14日,期望9月11日。
- 随后卖出100股时,旧代码扣了新批次,剩余未实现盈亏为 -0.75,而按老批次先卖应为 -1000.25。该样例分别使用20元/10元买入、0.25/0.75元买入费用和0.5元卖出费用;只有证券身份数据,不冒充真实市场行情。
## 回归覆盖
- 回报仍按原观察序号应用;老买入不得在收到之前进入持仓。
- 合法卖出老100股后,新100股仍不可在9月14日卖出。第二次冲突卖出拒绝且现金、股数、游标原子保留;现金7998.5、出入金0、剩余FIFO成本2000.25,费用没有串到另一批次。
- 最长持有期使用9月11日,买后3个交易日保护使用最新买入日9月14日,保护优先于最长持有退出。
- 整段引擎换股:旧股较早买入100股、已有新股较晚买入100股,旧股按2倍换成新股200股。随后卖200股先扣旧来源,留下新买100股;不重置开仓/最近买入日,成交来源及换股事件保留,已实现不含费用盈亏200。
- 整段平台表达式:真实手工两买一卖、次日送转、3日买后保护/卖后禁买、16日至17日显式锁定、最长持有退出同时配置。14日至15日审计分别记录保护和禁买,16日至17日记录锁定;18日只生成一笔卖200股、5元的最长持有退出,不重复附加显式卖单。三笔手工来源保留、不计出入金,旧股转成200股后计时不重置。
- 同一整段测试另验16日锁定期间的已确认手工卖出200股:与14日保护期间卖100股两个变体分别验证。前者15日送转后400股,手工卖出后200股;两个变体最终都在18日仅自动退出剩余200股。锁定仍阻止自动交易,不阻止已授权手工事实入账;T+1未绕过,买后保护和卖后禁买分别沿实际日期计算。
本机Core889、Trading625、Runner460/API127通过,ignore另计;针对性完整审计断言另行通过。两次测试编写阶段的私有方法/辅助函数名编译错误已修正,不计作框架失败或通过证据。不是实际Source或GT交易验收。
## 仍须继续
实际 Source/Runner 联合回放和未准入 Arrow 性能门禁尚未通过;Source明确冻结仍待独立解除授权。另需继续验证迟到回报跨越已经执行过的除权/派息/换股事件、跨模式历史持有事实及其余参数矩阵。本节只证明列出的组合,不能外推全部公司行为或关闭完整目标。
旧opening-clock-UUx5ru与FewUWP收据均不包含本次账本修复,不得覆盖。后续新的Linux/发布证据另附,本轮不修改原池、任务、历史或交易开关。
+53
View File
@@ -0,0 +1,53 @@
# 手工观察主时钟接入候选
最新显式开盘/跨日 ETF 修复与验收见 [opening-and-deferred-clock-20260914.md](opening-and-deferred-clock-20260914.md)。下面保留早期阶段证据;原“开盘晚于配置窗口一律拒绝”已改为共享时钟与真实收盘边界,不再依赖订阅是否启用。
2026-09-14,未发布,完整Goal不关闭。不是生产手工影子回放验收。
## 本阶段已实现
- BacktestEngine可显式绑定严格v2手工观察输入。原始回报时刻驱动账本;同一时刻按真实观察序号逐笔原子应用,回调能看到100、200而不是第一笔就看到两笔总量。
- 默认盘前、开盘、盘中、收盘/结算及当日晚到回报纳入处理;跨会话观察先于下一会话公司行为,不生成行情行;结束后仍未覆盖的观察明确失败,不截断成成功。
- 手工成交写独立来源及应用明细,不冒充模拟策略FillEvent。账户变化不计作出入金;手续费只扣一次,最终费用来源/时间仍单独保留。
- 股数及现金改变后通知策略,真实买卖日期更新持有保护和卖后禁买证据;券商模拟器的当日卖后禁买规则同样接收手工卖出,不把手工绕过自动条件理解为抹掉真实成交历史。
- 分钟时钟不必依赖策略订阅或同一时刻市场报价,手工价格也不会伪造为市场行情。已有挂单/待执行目标冲突仍明确拒绝,不替用户撤单或重建目标。
- 流式数量、原始观察明细及换手率纳入手工应用;纯无成交的来源不会改变自然策略时钟。
## 已复现并修正的问题
旧默认OpenAuction回调在09:31,接着却可能执行09:30日内步骤。手工09:27观察会由此先进入09:31再倒退到09:30。已把默认开盘阶段放在09:25,并保留显式调度时间。
盘前08:50/09:10规则原来在同一状态上顺序计算,不能正确看到夹在两者之间的08:55回报。现按实际到期时间交错处理回报、调度、资金等指令和撤改控制;盘前阶段若跨越开盘阶段,明确报告冲突,不把晚时点状态带回早时点。
## 当前验证
Core872通过(9项原ignore不计通过),交易工作区619普通测试通过;不是实际券商行情验收。此前默认阶段样例最终600股、现金3991、权益9991、出入金0,原四个基础用例保留。
本轮新增8项回归,不重复把基础样例当新验收:
- OpenAuction 09:20/09:26、AfterTrading 15:15/16:00、Settlement 16:10与09:22/15:30/16:05手工观察交错。原候选09:20提前读到100股,16:00/16:10仍只读到100股;修复后依次为0/100/100/200/300股。
- 盘后16:00的100股与结算16:10的200股显式指令,下一交易日各执行一次、共300股,信号日价格10不冒充执行日价格12。lag0/lag1保留原信号日、意图创建日和实际成交日;测试还抓到立即成交记录曾被统一注释为新信号日,已按批次原始日期记录。
- 多个完整目标在进入待执行队列时就只保留最新一份;次日新的0%完整目标不会先执行旧买入。显式股数指令不作为完整目标覆盖。
- 结束日期的两笔显式意图没有生成委托/成交,完整原指令留在terminalAudit;完整目标只留最新一份。NaN/Inf不能在JSON中被悄悄变成null。
- 存在真实行情/风控但没有新因子选股快照的下一交易日,仍执行已有指令,不等到后日再运行。声明盘后阶段的策略使用完整市场日历,外部指标可用同一`backtest_execution_dates_with_rules`对齐;当前Runner的Platform策略只暴露OpenAuction/OnDay/Minute,不宣称已支持配置盘后阶段。
- 显式开盘调度越过已配置执行窗口被拒绝;盘后GTC撤单立即作为控制执行,不变成次日新委托。
盘后处理使用正常账本/报价/风控入口,不创建模拟外部Fill,不越过结束日期。旧DAY订单仍按到期失效,下一日处理的是尚未提交的策略意图,并非延长旧订单有效期。无新信号的报价时钟复用有序迭代器,不复制整日Tick列表。
结果协议和API/Runner的候选接入见fidc-backtest-service/docs/manual-execution-run-contract-20260914.md。当前正常记录/费用原始精度不改;所有新代码尚未发布,影子调用仍没有解除四类纯比例拒绝门禁。
## 必须继续
1. 本轮已覆盖上述显式阶段与跨日用例;仍需补完整混合时钟矩阵,特别是显式开盘晚于盘中报价/ETF开盘、无新信号日同时有ETF待执行目标、公司行为和跨日保护组合。不得修改market_open已有09:31语义或把这些未验组合静默跳过以让测试通过。
2. 完成影子调度调用、所需历史证券范围、来源权限/归属、实际HTTP和Linux验收;不以独立输入/结果单测冒充端到端。
3. 结果委托/成交分页接口与统一UI仍须合并展示外部手工来源,保留未知组件和完整原始ID,不把仅落库视为呈现已完成。
4. 核对GT正式总费用来源、整仓关键日志严格持久化及完整参数矩阵后再配套发布。
本轮未重启生产或发送委托。同期其他维护已将Backtest发布为Engine665653c/Service501f6d0;这不包含本文件所述主时钟候选。交易仍166998d/v2026.9.14.6Source d5/PID1700096冻结与研究暂停不改。
## 2026-09-14 运行级仓位配置补充
v3 手工输入独立携带审计仓位/权重时间线与旧日级前缀,不覆盖原策略或股票池。仅已成交证券产生独立行情需求;补充范围不会成为选股候选。恢复跟随回到原规则,未来事件不能被伪称为截止时刻前已观察事实。Core 878 项本机通过,尚未部署;PG、期间隔离、权限与剩余联合验收见 `../../fidc-trading-platform/docs/shadow-manual-input-20260914.md`。本节不替代前述时钟证据,也不宣称全部矩阵完成。
## 逐日手工交付补充
手工观察输入可通过Arc与进度投影共享;默认紧凑进度保留当日手工应用及独立累计计数,原生明细开关不改。新增可失败进度回调,投影来源/计数错误会终止本次回测,不忽略错误后返回成功。Core879本机通过,当前完整版本Linux及发布验收未完成;共享最终/逐日投影与真实本机WebSocket证据见Service `docs/manual-stream-projection-20260914.md`
+45
View File
@@ -0,0 +1,45 @@
# 手工成交观察回放:基础合同与当前断点
2026-09-14。当前候选已升级v2并与交易端权威读取配套,仍未接入Runner/API或引擎主时钟、未发布。交易最近发布是166998d/v2026.9.14.6,回测仍81acc54/e81;完整Goal和手工影子回放均未完成。
## v2读取合同补充
默认主时钟、盘前交错与独立结果来源已开始配套接入,当前阶段/真实缺口改由docs/manual-execution-clock-20260914.md维护。本基础模块通过不等于完整阶段日历或生产影子已启用。
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。
最新main a29c434的DayOpen和列存变更已按ff-only保留合入;组合Core860通过,其中本模块18项。交易端读取四类来源及验证范围见fidc-trading-platform/docs/manual-replay-capture-20260914.md。未将整仓无订单、Paper一例与Live一例外推完整参数/时钟/券商验收,不据此解除门禁。
## 已实现
`manual_execution`提供`fidc.observed-manual-executions/v2`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。下面保留初版阶段的实现说明,费用和时间字段以本节v2补充为准。
- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。
- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。
- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。
- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。
- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。
- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。
- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。
本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。
## 已核对的持久化入口
Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids`paper_fills``paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。
Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。
费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_taxLive事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。
## 必须继续,不能把本阶段当完成
1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。
2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。
3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。
4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。
5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。
下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。
@@ -0,0 +1,43 @@
# 显式开盘、跨日 ETF 与委托时钟修复
2026-09-14。候选本机及独立 Linux 已验证,尚未发布;完整股票池工作不以本阶段关闭。
## 已复现的错误
1. `market_open(0, 0)` 的既有语义为 09:31。旧引擎先执行该开盘回调,再执行 09:30 行情回调。09:31 的手工买入 100 股提前出现在 09:30 上下文,造成未来状态可见。
2. 开盘回调顺序修正后,`MinuteLast` 撮合仍把配置的 09:30 窗口起点当作实际执行时刻,将 09:31 新订单记成 09:30 成交;新挂单也可能记成较早起点。
3. 没有新因子/选股快照的下一交易日,原实现先执行 13:00 的普通待执行指令,再执行 ETF 的 09:30 开盘目标。负向测试中第一笔是股票 100 股、20 元、13:00,后面才出现较早 ETF 成交。此顺序会影响实际现金分配,不能只排序最终表格。
4. 行情可用但剩余现金不足一手时,报价撮合丢失预算阻断原因,最终错误显示“intraday quote liquidity exhausted”。
## 修复合同
- 开盘调度、已订阅行情、ETF 开盘、实际手工观察、委托窗口与到期时刻进入同一时间序列。保留 09:31 的已配置含义,不改成 09:25 避开反例。开盘阶段不允许越过收盘阶段;其可用性不再依赖是否订阅分钟回调。
- 新订阅从实际启用时刻开始接收后续行情,不重放较早缓存报价;仍订阅中的证券不丢失其较早合法行情。
- 新委托和续撮使用当前执行时刻,原委托创建时刻在后续重试中保留。行情来源时刻与成交时刻分开,日线/分钟/ETF 既有定价合同、价格精度、费用和证券规则不改。
- 无新信号日不重新生成策略目标;只执行已存在意图、ETF 目标及挂单。相同时间先处理到期 ETF,再执行普通批次,后续按真实报价和到期时刻推进。没有可用信号上下文时只发布原始事实,不伪造策略回调。
- 手工观察遇到尚未结束的影子订单/ETF 目标仍明确拒绝。不能把 09:30 的成交提前应用以让 09:15 的手工冲突消失。
- 零成交预算阻断保留资金不足/金额预算/非法价原因,不伪装成流动性不足。真实无行情或容量不足的规则保持。
## 本机验证
源基线 Engine `232e9ae1546842224d7a21aa07d3c0696ece4b11`Service `49f280075e6b9dce2ef149fc190cfe663411b905`、Trading `ae83fd30f56a5420a235022b0a169aaf0bae55cf`
- Core 885 项通过,9 项原 ignore 不计通过。
- Trading 工作区 625 项通过,63 项私有依赖 ignore 不计通过;没有重复运行已有数据库夹具。
- Runner 460、API 127 项通过,16 项 ignore 不计通过。Mac 原 `is_source_row_file` dead-code 警告仍存在。
- 手工 09:31 买入:09:30 回调 0 股,09:31 开盘回调 100 股;后续报价只处理一次。
- NextBarOpen/MinuteLast × 订阅/未订阅四种组合:09:31 的 100 股新单恰好成交一次,时间均为 09:31;后续重试不改原创建时刻。
- ETF 和晚开盘:09:15 回调未持有;09:30 成交 3,700 股、4 元;09:31 开盘及 09:32 行情各看见该唯一成交。
- 无新信号日:ETF 09:30 先成交 3,700 股。13:00 股票卖出 100 股随后成交;股票买入 100 股的对照因剩余现金不足而拒绝,不能抢先花费 ETF 应使用的现金。无普通待执行意图的 ETF 单独分支也通过。
- 同一无信号日的手工卖出:09:15 与未结束 ETF 目标冲突时拒绝;09:31、ETF 完成后的真实手工卖出应用一次,原股票持仓从 1,500 到 1,400 股。
- 定位过程的失败、类型修正和资金不足断言修正不计通过;没有改动原池或补造行情。
## 发布与剩余工作
功能提交 `13c89e8d59f21df6280d722f369d0ec8d9a6457e` 已推送并核对 main,177 引擎开发源码已快进到同一 SHAtracked clean。保留 Service 并行开发提交 `5991e7733d9e2a770f740e971924b12cc5f7d29d`;联合测试使用已推送 Service49f2800/Tradingae83fd3,不覆盖该工作树。
新的只读 `opening-clock-20260914-UUx5ru` 快照 959 个文件,4CPU/12GiB独立 scope、boris 执行、1GiB磁盘线不变。Linux Core885、Trading625、Runner/API589Linux额外2项平台测试)全部通过,源码前后不变。收据 [linux-tests.json](evidence/opening-clock-20260914/linux-tests.json),只读生产状态 [runtime.json](evidence/opening-clock-20260914/runtime.json)。这是独立程序测试,不是 Source 正式数据回放或生产交易验收。
本修复不在已构建的 `manual-stream-20260914-FewUWP` 二进制中。该目录及既有 Linux 收据继续保留,不能覆盖或改写成包含本修复。本轮没有新 release 构建/发布 tag;仍须真实 Source/Runner 联合验收及未准入 Arrow 性能门禁。
Source `d5` 版本冻结、研究/信号暂停、Live disabled 与旧任务/历史不变。Source 清单权威修复和新验证合同仍待明确解冻授权;本阶段没有发单、撤单或生产重启。后续继续公司行为、跨日保护/禁买及完整参数/适配器矩阵,不把以上确定性例子外推为全量生产完成。
+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,146 @@
# Selection Risk Plan Performance
## Status
Published to Backtest in the combined 665653c/501f6d0 release described below.
The change removes selection calls that have
no possible effect under the current frozen policy. It does not disable any
configured rule, execution-day check or strategy expression. Engine time falls
slightly in the measured cases; this is not the solution to the main remaining
data construction cost and is not a general whole-backtest speedup claim.
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
was subsequently resolved by business-main work and published in the clock and
81acc54 callback releases. That correction is not attributed to this candidate.
Current published Backtest uses 501f6d0/665653c; the measurements below retain
their earlier c98 baseline. Complete manual-replay integration remains open.
Source d5b682c6 remains frozen;
research and signal work stay paused. No trading operation was submitted.
## Evidence Leading to the Change
The official HTTP diagnostic replay btr_1789322878865_2871869_0 preserved the
original canonical and result-store SHA. Ten bounded Boris-only GDB snapshots
showed source inventory, PreparedDayBuilder, factor normalization and price
series construction, followed by repeated selection risk calls. GDB pauses are
not normal performance measurements and snapshot counts are not flamegraph
percentages. Source/target PID, binary SHA and CPU/thread resources stayed fixed.
The diagnostic helper now shares the existing canonical executable policy with
the saved-run profiler: it accepts both audited build roots and immutable API
release directories, but not arbitrary paths. Seven related tests passed.
## Implementation and Correctness
StaticRiskRuleConfig reports whether selection has an enabled state rule or an
enabled nonempty blacklist. The strategy computes this once before iterating
candidate symbols. If no such rule exists, the old selection function would
always return None, so that no-op call is omitted. Explicit universe conditions,
market/factor checks and all buy/sell execution paths are unchanged.
The ten state flags are also shared with the existing missing-risk-state checks
to avoid maintaining three separate flag lists. Blacklist presence is kept
separate: a blacklist is not missing market-risk data. No cross-strategy cache,
strategy identifier, fixed date, trading time or account state is introduced.
Tests enumerate all 4,096 combinations of ten selection flags and blacklist
enabled/populated states. The flag list is checked against the serialized
configuration, so adding a selection field requires updating the activation
test. Further tests retain missing-state behavior and show that paused buys
and sells remain rejected when selection checks are inactive.
On 177: 805 core unit/integration tests passed (9 ignored), 448 runner tests
passed (9 ignored), 119 API tests passed (5 ignored), and 28 benchmark/profiler
tests passed. These counts do not resolve the independently recorded clock
failure, which is not part of this frozen committed test tree.
## Reproducible Shared-Input Method
Each of the twelve replays has a new process and a new private result root.
The official runner benchmark gained --shared-runtime-cache. It resolves the
explicit cache root from the declared Boris service, requires canonical private
storage, hashes existing inputs before and after, and refuses any changed or
removed original. This mode cannot invoke copied-input disposal.
All twelve runs used the same 9,257 files / 12,596,608,049 bytes. Their complete
input inventories, file identities and byte SHA values are equal. No new Arrow
or binary cache input appeared. No backtest result was reused. Hash preparation
and verification are outside the measured runner interval; this is a shared
warm-input test, not raw-disk cold IO. Unlike the earlier copied-cache method,
it does not allocate another approximately 2 GB per replay on the nearly full
SSD. Original inputs and every result remain intact.
The common execution interval is 2021-08-23 through 2025-11-17 with 10,000,000
initial cash and each case's unchanged frozen strategy/bundle. This is not five
complete execution years. CPU affinity and 8 Rayon / 16 Tokio threads match the
declared reference service; no global resource limit was increased.
## Measurements
| Case | Wall seconds | Source validation | Data preparation | Engine |
|---|---:|---:|---:|---:|
| Rotation control 1 | 30.986 | 12.244 | 8.445 | 8.790 |
| Rotation candidate 1 | 12.977 | 0.004 | 5.132 | 6.682 |
| Rotation control 2 | 13.275 | 0.209 | 5.190 | 6.739 |
| Rotation candidate 2 | 12.927 | 0.005 | 5.128 | 6.640 |
| Rotation candidate 3 | 12.926 | 0.006 | 5.113 | 6.664 |
| Rotation control 3 | 13.044 | 0.005 | 5.159 | 6.732 |
| Trend 40 control | 15.628 | 0.694 | 5.234 | 8.199 |
| Trend 40 candidate | 14.826 | 0.004 | 5.276 | 8.087 |
| Pullback 40 control | 13.776 | 0.005 | 4.893 | 7.374 |
| Pullback 40 candidate | 13.928 | 0.003 | 5.137 | 7.239 |
| Volume momentum 80 control | 18.176 | 0.004 | 4.585 | 11.154 |
| Volume momentum 80 candidate | 18.627 | 0.005 | 5.200 | 11.013 |
Rotation engine medians are 6.739 versus 6.664 seconds, approximately 1.1%.
The other paired engine reductions are approximately 1.4%, 1.8% and 1.3%.
These are small CPU-path improvements. Pullback and volume total latency did
not improve because their preparation times were higher. The first control's
Source wait and unexplained slower construction are recorded, not attributed
to this code or discarded to manufacture a large speedup. Peak RSS stays about
6.76-6.83 GiB; there is no significant memory reduction claim.
Each case matches its independent prior baseline for all six canonical
sections and store bytes: 21,393 / 29,776 / 31,862 / 51,300 fills. Result receipts,
runtime/strategy identities, physical manifests and 3,506 fact blocks were
verified. The shared input inventory SHA is in the acceptance record. Full
unaltered receipts remain on 177; the repository stores the compact verified
summary rather than repeating the 9,257-file inventory in every document.
## Remaining Work
Prioritize direct typed-column reuse during daily snapshot and DataSet
construction; approximately five seconds of preparation remain in these warm
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
Source cold-query and contract-validation latency remain separate tasks under
the Source freeze. The cache-boundary candidate later passed its conditional
cross-window/full-input gate and the combination passed daily HTTP publication;
neither establishes cold or universal performance. Financial PIT, broader minute
behavior, signal lifecycle and UI factor
condition acceptance are not claimed complete.
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
- Candidate service source: 4e23c7558d8301ba697543c39d5604289bb82c53.
- Control runner SHA: b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d.
- Candidate runner SHA: 1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb.
- Candidate API SHA: 30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501.
- Evidence root: /srv/fidc/canonical/run/research/selection-risk-plan-20260914.
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
## Combined Release
After merging engine 665653c, 860 core / 448 runner / 119 API tests and six
additional new-process replays passed. The guarded official workflow deployed
Backtest only, then nine HTTP runs matched their respective canonical/store
baselines. A multi-strategy sequence proved actual immutable DataSet hit counts
0/1/2/3/4 with distinct strategy results and repeatable trend results. Default
90-day cleared-DataSet HTTP mean 13.646 before versus 13.740 seconds after does
not demonstrate a general latency gain.
The active root /srv/fidc/canonical/build/factor-reserve-20260913 is protected
from reuse/cleanup. Source, paused research, trading services and all execution
permissions remain unchanged. This does not activate the manual-replay module.
Actual identities, timings and evidence are maintained in
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
@@ -0,0 +1,147 @@
# Exact Series Column Storage
## Status
The subsequent business-main merge includes the separately published 81acc54
clock/callback fixes and 5e11f3d manual-replay foundation. The combined version
passed 857 core, 448 runner and 119 API tests, six long reference replays and
three additional strategy replays;
see `/Users/boris/WorkSpace/fidc-backtest-service/docs/arrow-factor-scratch-rejection-20260914.md`.
The scratch candidate from that experiment was removed. Series storage is
now published to Backtest only in the combined 665653c/501f6d0 release below;
existing measurements retain their original versions.
The original twelve real long replays preserve their independent
business baselines and reduce peak RSS by about 9.5%. Construction latency is
mixed, including a reversed pair where the control is faster. This is accepted
as evidence of a smaller working set, not as a proved general speedup or closure
of the main performance objective. Original results are retained unchanged.
The Source implementation remains d5b682c6d09704ff23d725a8dd8b155db3eb6967.
Research/signal work remains paused. The initial experiments ran while e81bf47/c98
was published; later business work published e81bf47/81acc54. This performance
task did not restart Source, trading or another user's process. The original
clock counterexample was resolved by that business work; complete manual-replay
integration remains open and is not proved by these performance tests.
## Separate DayOpen Correction
Code inspection found that PriceField::DayOpen selected the Open prefix sums,
although direct history access returned day_open. For day_open values 10/12
and open values 20/24, that path computes 22 instead of the expected 11.
The correction adds its own day-open prefix and a regression checking both
fields plus empty/insufficient windows. No price field is substituted.
This correction was built and tested independently before the storage change:
806 core unit/integration tests, 448 runner tests and 119 API tests passed.
The resulting control runner is
8859459f54389f12af1ab7d4e36802c01aff63fb10fbb679243ccdd54d013e2d.
It also preserves the real rotation baseline. Both subsequent A/B variants
include the fix, so corrected calculation semantics are not counted as speedup.
## Storage Design
SymbolPriceSeries previously allocated separate vectors for last/bid/ask,
their prefix, timestamps, trading phases and three quote-volume fields, even
when actual data repeated or exactly matched the existing close series.
- ReferenceMatchedValues aliases the existing column only after every consumed
value matches by f64::to_bits. A mismatch materializes the exact preceding
values and continues as an owned vector. No missing/invalid price is replaced
by close; signed zero and NaN payload differences prevent sharing.
- RepeatedValues retains the actual first value and logical length. It avoids
expanding equal values, including nonzero volumes and Some strings. The
first difference materializes the exact prior values. None is distinct from
an empty string; no value is inferred from the backtest frequency.
- Intraday updates materialize only changed columns. Cloned views retain their
original values and immutable daily base. Last-price prefix sums use the same
accumulation order and actual values as before. History cutoffs are unchanged.
There is no new dependency, unsafe code, strategy-specific branch, disk schema,
source-data rewrite or account/result sharing. Construction and data validation
remain in the existing paths. The overlay comment now accurately states that
quote fields affect Last history while daily OHLC remains unchanged.
The full candidate passes 813 core unit/integration tests (9 ignored), 448 runner
tests (9 ignored) and 119 API tests (5 ignored). New tests cover exact bit
identity, distinct zero/NaN values, repeated nonzero/string values, mutation
isolation, unknown dates, full snapshot equality and history-date cutoffs.
These tests do not prove the separately known same-day execution-clock issue.
## Real A/B
All cases execute 2021-08-23 through 2025-11-17 with their unchanged frozen
strategy/runtime/bundle and 10,000,000 initial cash. This is not five complete
execution years. Every run is a new process with private result artifacts and
the same verified shared inputs: 9,257 files / 12,596,608,049 bytes. No original
input changed and no Arrow/bin input was newly created. Hashing is outside the
runner timer; no result is reused. Source/OS caches are not cold.
| Case | Wall s | Data s | DataSet construction s | Engine s | RSS KiB |
|---|---:|---:|---:|---:|---:|
| Rotation control 1 | 23.879 | 5.213 | 1.901 | 6.612 | 7,137,676 |
| Rotation candidate 1 | 24.126 | 4.818 | 1.589 | 6.674 | 6,463,660 |
| Rotation control 2 | 18.180 | 7.272 | 2.824 | 9.509 | 7,138,628 |
| Rotation candidate 2 | 15.078 | 5.543 | 2.094 | 8.171 | 6,454,624 |
| Rotation candidate 3 | 13.125 | 5.103 | 1.853 | 6.649 | 6,457,728 |
| Rotation control 3 | 12.725 | 4.955 | 1.713 | 6.583 | 7,140,772 |
| Trend 40 control | 14.779 | 5.418 | 1.921 | 7.867 | 7,157,844 |
| Trend 40 candidate | 15.012 | 5.168 | 1.851 | 8.011 | 6,470,768 |
| Pullback 40 control | 14.011 | 5.172 | 1.893 | 7.298 | 7,167,408 |
| Pullback 40 candidate | 13.877 | 5.117 | 1.837 | 7.219 | 6,478,492 |
| Volume 80 control | 18.577 | 5.163 | 1.891 | 10.960 | 7,210,220 |
| Volume 80 candidate | 18.476 | 5.051 | 1.844 | 10.989 | 6,527,236 |
The final rotation pair deliberately ran candidate before control. Rotation
RSS medians are 7,138,628 versus 6,457,728 KiB, about 665 MiB / 9.5% lower.
Other strategy pairs save about 670-673 MiB. These are measured process peaks,
not estimates obtained by adding cgroup limits or counting mmap as private RAM.
Construction medians are 1.901 versus 1.853 seconds for rotation. The first pair
has a larger reduction, but other samples and the reversed pair do not support
a universal 16% construction or total-latency claim. Source validation waits and
independent phase variation remain in the full evidence. A read-only host sample
showed load near 49 and thermal readings 53/58/69 C; it does not prove the cause
of timing variation. No host policy or another user's workload was changed.
All six canonical sections and result-store SHA match the appropriate existing
baselines: 21,393 / 29,776 / 31,862 / 51,300 fills. Result and request evidence,
physical manifests and all 3,506 fact blocks were verified. No earlier failed
or successful receipt was rewritten. Complete receipts remain on 177; only the
compact verified summary is stored here to avoid duplicating input inventories.
## Remaining Work
Do not publish this as the main performance fix. Next, target the remaining
daily snapshot/factor construction and direct typed-column reuse, avoiding
new per-access branches or post-hoc compression passes. Cold-query acceptance,
real minute-mode acceptance remain outstanding for this storage change. The
combined version subsequently passed daily HTTP publication below. The original
clock issue was fixed by subsequent business
work, not this experiment. Signal lifecycle, financial PIT and UI factor conditions
remain outside this completed storage experiment.
- Engine candidate: 996b909608589fb1987f33c0cfb4c62099f69617.
- Service source: 443ed421c2c9c854a01fab69ce58957690504570.
- Candidate runner: 40bcf65c1977dbd93ab8bc80e3ff04d0db5e27b61fce1afdce99cf1b5e58eb43.
- Candidate API: c54be3a8196c32051520c709f793bcb974d869467bb12700d846efaad8c2180e.
- Evidence: /srv/fidc/canonical/run/research/series-column-storage-20260914.
[Verified summary](evidence/series-column-storage-20260914/acceptance.json).
## Combined Release
Engine 665653c / service 501f6d0 passed 860 core, 448 runner and 119 API tests,
six additional independent-process replays and nine post-publication HTTP runs.
Complete canonical/store results remain equal to each strategy's own baseline.
An adjacent original/new rotation pair measures 15.279/14.579 seconds and
6,923,640/6,369,900 KiB peak RSS, but the default-window HTTP means are essentially
unchanged (13.646/13.740 seconds). Reduced memory and conditional cross-window
reuse are not promoted to a universal latency improvement.
The official Backtest-only publication preserves Source d5, paused research,
trading services and execution permissions. Its active build root
/srv/fidc/canonical/build/factor-reserve-20260913 must not be overwritten or
reused. Shared DataSet acceptance proves input reuse while distinct strategies
execute independently; no results are cached. Identities and original receipts:
`/Users/boris/WorkSpace/fidc-backtest-service/docs/cache-boundary-planning-performance-20260914.md`.
@@ -0,0 +1,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下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。