feat: add decision-scoped buy denials to broker submission
This commit is contained in:
@@ -380,6 +380,7 @@ pub struct BrokerSimulator<C, R> {
|
||||
runtime_intraday_start_time: Cell<Option<NaiveTime>>,
|
||||
runtime_intraday_end_time: Cell<Option<NaiveTime>>,
|
||||
runtime_decision_date: Cell<Option<NaiveDate>>,
|
||||
runtime_buy_denials: RefCell<BTreeMap<String, String>>,
|
||||
runtime_order_created_date: Cell<Option<NaiveDate>>,
|
||||
runtime_decision_total_equity: Cell<Option<f64>>,
|
||||
runtime_target_position_limit: Cell<Option<usize>>,
|
||||
@@ -412,6 +413,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_intraday_start_time: Cell::new(None),
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
@@ -448,6 +450,7 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
runtime_intraday_start_time: Cell::new(None),
|
||||
runtime_intraday_end_time: Cell::new(None),
|
||||
runtime_decision_date: Cell::new(None),
|
||||
runtime_buy_denials: RefCell::new(BTreeMap::new()),
|
||||
runtime_order_created_date: Cell::new(None),
|
||||
runtime_decision_total_equity: Cell::new(None),
|
||||
runtime_target_position_limit: Cell::new(None),
|
||||
@@ -1385,6 +1388,7 @@ where
|
||||
decision: &StrategyDecision,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let previous_decision_date = self.runtime_decision_date.get();
|
||||
let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone());
|
||||
let previous_order_created_date = self.runtime_order_created_date.get();
|
||||
let previous_decision_total_equity = self.runtime_decision_total_equity.get();
|
||||
self.runtime_decision_date.set(Some(decision_date));
|
||||
@@ -1393,6 +1397,7 @@ where
|
||||
self.runtime_decision_total_equity
|
||||
.set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0));
|
||||
let result = self.execute_with_runtime_dates(date, portfolio, data, decision);
|
||||
self.runtime_buy_denials.replace(previous_buy_denials);
|
||||
self.runtime_decision_date.set(previous_decision_date);
|
||||
self.runtime_order_created_date
|
||||
.set(previous_order_created_date);
|
||||
@@ -4189,6 +4194,9 @@ where
|
||||
if !rule.allowed {
|
||||
return rule.reason;
|
||||
}
|
||||
if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) {
|
||||
return Some(reason.clone());
|
||||
}
|
||||
match self.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
@@ -6140,6 +6148,12 @@ where
|
||||
data.instrument(symbol),
|
||||
algo_request,
|
||||
);
|
||||
let rule = if rule.allowed && emit_creation_events {
|
||||
self.runtime_buy_denials.borrow().get(symbol)
|
||||
.map_or(rule, |reason| RuleCheck::reject(reason.clone()))
|
||||
} else {
|
||||
rule
|
||||
};
|
||||
if !rule.allowed {
|
||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||
let status = match rule.reason.as_deref() {
|
||||
@@ -8204,6 +8218,7 @@ mod tests {
|
||||
|
||||
fn next_open_buy_decision() -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
quantity: 100,
|
||||
@@ -8213,8 +8228,58 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_buy_denial_blocks_topup_but_allows_sell_and_does_not_leak() {
|
||||
let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||
let data = DataSet::from_components(
|
||||
vec![limit_test_instrument()],
|
||||
vec![dated_limit_test_snapshot(first), dated_limit_test_snapshot(second)],
|
||||
Vec::new(),
|
||||
vec![dated_limit_test_candidate(first, false, false, true, true),
|
||||
dated_limit_test_candidate(second, false, false, true, true)],
|
||||
vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)],
|
||||
).unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap();
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
|
||||
let mut blocked = StrategyDecision::default();
|
||||
blocked.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
|
||||
blocked.order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(),
|
||||
});
|
||||
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
|
||||
assert!(report.fills.is_empty());
|
||||
assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert!(broker.runtime_buy_denials.borrow().is_empty());
|
||||
blocked.order_intents = next_open_sell_decision().order_intents;
|
||||
let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap();
|
||||
assert_eq!(report.fills.len(), 1);
|
||||
assert_eq!(report.fills[0].side, OrderSide::Sell);
|
||||
assert!(broker.runtime_buy_denials.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_buy_denial_does_not_rewrite_existing_pending_order() {
|
||||
let date = limit_test_snapshot().date;
|
||||
let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()],
|
||||
Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap();
|
||||
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
|
||||
.with_matching_type(MatchingType::CurrentBarClose);
|
||||
broker.upsert_open_order(test_open_order(99));
|
||||
let mut decision = StrategyDecision::default();
|
||||
decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string());
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap();
|
||||
assert!(!report.fills.is_empty());
|
||||
assert!(broker.runtime_buy_denials.borrow().is_empty());
|
||||
}
|
||||
|
||||
fn next_open_sell_decision() -> StrategyDecision {
|
||||
StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
quantity: -100,
|
||||
@@ -10192,6 +10257,7 @@ mod tests {
|
||||
)
|
||||
.expect("valid dataset");
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![
|
||||
OrderIntent::TargetValue {
|
||||
symbol: "000002.SZ".to_string(),
|
||||
@@ -10304,6 +10370,7 @@ mod tests {
|
||||
.position_mut("000002.SZ")
|
||||
.buy(prev_date, 1_000, 10.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![
|
||||
OrderIntent::TargetValue {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
@@ -10371,6 +10438,7 @@ mod tests {
|
||||
.position_mut("000002.SZ")
|
||||
.buy(prev_date, 1_000, 10.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![
|
||||
OrderIntent::TargetValue {
|
||||
symbol: "000003.SZ".to_string(),
|
||||
@@ -10431,6 +10499,7 @@ mod tests {
|
||||
.position_mut("000001.SZ")
|
||||
.buy(prev_date, 1_000, 10.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::TargetValue {
|
||||
symbol: "000003.SZ".to_string(),
|
||||
target_value: 9_000.0,
|
||||
@@ -10595,6 +10664,7 @@ mod tests {
|
||||
.with_inactive_limit(false);
|
||||
let mut portfolio = PortfolioState::new(20_000.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::TargetValue {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
target_value: 10_000.0,
|
||||
@@ -10636,6 +10706,7 @@ mod tests {
|
||||
.with_inactive_limit(false);
|
||||
let mut portfolio = PortfolioState::new(20_000.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::TargetValue {
|
||||
symbol: "000001.SZ".to_string(),
|
||||
target_value: 10_000.0,
|
||||
@@ -10673,6 +10744,7 @@ mod tests {
|
||||
.with_inactive_limit(false);
|
||||
let mut portfolio = PortfolioState::new(20_000.0);
|
||||
let decision = StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
||||
target_weights: BTreeMap::from([("000001.SZ".to_string(), 0.5)]),
|
||||
order_prices: None,
|
||||
|
||||
@@ -4856,6 +4856,7 @@ mod tests {
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -4896,6 +4897,7 @@ mod tests {
|
||||
);
|
||||
if ctx.portfolio.position(SYMBOL).is_none() {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -4955,6 +4957,7 @@ mod tests {
|
||||
rule: &ScheduleRule,
|
||||
) -> Result<StrategyDecision, super::BacktestError> {
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -4992,6 +4995,7 @@ mod tests {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -5028,6 +5032,7 @@ mod tests {
|
||||
return Ok(StrategyDecision::default());
|
||||
}
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::TargetPortfolioSmart {
|
||||
target_weights: self.target_weights.clone(),
|
||||
order_prices: None,
|
||||
@@ -5070,6 +5075,7 @@ mod tests {
|
||||
Vec::new()
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents,
|
||||
..StrategyDecision::default()
|
||||
})
|
||||
@@ -5106,6 +5112,7 @@ mod tests {
|
||||
return Ok(StrategyDecision::default());
|
||||
};
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol,
|
||||
quantity: 100,
|
||||
@@ -5166,6 +5173,7 @@ mod tests {
|
||||
.unwrap_or_default()
|
||||
));
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -5206,6 +5214,7 @@ mod tests {
|
||||
&& ctx.portfolio.position(SYMBOL).is_none()
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -5217,6 +5226,7 @@ mod tests {
|
||||
if ctx.decision_date == self.sell_decision_date {
|
||||
if let Some(position) = ctx.portfolio.position(SYMBOL) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: -(position.quantity as i32),
|
||||
@@ -5256,6 +5266,7 @@ mod tests {
|
||||
&& ctx.portfolio.position(SYMBOL).is_none()
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
quantity: 100,
|
||||
@@ -5267,6 +5278,7 @@ mod tests {
|
||||
if ctx.decision_date == self.rebuy_decision_date {
|
||||
if let Some(position) = ctx.portfolio.position(SYMBOL) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
order_intents: vec![
|
||||
OrderIntent::Shares {
|
||||
symbol: SYMBOL.to_string(),
|
||||
|
||||
@@ -9913,6 +9913,7 @@ impl PlatformExprStrategy {
|
||||
)];
|
||||
diagnostics.extend(action_diagnostics);
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -12578,6 +12579,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
));
|
||||
}
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols,
|
||||
@@ -13854,6 +13856,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
];
|
||||
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols,
|
||||
|
||||
@@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 {
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StrategyDecision {
|
||||
pub buy_denials: BTreeMap<String, String>,
|
||||
pub rebalance: bool,
|
||||
pub target_weights: BTreeMap<String, f64>,
|
||||
pub exit_symbols: BTreeSet<String>,
|
||||
@@ -988,6 +989,7 @@ pub struct StrategyDecision {
|
||||
|
||||
impl StrategyDecision {
|
||||
pub fn merge_from(&mut self, mut other: StrategyDecision) {
|
||||
self.buy_denials.append(&mut other.buy_denials);
|
||||
self.rebalance |= other.rebalance;
|
||||
self.target_weights.append(&mut other.target_weights);
|
||||
self.exit_symbols.append(&mut other.exit_symbols);
|
||||
@@ -998,7 +1000,8 @@ impl StrategyDecision {
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.rebalance
|
||||
self.buy_denials.is_empty()
|
||||
&& !self.rebalance
|
||||
&& self.target_weights.is_empty()
|
||||
&& self.exit_symbols.is_empty()
|
||||
&& self.order_intents.is_empty()
|
||||
@@ -1569,6 +1572,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
if self.config.in_skip_window(ctx.decision_date) {
|
||||
self.last_gross_exposure = Some(0.0);
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
@@ -1590,6 +1594,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
if message.contains("signal series insufficient") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -1765,6 +1770,7 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
self.last_gross_exposure = Some(gross_exposure);
|
||||
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance,
|
||||
target_weights,
|
||||
exit_symbols,
|
||||
@@ -2773,6 +2779,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
let lagged_execution = ctx.is_lagged_execution();
|
||||
if self.config.in_skip_window(signal_date) {
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
@@ -2803,6 +2810,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
if message.contains("insufficient benchmark") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
@@ -3013,6 +3021,7 @@ impl Strategy for OmniMicroCapStrategy {
|
||||
];
|
||||
|
||||
Ok(StrategyDecision {
|
||||
buy_denials: Default::default(),
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols,
|
||||
|
||||
Reference in New Issue
Block a user