feat(stock-pool): unify target execution, durable intent state and ETF rules
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
//! Executes one frozen pool intent against real broker-simulator state.
|
||||
use super::*;
|
||||
use crate::holding_policy::HoldingLifecycleEvidence;
|
||||
use crate::stock_pool_execution as pool;
|
||||
use rust_decimal::{Decimal, prelude::ToPrimitive};
|
||||
|
||||
fn decimal(value: f64, label: &str) -> Result<Decimal, BacktestError> {
|
||||
if !value.is_finite() {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_nonfinite_{label}"
|
||||
)));
|
||||
}
|
||||
value
|
||||
.to_string()
|
||||
.parse()
|
||||
.map_err(|_| BacktestError::Execution(format!("stock_pool_decimal_range_{label}")))
|
||||
}
|
||||
|
||||
fn pool_positions(
|
||||
portfolio: &PortfolioState,
|
||||
date: NaiveDate,
|
||||
) -> Result<Vec<pool::Position>, BacktestError> {
|
||||
portfolio
|
||||
.positions()
|
||||
.values()
|
||||
.filter(|p| p.quantity > 0)
|
||||
.map(|p| {
|
||||
Ok(pool::Position {
|
||||
symbol: p.symbol.clone(),
|
||||
quantity: Decimal::from(p.quantity),
|
||||
closable_quantity: Decimal::from(p.sellable_qty(date)),
|
||||
average_cost: decimal(p.average_cost, "position_cost")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
||||
fn pool_quote_inputs(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
data: &DataSet,
|
||||
symbols: &BTreeSet<String>,
|
||||
execution_clock: Option<NaiveDateTime>,
|
||||
) -> Result<Vec<pool::MarketSnapshot>, BacktestError> {
|
||||
symbols
|
||||
.iter()
|
||||
.map(|symbol| {
|
||||
let snapshot = data.market(date, symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_execution_snapshot_missing:{symbol}:{date}"
|
||||
))
|
||||
})?;
|
||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
||||
})?;
|
||||
let (price, prev, volume, amount, bid, ask, buy_price, sell_price) = if self
|
||||
.matching_type_uses_intraday_quotes()
|
||||
{
|
||||
let time = self
|
||||
.runtime_intraday_start_time
|
||||
.get()
|
||||
.or(self.intraday_execution_start_time)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(
|
||||
"stock_pool_intraday_execution_clock_required".into(),
|
||||
)
|
||||
})?;
|
||||
let clock = execution_clock
|
||||
.unwrap_or(date.and_time(time))
|
||||
.max(date.and_time(time));
|
||||
let quote = data
|
||||
.execution_quotes_on(date, symbol)
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|quote| quote.timestamp <= clock)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
if !quote.last_price.is_finite() || quote.last_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_execution_quote_invalid:{symbol}:{clock}"
|
||||
)));
|
||||
}
|
||||
let raw_buy = self
|
||||
.select_quote_reference_price(
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Buy,
|
||||
self.matching_type,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_buy_reference_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
let raw_sell = self
|
||||
.select_quote_reference_price(
|
||||
snapshot,
|
||||
quote,
|
||||
OrderSide::Sell,
|
||||
self.matching_type,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"stock_pool_sell_reference_missing:{symbol}:{clock}"
|
||||
))
|
||||
})?;
|
||||
let calibration = self.slippage_calibration(data, snapshot)?;
|
||||
let buy = self.quote_execution_price(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
raw_buy,
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
let sell = self.quote_execution_price(
|
||||
snapshot,
|
||||
OrderSide::Sell,
|
||||
raw_sell,
|
||||
None,
|
||||
calibration.as_ref(),
|
||||
)?;
|
||||
(
|
||||
quote.last_price,
|
||||
snapshot.prev_close,
|
||||
Some(quote.volume_delta as f64),
|
||||
Some(quote.amount_delta),
|
||||
Some(quote.bid1),
|
||||
Some(quote.ask1),
|
||||
buy,
|
||||
sell,
|
||||
)
|
||||
} else {
|
||||
let price = snapshot.price(self.effective_execution_price_field(date));
|
||||
if !price.is_finite() || price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"stock_pool_execution_price_missing:{symbol}:{date}"
|
||||
)));
|
||||
}
|
||||
// A daily open does not reveal the session's volume/turnover.
|
||||
let completed = self.effective_execution_price_field(date) == PriceField::Close;
|
||||
(
|
||||
price,
|
||||
snapshot.prev_close,
|
||||
completed.then_some(snapshot.volume as f64),
|
||||
None,
|
||||
Some(price),
|
||||
Some(price),
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Buy, None)?,
|
||||
self.snapshot_execution_price(data, snapshot, OrderSide::Sell, None)?,
|
||||
)
|
||||
};
|
||||
Ok(pool::MarketSnapshot {
|
||||
symbol: symbol.clone(),
|
||||
last_price: decimal(price, "price")?,
|
||||
prev_close: Some(decimal(prev, "prev_close")?),
|
||||
volume: volume.map(|v| decimal(v, "volume")).transpose()?,
|
||||
turnover: amount.map(|v| decimal(v, "amount")).transpose()?,
|
||||
bid_price_1: bid.map(|v| decimal(v, "bid")).transpose()?,
|
||||
ask_price_1: ask.map(|v| decimal(v, "ask")).transpose()?,
|
||||
is_kcb: Some(instrument.board.eq_ignore_ascii_case("KSH")),
|
||||
instrument_rules: Some(pool::StockPoolInstrumentRules {
|
||||
price_tick: decimal(snapshot.price_tick, "price_tick")?,
|
||||
quantity_step: instrument.order_step_size().into(),
|
||||
minimum_buy_quantity: instrument.minimum_order_quantity().into(),
|
||||
}),
|
||||
buy_sizing_price: Some(decimal(buy_price, "buy_price")?),
|
||||
sell_sizing_price: Some(decimal(sell_price, "sell_price")?),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn process_stock_pool_contract(
|
||||
&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,
|
||||
) -> Result<(), BacktestError> {
|
||||
if contract.signal_date > date
|
||||
|| contract.frozen_equity < Decimal::ZERO
|
||||
|| contract.generation.is_empty()
|
||||
|| contract.pool_id.trim().is_empty()
|
||||
{
|
||||
return Err(BacktestError::Execution(
|
||||
"stock_pool_frozen_intent_invalid".into(),
|
||||
));
|
||||
}
|
||||
if self.matching_type == MatchingType::NextBarOpen && contract.signal_date >= date {
|
||||
return Err(BacktestError::Execution(
|
||||
"stock_pool_next_open_requires_prior_signal".into(),
|
||||
));
|
||||
}
|
||||
let mut selection = contract.selection.clone();
|
||||
let mut members = contract.members.clone();
|
||||
for symbol in &contract.selection.requested_symbols {
|
||||
let instrument = data.instruments().get(symbol).ok_or_else(|| {
|
||||
BacktestError::Execution(format!("stock_pool_instrument_missing:{symbol}"))
|
||||
})?;
|
||||
if portfolio.position(symbol).is_none()
|
||||
&& let Some(reason) = instrument.dated_market_absence_reason(date)
|
||||
{
|
||||
selection.requested_symbols.retain(|v| v != symbol);
|
||||
selection.normal_trading_symbols.retain(|v| v != symbol);
|
||||
selection.risk_eligible_symbols.retain(|v| v != symbol);
|
||||
selection.final_symbols.retain(|v| v != symbol);
|
||||
members.retain(|v| &v.symbol != symbol);
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_market_absence symbol={symbol} date={date} reason={reason}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut scope = selection
|
||||
.requested_symbols
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
scope.extend(portfolio.positions().keys().cloned());
|
||||
let official_dates = data.calendar().iter().collect::<Vec<_>>();
|
||||
let initial_positions = pool_positions(portfolio, date)?;
|
||||
let state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
.observe(
|
||||
contract.signal_date,
|
||||
date,
|
||||
&official_dates,
|
||||
&members,
|
||||
&initial_positions,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, state)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
if self.has_open_orders() {
|
||||
report
|
||||
.diagnostics
|
||||
.push("stock_pool_waiting_for_active_orders no_new_intent=true".into());
|
||||
return Ok(());
|
||||
}
|
||||
let mut constraints = contract.constraints.clone();
|
||||
constraints.execution_date = Some(date);
|
||||
constraints.frozen_positions.clear();
|
||||
let mut quote_scope = scope.clone();
|
||||
for symbol in &scope {
|
||||
let paused = data.market(date, symbol).is_some_and(|row| row.paused)
|
||||
|| data
|
||||
.candidate(date, symbol)
|
||||
.is_some_and(|row| row.is_paused);
|
||||
if !paused {
|
||||
continue;
|
||||
}
|
||||
quote_scope.remove(symbol);
|
||||
if let Some(position) = portfolio
|
||||
.position(symbol)
|
||||
.filter(|position| position.quantity > 0)
|
||||
{
|
||||
constraints.frozen_positions.insert(
|
||||
symbol.clone(),
|
||||
pool::FrozenStockPoolPosition {
|
||||
trade_date: date,
|
||||
reason: "paused".into(),
|
||||
valuation_price: decimal(position.last_price, "paused_holding_valuation")?,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
selection
|
||||
.normal_trading_symbols
|
||||
.retain(|item| item != symbol);
|
||||
selection
|
||||
.risk_eligible_symbols
|
||||
.retain(|item| item != symbol);
|
||||
selection.final_symbols.retain(|item| item != symbol);
|
||||
selection
|
||||
.exclusion_reasons
|
||||
.entry(symbol.clone())
|
||||
.or_default()
|
||||
.push("paused".into());
|
||||
}
|
||||
}
|
||||
let before_positions = portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
for side in [pool::OrderSide::Sell, pool::OrderSide::Buy] {
|
||||
let quotes =
|
||||
self.pool_quote_inputs(date, data, "e_scope, *global_execution_cursor)?;
|
||||
let positions = pool_positions(portfolio, date)?;
|
||||
let execution_state = portfolio
|
||||
.stock_pool_execution_state(&contract.pool_id)
|
||||
.observe(
|
||||
contract.signal_date,
|
||||
date,
|
||||
&official_dates,
|
||||
&members,
|
||||
&positions,
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
||||
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||
let account = pool::AccountSnapshot {
|
||||
total_equity: contract.frozen_equity,
|
||||
cash: decimal(portfolio.cash(), "cash")?,
|
||||
frozen_cash: Decimal::ZERO,
|
||||
};
|
||||
constraints.automatic_permissions.clear();
|
||||
if contract.rule.automatic_trade_protection.enabled() {
|
||||
for symbol in &scope {
|
||||
let position = portfolio.position(symbol).filter(|p| p.quantity > 0);
|
||||
let sold = self
|
||||
.same_day_sold_symbols
|
||||
.borrow()
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(day, symbols)| **day <= date && symbols.contains(symbol))
|
||||
.map(|(day, _)| *day);
|
||||
let evidence = HoldingLifecycleEvidence {
|
||||
has_position: position.is_some(),
|
||||
opened_date: position.and_then(|p| p.opened_date()),
|
||||
last_buy_date: position.and_then(|p| p.last_buy_date()),
|
||||
last_sell_date: sold,
|
||||
};
|
||||
let permission = contract
|
||||
.rule
|
||||
.automatic_trade_protection
|
||||
.evaluate(symbol, date, &evidence, data.calendar())
|
||||
.map_err(BacktestError::Execution)?;
|
||||
constraints
|
||||
.automatic_permissions
|
||||
.insert(symbol.clone(), permission);
|
||||
}
|
||||
}
|
||||
if self
|
||||
.risk_config
|
||||
.static_rules
|
||||
.forbid_same_day_rebuy_after_sell
|
||||
{
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
self.same_day_sold_symbols
|
||||
.borrow()
|
||||
.get(&date)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
constraints.same_day_sold_symbols.extend(
|
||||
before_positions
|
||||
.iter()
|
||||
.filter(|symbol| portfolio.position(symbol).is_none_or(|p| p.quantity == 0))
|
||||
.cloned(),
|
||||
);
|
||||
let fee =
|
||||
|symbol: &str, side: pool::OrderSide, gross: Decimal| -> Result<Decimal, String> {
|
||||
let amount = gross
|
||||
.to_f64()
|
||||
.ok_or("stock_pool_cost_amount_out_of_range")?;
|
||||
decimal(
|
||||
self.cost_model
|
||||
.calculate_for_instrument(
|
||||
date,
|
||||
if side == pool::OrderSide::Buy {
|
||||
OrderSide::Buy
|
||||
} else {
|
||||
OrderSide::Sell
|
||||
},
|
||||
amount,
|
||||
data.instruments().get(symbol),
|
||||
)
|
||||
.total(),
|
||||
"fee",
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
};
|
||||
let plan = pool::build_stock_pool_target_plan_with_fee_model(
|
||||
&selection,
|
||||
&members,
|
||||
&contract.rule,
|
||||
&account,
|
||||
&positions,
|
||||
"es,
|
||||
contract.invest_ratio_bps,
|
||||
contract.reserve_cash,
|
||||
&contract.out_of_pool_policy,
|
||||
"full_rebalance",
|
||||
&constraints,
|
||||
&contract.generation,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Decimal::ZERO,
|
||||
Some(&fee),
|
||||
)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
let updated = execution_state
|
||||
.record_plan(contract.signal_date, &contract.generation, &plan)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio
|
||||
.set_stock_pool_execution_state(&contract.pool_id, updated)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
report.diagnostics.push(format!("stock_pool_plan phase={side:?} generation={} requested_bps={} effective_bps={} budget={}",contract.generation,plan.requested_invest_ratio_bps,plan.effective_invest_ratio_bps,plan.budget));
|
||||
let max_positions = constraints
|
||||
.target_holding_count
|
||||
.unwrap_or(selection.final_symbols.len());
|
||||
for row in plan.rows {
|
||||
if side == pool::OrderSide::Buy && row.side.is_none() {
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_decision symbol={} status={} current={} target={} reason={}",
|
||||
row.symbol,
|
||||
row.status,
|
||||
row.current_quantity,
|
||||
row.target_quantity,
|
||||
row.reason
|
||||
));
|
||||
}
|
||||
if row.side != Some(side) {
|
||||
continue;
|
||||
}
|
||||
if side == pool::OrderSide::Buy
|
||||
&& portfolio
|
||||
.position(&row.symbol)
|
||||
.is_none_or(|p| p.quantity == 0)
|
||||
&& Self::positive_position_count(portfolio) >= max_positions
|
||||
{
|
||||
report.diagnostics.push(format!(
|
||||
"stock_pool_buy_deferred symbol={} reason=occupied_position_slots",
|
||||
row.symbol
|
||||
));
|
||||
continue;
|
||||
}
|
||||
let target = row.target_quantity.to_i32().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_target_quantity_out_of_range".into())
|
||||
})?;
|
||||
let reason = row.source_intent.as_deref().unwrap_or("stock_pool_target");
|
||||
if let Some(price) = row.limit_price {
|
||||
self.process_limit_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
price.to_f64().ok_or_else(|| {
|
||||
BacktestError::Execution("stock_pool_limit_price_out_of_range".into())
|
||||
})?,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)?;
|
||||
} else {
|
||||
self.process_target_shares(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
&row.symbol,
|
||||
target,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
commission_state,
|
||||
report,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user