修复回报上下文与盘前意图并在提交前采用最新完整目标
This commit is contained in:
@@ -228,6 +228,13 @@ struct RestingOrderOrigin {
|
|||||||
accepted_date: NaiveDate,
|
accepted_date: NaiveDate,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum BrokerCallbackPhase {
|
||||||
|
Normal,
|
||||||
|
ControlsOnly,
|
||||||
|
BeforeStrategy,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct BrokerExecutionSession {
|
struct BrokerExecutionSession {
|
||||||
date: Option<NaiveDate>,
|
date: Option<NaiveDate>,
|
||||||
@@ -463,6 +470,7 @@ pub struct BrokerSimulator<C, R> {
|
|||||||
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||||
runtime_execution_clock: Cell<Option<NaiveTime>>,
|
runtime_execution_clock: Cell<Option<NaiveTime>>,
|
||||||
|
runtime_callback_phase: Cell<BrokerCallbackPhase>,
|
||||||
runtime_algo_schedule: Cell<Option<AlgoExecutionRequest>>,
|
runtime_algo_schedule: Cell<Option<AlgoExecutionRequest>>,
|
||||||
runtime_unprocessed_algorithm_cash: Cell<FixedMoney>,
|
runtime_unprocessed_algorithm_cash: Cell<FixedMoney>,
|
||||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
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_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_execution_clock: Cell::new(None),
|
runtime_execution_clock: Cell::new(None),
|
||||||
|
runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal),
|
||||||
runtime_algo_schedule: Cell::new(None),
|
runtime_algo_schedule: Cell::new(None),
|
||||||
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
||||||
runtime_decision_date: Cell::new(None),
|
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_start_time: Cell::new(None),
|
||||||
runtime_intraday_end_time: Cell::new(None),
|
runtime_intraday_end_time: Cell::new(None),
|
||||||
runtime_execution_clock: Cell::new(None),
|
runtime_execution_clock: Cell::new(None),
|
||||||
|
runtime_callback_phase: Cell::new(BrokerCallbackPhase::Normal),
|
||||||
runtime_algo_schedule: Cell::new(None),
|
runtime_algo_schedule: Cell::new(None),
|
||||||
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
runtime_unprocessed_algorithm_cash: Cell::new(FixedMoney::ZERO),
|
||||||
runtime_decision_date: Cell::new(None),
|
runtime_decision_date: Cell::new(None),
|
||||||
@@ -1643,17 +1653,21 @@ where
|
|||||||
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
|
self.deferred_stock_pools.borrow_mut().remove(&contract.pool_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.process_open_orders(
|
if self.runtime_callback_phase.get() != BrokerCallbackPhase::ControlsOnly {
|
||||||
date,
|
self.process_open_orders(
|
||||||
portfolio,
|
date,
|
||||||
data,
|
portfolio,
|
||||||
&mut session.intraday_turnover,
|
data,
|
||||||
&mut session.execution_cursors,
|
&mut session.intraday_turnover,
|
||||||
&mut session.global_execution_cursor,
|
&mut session.execution_cursors,
|
||||||
&mut session.commission_state,
|
&mut session.global_execution_cursor,
|
||||||
&mut report,
|
&mut session.commission_state,
|
||||||
)?;
|
&mut report,
|
||||||
self.resume_stock_pool_executions(date, portfolio, data, session, &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() {
|
if !decision.order_intents.is_empty() {
|
||||||
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
|
let mut ordered_intents = decision.order_intents.iter().collect::<Vec<_>>();
|
||||||
if self.effective_rebalance_cash_mode() != RebalanceCashMode::PreOpenCash
|
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)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(crate) fn execute_coarse_at_clock(
|
pub(crate) fn execute_coarse_at_clock(
|
||||||
&self,
|
&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(
|
pub fn execute_between_with_event_dates(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
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_eq!(last.order_events.last().unwrap().filled_quantity, 200);
|
||||||
assert!(broker.open_order_views().is_empty());
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+865
-50
File diff suppressed because it is too large
Load Diff
@@ -990,6 +990,15 @@ pub struct StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl StrategyDecision {
|
impl StrategyDecision {
|
||||||
|
pub(crate) fn is_portfolio_target_only(&self) -> bool {
|
||||||
|
(self.rebalance && self.order_intents.is_empty())
|
||||||
|
|| (self.order_intents.len() == 1
|
||||||
|
&& matches!(
|
||||||
|
self.order_intents[0].unwrapped(),
|
||||||
|
OrderIntent::StockPool { .. } | OrderIntent::TargetPortfolioSmart { .. }
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
|
pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet<String> {
|
||||||
let mut symbols = BTreeSet::new();
|
let mut symbols = BTreeSet::new();
|
||||||
if self.rebalance {
|
if self.rebalance {
|
||||||
@@ -1003,9 +1012,24 @@ impl StrategyDecision {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||||
|
if self.is_portfolio_target_only() && other.is_portfolio_target_only() {
|
||||||
|
let mut previous = std::mem::replace(self, other);
|
||||||
|
previous
|
||||||
|
.diagnostics
|
||||||
|
.push("unsubmitted_portfolio_target_superseded".into());
|
||||||
|
self.notes.splice(0..0, previous.notes);
|
||||||
|
self.diagnostics.splice(0..0, previous.diagnostics);
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.buy_denials.append(&mut other.buy_denials);
|
self.buy_denials.append(&mut other.buy_denials);
|
||||||
self.rebalance |= other.rebalance;
|
if other.rebalance {
|
||||||
self.target_weights.append(&mut other.target_weights);
|
// 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.exit_symbols.append(&mut other.exit_symbols);
|
||||||
self.order_intents.append(&mut other.order_intents);
|
self.order_intents.append(&mut other.order_intents);
|
||||||
self.notes.append(&mut other.notes);
|
self.notes.append(&mut other.notes);
|
||||||
@@ -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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum AlgoOrderStyle {
|
pub enum AlgoOrderStyle {
|
||||||
Vwap,
|
Vwap,
|
||||||
|
|||||||
@@ -1535,6 +1535,90 @@ fn engine_executes_futures_order_intents_against_future_account() {
|
|||||||
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
|
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_directive_notifications_include_the_actual_recorded_fill() {
|
||||||
|
struct Observed {
|
||||||
|
inner: FuturesOrderStrategy,
|
||||||
|
seen: Rc<RefCell<Vec<u64>>>,
|
||||||
|
}
|
||||||
|
impl Strategy for Observed {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"observed-futures-directive"
|
||||||
|
}
|
||||||
|
fn on_day(
|
||||||
|
&mut self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
|
self.inner.on_day(ctx)
|
||||||
|
}
|
||||||
|
fn on_process_event(
|
||||||
|
&mut self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
event: &ProcessEvent,
|
||||||
|
) -> Result<(), fidc_core::BacktestError> {
|
||||||
|
if event.kind == ProcessEventKind::Trade
|
||||||
|
&& event.symbol.as_deref() == Some("IF2501")
|
||||||
|
{
|
||||||
|
let id = event.order_id.unwrap();
|
||||||
|
assert!(
|
||||||
|
ctx.fills
|
||||||
|
.iter()
|
||||||
|
.any(|fill| fill.order_id == Some(id) && fill.symbol == "IF2501")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ctx.order_events
|
||||||
|
.iter()
|
||||||
|
.any(|order| order.order_id == Some(id)
|
||||||
|
&& order.status == OrderStatus::Filled)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ctx.current_datetime().map(|time| time.date()),
|
||||||
|
Some(ctx.execution_date)
|
||||||
|
);
|
||||||
|
self.seen.borrow_mut().push(id);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let seen = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Open,
|
||||||
|
)
|
||||||
|
.with_volume_capacity_mode(
|
||||||
|
fidc_core::execution_capacity::VolumeCapacityMode::SessionCapacityAudit,
|
||||||
|
);
|
||||||
|
let mut engine = BacktestEngine::new(
|
||||||
|
two_day_futures_data(),
|
||||||
|
Observed {
|
||||||
|
inner: FuturesOrderStrategy,
|
||||||
|
seen: seen.clone(),
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 100_000.,
|
||||||
|
benchmark_code: "000300.SH".into(),
|
||||||
|
start_date: Some(d(2025, 1, 2)),
|
||||||
|
end_date: Some(d(2025, 1, 3)),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Open,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_futures_initial_cash(500_000.);
|
||||||
|
let result = engine.run().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
*seen.borrow(),
|
||||||
|
result
|
||||||
|
.fills
|
||||||
|
.iter()
|
||||||
|
.filter(|fill| fill.symbol == "IF2501")
|
||||||
|
.map(|fill| fill.order_id.unwrap())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
);
|
||||||
|
assert_eq!(seen.borrow().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
|
fn platform_runtime_actions_execute_generic_futures_open_and_close() {
|
||||||
let mut cfg = PlatformExprStrategyConfig::generic();
|
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
|
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
|
||||||
let data = data_with_suspension(1_000_000, Some(day(6)));
|
let data = data_with_suspension(1_000_000, Some(day(6)));
|
||||||
|
|||||||
@@ -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冻结、研究/信号暂停、现有任务配置和真实路由不改。
|
||||||
Reference in New Issue
Block a user