修复回报上下文与盘前意图并在提交前采用最新完整目标

This commit is contained in:
boris
2026-09-14 06:06:57 +08:00
parent 600808b171
commit 81acc54228
7 changed files with 1366 additions and 63 deletions
+98 -11
View File
@@ -228,6 +228,13 @@ struct RestingOrderOrigin {
accepted_date: NaiveDate,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BrokerCallbackPhase {
Normal,
ControlsOnly,
BeforeStrategy,
}
#[derive(Debug, Default)]
struct BrokerExecutionSession {
date: Option<NaiveDate>,
@@ -463,6 +470,7 @@ pub struct BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
runtime_execution_clock: Cell<Option<NaiveTime>>,
runtime_callback_phase: Cell<BrokerCallbackPhase>,
runtime_algo_schedule: Cell<Option<AlgoExecutionRequest>>,
runtime_unprocessed_algorithm_cash: Cell<FixedMoney>,
runtime_decision_date: Cell<Option<NaiveDate>>,
@@ -510,6 +518,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_execution_clock: Cell::new(None),
runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal),
runtime_algo_schedule: Cell::new(None),
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
runtime_decision_date: Cell::new(None),
@@ -561,6 +570,7 @@ impl<C, R> BrokerSimulator<C, R> {
runtime_intraday_start_time: Cell::new(None),
runtime_intraday_end_time: Cell::new(None),
runtime_execution_clock: Cell::new(None),
runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal),
runtime_algo_schedule: Cell::new(None),
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
runtime_decision_date: Cell::new(None),
@@ -1643,17 +1653,21 @@ where
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
}
}
self.process_open_orders(
date,
portfolio,
data,
&mut session.intraday_turnover,
&mut session.execution_cursors,
&mut session.global_execution_cursor,
&mut session.commission_state,
&mut report,
)?;
self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?;
if self.runtime_callback_phase.get() != BrokerCallbackPhase::ControlsOnly {
self.process_open_orders(
date,
portfolio,
data,
&mut session.intraday_turnover,
&mut session.execution_cursors,
&mut session.global_execution_cursor,
&mut session.commission_state,
&mut report,
)?;
if self.runtime_callback_phase.get() == BrokerCallbackPhase::Normal {
self.resume_stock_pool_executions(date, portfolio, data, session, &mut report)?;
}
}
if !decision.order_intents.is_empty() {
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
@@ -1830,6 +1844,50 @@ where
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn execute_controls_without_matching(
&self,
date: NaiveDate,
decision_date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
clock: Option<NaiveTime>,
) -> Result<BrokerExecutionReport, BacktestError> {
if decision.rebalance
|| !decision.target_weights.is_empty()
|| !decision.exit_symbols.is_empty()
|| decision.order_intents.iter().any(|intent| {
!matches!(
intent.unwrapped(),
OrderIntent::CancelOrder { .. }
| OrderIntent::CancelSymbol { .. }
| OrderIntent::CancelAll { .. }
| OrderIntent::ModifyOrder { .. }
)
})
{
return Err(BacktestError::Execution(
"non-matching control phase only accepts cancel or modify requests".into(),
));
}
let _guard = RestoreCell(
&self.runtime_callback_phase,
self.runtime_callback_phase
.replace(BrokerCallbackPhase::ControlsOnly),
);
self.execute_between_with_event_dates(
date,
decision_date,
decision_date,
portfolio,
data,
decision,
clock,
clock,
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn execute_coarse_at_clock(
&self,
@@ -1861,6 +1919,35 @@ where
)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn execute_before_strategy_at_clock(
&self,
date: NaiveDate,
decision_date: NaiveDate,
order_created_date: NaiveDate,
decision_total_equity: Option<f64>,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
clock: Option<NaiveTime>,
) -> Result<BrokerExecutionReport, BacktestError> {
let _guard = RestoreCell(
&self.runtime_callback_phase,
self.runtime_callback_phase
.replace(BrokerCallbackPhase::BeforeStrategy),
);
self.execute_coarse_at_clock(
date,
decision_date,
order_created_date,
decision_total_equity,
portfolio,
data,
decision,
clock,
)
}
pub fn execute_between_with_event_dates(
&self,
date: NaiveDate,
@@ -675,3 +675,104 @@ fn a_clock_slice_does_not_turn_window_twap_into_an_unlimited_instant_order() {
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
);
}
File diff suppressed because it is too large Load Diff
+72 -2
View File
@@ -990,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 {
@@ -1003,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);
@@ -1025,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,
+84
View File
@@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() {
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
}
#[test]
fn futures_directive_notifications_include_the_actual_recorded_fill() {
struct Observed {
inner: FuturesOrderStrategy,
seen: Rc<RefCell<Vec<u64>>>,
}
impl Strategy for Observed {
fn name(&self) -> &str {
"observed-futures-directive"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
self.inner.on_day(ctx)
}
fn on_process_event(
&mut self,
ctx: &StrategyContext<'_>,
event: &ProcessEvent,
) -> Result<(), fidc_core::BacktestError> {
if event.kind == ProcessEventKind::Trade
&& event.symbol.as_deref() == Some("IF2501")
{
let id = event.order_id.unwrap();
assert!(
ctx.fills
.iter()
.any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501")
);
assert!(
ctx.order_events
.iter()
.any(|order| order.order_id == Some(id)
&& order.status == OrderStatus::Filled)
);
assert_eq!(
ctx.current_datetime().map(|time| time.date()),
Some(ctx.execution_date)
);
self.seen.borrow_mut().push(id);
}
Ok(())
}
}
let seen = Rc::new(RefCell::new(Vec::new()));
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Open,
)
.with_volume_capacity_mode(
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
);
let mut engine = BacktestEngine::new(
two_day_futures_data(),
Observed {
inner: FuturesOrderStrategy,
seen: seen.clone(),
},
broker,
BacktestConfig {
initial_cash: 100_000.,
benchmark_code: "000300.SH".into(),
start_date: Some(d(2025, 1, 2)),
end_date: Some(d(2025, 1, 3)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_futures_initial_cash(500_000.);
let result = engine.run().unwrap();
assert_eq!(
*seen.borrow(),
result
.fills
.iter()
.filter(|fill| fill.symbol == "IF2501")
.map(|fill| fill.order_id.unwrap())
.collect::<Vec<_>>()
);
assert_eq!(seen.borrow().len(), 1);
}
#[test]
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
let mut cfg = PlatformExprStrategyConfig::generic();
@@ -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)));