初始化回测核心引擎骨架
This commit is contained in:
390
crates/fidc-core/src/broker.rs
Normal file
390
crates/fidc-core/src/broker.rs
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user