初始化回测核心引擎骨架

This commit is contained in:
zsb
2026-04-06 23:56:37 -07:00
commit 334864cbc5
25 changed files with 2878 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
use std::collections::{BTreeMap, BTreeSet};
use chrono::NaiveDate;
use crate::cost::CostModel;
use crate::data::{DataSet, PriceField};
use crate::engine::BacktestError;
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
use crate::portfolio::PortfolioState;
use crate::rules::EquityRuleHooks;
use crate::strategy::StrategyDecision;
#[derive(Debug, Default)]
pub struct BrokerExecutionReport {
pub order_events: Vec<OrderEvent>,
pub fill_events: Vec<FillEvent>,
pub position_events: Vec<PositionEvent>,
pub account_events: Vec<AccountEvent>,
}
pub struct BrokerSimulator<C, R> {
cost_model: C,
rules: R,
board_lot_size: u32,
}
impl<C, R> BrokerSimulator<C, R> {
pub fn new(cost_model: C, rules: R) -> Self {
Self {
cost_model,
rules,
board_lot_size: 100,
}
}
}
impl<C, R> BrokerSimulator<C, R>
where
C: CostModel,
R: EquityRuleHooks,
{
pub fn execute(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
decision: &StrategyDecision,
) -> Result<BrokerExecutionReport, BacktestError> {
let mut report = BrokerExecutionReport::default();
let target_quantities = if decision.rebalance {
self.target_quantities(date, portfolio, data, &decision.target_weights)?
} else {
BTreeMap::new()
};
let mut sell_symbols = BTreeSet::new();
sell_symbols.extend(portfolio.positions().keys().cloned());
sell_symbols.extend(decision.exit_symbols.iter().cloned());
sell_symbols.extend(target_quantities.keys().cloned());
for symbol in sell_symbols {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
if current_qty == 0 {
continue;
}
let target_qty = if decision.exit_symbols.contains(&symbol) {
0
} else if decision.rebalance {
*target_quantities.get(&symbol).unwrap_or(&0)
} else {
current_qty
};
if current_qty > target_qty {
let requested_qty = current_qty - target_qty;
self.process_sell(
date,
portfolio,
data,
&symbol,
requested_qty,
sell_reason(decision, &symbol),
&mut report,
)?;
}
}
if decision.rebalance {
for (symbol, target_qty) in target_quantities {
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
if target_qty > current_qty {
let requested_qty = target_qty - current_qty;
self.process_buy(
date,
portfolio,
data,
&symbol,
requested_qty,
"rebalance_buy",
&mut report,
)?;
}
}
}
portfolio.prune_flat_positions();
Ok(report)
}
fn target_quantities(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
target_weights: &BTreeMap<String, f64>,
) -> Result<BTreeMap<String, u32>, BacktestError> {
let equity = self.total_equity_at(date, portfolio, data, PriceField::Open)?;
let mut targets = BTreeMap::new();
for (symbol, weight) in target_weights {
let price = data
.price(date, symbol, PriceField::Open)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: symbol.clone(),
field: "open",
})?;
let raw_qty = ((equity * weight) / price).floor() as u32;
let rounded_qty = self.round_buy_quantity(raw_qty);
targets.insert(symbol.clone(), rounded_qty);
}
Ok(targets)
}
fn process_sell(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
reason: &str,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let Some(position) = portfolio.position(symbol) else {
return Ok(());
};
let rule = self.rules.can_sell(date, snapshot, candidate, position);
if !rule.allowed {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let sellable = position.sellable_qty(date);
let filled_qty = requested_qty.min(sellable);
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: no sellable quantity"),
});
return Ok(());
}
let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Sell, gross_amount);
let net_cash = gross_amount - cost.total();
let realized_pnl = portfolio
.position_mut(symbol)
.sell(filled_qty, snapshot.open)
.map_err(BacktestError::Execution)?;
portfolio.apply_cash_delta(net_cash);
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
} else {
OrderStatus::Filled
};
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: reason.to_string(),
});
report.fill_events.push(FillEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Sell,
quantity: filled_qty,
price: snapshot.open,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: net_cash,
reason: reason.to_string(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: -(filled_qty as i32),
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: realized_pnl,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?,
note: format!("sell {symbol} {reason}"),
});
Ok(())
}
fn process_buy(
&self,
date: NaiveDate,
portfolio: &mut PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
reason: &str,
report: &mut BrokerExecutionReport,
) -> Result<(), BacktestError> {
let snapshot = data.require_market(date, symbol)?;
let candidate = data.require_candidate(date, symbol)?;
let rule = self.rules.can_buy(date, snapshot, candidate);
if !rule.allowed {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
});
return Ok(());
}
let filled_qty =
self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty);
if filled_qty == 0 {
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!("{reason}: insufficient cash after fees"),
});
return Ok(());
}
let cash_before = portfolio.cash();
let gross_amount = snapshot.open * filled_qty as f64;
let cost = self.cost_model.calculate(OrderSide::Buy, gross_amount);
let cash_out = gross_amount + cost.total();
portfolio.apply_cash_delta(-cash_out);
portfolio.position_mut(symbol).buy(date, filled_qty, snapshot.open);
let status = if filled_qty < requested_qty {
OrderStatus::PartiallyFilled
} else {
OrderStatus::Filled
};
report.order_events.push(OrderEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
requested_quantity: requested_qty,
filled_quantity: filled_qty,
status,
reason: reason.to_string(),
});
report.fill_events.push(FillEvent {
date,
symbol: symbol.to_string(),
side: OrderSide::Buy,
quantity: filled_qty,
price: snapshot.open,
gross_amount,
commission: cost.commission,
stamp_tax: cost.stamp_tax,
net_cash_flow: -cash_out,
reason: reason.to_string(),
});
report.position_events.push(PositionEvent {
date,
symbol: symbol.to_string(),
delta_quantity: filled_qty as i32,
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
average_cost: portfolio
.position(symbol)
.map(|pos| pos.average_cost)
.unwrap_or(0.0),
realized_pnl_delta: 0.0,
reason: reason.to_string(),
});
report.account_events.push(AccountEvent {
date,
cash_before,
cash_after: portfolio.cash(),
total_equity: self.total_equity_at(date, portfolio, data, PriceField::Open)?,
note: format!("buy {symbol} {reason}"),
});
Ok(())
}
fn total_equity_at(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
field: PriceField,
) -> Result<f64, BacktestError> {
let mut market_value = 0.0;
for position in portfolio.positions().values() {
let price = data
.price(date, &position.symbol, field)
.ok_or_else(|| BacktestError::MissingPrice {
date,
symbol: position.symbol.clone(),
field: match field {
PriceField::Open => "open",
PriceField::Close => "close",
},
})?;
market_value += price * position.quantity as f64;
}
Ok(portfolio.cash() + market_value)
}
fn round_buy_quantity(&self, quantity: u32) -> u32 {
(quantity / self.board_lot_size) * self.board_lot_size
}
fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 {
let mut quantity = self.round_buy_quantity(requested_qty);
while quantity > 0 {
let gross = price * quantity as f64;
let cost = self.cost_model.calculate(OrderSide::Buy, gross);
if gross + cost.total() <= cash + 1e-6 {
return quantity;
}
quantity = quantity.saturating_sub(self.board_lot_size);
}
0
}
}
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
if decision.exit_symbols.contains(symbol) {
"exit_hook_sell"
} else {
"rebalance_sell"
}
}