修复回报上下文与盘前意图并在提交前采用最新完整目标
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
+865
-50
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user