Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81acc54228 | |||
| 600808b171 | |||
| d2f1b64af1 | |||
| 237ee15a51 | |||
| 3a3091a2cf | |||
| d2aa16a2f0 | |||
| 0576cf9b6d | |||
| 636e0dfd05 | |||
| c98bcc3eb2 | |||
| 53af3a6a85 | |||
| 70c6f7e90b | |||
| 0ed6752a73 | |||
| 3e8cc63b9a | |||
| be171683c9 | |||
| 0a6fab9038 | |||
| e8abf43cd4 | |||
| 2286bfa757 | |||
| 93809eea1b | |||
| f7f0ff2951 | |||
| effa0c6456 | |||
| b1ca2dfada | |||
| d15abc18ae |
+542
-81
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,778 @@
|
||||
use super::*;
|
||||
|
||||
fn time(minute: u32) -> NaiveTime {
|
||||
NaiveTime::from_hms_opt(10, minute, 0).unwrap()
|
||||
}
|
||||
|
||||
fn data(quotes: &[(u32, f64, u32)]) -> DataSet {
|
||||
data_with_snapshot(quotes, limit_test_snapshot())
|
||||
}
|
||||
|
||||
fn data_with_snapshot(quotes: &[(u32, f64, u32)], snapshot: DailyMarketSnapshot) -> DataSet {
|
||||
DataSet::from_components_with_actions_and_quotes(
|
||||
vec![limit_test_instrument()],
|
||||
vec![snapshot],
|
||||
vec![],
|
||||
vec![limit_test_candidate(true, true)],
|
||||
vec![limit_test_benchmark()],
|
||||
vec![],
|
||||
quotes
|
||||
.iter()
|
||||
.map(|&(minute, price, volume)| {
|
||||
let mut quote = limit_test_quote(price, price, price);
|
||||
quote.timestamp = quote.date.and_time(time(minute));
|
||||
quote.volume_delta = u64::from(volume);
|
||||
quote.amount_delta = price * f64::from(volume);
|
||||
quote.bid1_volume = u64::from(volume / 100);
|
||||
quote.ask1_volume = u64::from(volume / 100);
|
||||
quote
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn broker() -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
|
||||
BrokerSimulator::new(
|
||||
ChinaAShareCostModel::default()
|
||||
.with_commission_rate(0.0003)
|
||||
.with_minimum_commission(5.),
|
||||
ChinaEquityRuleHooks,
|
||||
)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last)
|
||||
.with_intraday_execution_start_time(time(0))
|
||||
.with_volume_limit(true)
|
||||
.with_volume_percent(0.25)
|
||||
.with_liquidity_limit(false)
|
||||
.with_inactive_limit(false)
|
||||
.with_strict_value_budget(true)
|
||||
}
|
||||
|
||||
fn intent(style: AlgoOrderStyle, value: f64) -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
order_intents: vec![OrderIntent::AlgoValue {
|
||||
symbol: "000001.SZ".into(),
|
||||
value,
|
||||
style,
|
||||
start_time: Some(time(0)),
|
||||
end_time: Some(time(10)),
|
||||
reason: "clock-algorithm".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn step(
|
||||
broker: &BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks>,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
minute: u32,
|
||||
decision: &StrategyDecision,
|
||||
) -> BrokerExecutionReport {
|
||||
broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
portfolio,
|
||||
data,
|
||||
decision,
|
||||
Some(time(minute)),
|
||||
Some(time(minute)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twap_clock_preserves_quantity_prices_fees_budget_and_parent_order() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10.1, 4_000),
|
||||
(5, 10.2, 4_000),
|
||||
(10, 10.3, 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 5, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
assert!(
|
||||
batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.execution_timestamp.unwrap().time() <= time(minute))
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
let canonical = |rows: &[crate::events::FillEvent]| {
|
||||
rows.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.commission.to_bits(),
|
||||
fill.stamp_tax.to_bits(),
|
||||
fill.transfer_fee.to_bits(),
|
||||
fill.execution_timestamp,
|
||||
fill.order_id,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(canonical(&fills), canonical(&reference.fill_events));
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(fills.iter().map(|fill| fill.quantity).sum::<u32>(), 900);
|
||||
assert_eq!(fills.iter().map(|fill| fill.commission).sum::<f64>(), 5.);
|
||||
assert!(fills.iter().map(|fill| -fill.net_cash_flow).sum::<f64>() <= 10_000.);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_algorithm_cancel_releases_reservation_and_never_executes_the_remainder() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(5, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert_eq!(broker.open_order_views()[0].reserved_cash, Some(10_000.));
|
||||
let partial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
partial
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
let working = broker.open_order_views();
|
||||
assert_eq!(working[0].order_id, 1);
|
||||
assert_eq!(working[0].filled_quantity, 100);
|
||||
assert_eq!(
|
||||
working[0].reserved_cash,
|
||||
Some(10_000. + partial.fill_events[0].net_cash_flow)
|
||||
);
|
||||
let cancel = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
3,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "explicit-user-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(cancel.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_expiry_without_a_quote_does_not_reuse_old_liquidity() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
broker.next_day_order_expiry(limit_test_snapshot().date),
|
||||
Some(time(10))
|
||||
);
|
||||
let terminal = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(terminal.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
terminal.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(terminal.order_events.last().unwrap().filled_quantity, 100);
|
||||
assert!(
|
||||
terminal
|
||||
.process_events
|
||||
.iter()
|
||||
.any(|event| event.detail.contains("Expired")),
|
||||
"{:?}",
|
||||
terminal.process_events
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_buy_cannot_spend_the_working_algorithms_cash_budget() {
|
||||
let data = data(&[
|
||||
(0, 10., 4_000),
|
||||
(1, 10., 4_000),
|
||||
(2, 10., 4_000),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(11_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let other = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
1,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 1_000,
|
||||
reason: "separate-buy".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
other.fill_events.is_empty(),
|
||||
"cash reserved for order 1 was spent: {:?}",
|
||||
other.fill_events
|
||||
);
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert!(
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.all(|fill| fill.order_id == Some(1))
|
||||
);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 900);
|
||||
assert!(account.cash() >= 1_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_the_later_daily_close_does_not_resize_an_algorithm_submitted_now() {
|
||||
let quotes = [(0, 10., 4_000), (2, 10.1, 4_000), (10, 10.2, 4_000)];
|
||||
let mut changed = limit_test_snapshot();
|
||||
changed.close = 100.;
|
||||
changed.last_price = 100.;
|
||||
let run = |data: DataSet| {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let initial = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
assert!(initial.fill_events.is_empty());
|
||||
let quantity = broker.open_order_views()[0].requested_quantity;
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
(
|
||||
quantity,
|
||||
final_batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| {
|
||||
(
|
||||
fill.quantity,
|
||||
fill.price.to_bits(),
|
||||
fill.net_cash_flow.to_bits(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
run(data("es)),
|
||||
run(data_with_snapshot("es, changed))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vwap_clock_preserves_cash_costs_and_does_not_spend_future_volume() {
|
||||
let data = data(&[
|
||||
(0, 10., 400),
|
||||
(2, 10., 800),
|
||||
(5, 10., 1_200),
|
||||
(10, 10., 4_000),
|
||||
]);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
let mut synchronous_account = PortfolioState::new(20_000.);
|
||||
let reference = broker()
|
||||
.execute(
|
||||
limit_test_snapshot().date,
|
||||
&mut synchronous_account,
|
||||
&data,
|
||||
&decision,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let empty = StrategyDecision::default();
|
||||
let mut filled = 0;
|
||||
let mut commission = 0.;
|
||||
for (minute, expected) in [(0, 100), (2, 300), (5, 600), (10, 900)] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
filled += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>();
|
||||
commission += batch
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>();
|
||||
assert_eq!(filled, expected);
|
||||
assert!(batch.fill_events.iter().all(|fill| fill.order_id == Some(1)
|
||||
&& fill.execution_timestamp.unwrap().time() <= time(minute)));
|
||||
}
|
||||
assert_eq!(account.cash(), synchronous_account.cash());
|
||||
assert_eq!(
|
||||
commission,
|
||||
reference
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.commission)
|
||||
.sum::<f64>()
|
||||
);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_vwap_matching_keeps_the_same_working_order_between_clock_ticks() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 400), (10, 10., 4_000)]);
|
||||
let broker = broker().with_matching_type(MatchingType::Vwap);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 900,
|
||||
reason: "configured-vwap".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
broker.open_order_views().len(),
|
||||
1,
|
||||
"{:?}",
|
||||
first.order_events
|
||||
);
|
||||
let second = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(second.fill_events[0].quantity, 100);
|
||||
assert_eq!(second.fill_events[0].order_id, Some(1));
|
||||
let final_batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(final_batch.fill_events[0].quantity, 700);
|
||||
assert_eq!(final_batch.fill_events[0].order_id, Some(1));
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn algorithm_sell_honors_t_plus_one_and_keeps_original_quantity_after_partial_fills() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 800), (10, 10., 4_000)]);
|
||||
let date = limit_test_snapshot().date;
|
||||
for acquired_today in [false, true] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
account.position_mut("000001.SZ").buy(
|
||||
if acquired_today {
|
||||
date
|
||||
} else {
|
||||
date.pred_opt().unwrap()
|
||||
},
|
||||
1_000,
|
||||
10.,
|
||||
);
|
||||
let decision = intent(AlgoOrderStyle::Vwap, -10_000.);
|
||||
let mut fills = Vec::new();
|
||||
let mut events = Vec::new();
|
||||
let empty = StrategyDecision::default();
|
||||
for minute in [0, 2, 10] {
|
||||
let batch = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
minute,
|
||||
if minute == 0 { &decision } else { &empty },
|
||||
);
|
||||
fills.extend(batch.fill_events);
|
||||
events.extend(batch.order_events);
|
||||
}
|
||||
assert_eq!(
|
||||
fills.iter().map(|fill| fill.quantity).sum::<u32>(),
|
||||
if acquired_today { 0 } else { 1_000 }
|
||||
);
|
||||
assert!(events.iter().all(|event| event.order_id == Some(1)));
|
||||
if !acquired_today {
|
||||
assert_eq!(events.last().unwrap().status, OrderStatus::Filled);
|
||||
assert_eq!(events.last().unwrap().requested_quantity, 1_000);
|
||||
assert_eq!(events.last().unwrap().filled_quantity, 1_000);
|
||||
}
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_ioc_or_fok_does_not_become_a_persistent_algorithm() {
|
||||
let data = data(&[(0, 10., 400), (2, 10., 4_000), (10, 10., 4_000)]);
|
||||
for tif in [
|
||||
OrderTimeInForce::Ioc,
|
||||
OrderTimeInForce::Fok,
|
||||
OrderTimeInForce::Day,
|
||||
OrderTimeInForce::Gtc,
|
||||
] {
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Vwap, 10_000.);
|
||||
if !decision.order_intents[0].supports_time_in_force(tif) {
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let error = broker
|
||||
.execute_between(
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&decision,
|
||||
Some(time(0)),
|
||||
Some(time(0)),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("is not supported for this order intent")
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
continue;
|
||||
}
|
||||
decision.order_intents = decision
|
||||
.order_intents
|
||||
.into_iter()
|
||||
.map(|intent| intent.with_time_in_force(tif))
|
||||
.collect();
|
||||
let first = step(&broker, &mut account, &data, 0, &decision);
|
||||
let persists = matches!(tif, OrderTimeInForce::Day | OrderTimeInForce::Gtc);
|
||||
assert_eq!(
|
||||
!broker.open_order_views().is_empty(),
|
||||
persists,
|
||||
"{tif:?}: {:?}",
|
||||
first.order_events
|
||||
);
|
||||
if !persists {
|
||||
assert!(
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default()
|
||||
)
|
||||
.fill_events
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_working_algorithms_reserve_only_real_cash_without_starving_the_first() {
|
||||
let data = data(&[(0, 10., 40_000), (10, 10., 40_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(15_000.);
|
||||
let mut decision = intent(AlgoOrderStyle::Twap, 10_000.);
|
||||
decision
|
||||
.order_intents
|
||||
.extend(intent(AlgoOrderStyle::Twap, 10_000.).order_intents);
|
||||
step(&broker, &mut account, &data, 0, &decision);
|
||||
assert_eq!(
|
||||
broker
|
||||
.open_order_views()
|
||||
.iter()
|
||||
.map(|order| order.reserved_cash.unwrap())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![10_000., 5_000.]
|
||||
);
|
||||
let report = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
report
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| (fill.order_id, fill.quantity))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(Some(1), 900), (Some(2), 500)]
|
||||
);
|
||||
assert!(account.cash() >= 0.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
|
||||
let data = data(&[(0, 10., 100), (2, 10., 100), (10, 10.1, 100)]);
|
||||
let broker = broker()
|
||||
.with_volume_limit(false)
|
||||
.with_liquidity_limit(false);
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&intent(AlgoOrderStyle::Twap, 10_000.),
|
||||
);
|
||||
let first = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
2,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
let last = step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
10,
|
||||
&StrategyDecision::default(),
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.fill_events
|
||||
.iter()
|
||||
.map(|fill| fill.quantity)
|
||||
.sum::<u32>(),
|
||||
100
|
||||
);
|
||||
assert_eq!(
|
||||
last.order_events.last().unwrap().status,
|
||||
OrderStatus::Expired
|
||||
);
|
||||
assert_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_matching_controls_amend_or_cancel_without_filling_a_crossing_quote() {
|
||||
let data = data(&[(0, 10., 4_000), (2, 9.4, 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
step(
|
||||
&broker,
|
||||
&mut account,
|
||||
&data,
|
||||
0,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![
|
||||
OrderIntent::LimitShares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
limit_price: 9.5,
|
||||
reason: "resting".into(),
|
||||
}
|
||||
.with_time_in_force(OrderTimeInForce::Gtc),
|
||||
],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(broker.open_order_views().len(), 1);
|
||||
let modify = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::ModifyOrder {
|
||||
order_id: 1,
|
||||
new_total_quantity: Some(200),
|
||||
new_limit_price: Some(9.3),
|
||||
reason: "pre-open-amend".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(modify.fill_events.is_empty());
|
||||
assert_eq!(broker.open_order_views()[0].limit_price, 9.3);
|
||||
assert_eq!(broker.open_order_views()[0].requested_quantity, 200);
|
||||
let cancel = broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&StrategyDecision {
|
||||
order_intents: vec![OrderIntent::CancelAll {
|
||||
reason: "pre-open-cancel".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
Some(time(2)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cancel.fill_events.is_empty());
|
||||
assert_eq!(
|
||||
cancel.order_events.last().unwrap().status,
|
||||
OrderStatus::Canceled
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert!(broker.open_order_views().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_only_phase_cannot_be_used_to_submit_an_order_or_leave_matching_disabled() {
|
||||
let data = data(&[(0, 10., 4_000)]);
|
||||
let broker = broker();
|
||||
let mut account = PortfolioState::new(20_000.);
|
||||
let submit = StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".into(),
|
||||
quantity: 100,
|
||||
reason: "normal-order".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
broker
|
||||
.execute_controls_without_matching(
|
||||
limit_test_snapshot().date,
|
||||
limit_test_snapshot().date,
|
||||
&mut account,
|
||||
&data,
|
||||
&submit,
|
||||
Some(time(0))
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(account.cash(), 20_000.);
|
||||
assert_eq!(
|
||||
step(&broker, &mut account, &data, 0, &submit).fill_events[0].quantity,
|
||||
100
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,35 @@ use super::*;
|
||||
use crate::holding_policy::HoldingLifecycleEvidence;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
use chrono::Timelike;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct DeferredStockPoolExecution {
|
||||
date: NaiveDate,
|
||||
contract: Box<pool::FrozenStockPoolIntent>,
|
||||
buy_only: bool,
|
||||
symbols: BTreeSet<String>,
|
||||
initial_holdings: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R> {
|
||||
pub(crate) fn pending_stock_pool_symbols(&self) -> BTreeSet<String> {
|
||||
self.deferred_stock_pools.borrow().values().flat_map(|pending| pending.symbols.iter().cloned()).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn has_pending_stock_pool_execution(&self) -> bool {
|
||||
!self.deferred_stock_pools.borrow().is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn finish_stock_pool_session(&self, date: NaiveDate, report: &mut BrokerExecutionReport) {
|
||||
self.deferred_stock_pools.borrow_mut().retain(|_, pending| {
|
||||
if pending.date <= date {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
false
|
||||
} else { true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
||||
if !value.is_finite() {
|
||||
@@ -41,6 +70,48 @@ fn pool_positions(
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
pub(super) fn resume_stock_pool_executions(&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
session: &mut BrokerExecutionSession, report: &mut BrokerExecutionReport) -> Result<(), BacktestError> {
|
||||
let clock = self.runtime_intraday_start_time.get().or(self.intraday_execution_start_time);
|
||||
let mut expired = Vec::new();
|
||||
for (id, pending) in self.deferred_stock_pools.borrow().iter() {
|
||||
let end = NaiveTime::parse_from_str(&pending.contract.rule.window_end, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if pending.date != date || clock.is_some_and(|clock| clock >= end) { expired.push(id.clone()); }
|
||||
}
|
||||
for id in expired {
|
||||
if let Some(pending) = self.deferred_stock_pools.borrow_mut().remove(&id) {
|
||||
report.diagnostics.push(format!("stock_pool_unsubmitted_phase_expired generation={} date={date} no_buy_order_created=true",pending.contract.generation));
|
||||
}
|
||||
}
|
||||
if self.has_open_orders() || clock.is_none() { return Ok(()); }
|
||||
let pending = std::mem::take(&mut *self.deferred_stock_pools.borrow_mut());
|
||||
for (id, pending) in pending {
|
||||
let now = clock.expect("clock checked above");
|
||||
let start = NaiveTime::parse_from_str(&pending.contract.rule.window_start, "%H:%M")
|
||||
.map_err(|_| BacktestError::Execution("stock_pool_execution_window_invalid".into()))?;
|
||||
if now < start || !pool::stock_pool_is_trading_minute(now.hour() * 60 + now.minute()) {
|
||||
self.deferred_stock_pools.borrow_mut().insert(id, pending);
|
||||
continue;
|
||||
}
|
||||
let prior_followup = self.runtime_stock_pool_followup.replace(true);
|
||||
let prior_decision = self.runtime_decision_date.replace(Some(pending.contract.signal_date));
|
||||
let prior_created = self.runtime_order_created_date.replace(Some(date));
|
||||
let order_start = report.order_events.len();
|
||||
let fill_start = report.fill_events.len();
|
||||
report.diagnostics.push(format!("stock_pool_resume_after_order_reports generation={} clock={} cash={}",pending.contract.generation,clock.unwrap(),portfolio.cash()));
|
||||
let result = self.process_stock_pool_contract_phase(date, portfolio, data, &pending.contract,
|
||||
&mut session.intraday_turnover, &mut session.execution_cursors, &mut session.global_execution_cursor,
|
||||
&mut session.commission_state, report, pending.buy_only, Some(&pending.initial_holdings));
|
||||
self.runtime_stock_pool_followup.set(prior_followup);
|
||||
self.runtime_decision_date.set(prior_decision);
|
||||
self.runtime_order_created_date.set(prior_created);
|
||||
result?;
|
||||
Self::annotate_report_range(report, order_start, fill_start, pending.contract.signal_date, date, date);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pool_quote_inputs(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -102,7 +173,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -114,7 +185,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Sell,
|
||||
self.matching_type,
|
||||
self.matching_type_for_algo_request(None),
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
@@ -226,6 +297,17 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
self.process_stock_pool_contract_phase(date, portfolio, data, contract, intraday_turnover,
|
||||
execution_cursors, global_execution_cursor, commission_state, report, false, None)
|
||||
}
|
||||
|
||||
fn process_stock_pool_contract_phase(
|
||||
&self, date: NaiveDate, portfolio: &mut PortfolioState, data: &DataSet,
|
||||
contract: &pool::FrozenStockPoolIntent, intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut IntradayExecutionLedger, global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
commission_state: &mut BTreeMap<u64, f64>, report: &mut BrokerExecutionReport, buy_only: bool,
|
||||
initial_holdings: Option<&BTreeSet<String>>,
|
||||
) -> Result<(), BacktestError> {
|
||||
if contract.signal_date > date
|
||||
|| contract.frozen_equity < Decimal::ZERO
|
||||
@@ -266,6 +348,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
scope.extend(portfolio.positions().keys().cloned());
|
||||
let before_positions = initial_holdings.cloned().unwrap_or_else(|| portfolio.positions().keys().cloned().collect());
|
||||
let official_dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
let initial_positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio
|
||||
@@ -284,6 +367,9 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
let superseded = self.deferred_etf_targets.borrow_mut().replace_generation(&contract.pool_id, &contract.generation);
|
||||
if superseded > 0 { report.diagnostics.push(format!("etf_daily_open_fallback:superseded pool={} generation={} targets={superseded}", contract.pool_id, contract.generation)); }
|
||||
if self.has_open_orders() {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only, symbols: scope, initial_holdings: before_positions,
|
||||
});
|
||||
report
|
||||
.diagnostics
|
||||
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
|
||||
@@ -329,15 +415,19 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
.push("paused".into());
|
||||
}
|
||||
}
|
||||
let before_positions = portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
// All delayed symbols in a generation share immutable configuration.
|
||||
// Do not duplicate an N-member pool N times in a large mixed pool.
|
||||
let mut deferred_configuration = None;
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
if buy_only && side == pool::OrderSide::Sell { continue; }
|
||||
if side == pool::OrderSide::Buy && self.has_open_orders()
|
||||
&& self.effective_rebalance_cash_mode() == RebalanceCashMode::SellThenBuy {
|
||||
self.deferred_stock_pools.borrow_mut().insert(contract.pool_id.clone(), DeferredStockPoolExecution {
|
||||
date, contract: Box::new(contract.clone()), buy_only: true, symbols: quote_scope.clone(), initial_holdings: before_positions.clone(),
|
||||
});
|
||||
report.diagnostics.push(format!("stock_pool_waiting_for_sell_reports generation={} no_buy_order_created=true",contract.generation));
|
||||
break;
|
||||
}
|
||||
let mut fallback_references = BTreeMap::new();
|
||||
for symbol in "e_scope {
|
||||
if let Some(reference) = self.pool_etf_fallback_reference(date, data, symbol, *global_execution_cursor)? {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2250,6 +2250,17 @@ impl DataSet {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Counts market, factor, candidate, benchmark and corporate-action rows without cloning them.
|
||||
pub fn snapshot_row_counts(&self) -> (usize, usize, usize, usize, usize) {
|
||||
(
|
||||
self.market_by_date.values().map(Vec::len).sum(),
|
||||
self.factor_by_date.values().map(Vec::len).sum(),
|
||||
self.candidate_by_date.values().map(Vec::len).sum(),
|
||||
self.benchmark_by_date.len(),
|
||||
self.corporate_actions_by_date.values().map(Vec::len).sum(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn execution_quotes_on(&self, date: NaiveDate, symbol: &str) -> &[IntradayExecutionQuote] {
|
||||
self.execution_quotes_by_date
|
||||
.get(&date)
|
||||
@@ -5474,6 +5485,10 @@ mod tests {
|
||||
)
|
||||
.expect("daily bundle dataset");
|
||||
|
||||
let row_count = dates.len() * symbols.len();
|
||||
let expected_counts = (row_count, row_count, row_count, dates.len(), dates.len());
|
||||
assert_eq!(flat.snapshot_row_counts(), expected_counts);
|
||||
assert_eq!(grouped.snapshot_row_counts(), expected_counts);
|
||||
assert_eq!(flat.calendar().days(), grouped.calendar().days());
|
||||
assert_eq!(flat.benchmark_code(), grouped.benchmark_code());
|
||||
for date in dates {
|
||||
|
||||
+1705
-345
File diff suppressed because it is too large
Load Diff
@@ -38,6 +38,11 @@ impl NumericFactorMap {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
/// Reserve known new fields without geometric spare capacity per snapshot.
|
||||
pub fn reserve_exact(&mut self, additional: usize) {
|
||||
self.entries.reserve_exact(additional);
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&f64> {
|
||||
self.entries
|
||||
.binary_search_by(|(name, _)| name.as_str().cmp(key))
|
||||
@@ -253,6 +258,28 @@ impl<'de> Deserialize<'de> for NumericFactorMap {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_reservation_preserves_values_and_avoids_growth_during_known_inserts() {
|
||||
let mut map = NumericFactorMap::from([
|
||||
(Cow::Borrowed("amount"), 125.25),
|
||||
(Cow::Borrowed("nullable_value"), f64::from_bits(0x7ff8000000000042)),
|
||||
(Cow::Borrowed("signal"), -0.0),
|
||||
]);
|
||||
let original = map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>();
|
||||
map.reserve_exact(2);
|
||||
assert_eq!(map.iter().map(|(key, value)| (key.to_string(), value.to_bits())).collect::<Vec<_>>(), original);
|
||||
let buffer = map.entries.as_ptr();
|
||||
map.insert(Cow::Borrowed("pre_close"), 12.5);
|
||||
map.insert(Cow::Borrowed("no_limit"), 0.0);
|
||||
assert_eq!(map.entries.as_ptr(), buffer);
|
||||
assert_eq!(map.len(), 5);
|
||||
assert_eq!(map["signal"].to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(map["nullable_value"].to_bits(), 0x7ff8000000000042);
|
||||
let before = map.entries.as_ptr();
|
||||
map.reserve_exact(0);
|
||||
assert_eq!(map.entries.as_ptr(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_keys_inline_dynamic_names_and_keep_long_static_storage() {
|
||||
const LONG: &str = "a_long_static_factor_identifier_that_must_remain_borrowed";
|
||||
|
||||
@@ -961,6 +961,16 @@ struct DayExpressionState {
|
||||
available_text_factor_names: BTreeSet<String>,
|
||||
}
|
||||
|
||||
fn collect_available_factor_names<'a>(names: impl Iterator<Item = &'a str>) -> BTreeSet<String> {
|
||||
// BTreeSet::from_iter first sorts a Vec containing every repeated name.
|
||||
// The daily universe has many rows but usually few distinct factor fields.
|
||||
let mut unique = BTreeSet::new();
|
||||
for name in names {
|
||||
unique.insert(name);
|
||||
}
|
||||
unique.into_iter().map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct StockExpressionState {
|
||||
symbol: Arc<str>,
|
||||
@@ -4388,7 +4398,7 @@ impl PlatformExprStrategy {
|
||||
is_month_start: date.day() == 1,
|
||||
is_month_end,
|
||||
available_factor_names: if self.stock_extra_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_snapshot_rows_on(date)
|
||||
.iter()
|
||||
.flat_map(|row| {
|
||||
@@ -4396,23 +4406,15 @@ impl PlatformExprStrategy {
|
||||
row.adjustment_factor_backward1
|
||||
.map(|_| BACKWARD_ADJUSTMENT_FACTOR_FIELD),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
available_text_factor_names: if self.stock_text_factors_required {
|
||||
ctx.data
|
||||
collect_available_factor_names(ctx.data
|
||||
.factor_text_rows_on(date)
|
||||
.iter()
|
||||
.map(|row| row.field.as_str())
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
.map(|row| row.field.as_str()))
|
||||
} else {
|
||||
BTreeSet::new()
|
||||
},
|
||||
@@ -10351,6 +10353,7 @@ impl PlatformExprStrategy {
|
||||
) -> (Vec<u32>, Vec<FidcRiskDecisionAudit>) {
|
||||
let mut symbol_ids = Vec::new();
|
||||
let mut decisions = Vec::new();
|
||||
let selection_checks_enabled = self.config.risk_config.static_rules.selection_checks_enabled();
|
||||
let mut eligible_symbols = vec![false; ctx.data.symbol_count()];
|
||||
let execution_day = ctx.data.daily_snapshot_view(date);
|
||||
let factor_day = ctx.data.daily_snapshot_view(factor_date);
|
||||
@@ -10396,7 +10399,9 @@ impl PlatformExprStrategy {
|
||||
let Some(market) = execution_day.market(symbol_id) else {
|
||||
continue;
|
||||
};
|
||||
let (reject_from_universe, selection_decision) = if collect_risk_decisions {
|
||||
let (reject_from_universe, selection_decision) = if !selection_checks_enabled {
|
||||
(false, None)
|
||||
} else if collect_risk_decisions {
|
||||
let decision = ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date,
|
||||
candidate,
|
||||
@@ -14594,6 +14599,27 @@ mod tests {
|
||||
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_sparse_and_repeated_fields() {
|
||||
let fields = ["amount", "model_score", "amount", "adjustment_factor_backward1"];
|
||||
let names = (0..5_000).flat_map(|_| fields.iter().copied());
|
||||
let expected = names.clone().collect::<BTreeSet<_>>()
|
||||
.into_iter().map(str::to_owned).collect::<BTreeSet<_>>();
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
assert!(super::collect_available_factor_names(std::iter::empty()).is_empty());
|
||||
assert_eq!(super::collect_available_factor_names(["today_only"].into_iter()),
|
||||
BTreeSet::from(["today_only".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn available_factor_name_collection_preserves_wide_dynamic_field_identity() {
|
||||
let fields = (0..4_000).map(|index| format!("dynamic_{index:04}"))
|
||||
.chain(["Model_score".to_string(), "model_score".to_string()]).collect::<Vec<_>>();
|
||||
let expected = fields.iter().cloned().collect::<BTreeSet<_>>();
|
||||
let names = fields.iter().rev().chain(fields.iter()).map(String::as_str);
|
||||
assert_eq!(super::collect_available_factor_names(names), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_filter_attaches_denials_without_rewriting_selection() {
|
||||
let prev = d(2025, 1, 2);
|
||||
@@ -36208,6 +36234,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
@@ -36356,6 +36383,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 9.9,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_buy".to_string(),
|
||||
},
|
||||
OpenOrderView {
|
||||
@@ -36370,6 +36398,7 @@ mod tests {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 10.2,
|
||||
reserved_cash: None,
|
||||
reason: "pending_limit_sell".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -76,6 +76,26 @@ impl Default for StaticRiskRuleConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticRiskRuleConfig {
|
||||
pub(crate) fn selection_checks_enabled(&self) -> bool {
|
||||
(self.blacklist_enabled && !self.blacklisted_symbols.is_empty())
|
||||
|| self.selection_state_checks_enabled()
|
||||
}
|
||||
|
||||
fn selection_state_checks_enabled(&self) -> bool {
|
||||
self.reject_st_selection
|
||||
|| self.reject_star_st_selection
|
||||
|| self.reject_paused_selection
|
||||
|| self.reject_inactive_selection
|
||||
|| self.reject_new_listing_selection
|
||||
|| self.reject_kcb_selection
|
||||
|| self.reject_bjse_selection
|
||||
|| self.reject_one_yuan_selection
|
||||
|| self.reject_upper_limit_selection
|
||||
|| self.reject_lower_limit_selection
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TradingConstraintConfig {
|
||||
/// Shared execution limits. These fields intentionally use the same
|
||||
@@ -654,16 +674,7 @@ fn missing_risk_state_fields(code: &str) -> Vec<String> {
|
||||
fn missing_selection_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -> bool {
|
||||
let fields = missing_risk_state_fields(code);
|
||||
if fields.is_empty() {
|
||||
return config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection;
|
||||
return config.static_rules.selection_state_checks_enabled();
|
||||
}
|
||||
missing_field_rejected(&fields, config, RiskCheckScope::Selection)
|
||||
}
|
||||
@@ -778,18 +789,7 @@ fn missing_single_field_rejected(
|
||||
RiskCheckScope::Sell => config.static_rules.reject_lower_limit_sell,
|
||||
},
|
||||
_ => match scope {
|
||||
RiskCheckScope::Selection => {
|
||||
config.static_rules.reject_st_selection
|
||||
|| config.static_rules.reject_star_st_selection
|
||||
|| config.static_rules.reject_paused_selection
|
||||
|| config.static_rules.reject_inactive_selection
|
||||
|| config.static_rules.reject_new_listing_selection
|
||||
|| config.static_rules.reject_kcb_selection
|
||||
|| config.static_rules.reject_bjse_selection
|
||||
|| config.static_rules.reject_one_yuan_selection
|
||||
|| config.static_rules.reject_upper_limit_selection
|
||||
|| config.static_rules.reject_lower_limit_selection
|
||||
}
|
||||
RiskCheckScope::Selection => config.static_rules.selection_state_checks_enabled(),
|
||||
RiskCheckScope::Buy => {
|
||||
config.static_rules.reject_st_buy
|
||||
|| config.static_rules.reject_star_st_buy
|
||||
@@ -914,6 +914,69 @@ mod tests {
|
||||
position
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_check_activation_covers_every_configured_flag_and_blacklist_state() {
|
||||
let fields = [
|
||||
"reject_st_selection", "reject_star_st_selection", "reject_paused_selection",
|
||||
"reject_inactive_selection", "reject_new_listing_selection", "reject_kcb_selection",
|
||||
"reject_bjse_selection", "reject_one_yuan_selection", "reject_upper_limit_selection",
|
||||
"reject_lower_limit_selection",
|
||||
];
|
||||
let base = serde_json::to_value(StaticRiskRuleConfig::default()).unwrap();
|
||||
let declared = base.as_object().unwrap().keys()
|
||||
.filter(|key| key.ends_with("_selection"))
|
||||
.map(String::as_str).collect::<BTreeSet<_>>();
|
||||
assert_eq!(declared, fields.into_iter().collect());
|
||||
for mask in 0..(1_u32 << fields.len()) {
|
||||
for (blacklist_enabled, populated) in [(false, false), (false, true), (true, false), (true, true)] {
|
||||
let mut value = base.clone();
|
||||
for (bit, field) in fields.iter().enumerate() {
|
||||
value[*field] = serde_json::json!(mask & (1 << bit) != 0);
|
||||
}
|
||||
value["blacklist_enabled"] = serde_json::json!(blacklist_enabled);
|
||||
value["blacklisted_symbols"] = if populated {
|
||||
serde_json::json!(["002633.SZ"])
|
||||
} else { serde_json::json!([]) };
|
||||
let config: StaticRiskRuleConfig = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(config.selection_checks_enabled(), mask != 0 || (blacklist_enabled && populated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inactive_selection_checks_preserve_missing_facts_and_execution_rejections() {
|
||||
let date = d(2025, 2, 6);
|
||||
let mut candidate = candidate(date);
|
||||
candidate.is_st = true;
|
||||
candidate.is_star_st = true;
|
||||
candidate.is_paused = true;
|
||||
candidate.is_new_listing = true;
|
||||
candidate.is_kcb = true;
|
||||
candidate.is_one_yuan = true;
|
||||
candidate.allow_buy = false;
|
||||
let snapshot = market(date, 0.9, 0.9);
|
||||
let config = FidcRiskControlConfig::default();
|
||||
assert!(!config.static_rules.selection_checks_enabled());
|
||||
let instrument = instrument("delisted", Some(date));
|
||||
for code in [None, Some("not_listed"), Some("inactive_or_delisted"),
|
||||
Some("missing_risk_state"), Some("missing_risk_state:is_st;is_kcb|allow_buy"),
|
||||
Some("missing_risk_state:unknown_fact"), Some("missing_risk_state:IS_PAUSED")] {
|
||||
candidate.risk_level_code = code.map(str::to_owned);
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_decision_with_config(
|
||||
date, &candidate, &snapshot, Some(&instrument), &config), None);
|
||||
}
|
||||
candidate.risk_level_code = None;
|
||||
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, 0.9, &config), Some("paused"));
|
||||
assert_eq!(ChinaAShareRiskControl::sell_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, None, 0.9, &config), Some("paused"));
|
||||
let mut blacklist_only = config;
|
||||
blacklist_only.static_rules.blacklisted_symbols.insert(candidate.symbol.to_string());
|
||||
assert!(blacklist_only.static_rules.selection_checks_enabled());
|
||||
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
|
||||
date, &candidate, &snapshot, None, &blacklist_only), Some("blacklisted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
|
||||
let day = d(2025, 2, 6);
|
||||
|
||||
@@ -102,6 +102,7 @@ pub struct OpenOrderView {
|
||||
pub avg_price: f64,
|
||||
pub transaction_cost: f64,
|
||||
pub limit_price: f64,
|
||||
pub reserved_cash: Option<f64>,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
@@ -497,6 +498,7 @@ impl StrategyContext<'_> {
|
||||
.iter()
|
||||
.filter(|order| order.side == OrderSide::Buy)
|
||||
.map(|order| {
|
||||
if let Some(reserved) = order.reserved_cash { return reserved; }
|
||||
let price = if order.limit_price.is_finite() {
|
||||
order.limit_price.max(0.0)
|
||||
} else {
|
||||
@@ -988,6 +990,15 @@ pub struct StrategyDecision {
|
||||
}
|
||||
|
||||
impl StrategyDecision {
|
||||
pub(crate) fn is_portfolio_target_only(&self) -> bool {
|
||||
(self.rebalance && self.order_intents.is_empty())
|
||||
|| (self.order_intents.len() == 1
|
||||
&& matches!(
|
||||
self.order_intents[0].unwrapped(),
|
||||
OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. }
|
||||
))
|
||||
}
|
||||
|
||||
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
|
||||
let mut symbols = BTreeSet::new();
|
||||
if self.rebalance {
|
||||
@@ -1001,9 +1012,24 @@ impl StrategyDecision {
|
||||
}
|
||||
|
||||
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||
if self.is_portfolio_target_only() && other.is_portfolio_target_only() {
|
||||
let mut previous = std::mem::replace(self, other);
|
||||
previous
|
||||
.diagnostics
|
||||
.push("unsubmitted_portfolio_target_superseded".into());
|
||||
self.notes.splice(0..0, previous.notes);
|
||||
self.diagnostics.splice(0..0, previous.diagnostics);
|
||||
return;
|
||||
}
|
||||
self.buy_denials.append(&mut other.buy_denials);
|
||||
self.rebalance |= other.rebalance;
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
if other.rebalance {
|
||||
// Rebalance targets are a complete portfolio, not an additive
|
||||
// list. A newer unsent target replaces the earlier allocation.
|
||||
self.rebalance = true;
|
||||
self.target_weights = std::mem::take(&mut other.target_weights);
|
||||
} else {
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
}
|
||||
self.exit_symbols.append(&mut other.exit_symbols);
|
||||
self.order_intents.append(&mut other.order_intents);
|
||||
self.notes.append(&mut other.notes);
|
||||
@@ -1023,6 +1049,52 @@ impl StrategyDecision {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod decision_merge_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn newer_complete_target_replaces_old_symbols_without_discarding_explicit_actions() {
|
||||
let mut earlier = StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("A".into(), 0.5), ("B".into(), 0.5)]),
|
||||
exit_symbols: BTreeSet::from(["risk_exit".into()]),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "explicit".into(),
|
||||
quantity: 100,
|
||||
reason: "explicit action".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
earlier.merge_from(StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("C".into(), 1.)]),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(earlier.target_weights, BTreeMap::from([("C".into(), 1.)]));
|
||||
assert!(earlier.rebalance);
|
||||
assert!(earlier.exit_symbols.contains("risk_exit"));
|
||||
assert_eq!(earlier.order_intents.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_empty_complete_target_replaces_old_allocation_but_empty_callback_does_not() {
|
||||
let mut decision = StrategyDecision {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::from([("A".into(), 1.)]),
|
||||
..Default::default()
|
||||
};
|
||||
decision.merge_from(StrategyDecision::default());
|
||||
assert_eq!(decision.target_weights.len(), 1);
|
||||
decision.merge_from(StrategyDecision {
|
||||
rebalance: true,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(decision.target_weights.is_empty());
|
||||
assert!(decision.rebalance);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlgoOrderStyle {
|
||||
Vwap,
|
||||
|
||||
@@ -5,7 +5,7 @@ use fidc_core::{
|
||||
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, PriceField, Strategy, StrategyContext,
|
||||
StrategyDecision,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
@@ -163,7 +163,48 @@ fn single_day_quote_plan_data(date: NaiveDate) -> DataSet {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
fn runtime_account_dependent_quote_scope_uses_the_actual_account() {
|
||||
struct AccountDependentQuoteReader;
|
||||
impl Strategy for AccountDependentQuoteReader {
|
||||
fn name(&self) -> &str { "account_dependent_quote_reader" }
|
||||
fn decision_quote_times(&self) -> Vec<NaiveTime> { vec![t(10, 18, 0)] }
|
||||
fn decision_quote_symbols(&mut self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, fidc_core::BacktestError> {
|
||||
Ok(if ctx.portfolio.cash() < 50_000.0 {
|
||||
BTreeSet::from(["000001.SZ".into()])
|
||||
} else { BTreeSet::new() })
|
||||
}
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
let loaded = ctx.data.execution_quotes_on(ctx.execution_date, "000001.SZ").iter().any(|quote|
|
||||
quote.timestamp.time()==t(10,17,59) && quote.last_price==10.0);
|
||||
assert_eq!(loaded, ctx.portfolio.cash() < 50_000.0,
|
||||
"quote scope must match this account, not a fixed-capital planning account");
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let date = d(2026, 1, 5);
|
||||
for initial_cash in [10_000.0, 100_000.0] {
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(), ChinaEquityRuleHooks, PriceField::Close,
|
||||
).with_volume_capacity_mode(fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let config = BacktestConfig {
|
||||
initial_cash, benchmark_code:"000852.SH".into(),
|
||||
start_date:Some(date), end_date:Some(date), decision_lag_trading_days:0,
|
||||
execution_price_field:PriceField::Close,
|
||||
};
|
||||
let mut engine = BacktestEngine::new(single_day_quote_plan_data(date), AccountDependentQuoteReader, broker, config)
|
||||
.with_execution_quote_loader(move |request| Ok(request.symbols.into_iter().map(|symbol| IntradayExecutionQuote {
|
||||
observation_kind:Default::default(), date:request.date, symbol,
|
||||
timestamp:request.date.and_time(t(10,17,59)), last_price:10.0,bid1:10.0,ask1:10.0,
|
||||
bid1_volume:10_000,ask1_volume:10_000,volume_delta:10_000,amount_delta:100_000.0,
|
||||
trading_phase:Some("continuous".into()),
|
||||
}).collect()));
|
||||
engine.run().expect("account-dependent quote planning");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_resolves_the_runtime_strategy_scope_when_a_loader_exists() {
|
||||
let date = d(2026, 1, 5);
|
||||
let data = single_day_quote_plan_data(date);
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
@@ -186,10 +227,6 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
symbol_plan_calls: Arc::clone(&symbol_plan_calls),
|
||||
};
|
||||
let captured_loader_calls = Arc::clone(&loader_calls);
|
||||
let preplanned = Arc::new(BTreeMap::from([(
|
||||
date,
|
||||
BTreeSet::from(["000001.SZ".to_string()]),
|
||||
)]));
|
||||
let mut engine = BacktestEngine::new(data, strategy, broker, config)
|
||||
.with_execution_quote_loader(move |request| {
|
||||
*captured_loader_calls.lock().expect("loader counter mutex") += 1;
|
||||
@@ -210,20 +247,19 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.with_preplanned_decision_quote_symbols_by_date(preplanned);
|
||||
});
|
||||
|
||||
engine.run().expect("backtest should run");
|
||||
|
||||
assert_eq!(
|
||||
*symbol_plan_calls.lock().expect("symbol plan counter mutex"),
|
||||
0,
|
||||
"the strategy plan must not be recomputed after a complete plan is supplied"
|
||||
1,
|
||||
"quote planning must use the actual run context"
|
||||
);
|
||||
assert_eq!(
|
||||
*loader_calls.lock().expect("loader counter mutex"),
|
||||
1,
|
||||
"the supplied symbols must still pass through the normal quote loader"
|
||||
0,
|
||||
"an empty runtime scope must not fetch unrequested symbols"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
||||
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn futures_directive_notifications_include_the_actual_recorded_fill() {
|
||||
struct Observed {
|
||||
inner: FuturesOrderStrategy,
|
||||
seen: Rc<RefCell<Vec<u64>>>,
|
||||
}
|
||||
impl Strategy for Observed {
|
||||
fn name(&self) -> &str {
|
||||
"observed-futures-directive"
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
self.inner.on_day(ctx)
|
||||
}
|
||||
fn on_process_event(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
event: &ProcessEvent,
|
||||
) -> Result<(), fidc_core::BacktestError> {
|
||||
if event.kind == ProcessEventKind::Trade
|
||||
&& event.symbol.as_deref() == Some("IF2501")
|
||||
{
|
||||
let id = event.order_id.unwrap();
|
||||
assert!(
|
||||
ctx.fills
|
||||
.iter()
|
||||
.any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501")
|
||||
);
|
||||
assert!(
|
||||
ctx.order_events
|
||||
.iter()
|
||||
.any(|order| order.order_id == Some(id)
|
||||
&& order.status == OrderStatus::Filled)
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.current_datetime().map(|time| time.date()),
|
||||
Some(ctx.execution_date)
|
||||
);
|
||||
self.seen.borrow_mut().push(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||
let broker = BrokerSimulator::new_with_execution_price(
|
||||
ChinaAShareCostModel::default(),
|
||||
ChinaEquityRuleHooks,
|
||||
PriceField::Open,
|
||||
)
|
||||
.with_volume_capacity_mode(
|
||||
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||
);
|
||||
let mut engine = BacktestEngine::new(
|
||||
two_day_futures_data(),
|
||||
Observed {
|
||||
inner: FuturesOrderStrategy,
|
||||
seen: seen.clone(),
|
||||
},
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 100_000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(d(2025, 1, 2)),
|
||||
end_date: Some(d(2025, 1, 3)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Open,
|
||||
},
|
||||
)
|
||||
.with_futures_initial_cash(500_000.);
|
||||
let result = engine.run().unwrap();
|
||||
assert_eq!(
|
||||
*seen.borrow(),
|
||||
result
|
||||
.fills
|
||||
.iter()
|
||||
.filter(|fill| fill.symbol == "IF2501")
|
||||
.map(|fill| fill.order_id.unwrap())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(seen.borrow().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
|
||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||
@@ -2748,6 +2832,7 @@ fn strategy_context_exposes_engine_native_account_runtime_view() {
|
||||
avg_price: 0.0,
|
||||
transaction_cost: 0.0,
|
||||
limit_price: 12.0,
|
||||
reserved_cash: None,
|
||||
reason: "pending_buy".to_string(),
|
||||
}];
|
||||
let subscriptions = BTreeSet::new();
|
||||
|
||||
@@ -224,6 +224,117 @@ fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_zero_target_prevents_resuming_the_previous_unsubmitted_buy_leg() {
|
||||
use fidc_core::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext};
|
||||
struct Probe;
|
||||
impl Strategy for Probe {
|
||||
fn name(&self) -> &str {
|
||||
"fresh-target-before-resume"
|
||||
}
|
||||
fn requires_minute_callbacks(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||
vec![
|
||||
ScheduleRule::daily("earlier-pool", ScheduleStage::Minute)
|
||||
.with_time_rule(ScheduleTimeRule::physical_time(9, 30)),
|
||||
]
|
||||
}
|
||||
fn on_scheduled(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
_: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date != day(5) {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
let mut old = contract(day(5), 2, false);
|
||||
old.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
|
||||
old.rule.window_end = "13:30".into();
|
||||
old.rule.pricing_mode = POOL_PRICE_FORMULA_LIMIT.into();
|
||||
old.generation = "earlier-pool-at-open".into();
|
||||
Ok(decision(old))
|
||||
}
|
||||
fn on_day(
|
||||
&mut self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||
if ctx.execution_date == day(2) {
|
||||
return Ok(StrategyDecision {
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: code(1),
|
||||
quantity: 100,
|
||||
reason: "original-holding".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
assert!(ctx.open_orders.is_empty());
|
||||
let mut latest = contract(day(5), 2, false);
|
||||
latest.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
|
||||
latest.rule.window_end = "13:30".into();
|
||||
latest.invest_ratio_bps = 0;
|
||||
latest.generation = "fresh-zero-at-1300".into();
|
||||
Ok(decision(latest))
|
||||
}
|
||||
}
|
||||
let mut rows = data(false).snapshot_components();
|
||||
let mut quotes = Vec::new();
|
||||
for mut quote in rows.execution_quotes {
|
||||
if quote.date > day(5) {
|
||||
continue;
|
||||
}
|
||||
let mut afternoon = quote.clone();
|
||||
afternoon.timestamp = quote.date.and_hms_opt(13, 0, 0).unwrap();
|
||||
quotes.push(afternoon);
|
||||
if quote.date == day(5) && quote.symbol == code(1) {
|
||||
quote.volume_delta = 100;
|
||||
quote.amount_delta = quote.last_price * 100.;
|
||||
}
|
||||
quotes.push(quote);
|
||||
}
|
||||
rows.execution_quotes = quotes;
|
||||
let data = DataSet::from_components_with_actions_and_quotes(
|
||||
rows.instruments,
|
||||
rows.market,
|
||||
rows.factors,
|
||||
rows.candidates,
|
||||
rows.benchmarks,
|
||||
rows.corporate_actions,
|
||||
rows.execution_quotes,
|
||||
)
|
||||
.unwrap();
|
||||
let broker = broker(true)
|
||||
.with_matching_type(MatchingType::MinuteLast)
|
||||
.with_intraday_execution_start_time(chrono::NaiveTime::from_hms_opt(13, 0, 0).unwrap());
|
||||
let result = BacktestEngine::new(
|
||||
data,
|
||||
Probe,
|
||||
broker,
|
||||
BacktestConfig {
|
||||
initial_cash: 30_000.,
|
||||
benchmark_code: "000300.SH".into(),
|
||||
start_date: Some(day(2)),
|
||||
end_date: Some(day(5)),
|
||||
decision_lag_trading_days: 0,
|
||||
execution_price_field: PriceField::Last,
|
||||
},
|
||||
)
|
||||
.run()
|
||||
.unwrap();
|
||||
assert_eq!(result.fills.len(), 3, "{:?}", result.fills);
|
||||
assert!(result.fills.iter().all(|fill| fill.symbol == code(1)));
|
||||
assert_eq!(result.fills[1].side, fidc_core::OrderSide::Sell);
|
||||
assert_eq!(
|
||||
result.fills[2].execution_timestamp,
|
||||
day(5).and_hms_opt(13, 0, 0)
|
||||
);
|
||||
assert_eq!(result.fills[1].order_id, result.fills[2].order_id);
|
||||
assert_eq!(result.fills[1].quantity + result.fills[2].quantity, 100);
|
||||
assert!(result.holdings_summary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
|
||||
let data = data_with_suspension(1_000_000, Some(day(6)));
|
||||
@@ -887,6 +998,42 @@ fn historical_etf_late_signal_freezes_money_and_requantifies_at_next_official_op
|
||||
assert!(result.terminal_audit.is_clean());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deferred_etf_open_does_not_appear_in_a_pre_open_minute_callback() {
|
||||
use fidc_core::strategy::{Strategy,StrategyContext};
|
||||
use std::{cell::RefCell,rc::Rc};
|
||||
struct ObservedPool { inner:EtfPoolSignal, observations:Rc<RefCell<Vec<(chrono::NaiveDateTime,u32,usize)>>> }
|
||||
impl Strategy for ObservedPool {
|
||||
fn name(&self)->&str {"ETF actual opening clock"}
|
||||
fn initial_subscriptions(&self)->BTreeSet<String> {BTreeSet::from([code(1)])}
|
||||
fn decision_quote_times(&self)->Vec<chrono::NaiveTime> {self.inner.decision_quote_times()}
|
||||
fn decision_quote_symbols(&mut self,ctx:&StrategyContext<'_>)->Result<BTreeSet<String>,fidc_core::BacktestError> {self.inner.decision_quote_symbols(ctx)}
|
||||
fn on_day(&mut self,ctx:&StrategyContext<'_>)->Result<StrategyDecision,fidc_core::BacktestError> {self.inner.on_day(ctx)}
|
||||
fn on_minute(&mut self,ctx:&StrategyContext<'_>,quote:&IntradayExecutionQuote)->Result<StrategyDecision,fidc_core::BacktestError> {
|
||||
if quote.date==day(5) {self.observations.borrow_mut().push((quote.timestamp,
|
||||
ctx.portfolio.position(&code(2)).map_or(0,|position|position.quantity),ctx.fills.iter().filter(|fill|fill.symbol==code(2)).count()));}
|
||||
Ok(StrategyDecision::default())
|
||||
}
|
||||
}
|
||||
let time=chrono::NaiveTime::from_hms_opt(13,0,0).unwrap();
|
||||
let mut data=etf_fallback_fixture(time);
|
||||
let quote=data.execution_quotes_on(day(5),&code(1))[0].clone();
|
||||
data.add_execution_quotes([(9,15),(9,31)].into_iter().map(|(hour,minute)| {
|
||||
let mut row=quote.clone();row.timestamp=day(5).and_hms_opt(hour,minute,0).unwrap();row
|
||||
}).collect());
|
||||
let observations=Rc::new(RefCell::new(Vec::new()));
|
||||
let broker=broker(false).with_matching_type(MatchingType::MinuteLast)
|
||||
.with_execution_price_field(PriceField::Last).with_intraday_execution_start_time(time)
|
||||
.with_historical_etf_open_fallback(true);
|
||||
let result=BacktestEngine::new(data,ObservedPool {inner:EtfPoolSignal{at:time,condition:String::new()},observations:observations.clone()},broker,BacktestConfig {
|
||||
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Last,
|
||||
}).with_execution_quote_loader(|_|Ok(vec![])).run().unwrap();
|
||||
let observations=observations.borrow();
|
||||
assert_eq!(observations[0],(day(5).and_hms_opt(9,15,0).unwrap(),0,0));
|
||||
assert_eq!(observations[1],(day(5).and_hms_opt(9,31,0).unwrap(),3700,1));
|
||||
assert_eq!(result.fills.iter().filter(|fill|fill.symbol==code(2)).count(),1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_etf_pending_target_at_end_is_not_a_fake_order_or_fill() {
|
||||
let result=run_etf_fallback(chrono::NaiveTime::from_hms_opt(13,0,0).unwrap(),day(2),true,"",false,false).unwrap();
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# 回报上下文、盘前意图与尚未提交的目标
|
||||
|
||||
2026-09-14。本轮为v2026.9.14.4之后的候选,当前只有本机验证,尚未发布;完整股票池Goal继续。
|
||||
|
||||
## 已复现问题
|
||||
|
||||
1. `on_process_event`总是收到`active_datetime=None`及空委托/成交数组。10:00账本已有100股,但Trade/PostMinute回调的成交数量仍为0;不能靠普通`on_minute`已修复就认为通知链也完整。
|
||||
2. 15:05盘后成交后,PreAfterTrading仍被标为15:00;跨日模式的PostOnDay又使用信号日描述执行日已发生的成交。
|
||||
3. BeforeTrading调度只处理订阅、账户和期货指令,剩余股票买卖/撤改意图没有后续消费。简单在开盘调用普通broker执行还会让旧挂单先成交再撤单。
|
||||
4. 合并完整目标时只追加权重会保留旧证券;更重要的是,不能先提交盘前旧组合,之后才计算同一窗口的新目标,否则T+1可能使错误买入无法纠正。
|
||||
5. 策略计算前的空broker调用也会恢复上一目标的未提交买入腿。反例中原持仓100股,09:30卖25股、13:00卖剩余75股;若此时先恢复旧买入,已经准备将新目标设为0%的策略仍会买入另一股票3000股。
|
||||
|
||||
## 本轮处理
|
||||
|
||||
- 事件通知显式携带当前可见的委托、成交与回调时钟,移动已完成记录后再通知,不按每个回调复制整段历史。上下文是通知时已完成批次的最新状态,不冒充每一历史通知发生瞬间的账本快照。
|
||||
- 信号计算回调保留信号日;账户/委托通知使用实际执行日与物理时钟。默认收盘和结算不早于已处理时刻及当前适用的盘后结束点,管理费回调沿用同一完成时钟。
|
||||
- 盘前撤改走明确的非撮合控制阶段,保持原订单ID和实际已成交量;该入口拒绝买卖目标,不会顺带撮合旧单。普通显式买卖按原配置窗口执行,后续回调读取撤改后的真实活动订单。
|
||||
- 盘前与集合竞价的显式命令保留各自批次及约束。纯完整组合(完整rebalance或单一StockPool/TargetPortfolioSmart)可以被更新的完整意图替换;空回调不等于清仓,显式空完整目标才清仓。被替换意图的旧买入限制不能污染新完整目标。
|
||||
- 尚未提交的完整目标保留到当前窗口日度策略算完;新执行意图优先,只有没有新执行意图时才使用前面的目标。已提交挂单可以先更新实际成交,但策略计算前不恢复旧的未提交买入腿,之后再由正常执行路径处理当前意图。
|
||||
- 订阅/账户/直接期货指令通知同样获得完成后的历史;本轮不改变期货成交、会话或费用规则。
|
||||
|
||||
## 回归证据
|
||||
|
||||
- 通知链:09:30为空、10:00/10:01均看到100股及1笔实际成交,Trade通知可找到相同订单。
|
||||
- 盘后:15:05成交后的默认收盘/结算和管理费通知不倒退;next-open保持独立信号日和执行日。
|
||||
- 盘前:09:00生成100股命令,分别只在09:30/13:00配置窗口成交;保留备注/诊断。跨日撤销原GTC订单后,新订单只成交100股,未让旧单先成交。
|
||||
- 完整目标:盘前A、集合竞价B、日度A或显式空目标,最终只采用有效最新目标;日度无新信号时保持B。显式逐股命令不会被目标合并丢弃。
|
||||
- 恢复顺序:开启正常旧恢复的单点负向对照确实多买3000股;恢复BeforeStrategy阶段后,只有原股票同一卖单的25+75股成交,无新增买入,最终持仓为空。
|
||||
- 本机Core834项通过(9项原有ignore),Trading613、最新main Runner446/API119通过。外部数据库及平台ignore不当作通过。
|
||||
|
||||
当前代码尚需精确Linux构建、真实历史合同回放和配套发布;不得把本机验证当生产或真实券商成交验收。
|
||||
|
||||
## 继续范围
|
||||
|
||||
显式逐笔手工影子回放仍未完成,四类手工来源继续拒绝纯比例影子;原始撤单意图时刻不能用网关回报时刻冒充。还需继续检查会话外调度产生的未提交意图、完整阶段日历与其余参数/生命周期/适配器矩阵。Source冻结、研究/信号暂停、现有任务配置和真实路由不改。
|
||||
@@ -0,0 +1,258 @@
|
||||
{
|
||||
"verified_at": "2026-09-13T20:22:24.750379+00:00",
|
||||
"tag": "v2026.9.14.4",
|
||||
"processes": {
|
||||
"fidc-backtest-service-highmem177.service": {
|
||||
"pid": 3612875,
|
||||
"sha256": "4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2",
|
||||
"journal_since": "2026-09-13T20:14:17.444770+00:00",
|
||||
"journal_lines": 54,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-trading-control-highmem177.service": {
|
||||
"pid": 3617963,
|
||||
"sha256": "cd587928591fef952f2e98b47aa338a1702def5edf016a7da9751296667f6674",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-market-data-highmem177.service": {
|
||||
"pid": 3617964,
|
||||
"sha256": "2efb1d3ad7d510cf85e6047dd6d1981d0a30d768ff3d33c842adc52211002bdc",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-strategy-runtime-highmem177.service": {
|
||||
"pid": 3618140,
|
||||
"sha256": "3d7f3f2e8756e7f3439075344fe9c8bc0b55df33e7e6f251282712274c00339d",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 5,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-paper-trading-highmem177.service": {
|
||||
"pid": 3618260,
|
||||
"sha256": "0383b1d6cc7b3c48c6902dd7fd4760a38698c1916e0eaff63be26fe3f6b1a2ab",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
},
|
||||
"fidc-live-trading-highmem177.service": {
|
||||
"pid": 3618246,
|
||||
"sha256": "583f52e204aeb416574ee17daa20cebed49e0659a194c81e8072a9249824e48e",
|
||||
"journal_since": "2026-09-13T20:19:57.821843+00:00",
|
||||
"journal_lines": 6,
|
||||
"error_lines": 0
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"commit": "d5b682c6d097",
|
||||
"pid": 1700096,
|
||||
"loaded_at": "2026-09-12T03:57:06.665536+00:00",
|
||||
"source_stale": false,
|
||||
"loaded_server_sha256": "ef827ce6b95e0ea63047a0068af2677633716e0a5d63cf350de6c91a3413e352"
|
||||
},
|
||||
"source_checkouts": {
|
||||
"fidc-backtest-engine": {
|
||||
"head": "9a54156df94cfbf11a1e6335ec6ef5449bd6ac17",
|
||||
"runtime_commit": "237ee15a518a668297959509daffc4b88995f310",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-backtest-service": {
|
||||
"head": "5ec8dc86d99736a0c0140440bd039d11e118c1c6",
|
||||
"runtime_commit": "e81bf47806f5ac4ae4798bb5f5955a56638f754c",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"fidc-trading-platform": {
|
||||
"head": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
|
||||
"runtime_commit": "dab98e0cc09793df15b8c72841a6dc7e9a58a208",
|
||||
"tracked_dirty": false
|
||||
},
|
||||
"omniquant": {
|
||||
"head": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"runtime_commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"tracked_dirty": false
|
||||
}
|
||||
},
|
||||
"ui_unchanged": {
|
||||
"commit": "6a2b2604b40505fa754453307c517fef60743426",
|
||||
"pid": 3089476
|
||||
},
|
||||
"http_cases": [
|
||||
{
|
||||
"name": "manual_first",
|
||||
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "0830216850b64d6e83291e072b31a9989f179915ee3341a75a77c73d1f9081a3",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "automatic_first",
|
||||
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "c75cabcc03760f415bb664d20060e81c620d7a0201dd348ea71f75c932571de7",
|
||||
"trade_count": 10,
|
||||
"holding_count": 4,
|
||||
"final_equity": 9706248.648662,
|
||||
"old_result_unchanged": true
|
||||
},
|
||||
{
|
||||
"name": "stock24",
|
||||
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
|
||||
"status": "succeeded",
|
||||
"canonical_sha256": "270b403542ab41290c3d6e027b89cdab24dd41a8e2851d8786b33daa51e0051f",
|
||||
"trade_count": 51,
|
||||
"holding_count": 21,
|
||||
"final_equity": 9685563.876924999,
|
||||
"old_result_unchanged": true
|
||||
}
|
||||
],
|
||||
"durable_events": [
|
||||
{
|
||||
"run_id": "btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054",
|
||||
"count": 18,
|
||||
"unique_keys": 18,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74",
|
||||
"count": 32,
|
||||
"unique_keys": 32,
|
||||
"days": 5
|
||||
},
|
||||
{
|
||||
"run_id": "btr_req_a3c3dfe5cd81e27e565064a65665f561c60adebdc6c9c9b4",
|
||||
"count": 27,
|
||||
"unique_keys": 27,
|
||||
"days": 5
|
||||
}
|
||||
],
|
||||
"trading_state": {
|
||||
"paper": {
|
||||
"configuration": {
|
||||
"count": 3,
|
||||
"hash": "93f3224edef59c381164e0236529dacc"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 0
|
||||
}
|
||||
},
|
||||
"live": {
|
||||
"configuration": {
|
||||
"count": 0,
|
||||
"hash": "d41d8cd98f00b204e9800998ecf8427e"
|
||||
},
|
||||
"active": {
|
||||
"claims": 0,
|
||||
"orders": 1,
|
||||
"today_orders": 0,
|
||||
"orders_hash": "d4b56fbf3a541a41a383ad4e48891bb8",
|
||||
"route_mode": "disabled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"manual_facts_unchanged": {
|
||||
"paper": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 3,
|
||||
"manual_hash": "82572901ac0b5fdb4d8b984f71e1763d",
|
||||
"migrations_hash": "21d711b2ee52d2d66a8be4e99b179190",
|
||||
"new_orders": 0
|
||||
},
|
||||
"live": {
|
||||
"shadow_configurations": 0,
|
||||
"shadow_runs": 0,
|
||||
"manual_count": 0,
|
||||
"manual_hash": "d41d8cd98f00b204e9800998ecf8427e",
|
||||
"migrations_hash": "610528d4f350309379c9398c4ea43f66",
|
||||
"new_orders": 0
|
||||
}
|
||||
},
|
||||
"broker_submission": false,
|
||||
"linux_core_tests": {
|
||||
"passed": 822,
|
||||
"failed": 0,
|
||||
"ignored": 9,
|
||||
"log": "/srv/fidc/canonical/run/fidc-private/evidence/clock-candidate-gqx8g70l/linux-core-tests.log"
|
||||
},
|
||||
"cleanup": {
|
||||
"apply": true,
|
||||
"deleted": [
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/holding-protection-stage-wywd2682/fidc-trading-platform/debug/incremental",
|
||||
"kind": "incremental_compiler_state",
|
||||
"bytes": 9553190912,
|
||||
"device": 2101,
|
||||
"inode": 39877787,
|
||||
"mtime_ns": 1789318996850462500,
|
||||
"links": 176,
|
||||
"size_bytes": 12288
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/incremental",
|
||||
"kind": "incremental_compiler_state",
|
||||
"bytes": 2103459840,
|
||||
"device": 2101,
|
||||
"inode": 29904450,
|
||||
"mtime_ns": 1789318494701447400,
|
||||
"links": 46,
|
||||
"size_bytes": 4096
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_backtest_service-46310ff8aeeb4040",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 398401536,
|
||||
"device": 2101,
|
||||
"inode": 29934330,
|
||||
"mtime_ns": 1789117975169420000,
|
||||
"links": 1,
|
||||
"size_bytes": 399242488
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-18c9b2429fdf6026",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 207015936,
|
||||
"device": 2101,
|
||||
"inode": 29918792,
|
||||
"mtime_ns": 1789166943885717800,
|
||||
"links": 1,
|
||||
"size_bytes": 207144208
|
||||
},
|
||||
{
|
||||
"path": "/srv/fidc/canonical/build/target-backtest/debug/deps/fidc_core-42f704a330411730",
|
||||
"kind": "superseded_test_binary",
|
||||
"bytes": 191205376,
|
||||
"device": 2101,
|
||||
"inode": 29933759,
|
||||
"mtime_ns": 1789117542401109800,
|
||||
"links": 1,
|
||||
"size_bytes": 191333360
|
||||
}
|
||||
],
|
||||
"reclaimed_allocated_bytes": 12453273600,
|
||||
"before": {
|
||||
"total": 1749269057536,
|
||||
"used": 1659299954688,
|
||||
"free": 1035599872
|
||||
},
|
||||
"after": {
|
||||
"total": 1749269057536,
|
||||
"used": 1647166930944,
|
||||
"free": 13168623616
|
||||
},
|
||||
"observed_free_change": 12133023744
|
||||
},
|
||||
"scope": "Intraday clock release verification; historical simulation only, not a performance or real broker liquidity acceptance.",
|
||||
"native_replays": 6
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
{
|
||||
"schema": "fidc.selection-risk-plan-acceptance/v1",
|
||||
"rows": [
|
||||
{
|
||||
"name": "control-1",
|
||||
"receiptSha256": "f18b3b484d40e2a813bd795cb38e263ff43f65b17f31004786d3a23a6af5bcb6",
|
||||
"wallSeconds": 30.986483575077727,
|
||||
"engineSeconds": 8.79,
|
||||
"dataSeconds": 8.445,
|
||||
"validationSeconds": 12.244,
|
||||
"resultSeconds": 1.292,
|
||||
"maxRssKiB": 7090392,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-2",
|
||||
"receiptSha256": "8e5f7f8fe77ba2a798056306277a4ae4f00b6aa98b8c277269aca8c235bbd0fb",
|
||||
"wallSeconds": 13.274638780159876,
|
||||
"engineSeconds": 6.739,
|
||||
"dataSeconds": 5.19,
|
||||
"validationSeconds": 0.209,
|
||||
"resultSeconds": 1.003,
|
||||
"maxRssKiB": 7092040,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "control-3",
|
||||
"receiptSha256": "d106ddae57c64f931e196b80ffa517443e5c0f11eb9c2079f84d55b2d693fb13",
|
||||
"wallSeconds": 13.043757867999375,
|
||||
"engineSeconds": 6.732,
|
||||
"dataSeconds": 5.159,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1,
|
||||
"maxRssKiB": 7089984,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-1",
|
||||
"receiptSha256": "a76e11c115ad42389dfdf72ed674ad75af8ec3d4646feb57feee9e6a4418f20d",
|
||||
"wallSeconds": 12.976857921108603,
|
||||
"engineSeconds": 6.682,
|
||||
"dataSeconds": 5.132,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.021,
|
||||
"maxRssKiB": 7091752,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-2",
|
||||
"receiptSha256": "38f61fd0d495daa5e29d6354679ce51e33473fb3ecbbb420c93d2fd41b74246f",
|
||||
"wallSeconds": 12.927155625075102,
|
||||
"engineSeconds": 6.64,
|
||||
"dataSeconds": 5.128,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.01,
|
||||
"maxRssKiB": 7092320,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "candidate-3",
|
||||
"receiptSha256": "9b56896d6dc048c5dd3d56cbe863778122b5bdf42fc9769eaa41f2d1b339dcd4",
|
||||
"wallSeconds": 12.926160736009479,
|
||||
"engineSeconds": 6.664,
|
||||
"dataSeconds": 5.113,
|
||||
"validationSeconds": 0.006,
|
||||
"resultSeconds": 1.006,
|
||||
"maxRssKiB": 7091128,
|
||||
"fills": 21393,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 93895,
|
||||
"sha256": "3f089cbcc5412e07bbe2308d0dd60ac561a119f0a2228010fc3323714fde8ca7",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 21555,
|
||||
"sha256": "8c839c89191d2b5220fa1dd86f8d74bdd57fa566a096a0bbf6932d0247b48e8a"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "f928695650bdd90eb25d2acd478e0064046883649efa25cd2d4cdbbd27035c27"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 21393,
|
||||
"sha256": "2a90bef0994dda1b7f6e914e4c58037ec0b91cd828eb09745f71aecde071f791"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 28353,
|
||||
"sha256": "799371917e516fb251b4afb60aeb4a1b8fc4b269ecfc27f827a3b388c4985a04"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 21491,
|
||||
"sha256": "0685ac0c31933b6cf5d1319912de3f398c40ce7ee01c28b4abec1b98f92fb318"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 78,
|
||||
"sha256": "e394cc9e8f3bac17a2f80f9db8738b91d4e960ff230647e17e5145b86d14b986"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "1905f0c8a887215279342b26d5769a6cbe40058971eef15adf86ef2eaa02aeb9",
|
||||
"verifiedFactBlocks": 290,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-control",
|
||||
"receiptSha256": "305f0ea34b50355661ef9d3583467f7160cfbffd95f03b9e21a631bebc37af64",
|
||||
"wallSeconds": 15.628366323187947,
|
||||
"engineSeconds": 8.199,
|
||||
"dataSeconds": 5.234,
|
||||
"validationSeconds": 0.694,
|
||||
"resultSeconds": 1.33,
|
||||
"maxRssKiB": 7108340,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "trend-40-candidate",
|
||||
"receiptSha256": "0174941bea20079730c019b3de4185cc439528160ab54cafc1be4e3f8a0a08fc",
|
||||
"wallSeconds": 14.82603678200394,
|
||||
"engineSeconds": 8.087,
|
||||
"dataSeconds": 5.276,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 1.322,
|
||||
"maxRssKiB": 7108656,
|
||||
"fills": 29776,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 128192,
|
||||
"sha256": "41209fed4c7a5e385a12e170afef685efae7a3b00137365fe2b9aea015dd7948",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 29968,
|
||||
"sha256": "fa578b86d94a5be9ad192258eb87c9be6a1b356d6713e70ae475b95fd130f61d"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "90f63f3c51c62f0fc0abb317a29ac48aa0ead6035d5f75d650a948c33ae1e9b3"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 29776,
|
||||
"sha256": "6626ea78cfaa5d88040496c575be63cd8b573d48d1afe6812579762bf7e2abd0"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 37367,
|
||||
"sha256": "491b32468148cc62779f4c01cee96e4dcf18ce1a6115ccecc437b468e49b2ceb"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 29932,
|
||||
"sha256": "565f5a6271a9e1f0fa31f152141c75db0e5f84784e4d4a83b2277b0fd14c45fc"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 124,
|
||||
"sha256": "d170e1704ee5f64a93b71cecf2178b062d30368df8f5da53c07416b8e8a9c5e5"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ab29a9f999a6b41330255ba82081696a9826825fa6d34941247332adac65249e",
|
||||
"verifiedFactBlocks": 293,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-control",
|
||||
"receiptSha256": "0d39c6af608d3ec89fc44d0715dab41229511c68cf5ea4eb01f763c10bded8bf",
|
||||
"wallSeconds": 13.775856785941869,
|
||||
"engineSeconds": 7.374,
|
||||
"dataSeconds": 4.893,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 1.358,
|
||||
"maxRssKiB": 7119352,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "pullback-40-candidate",
|
||||
"receiptSha256": "3f0ec7b8b6ad74fc7349586a8716d45b8075ebad03c77776ca78fab5188d49ee",
|
||||
"wallSeconds": 13.927610703045502,
|
||||
"engineSeconds": 7.239,
|
||||
"dataSeconds": 5.137,
|
||||
"validationSeconds": 0.003,
|
||||
"resultSeconds": 1.368,
|
||||
"maxRssKiB": 7119784,
|
||||
"fills": 31862,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 135630,
|
||||
"sha256": "49fdbd74245d7aa678f1a4851add29f7b12dde71fd50c93c070fd2ee57f35285",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 32010,
|
||||
"sha256": "9be3914d28d7766f12bac45227d2c2da47d61a921f9b918a4b11447da4b78baa"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "2044627b1152c4a2ad8ea92dca3351322d41cfc18c002cda93f274bd810efdad"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 31862,
|
||||
"sha256": "b33d1b4b0f7f86c96c082c217c9a27d86830ccac0de5a33714104ac4756df8d7"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 38679,
|
||||
"sha256": "a45b516a926c57b6c7827f8f1684b6e749d26118b8c5d8e8614afbb23cd87559"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 31966,
|
||||
"sha256": "73704496ab17d10d9a602429d97b728cf22d0896f2d467b0f2064f2f48bf04fb"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 88,
|
||||
"sha256": "2c8de7dbb11c7ca5ac678c17feabec0bd98aac0a031bbb4780eddaa144933293"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "ff32f177e5e0ec4b4f3f0597b61174efdafac77b4f36e6065448b6e9c414f07e",
|
||||
"verifiedFactBlocks": 281,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-control",
|
||||
"receiptSha256": "98239365828453888930a1fceb2a7d9b5402b03cd32c9303a9fa1532af3644ed",
|
||||
"wallSeconds": 18.176081838086247,
|
||||
"engineSeconds": 11.154,
|
||||
"dataSeconds": 4.585,
|
||||
"validationSeconds": 0.004,
|
||||
"resultSeconds": 2.268,
|
||||
"maxRssKiB": 7158556,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
},
|
||||
{
|
||||
"name": "volume-momentum-80-candidate",
|
||||
"receiptSha256": "1cbbccd9678bc8ea2754f2ffa678f3d43feb659a58f86393c4c54961daa5a8d0",
|
||||
"wallSeconds": 18.627057212870568,
|
||||
"engineSeconds": 11.013,
|
||||
"dataSeconds": 5.2,
|
||||
"validationSeconds": 0.005,
|
||||
"resultSeconds": 2.267,
|
||||
"maxRssKiB": 7152664,
|
||||
"fills": 51300,
|
||||
"canonical": {
|
||||
"schemaVersion": "fidc-canonical-backtest-result/v2",
|
||||
"algorithm": "sha256",
|
||||
"ordering": "engine_fact_order_v2",
|
||||
"totalRows": 234267,
|
||||
"sha256": "4359817bb1fbd73b02c2c3456e1f0b7ee7428b090529393bd203e8212979a1dc",
|
||||
"sections": {
|
||||
"accountEvents": {
|
||||
"rowCount": 51696,
|
||||
"sha256": "ca4d333cc4768ec4b528475d1833809c7e76a80de937c635e7e933a0f18264c6"
|
||||
},
|
||||
"equityFacts": {
|
||||
"rowCount": 1025,
|
||||
"sha256": "31865ccfeb71d260757979524a679880070d691c09083c82865c3de6dd47e440"
|
||||
},
|
||||
"fillEvents": {
|
||||
"rowCount": 51300,
|
||||
"sha256": "4f45cc0906b6cd02af9ce12450a52f509f6e80e9b26157695ae04e90ce7ca9ac"
|
||||
},
|
||||
"holdingSnapshots": {
|
||||
"rowCount": 78078,
|
||||
"sha256": "79098ebfa48dcd24b00ba4d19917c291adb80a129b4d90ee1087b5f2210c43aa"
|
||||
},
|
||||
"orderEvents": {
|
||||
"rowCount": 51783,
|
||||
"sha256": "340699c5d04407c5c6f71dc39c41ac2baedc096bb11185f1ccb99ab7100ba8f8"
|
||||
},
|
||||
"riskAudits": {
|
||||
"rowCount": 385,
|
||||
"sha256": "2aa0a4d6e6078dbea815b536ce0ee8f6ca03f9c44f63cdfecf4e37a0ff9cf561"
|
||||
}
|
||||
}
|
||||
},
|
||||
"storeSha256": "5748fe4db08f8d81607622b302cc43c1d2e4ef50c3b87739fb6c20cd40de79c0",
|
||||
"verifiedFactBlocks": 309,
|
||||
"sharedInputsUnchanged": true
|
||||
}
|
||||
],
|
||||
"sharedInputFiles": 9257,
|
||||
"sharedInputBytes": 12596608049,
|
||||
"sharedInputInventorySha256": "1a4818aaab906e77b750e28601d3d405ad9e14e0553f7937cc60b68be0c9b71d",
|
||||
"verifiedFactBlocks": 3506,
|
||||
"status": "candidate-not-deployed",
|
||||
"sourceCommit": "d5b682c6d09704ff23d725a8dd8b155db3eb6967",
|
||||
"engineCommit": "d2aa16a2f0064297d0d8c931060646d66422e9d4",
|
||||
"serviceCommit": "4e23c7558d8301ba697543c39d5604289bb82c53",
|
||||
"controlRunnerSha256": "b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d",
|
||||
"candidateRunnerSha256": "1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb",
|
||||
"candidateApiSha256": "30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501",
|
||||
"bounds": [
|
||||
"All twelve are new runner processes and private result artifacts using the same verified shared input files.",
|
||||
"Input hashing is outside the elapsed benchmark timer; no GDB samples are in these measurements.",
|
||||
"The first control had 12.244s Source validation and a slower preparation phase. Its entire latency difference is not candidate speedup.",
|
||||
"The full input set is identical across the twelve runs, not only a global cache hit counter.",
|
||||
"No Source/trading service was changed and no paused research/signal task resumed.",
|
||||
"The independently recorded intraday-clock counterexample remains open. These day-level replays do not close it."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
# 日内时钟与手工回放前置问题
|
||||
|
||||
2026-09-14。本轮日内时钟与工作中算法单修复已配套发布177,annotated tag `v2026.9.14.4`。当前Engine237ee15 / Service e81bf47 / Trading dab98e0;完整手工影子回放尚未实现,不据本阶段关闭Goal。
|
||||
|
||||
## 已复现的精确反例
|
||||
|
||||
`engine::tests::minute_observer_never_sees_a_later_fill_from_a_coarse_phase`使用实际BacktestEngine/BrokerSimulator测试入口、同一证券及合法测试日行情。开盘竞价回调生成100股限价10.0的委托,全天存在09:30、10:00、10:15、13:00、13:01报价,后续分钟回调读取真实模拟账本。
|
||||
|
||||
- CurrentBarClose/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- NextBarOpen/一天信号滞后/09:30窗口:10:15成交;10:00观察为0股,通过。
|
||||
- CurrentBarClose/13:00窗口:实际FillEvent时间13:00、数量100,但09:30、10:00、10:15回调均已观察到100股,失败。完整观察序列为`[(09:30,100),(10:00,100),(10:15,100),(13:00,100),(13:01,100)]`,不是仅日志显示错误。
|
||||
|
||||
根因路径是粗粒度auction/on_day阶段调用broker时使用未来的全局intraday_execution_start_time,先将13:00成交写进PortfolioState,随后引擎才从09:30开始遍历分钟事件。正常09:30路径已有边界,不能因为一次测试通过就断言所有时点安全,也不能把所有粗粒度调用一概认定有问题。
|
||||
|
||||
首次盘前调度夹具没有产生订单,因此不作为时钟证据;改用明确返回委托的open_auction回调完成上述复现。盘前on_scheduled普通委托是否被忽略应另行核对其正式合同,不能当空成功。
|
||||
|
||||
## 必须按真实执行时序修复
|
||||
|
||||
不能删掉早间回调或给显示持仓做遮掩。需要使已生成的未来执行意图、待执行批次、订单回报、策略回调、手工意图及实际投影按执行时钟前进;保留独立信号日与数据可见性。不能仅把新订单延迟却让依赖持仓的后续策略回调仍提前计算。
|
||||
|
||||
需覆盖当前/下一开盘、显式时间和默认收盘、限价/市价/算法单、部分成交及取消、股票池卖后续买、跨日/T+1、0%人工覆盖和恢复。已有真实回放与六类Canonical必须按各自合同核对,不能用收益接近或单个对照替代。
|
||||
|
||||
上述原失败回归已保留并修复:晚窗口执行与日度回调进入真实日内时钟,不再先写未来持仓。独立信号日及滞后执行的数据合同保留。仅有日内观察或待处理开盘目标时,未显式设时间的日线收盘回调才延至15:00;物理时钟与委托提交时点分离,不能把普通日线收盘撮合误变为15:05盘后委托。
|
||||
|
||||
## 本轮新增证据
|
||||
|
||||
- TWAP旧路径在13:00一次消费13:01、13:05报价,导致13:00观察到900股;现在逐时钟消费,同一父订单保留原始总量、已成交量、剩余金额、最低佣金余额和期限,不重新生成订单。
|
||||
- 分片时钟继续使用原算法窗口决定TWAP比例及深度约束,不把每个瞬时时钟当作新的不限量算法单;VWAP全局撮合也延续同一工作中订单。
|
||||
- 算法定量使用提交时已经可见的报价。改变当日后续收盘价不改变早先订单数量;真正缺报价明确失败,不读未来报价或日线价替代。
|
||||
- 当天已完成委托/成交记录及时移动到运行历史,后续分钟、日度与定时策略回调能读取;不逐分钟复制全部历史。
|
||||
- ETF下一开盘回退保留真实日线开盘价、3700股及原信号日,入账从早间预处理移到09:30事件;反例09:15原来可见3700股,修复后为0,09:31为3700且仅一笔ETF成交。不合成ETF分钟线。
|
||||
- 工作中算法单只预留真实可用现金;两个各10000元意图、15000元账户按顺序预留10000/5000,后续分别成交900/500股,先到订单不被后到订单的超额预留饿死。
|
||||
- 已验证部分成交后撤单、无末尾报价到期、T+1、IOC终止及原合同拒绝算法FOK/GTC;未新增不支持的有效期。
|
||||
- 同一TWAP与同步参考逐笔数量/价格/时间/订单ID/各项费用完全一致;VWAP逐时钟成交金额与总费用一致。最低佣金只扣一次,成交资金不超过冻结预算。
|
||||
|
||||
本机Core 822项通过、9项原有ignore;Trading工作区613项通过(外部PG等原有ignore未当通过);最新main的Runner446/API119项通过。同期main风控候选d2aa16a已保留并组合回归。本机测试不代替177不可变构建与真实数据回放。
|
||||
|
||||
## 发布前置与剩余边界
|
||||
|
||||
177于03:46只读核对仍为3Paper/0Live,原配置与旧委托摘要不变,Source d5b682c6/PID1700096未变,真实路由disabled。SSD剩约1.6GiB;首次Linux测试在18.02秒触及1GiB余量保护并中止,只停止本次Cargo进程,未重启服务,保留`clock-candidate-cena8gz9/first-attempt.json`及日志,不能算测试通过。
|
||||
|
||||
初次把清理预览的`reclaimed_allocated_bytes=0`误读为没有候选;完整plan实际已有5项、12,453,273,600字节。正式工具引用/锁/身份复核后仅清理2处闲置debug增量缓存和3个过期测试可执行文件,保留最新测试、全部静态/共享库、release、源码、行情及结果,余量恢复13,168,623,616字节。收据位于`/srv/fidc/canonical/run/fidc-private/evidence/clock-default-cleanup-20260914-0422/`。暂拟的静态库清理选项未执行并已撤回;最终Service脚本5ec8dc8只明确区分计划量与实际回收量,保持原清理边界。
|
||||
|
||||
代码修复已推送Engine `237ee15a518a668297959509daffc4b88995f310`;官方复用审计确认target-backtest无运行引用,新一轮仍保留1GiB余量保护,并独立保存重建前的旧二进制及SHA。实际构建读取只读Git archive快照237ee15与生产Service e81bf47,不夹带尚未生产验收的并行缓存规划代码,不覆盖维护工作树。
|
||||
|
||||
Linux精确快照Core822、Trading613通过。首次配套优化构建276.06秒成功,但收据写入因/tmp的跨用户既有文件保护失败;改为原子替换收据后,重新核对同一快照/测试/制品,未把日志缺失或异常算通过。前一轮日志及旧二进制仍保留,最终收据`/tmp/fidc-clock-candidate-20260914.json`。
|
||||
|
||||
## 发布与真实合同验收
|
||||
|
||||
Engine `237ee15a518a668297959509daffc4b88995f310`、Service `e81bf47806f5ac4ae4798bb5f5955a56638f754c`、Trading `dab98e0cc09793df15b8c72841a6dc7e9a58a208`均有已推送annotated tag `v2026.9.14.4`。API/Runner于04:14:17 CST切换,五交易服务于04:19:57切换;04:22只读复验实际SHA、迁移、旧单及配置。
|
||||
|
||||
| 已冻结原合同 | 原生A/B | 生产HTTP | 成交 / 期末持仓 | 期末权益 |
|
||||
| --- | --- | --- | --- | ---: |
|
||||
| 手选优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_60612ec2af9f97df26a81c13448aec7d95b23a93f824c303 | 10 / 4 | 9706248.648662 |
|
||||
| 自动优先四证券 | 完整Canonical及四类逐行导出相同 | btr_req_373da23c5ea5aaf4e59c38fbe37d663ae37731aeed8cc054 | 10 / 4 | 9706248.648662 |
|
||||
| 许总24只原v3 | 完整Canonical及四类逐行导出相同 | btr_req_5bb965ea83e047c998ec16be656f40ec28a4e5d870aa6d74 | 51 / 21 | 9685563.876924999 |
|
||||
|
||||
共六次独立原生执行、三次持久幂等HTTP提交,旧请求/旧结果未改写。候选顺序、父订单及卖后续买合同保持;重复目标委托0。三条新记录各有5个交易日事件,持久事件总数27/18/32、唯一键数完全相等;旧流式样本仍27条/5日。上述数据来自原历史合同,仍属日终容量审计,不证明实时盘口容量;1秒样本与首轮12秒Source准备不作为性能提速证据。
|
||||
|
||||
API二进制SHA `4e9f142be0ae3f9ca8e1c126507d4a9905cde4b69859df4544472afd1bda1ff2`,Runner `8b98a2ae9a13899e87d9931162d1637de7e9ab81844c284e00135904cda7b0e4`,运行实现身份 `96cf0dcfcec94c6f7e2a9fc64ba8b8e8547b12c869ad6a61a0e492f6c76b5d57`。当前不可变API目录`/srv/fidc/canonical/run/backtest-api/releases/clock-237ee15-c37rs7zq`,回退目录`/srv/fidc/canonical/run/build/clock-rollback-7qnhgco7`;交易回退目录`holding-protection-rollback-dkd1njej`。
|
||||
|
||||
五交易服务逐一核对实际文件SHA与manifest,新增ERROR日志0;3Paper/0Live、配置、旧活动委托、3个未确认Paper预览、迁移、shadow配置0及disabled均未变化,发布后Paper/Live新订单0。Source d5/PID1700096、UI6a2/PID3089476未重启,研究/信号暂停保持。177维护中的Engine9a54156工作树完整保留,不把该未部署候选冒充本次运行代码;实际编译来自237/e81和237/dab只读快照。
|
||||
|
||||
完整原始回放与HTTP收据:`/srv/fidc/canonical/run/research/stock-pool-clock-20260914/`。发布/审计收据:`/tmp/fidc-clock-{api-release,trading-release,final-audit}-20260914.json`。非敏感汇总已归档`docs/evidence/intraday-clock-20260914/acceptance.json`。
|
||||
|
||||
## 下一步
|
||||
|
||||
通用process-event回调的完整时间/订单观察、盘前调度普通意图、显式手工委托/撤单回放和所有剩余参数矩阵继续跟踪,不以当前阶段声明完整Goal完成。下一轮直接处理这些缺口,不重新做已通过的金额、页头、流式及本轮三组回放;当前仍不解除手工影子拒绝门禁,不修改既有任务配置、Source冻结和研究/信号暂停。
|
||||
|
||||
Live取消请求另有待核对项:`CancelRequested`当前在网关返回后才持久化,不能不加说明就把回报时间当最初请求时间。完整手工回放需要验证并补齐真实意图/提交/成交/取消关联,当前生产手工影子仍保持明确拒绝纯比例模拟。
|
||||
@@ -0,0 +1,126 @@
|
||||
# Selection Risk Plan Performance
|
||||
|
||||
## Status
|
||||
|
||||
Candidate tested, not deployed. The change removes selection calls that have
|
||||
no possible effect under the current frozen policy. It does not disable any
|
||||
configured rule, execution-day check or strategy expression. Engine time falls
|
||||
slightly in the measured cases; this is not the solution to the main remaining
|
||||
data construction cost and is not a general whole-backtest speedup claim.
|
||||
|
||||
The independent [intraday-clock counterexample](intraday-clock-causality-20260914.md)
|
||||
remains open. This work does not remove that test or its evidence, change the
|
||||
execution clock, or turn day-level parity into full framework acceptance.
|
||||
The published service stays at e81bf47/c98bcc3. Source d5b682c6 remains frozen;
|
||||
research and signal work stay paused. No trading operation was submitted.
|
||||
|
||||
## Evidence Leading to the Change
|
||||
|
||||
The official HTTP diagnostic replay btr_1789322878865_2871869_0 preserved the
|
||||
original canonical and result-store SHA. Ten bounded Boris-only GDB snapshots
|
||||
showed source inventory, PreparedDayBuilder, factor normalization and price
|
||||
series construction, followed by repeated selection risk calls. GDB pauses are
|
||||
not normal performance measurements and snapshot counts are not flamegraph
|
||||
percentages. Source/target PID, binary SHA and CPU/thread resources stayed fixed.
|
||||
|
||||
The diagnostic helper now shares the existing canonical executable policy with
|
||||
the saved-run profiler: it accepts both audited build roots and immutable API
|
||||
release directories, but not arbitrary paths. Seven related tests passed.
|
||||
|
||||
## Implementation and Correctness
|
||||
|
||||
StaticRiskRuleConfig reports whether selection has an enabled state rule or an
|
||||
enabled nonempty blacklist. The strategy computes this once before iterating
|
||||
candidate symbols. If no such rule exists, the old selection function would
|
||||
always return None, so that no-op call is omitted. Explicit universe conditions,
|
||||
market/factor checks and all buy/sell execution paths are unchanged.
|
||||
|
||||
The ten state flags are also shared with the existing missing-risk-state checks
|
||||
to avoid maintaining three separate flag lists. Blacklist presence is kept
|
||||
separate: a blacklist is not missing market-risk data. No cross-strategy cache,
|
||||
strategy identifier, fixed date, trading time or account state is introduced.
|
||||
|
||||
Tests enumerate all 4,096 combinations of ten selection flags and blacklist
|
||||
enabled/populated states. The flag list is checked against the serialized
|
||||
configuration, so adding a selection field requires updating the activation
|
||||
test. Further tests retain missing-state behavior and show that paused buys
|
||||
and sells remain rejected when selection checks are inactive.
|
||||
|
||||
On 177: 805 core unit/integration tests passed (9 ignored), 448 runner tests
|
||||
passed (9 ignored), 119 API tests passed (5 ignored), and 28 benchmark/profiler
|
||||
tests passed. These counts do not resolve the independently recorded clock
|
||||
failure, which is not part of this frozen committed test tree.
|
||||
|
||||
## Reproducible Shared-Input Method
|
||||
|
||||
Each of the twelve replays has a new process and a new private result root.
|
||||
The official runner benchmark gained --shared-runtime-cache. It resolves the
|
||||
explicit cache root from the declared Boris service, requires canonical private
|
||||
storage, hashes existing inputs before and after, and refuses any changed or
|
||||
removed original. This mode cannot invoke copied-input disposal.
|
||||
|
||||
All twelve runs used the same 9,257 files / 12,596,608,049 bytes. Their complete
|
||||
input inventories, file identities and byte SHA values are equal. No new Arrow
|
||||
or binary cache input appeared. No backtest result was reused. Hash preparation
|
||||
and verification are outside the measured runner interval; this is a shared
|
||||
warm-input test, not raw-disk cold IO. Unlike the earlier copied-cache method,
|
||||
it does not allocate another approximately 2 GB per replay on the nearly full
|
||||
SSD. Original inputs and every result remain intact.
|
||||
|
||||
The common execution interval is 2021-08-23 through 2025-11-17 with 10,000,000
|
||||
initial cash and each case's unchanged frozen strategy/bundle. This is not five
|
||||
complete execution years. CPU affinity and 8 Rayon / 16 Tokio threads match the
|
||||
declared reference service; no global resource limit was increased.
|
||||
|
||||
## Measurements
|
||||
|
||||
| Case | Wall seconds | Source validation | Data preparation | Engine |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Rotation control 1 | 30.986 | 12.244 | 8.445 | 8.790 |
|
||||
| Rotation candidate 1 | 12.977 | 0.004 | 5.132 | 6.682 |
|
||||
| Rotation control 2 | 13.275 | 0.209 | 5.190 | 6.739 |
|
||||
| Rotation candidate 2 | 12.927 | 0.005 | 5.128 | 6.640 |
|
||||
| Rotation candidate 3 | 12.926 | 0.006 | 5.113 | 6.664 |
|
||||
| Rotation control 3 | 13.044 | 0.005 | 5.159 | 6.732 |
|
||||
| Trend 40 control | 15.628 | 0.694 | 5.234 | 8.199 |
|
||||
| Trend 40 candidate | 14.826 | 0.004 | 5.276 | 8.087 |
|
||||
| Pullback 40 control | 13.776 | 0.005 | 4.893 | 7.374 |
|
||||
| Pullback 40 candidate | 13.928 | 0.003 | 5.137 | 7.239 |
|
||||
| Volume momentum 80 control | 18.176 | 0.004 | 4.585 | 11.154 |
|
||||
| Volume momentum 80 candidate | 18.627 | 0.005 | 5.200 | 11.013 |
|
||||
|
||||
Rotation engine medians are 6.739 versus 6.664 seconds, approximately 1.1%.
|
||||
The other paired engine reductions are approximately 1.4%, 1.8% and 1.3%.
|
||||
These are small CPU-path improvements. Pullback and volume total latency did
|
||||
not improve because their preparation times were higher. The first control's
|
||||
Source wait and unexplained slower construction are recorded, not attributed
|
||||
to this code or discarded to manufacture a large speedup. Peak RSS stays about
|
||||
6.76-6.83 GiB; there is no significant memory reduction claim.
|
||||
|
||||
Each case matches its independent prior baseline for all six canonical
|
||||
sections and store bytes: 21,393 / 29,776 / 31,862 / 51,300 fills. Result receipts,
|
||||
runtime/strategy identities, physical manifests and 3,506 fact blocks were
|
||||
verified. The shared input inventory SHA is in the acceptance record. Full
|
||||
unaltered receipts remain on 177; the repository stores the compact verified
|
||||
summary rather than repeating the 9,257-file inventory in every document.
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Prioritize direct typed-column reuse during daily snapshot and DataSet
|
||||
construction; approximately five seconds of preparation remain in these warm
|
||||
cases. Do not skip normalization, NULL, adjustment or date/uniqueness checks.
|
||||
Source cold-query and contract-validation latency remain separate tasks under
|
||||
the Source freeze. The earlier cache-boundary candidate still needs its missing
|
||||
cold/same-window acceptance, and this combined candidate has no HTTP publication
|
||||
gate yet. Financial PIT, minute-clock behavior, signal lifecycle and UI factor
|
||||
condition acceptance are not claimed complete.
|
||||
|
||||
- Candidate engine: d2aa16a2f0064297d0d8c931060646d66422e9d4.
|
||||
- Candidate service source: 4e23c7558d8301ba697543c39d5604289bb82c53.
|
||||
- Control runner SHA: b90886b80634c7565ca215fbe1f9ed0cbb5a6bd967373a9b1f6753be5164737d.
|
||||
- Candidate runner SHA: 1bda2d3acc016ca5addbb12e33cfcc31a23ece562f1d7d1ff8a825fbc83873fb.
|
||||
- Candidate API SHA: 30ac3b50996e1769c1d93bd5d302a23c4af7ebe773d3e8110ee278c44aeb9501.
|
||||
- Evidence root: /srv/fidc/canonical/run/research/selection-risk-plan-20260914.
|
||||
- HTTP diagnostic: /srv/fidc/canonical/run/research/http-phase-profile-20260914.
|
||||
|
||||
[Verified acceptance summary](evidence/selection-risk-plan-20260914/acceptance.json).
|
||||
@@ -0,0 +1,28 @@
|
||||
# 股票池卖出批次与买入续执行
|
||||
|
||||
2026-09-13开发,2026-09-14 00:00至00:06 CST完成177配套发布,annotated tag v2026.9.13.16。Engine c98bcc3、Service aa3fe40、Trading b1d402e;不是完整股票池验收结论。
|
||||
|
||||
## 原问题
|
||||
|
||||
真实混合四证券的手选优先/自动优先回测在09-11出现600276.SH与300811.SZ买量差异。冻结信号权益均9,733,801.863803、90%预算8,760,421.67742270,前一日持仓/现金也相同。原进程日志证明卖出000333.SZ 500股仍为Pending时,买单已经根据未释放的总仓位预算被创建或取消;其后卖单实际成交,执行器不再继续尚未提交的买入阶段。不能仅因为账户还有现金就忽略仓位预算,也不能通过重新跑策略/重复补单掩盖。
|
||||
|
||||
确定性回归在旧实现中稳定复现:200股卖出限价未成交,实际成交回报处理后新标的仍没有持仓;无需网络或外部数据。现增加每池单一未提交执行阶段,sell_then_buy在卖单活动期间不创建买单,报告终结后沿同一冻结信号/权益/配置,根据当时真实现金、持仓和报价只执行买入腿。策略不再次调用,已经提交的委托不替换、不去重补救。
|
||||
|
||||
## 边界
|
||||
|
||||
- 分批成交等待整批活动委托终结;余量保持原order_id。买入以真实成交后资金与仓位预算重新定量,不借预计卖出款。
|
||||
- 每池新意图先替换尚未提交阶段,已提交订单仍保留;同一次止盈/止损清仓的证券保留禁买事实,不能在等待后重新当作未建仓候选买回。
|
||||
- 买单真实提交日/时刻与原信号日分开。next-open卖单延迟后,新买单使用执行时点真实分钟报价,不回到09:30或用日线开盘价代替缺失报价。原始挂单起点不变。
|
||||
- 原窗口结束为排他边界,休市不创建买单;过期只终止未提交阶段,原券商模拟订单按原DAY/GTC时钟自然处理。交易日结束清除未提交阶段并记录原因,不跨日重用。
|
||||
- 引擎即使没有策略分钟订阅,也为活动批次维护真实报价时钟,并加载待买标的;不新增策略回调。
|
||||
- 未修改Source、行情/生命周期门禁、风控、原用户配置或历史结果。PreOpenCash/SamePointNet不因本补丁被强改成SellThenBuy。
|
||||
|
||||
## 当前测试
|
||||
|
||||
9项新增专项覆盖未成交卖出续买、部分成交/买单ID、窗口结束、新信号覆盖、发送前新价/日期、缺价拒绝、止盈清仓禁回买、跨日清理和不订阅分钟的完整引擎执行。全工作区803项通过、9项外部/专项忽略单列;配套Trading613通过,Runner本机432通过、9项忽略。完整引擎测试夹具需显式提供每日因子与候选,缺少两者会得到无执行日期,不能据空运行当作成功。
|
||||
|
||||
177独立进程对三个原请求分别执行原版和修复版,共六次原生回放;原版各自与原历史Canonical相等,原请求及数据包不变。修复后两种优先级均10成交/4持仓/权益9,706,248.648662,逐股数量、费用、时钟、逐日权益和持仓完全一致(订单ID仍按各自原顺序生成,不伪装为同一Canonical)。原24只回放51成交/21持仓/权益9,685,563.876924999,不强求保留旧54笔:09-08和09-10卖出晚于窗口,未提交买入阶段到期;09-11卖出09:31完成后继续买入。混合样本09-09与09-10同样在窗外不新建买单,09-11在09:34完成卖出后续买,已提交DAY单可在窗口后继续成交。
|
||||
|
||||
生产API三次验收分别为btr_req_6854471517438a896378785b96a81e4ab41f0d77f898bf37、btr_req_0d32c6e07598c16728992374f1800804ad2cd06d85f18d15、btr_req_4ae4ee17bf90bbba5ca579a79c7d4e1c410fc2d4506e5800,均与对应原生候选Canonical相同;旧结果/配置回读保持。未提交券商委托、创建交易任务或改写配置,Source冻结及研究/信号暂停保持。完整逐笔回执在177 /srv/fidc/canonical/run/research/stock-pool-sell-buy-20260913,部署回执/tmp/fidc-sell-buy-api-release-20260913.json与/tmp/fidc-sell-buy-trading-release-20260913.json。
|
||||
|
||||
优先级在真实资金或仓位约束不足时仍可影响分配,不能将本例结论外推所有排序。完整Goal下一项仍是手工委托影子回放、流式日期消息/摘要投影和剩余参数矩阵;不重复此已解决样本。
|
||||
Reference in New Issue
Block a user