Improve jq microcap execution semantics
This commit is contained in:
@@ -5,8 +5,9 @@ use thiserror::Error;
|
||||
use crate::broker::{BrokerExecutionReport, BrokerSimulator};
|
||||
use crate::cost::CostModel;
|
||||
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, PositionEvent};
|
||||
use crate::portfolio::{HoldingSummary, PortfolioState};
|
||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
||||
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||
use crate::rules::EquityRuleHooks;
|
||||
use crate::strategy::{Strategy, StrategyContext};
|
||||
|
||||
@@ -32,6 +33,8 @@ pub struct BacktestConfig {
|
||||
pub benchmark_code: String,
|
||||
pub start_date: Option<NaiveDate>,
|
||||
pub end_date: Option<NaiveDate>,
|
||||
pub decision_lag_trading_days: usize,
|
||||
pub execution_price_field: PriceField,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -56,6 +59,28 @@ pub struct BacktestResult {
|
||||
pub position_events: Vec<PositionEvent>,
|
||||
pub account_events: Vec<AccountEvent>,
|
||||
pub holdings_summary: Vec<HoldingSummary>,
|
||||
pub daily_holdings: Vec<HoldingSummary>,
|
||||
pub metrics: BacktestMetrics,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct BacktestDayProgress {
|
||||
#[serde(with = "date_format")]
|
||||
pub date: NaiveDate,
|
||||
pub cash: f64,
|
||||
pub market_value: f64,
|
||||
pub total_equity: f64,
|
||||
pub unit_nav: f64,
|
||||
pub total_return: f64,
|
||||
pub benchmark_close: f64,
|
||||
pub daily_fill_count: usize,
|
||||
pub cumulative_trade_count: usize,
|
||||
pub holding_count: usize,
|
||||
pub notes: String,
|
||||
pub diagnostics: String,
|
||||
pub orders: Vec<OrderEvent>,
|
||||
pub fills: Vec<FillEvent>,
|
||||
pub holdings: Vec<HoldingSummary>,
|
||||
}
|
||||
|
||||
pub struct BacktestEngine<S, C, R> {
|
||||
@@ -88,15 +113,28 @@ where
|
||||
R: EquityRuleHooks,
|
||||
{
|
||||
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
|
||||
self.run_with_progress(|_| {})
|
||||
}
|
||||
|
||||
pub fn run_with_progress<F>(&mut self, mut on_progress: F) -> Result<BacktestResult, BacktestError>
|
||||
where
|
||||
F: FnMut(&BacktestDayProgress),
|
||||
{
|
||||
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
||||
let execution_dates = self
|
||||
.data
|
||||
.calendar()
|
||||
.iter()
|
||||
.filter(|date| self.config.start_date.map(|start| *date >= start).unwrap_or(true))
|
||||
.filter(|date| {
|
||||
self.config
|
||||
.start_date
|
||||
.map(|start| *date >= start)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|date| self.config.end_date.map(|end| *date <= end).unwrap_or(true))
|
||||
.filter(|date| {
|
||||
!self.data.factor_snapshots_on(*date).is_empty() && !self.data.candidate_snapshots_on(*date).is_empty()
|
||||
!self.data.factor_snapshots_on(*date).is_empty()
|
||||
&& !self.data.candidate_snapshots_on(*date).is_empty()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut result = BacktestResult {
|
||||
@@ -105,8 +143,18 @@ where
|
||||
.data
|
||||
.benchmark_series()
|
||||
.into_iter()
|
||||
.filter(|row| self.config.start_date.map(|start| row.date >= start).unwrap_or(true))
|
||||
.filter(|row| self.config.end_date.map(|end| row.date <= end).unwrap_or(true))
|
||||
.filter(|row| {
|
||||
self.config
|
||||
.start_date
|
||||
.map(|start| row.date >= start)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.filter(|row| {
|
||||
self.config
|
||||
.end_date
|
||||
.map(|end| row.date <= end)
|
||||
.unwrap_or(true)
|
||||
})
|
||||
.collect(),
|
||||
order_events: Vec::new(),
|
||||
fills: Vec::new(),
|
||||
@@ -114,11 +162,33 @@ where
|
||||
account_events: Vec::new(),
|
||||
equity_curve: Vec::new(),
|
||||
holdings_summary: Vec::new(),
|
||||
daily_holdings: Vec::new(),
|
||||
metrics: BacktestMetrics::default(),
|
||||
};
|
||||
|
||||
for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() {
|
||||
let mut corporate_action_notes = Vec::new();
|
||||
let receivable_report = self.settle_cash_receivables(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, receivable_report);
|
||||
let delisting_report = self.settle_delisted_positions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, delisting_report);
|
||||
let corporate_action_report = self.apply_corporate_actions(
|
||||
execution_date,
|
||||
&mut portfolio,
|
||||
&mut corporate_action_notes,
|
||||
)?;
|
||||
self.extend_result(&mut result, corporate_action_report);
|
||||
|
||||
let decision = execution_idx
|
||||
.checked_sub(1)
|
||||
.checked_sub(self.config.decision_lag_trading_days)
|
||||
.map(|decision_idx| {
|
||||
let decision_date = execution_dates[decision_idx];
|
||||
self.strategy.on_day(&StrategyContext {
|
||||
@@ -132,21 +202,29 @@ where
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
|
||||
let report = self
|
||||
.broker
|
||||
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
||||
let report =
|
||||
self.broker
|
||||
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
||||
let daily_fill_count = report.fill_events.len();
|
||||
let day_orders = report.order_events.clone();
|
||||
let day_fills = report.fill_events.clone();
|
||||
self.extend_result(&mut result, report);
|
||||
|
||||
portfolio.update_prices(execution_date, &self.data, PriceField::Close)?;
|
||||
|
||||
let benchmark = self
|
||||
.data
|
||||
.benchmark(execution_date)
|
||||
.ok_or(BacktestError::MissingBenchmark {
|
||||
date: execution_date,
|
||||
})?;
|
||||
let notes = decision.notes.join(" | ");
|
||||
let benchmark =
|
||||
self.data
|
||||
.benchmark(execution_date)
|
||||
.ok_or(BacktestError::MissingBenchmark {
|
||||
date: execution_date,
|
||||
})?;
|
||||
let notes = corporate_action_notes
|
||||
.into_iter()
|
||||
.chain(decision.notes.into_iter())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
let diagnostics = decision.diagnostics.join(" | ");
|
||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||
|
||||
result.equity_curve.push(DailyEquityPoint {
|
||||
date: execution_date,
|
||||
@@ -157,20 +235,295 @@ where
|
||||
notes,
|
||||
diagnostics,
|
||||
});
|
||||
result.daily_holdings.extend(holdings_for_day.clone());
|
||||
let latest = result
|
||||
.equity_curve
|
||||
.last()
|
||||
.expect("equity point pushed for progress event");
|
||||
on_progress(&BacktestDayProgress {
|
||||
date: execution_date,
|
||||
cash: latest.cash,
|
||||
market_value: latest.market_value,
|
||||
total_equity: latest.total_equity,
|
||||
unit_nav: if self.config.initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
latest.total_equity / self.config.initial_cash
|
||||
},
|
||||
total_return: if self.config.initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(latest.total_equity / self.config.initial_cash) - 1.0
|
||||
},
|
||||
benchmark_close: latest.benchmark_close,
|
||||
daily_fill_count,
|
||||
cumulative_trade_count: result.fills.len(),
|
||||
holding_count: holdings_for_day.len(),
|
||||
notes: latest.notes.clone(),
|
||||
diagnostics: latest.diagnostics.clone(),
|
||||
orders: day_orders,
|
||||
fills: day_fills,
|
||||
holdings: holdings_for_day,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(last_date) = execution_dates.last().copied() {
|
||||
result.holdings_summary = portfolio.holdings_summary(last_date);
|
||||
}
|
||||
result.metrics = compute_backtest_metrics(
|
||||
&result.equity_curve,
|
||||
&result.fills,
|
||||
&result.daily_holdings,
|
||||
self.config.initial_cash,
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn extend_result(&self, result: &mut BacktestResult, report: BrokerExecutionReport) {
|
||||
result.order_events.extend(report.order_events);
|
||||
result.fills.extend(report.fill_events);
|
||||
result.position_events.extend(report.position_events);
|
||||
result.account_events.extend(report.account_events);
|
||||
fn extend_result(
|
||||
&self,
|
||||
result: &mut BacktestResult,
|
||||
report: BrokerExecutionReport,
|
||||
) -> BrokerExecutionReport {
|
||||
result.order_events.extend(report.order_events.clone());
|
||||
result.fills.extend(report.fill_events.clone());
|
||||
result.position_events.extend(report.position_events.clone());
|
||||
result.account_events.extend(report.account_events.clone());
|
||||
report
|
||||
}
|
||||
|
||||
fn apply_corporate_actions(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
for action in self.data.corporate_actions_on(date) {
|
||||
if !action.has_effect() {
|
||||
continue;
|
||||
}
|
||||
let Some(existing_position) = portfolio.position(&action.symbol) else {
|
||||
continue;
|
||||
};
|
||||
if existing_position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if action.share_cash.abs() > f64::EPSILON {
|
||||
let cash_before = portfolio.cash();
|
||||
let (cash_delta, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for dividend action");
|
||||
let cash_delta = position.apply_cash_dividend(action.share_cash);
|
||||
(cash_delta, position.quantity, position.average_cost)
|
||||
};
|
||||
if cash_delta.abs() > f64::EPSILON {
|
||||
let payable_date = action.payable_date.unwrap_or(date);
|
||||
let immediate_cash = payable_date <= date;
|
||||
let note = if immediate_cash {
|
||||
portfolio.apply_cash_delta(cash_delta);
|
||||
format!(
|
||||
"cash_dividend {} share_cash={:.6} quantity={} cash={:.2}",
|
||||
action.symbol, action.share_cash, quantity_after, cash_delta
|
||||
)
|
||||
} else {
|
||||
portfolio.add_cash_receivable(CashReceivable {
|
||||
symbol: action.symbol.clone(),
|
||||
ex_date: date,
|
||||
payable_date,
|
||||
amount: cash_delta,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
format!(
|
||||
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
|
||||
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
|
||||
)
|
||||
};
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity: 0,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("cash_dividend {:.6}", action.share_cash),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let split_ratio = action.split_ratio();
|
||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||
let (delta_quantity, quantity_after, average_cost) = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&action.symbol)
|
||||
.expect("position exists for split action");
|
||||
let delta_quantity = position.apply_split_ratio(split_ratio);
|
||||
(delta_quantity, position.quantity, position.average_cost)
|
||||
};
|
||||
if delta_quantity != 0 {
|
||||
let note = format!(
|
||||
"stock_split {} ratio={:.6} delta_qty={}",
|
||||
action.symbol, split_ratio, delta_quantity
|
||||
);
|
||||
notes.push(note);
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: action.symbol.clone(),
|
||||
delta_quantity,
|
||||
quantity_after,
|
||||
average_cost,
|
||||
realized_pnl_delta: 0.0,
|
||||
reason: format!("stock_split {:.6}", split_ratio),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
portfolio.prune_flat_positions();
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_cash_receivables(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let settled = portfolio.settle_cash_receivables(date);
|
||||
for receivable in settled {
|
||||
let note = format!(
|
||||
"cash_receivable_settled {} ex_date={} payable_date={} cash={:.2}",
|
||||
receivable.symbol, receivable.ex_date, receivable.payable_date, receivable.amount
|
||||
);
|
||||
notes.push(note.clone());
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before: portfolio.cash() - receivable.amount,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn settle_delisted_positions(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
notes: &mut Vec<String>,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||
for symbol in symbols {
|
||||
let Some(position) = portfolio.position(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
if position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(instrument) = self.data.instrument(&symbol) else {
|
||||
continue;
|
||||
};
|
||||
let should_settle = instrument.is_delisted_before(date)
|
||||
|| (instrument.status.eq_ignore_ascii_case("delisted")
|
||||
&& instrument.delisted_at.is_none()
|
||||
&& self.data.market(date, &symbol).is_none());
|
||||
if !should_settle {
|
||||
continue;
|
||||
}
|
||||
|
||||
let quantity = position.quantity;
|
||||
let fallback_reference_price = if position.last_price > 0.0 {
|
||||
position.last_price
|
||||
} else {
|
||||
position.average_cost
|
||||
};
|
||||
let effective_delisted_at = instrument
|
||||
.delisted_at
|
||||
.or_else(|| self.data.calendar().previous_day(date))
|
||||
.unwrap_or(date);
|
||||
let settlement_price = self
|
||||
.data
|
||||
.price_on_or_before(effective_delisted_at, &symbol, PriceField::Close)
|
||||
.or_else(|| self.data.price_on_or_before(date, &symbol, PriceField::Close))
|
||||
.filter(|price| price.is_finite() && *price > 0.0)
|
||||
.unwrap_or(fallback_reference_price);
|
||||
if !settlement_price.is_finite() || settlement_price <= 0.0 {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"missing delisting settlement price for {} on {}",
|
||||
symbol, date
|
||||
)));
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = settlement_price * quantity as f64;
|
||||
let realized_pnl_delta = {
|
||||
let position = portfolio
|
||||
.position_mut_if_exists(&symbol)
|
||||
.expect("position exists for delisting settlement");
|
||||
position
|
||||
.sell(quantity, settlement_price)
|
||||
.map_err(BacktestError::Execution)?
|
||||
};
|
||||
portfolio.apply_cash_delta(gross_amount);
|
||||
portfolio.prune_flat_positions();
|
||||
|
||||
let reason = format!(
|
||||
"delisted_cash_settlement effective_date={} status={}",
|
||||
effective_delisted_at, instrument.status
|
||||
);
|
||||
notes.push(reason.clone());
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: quantity,
|
||||
filled_quantity: quantity,
|
||||
status: OrderStatus::Filled,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.fill_events.push(FillEvent {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
side: OrderSide::Sell,
|
||||
quantity,
|
||||
price: settlement_price,
|
||||
gross_amount,
|
||||
commission: 0.0,
|
||||
stamp_tax: 0.0,
|
||||
net_cash_flow: gross_amount,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.position_events.push(PositionEvent {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
delta_quantity: -(quantity as i32),
|
||||
quantity_after: 0,
|
||||
average_cost: 0.0,
|
||||
realized_pnl_delta,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
report.account_events.push(AccountEvent {
|
||||
date,
|
||||
cash_before,
|
||||
cash_after: portfolio.cash(),
|
||||
total_equity: portfolio.total_equity(),
|
||||
note: reason,
|
||||
});
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user