Add process event stream for backtests
This commit is contained in:
@@ -5,7 +5,10 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
|||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
use crate::data::{DataSet, IntradayExecutionQuote, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
use crate::events::{
|
||||||
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
|
ProcessEventKind,
|
||||||
|
};
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||||
@@ -16,6 +19,7 @@ pub struct BrokerExecutionReport {
|
|||||||
pub fill_events: Vec<FillEvent>,
|
pub fill_events: Vec<FillEvent>,
|
||||||
pub position_events: Vec<PositionEvent>,
|
pub position_events: Vec<PositionEvent>,
|
||||||
pub account_events: Vec<AccountEvent>,
|
pub account_events: Vec<AccountEvent>,
|
||||||
|
pub process_events: Vec<ProcessEvent>,
|
||||||
pub diagnostics: Vec<String>,
|
pub diagnostics: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +444,25 @@ where
|
|||||||
order_id
|
order_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn emit_order_process_event(
|
||||||
|
report: &mut BrokerExecutionReport,
|
||||||
|
date: NaiveDate,
|
||||||
|
kind: ProcessEventKind,
|
||||||
|
order_id: u64,
|
||||||
|
symbol: &str,
|
||||||
|
side: OrderSide,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
) {
|
||||||
|
report.process_events.push(ProcessEvent {
|
||||||
|
date,
|
||||||
|
kind,
|
||||||
|
order_id: Some(order_id),
|
||||||
|
symbol: Some(symbol.to_string()),
|
||||||
|
side: Some(side),
|
||||||
|
detail: detail.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn target_quantities(
|
fn target_quantities(
|
||||||
&self,
|
&self,
|
||||||
date: NaiveDate,
|
date: NaiveDate,
|
||||||
@@ -881,6 +904,16 @@ where
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderPendingNew,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!("requested_quantity={requested_qty} reason={reason}"),
|
||||||
|
);
|
||||||
|
|
||||||
let rule = self.rules.can_sell(
|
let rule = self.rules.can_sell(
|
||||||
date,
|
date,
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -889,6 +922,7 @@ where
|
|||||||
self.execution_price_field,
|
self.execution_price_field,
|
||||||
);
|
);
|
||||||
if !rule.allowed {
|
if !rule.allowed {
|
||||||
|
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||||
let status = match rule.reason.as_deref() {
|
let status = match rule.reason.as_deref() {
|
||||||
Some("paused")
|
Some("paused")
|
||||||
| Some("sell disabled by eligibility flags")
|
| Some("sell disabled by eligibility flags")
|
||||||
@@ -903,11 +937,30 @@ where
|
|||||||
requested_quantity: requested_qty,
|
requested_quantity: requested_qty,
|
||||||
filled_quantity: 0,
|
filled_quantity: 0,
|
||||||
status,
|
status,
|
||||||
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
reason: format!("{reason}: {rule_reason}"),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!("status={status:?} reason={rule_reason}"),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderCreationPass,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
"sell order passed rule checks",
|
||||||
|
);
|
||||||
|
|
||||||
let sellable = position.sellable_qty(date);
|
let sellable = position.sellable_qty(date);
|
||||||
let mut partial_fill_reason = if sellable < requested_qty {
|
let mut partial_fill_reason = if sellable < requested_qty {
|
||||||
Some("sellable quantity limit".to_string())
|
Some("sellable quantity limit".to_string())
|
||||||
@@ -945,6 +998,18 @@ where
|
|||||||
status: zero_fill_status_for_reason(&limit_reason),
|
status: zero_fill_status_for_reason(&limit_reason),
|
||||||
reason: format!("{reason}: {limit_reason}"),
|
reason: format!("{reason}: {limit_reason}"),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!(
|
||||||
|
"status={:?} reason={limit_reason}",
|
||||||
|
zero_fill_status_for_reason(&limit_reason)
|
||||||
|
),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -959,6 +1024,15 @@ where
|
|||||||
status: OrderStatus::Rejected,
|
status: OrderStatus::Rejected,
|
||||||
reason: format!("{reason}: no sellable quantity"),
|
reason: format!("{reason}: no sellable quantity"),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
"status=Rejected reason=no sellable quantity",
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1031,6 +1105,15 @@ where
|
|||||||
net_cash_flow: net_cash,
|
net_cash_flow: net_cash,
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::Trade,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!("filled_quantity={} price={}", leg.quantity, leg.price),
|
||||||
|
);
|
||||||
report.position_events.push(PositionEvent {
|
report.position_events.push(PositionEvent {
|
||||||
date,
|
date,
|
||||||
symbol: symbol.to_string(),
|
symbol: symbol.to_string(),
|
||||||
@@ -1097,6 +1180,17 @@ where
|
|||||||
status,
|
status,
|
||||||
reason: order_reason,
|
reason: order_reason,
|
||||||
});
|
});
|
||||||
|
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Sell,
|
||||||
|
format!("status={status:?} filled_quantity={filled_qty}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1306,10 +1400,21 @@ where
|
|||||||
let snapshot = data.require_market(date, symbol)?;
|
let snapshot = data.require_market(date, symbol)?;
|
||||||
let candidate = data.require_candidate(date, symbol)?;
|
let candidate = data.require_candidate(date, symbol)?;
|
||||||
|
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderPendingNew,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!("requested_quantity={requested_qty} reason={reason}"),
|
||||||
|
);
|
||||||
|
|
||||||
let rule = self
|
let rule = self
|
||||||
.rules
|
.rules
|
||||||
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
||||||
if !rule.allowed {
|
if !rule.allowed {
|
||||||
|
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||||
let status = match rule.reason.as_deref() {
|
let status = match rule.reason.as_deref() {
|
||||||
Some("paused")
|
Some("paused")
|
||||||
| Some("buy disabled by eligibility flags")
|
| Some("buy disabled by eligibility flags")
|
||||||
@@ -1324,11 +1429,30 @@ where
|
|||||||
requested_quantity: requested_qty,
|
requested_quantity: requested_qty,
|
||||||
filled_quantity: 0,
|
filled_quantity: 0,
|
||||||
status,
|
status,
|
||||||
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
reason: format!("{reason}: {rule_reason}"),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!("status={status:?} reason={rule_reason}"),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderCreationPass,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
"buy order passed rule checks",
|
||||||
|
);
|
||||||
|
|
||||||
let mut partial_fill_reason = None;
|
let mut partial_fill_reason = None;
|
||||||
let market_limited_qty = self.market_fillable_quantity(
|
let market_limited_qty = self.market_fillable_quantity(
|
||||||
snapshot,
|
snapshot,
|
||||||
@@ -1357,6 +1481,18 @@ where
|
|||||||
status: zero_fill_status_for_reason(&limit_reason),
|
status: zero_fill_status_for_reason(&limit_reason),
|
||||||
reason: format!("{reason}: {limit_reason}"),
|
reason: format!("{reason}: {limit_reason}"),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!(
|
||||||
|
"status={:?} reason={limit_reason}",
|
||||||
|
zero_fill_status_for_reason(&limit_reason)
|
||||||
|
),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1432,6 +1568,20 @@ where
|
|||||||
.unwrap_or("insufficient cash after fees")
|
.unwrap_or("insufficient cash after fees")
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!(
|
||||||
|
"status=Rejected reason={}",
|
||||||
|
partial_fill_reason
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("insufficient cash after fees")
|
||||||
|
),
|
||||||
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1471,6 +1621,15 @@ where
|
|||||||
net_cash_flow: -cash_out,
|
net_cash_flow: -cash_out,
|
||||||
reason: reason.to_string(),
|
reason: reason.to_string(),
|
||||||
});
|
});
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::Trade,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!("filled_quantity={} price={}", leg.quantity, leg.price),
|
||||||
|
);
|
||||||
report.position_events.push(PositionEvent {
|
report.position_events.push(PositionEvent {
|
||||||
date,
|
date,
|
||||||
symbol: symbol.to_string(),
|
symbol: symbol.to_string(),
|
||||||
@@ -1536,6 +1695,17 @@ where
|
|||||||
status,
|
status,
|
||||||
reason: order_reason,
|
reason: order_reason,
|
||||||
});
|
});
|
||||||
|
if matches!(status, OrderStatus::Canceled | OrderStatus::Rejected) {
|
||||||
|
Self::emit_order_process_event(
|
||||||
|
report,
|
||||||
|
date,
|
||||||
|
ProcessEventKind::OrderUnsolicitedUpdate,
|
||||||
|
order_id,
|
||||||
|
symbol,
|
||||||
|
OrderSide::Buy,
|
||||||
|
format!("status={status:?} filled_quantity={filled_qty}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ use thiserror::Error;
|
|||||||
use crate::broker::{BrokerExecutionReport, BrokerSimulator};
|
use crate::broker::{BrokerExecutionReport, BrokerSimulator};
|
||||||
use crate::cost::CostModel;
|
use crate::cost::CostModel;
|
||||||
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
use crate::data::{BenchmarkSnapshot, DataSet, DataSetError, PriceField};
|
||||||
use crate::events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
use crate::events::{
|
||||||
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
|
ProcessEventKind,
|
||||||
|
};
|
||||||
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
@@ -59,6 +62,7 @@ pub struct BacktestResult {
|
|||||||
pub fills: Vec<FillEvent>,
|
pub fills: Vec<FillEvent>,
|
||||||
pub position_events: Vec<PositionEvent>,
|
pub position_events: Vec<PositionEvent>,
|
||||||
pub account_events: Vec<AccountEvent>,
|
pub account_events: Vec<AccountEvent>,
|
||||||
|
pub process_events: Vec<ProcessEvent>,
|
||||||
pub holdings_summary: Vec<HoldingSummary>,
|
pub holdings_summary: Vec<HoldingSummary>,
|
||||||
pub daily_holdings: Vec<HoldingSummary>,
|
pub daily_holdings: Vec<HoldingSummary>,
|
||||||
pub metrics: BacktestMetrics,
|
pub metrics: BacktestMetrics,
|
||||||
@@ -82,6 +86,7 @@ pub struct BacktestDayProgress {
|
|||||||
pub orders: Vec<OrderEvent>,
|
pub orders: Vec<OrderEvent>,
|
||||||
pub fills: Vec<FillEvent>,
|
pub fills: Vec<FillEvent>,
|
||||||
pub holdings: Vec<HoldingSummary>,
|
pub holdings: Vec<HoldingSummary>,
|
||||||
|
pub process_events: Vec<ProcessEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct BacktestEngine<S, C, R> {
|
pub struct BacktestEngine<S, C, R> {
|
||||||
@@ -166,6 +171,7 @@ where
|
|||||||
fills: Vec::new(),
|
fills: Vec::new(),
|
||||||
position_events: Vec::new(),
|
position_events: Vec::new(),
|
||||||
account_events: Vec::new(),
|
account_events: Vec::new(),
|
||||||
|
process_events: Vec::new(),
|
||||||
equity_curve: Vec::new(),
|
equity_curve: Vec::new(),
|
||||||
holdings_summary: Vec::new(),
|
holdings_summary: Vec::new(),
|
||||||
daily_holdings: Vec::new(),
|
daily_holdings: Vec::new(),
|
||||||
@@ -206,7 +212,32 @@ where
|
|||||||
portfolio: &portfolio,
|
portfolio: &portfolio,
|
||||||
};
|
};
|
||||||
let schedule_rules = self.strategy.schedule_rules();
|
let schedule_rules = self.strategy.schedule_rules();
|
||||||
|
let mut process_events = Vec::new();
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PreBeforeTrading,
|
||||||
|
"before_trading:pre",
|
||||||
|
);
|
||||||
self.strategy.before_trading(&daily_context)?;
|
self.strategy.before_trading(&daily_context)?;
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::BeforeTrading,
|
||||||
|
"before_trading",
|
||||||
|
);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PostBeforeTrading,
|
||||||
|
"before_trading:post",
|
||||||
|
);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PreOpenAuction,
|
||||||
|
"open_auction:pre",
|
||||||
|
);
|
||||||
let mut auction_decision = collect_scheduled_decisions(
|
let mut auction_decision = collect_scheduled_decisions(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
&scheduler,
|
&scheduler,
|
||||||
@@ -216,13 +247,32 @@ where
|
|||||||
&daily_context,
|
&daily_context,
|
||||||
)?;
|
)?;
|
||||||
auction_decision.merge_from(self.strategy.open_auction(&daily_context)?);
|
auction_decision.merge_from(self.strategy.open_auction(&daily_context)?);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::OpenAuction,
|
||||||
|
"open_auction",
|
||||||
|
);
|
||||||
let mut report = self.broker.execute(
|
let mut report = self.broker.execute(
|
||||||
execution_date,
|
execution_date,
|
||||||
&mut portfolio,
|
&mut portfolio,
|
||||||
&self.data,
|
&self.data,
|
||||||
&auction_decision,
|
&auction_decision,
|
||||||
)?;
|
)?;
|
||||||
|
process_events.append(&mut report.process_events);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PostOpenAuction,
|
||||||
|
"open_auction:post",
|
||||||
|
);
|
||||||
|
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PreOnDay,
|
||||||
|
"on_day:pre",
|
||||||
|
);
|
||||||
let mut decision = decision_slot
|
let mut decision = decision_slot
|
||||||
.map(|(decision_idx, decision_date)| {
|
.map(|(decision_idx, decision_date)| {
|
||||||
self.strategy.on_day(&StrategyContext {
|
self.strategy.on_day(&StrategyContext {
|
||||||
@@ -249,10 +299,17 @@ where
|
|||||||
portfolio: &portfolio,
|
portfolio: &portfolio,
|
||||||
},
|
},
|
||||||
)?);
|
)?);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::OnDay,
|
||||||
|
"on_day",
|
||||||
|
);
|
||||||
|
|
||||||
let intraday_report =
|
let mut intraday_report =
|
||||||
self.broker
|
self.broker
|
||||||
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
.execute(execution_date, &mut portfolio, &self.data, &decision)?;
|
||||||
|
process_events.append(&mut intraday_report.process_events);
|
||||||
report.order_events.extend(intraday_report.order_events);
|
report.order_events.extend(intraday_report.order_events);
|
||||||
report.fill_events.extend(intraday_report.fill_events);
|
report.fill_events.extend(intraday_report.fill_events);
|
||||||
report
|
report
|
||||||
@@ -260,6 +317,12 @@ where
|
|||||||
.extend(intraday_report.position_events);
|
.extend(intraday_report.position_events);
|
||||||
report.account_events.extend(intraday_report.account_events);
|
report.account_events.extend(intraday_report.account_events);
|
||||||
report.diagnostics.extend(intraday_report.diagnostics);
|
report.diagnostics.extend(intraday_report.diagnostics);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PostOnDay,
|
||||||
|
"on_day:post",
|
||||||
|
);
|
||||||
let daily_fill_count = report.fill_events.len();
|
let daily_fill_count = report.fill_events.len();
|
||||||
let day_orders = report.order_events.clone();
|
let day_orders = report.order_events.clone();
|
||||||
let day_fills = report.fill_events.clone();
|
let day_fills = report.fill_events.clone();
|
||||||
@@ -275,8 +338,44 @@ where
|
|||||||
data: &self.data,
|
data: &self.data,
|
||||||
portfolio: &portfolio,
|
portfolio: &portfolio,
|
||||||
};
|
};
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PreAfterTrading,
|
||||||
|
"after_trading:pre",
|
||||||
|
);
|
||||||
self.strategy.after_trading(&post_trade_context)?;
|
self.strategy.after_trading(&post_trade_context)?;
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::AfterTrading,
|
||||||
|
"after_trading",
|
||||||
|
);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PostAfterTrading,
|
||||||
|
"after_trading:post",
|
||||||
|
);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PreSettlement,
|
||||||
|
"settlement:pre",
|
||||||
|
);
|
||||||
self.strategy.on_settlement(&post_trade_context)?;
|
self.strategy.on_settlement(&post_trade_context)?;
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::Settlement,
|
||||||
|
"settlement",
|
||||||
|
);
|
||||||
|
push_phase_event(
|
||||||
|
&mut process_events,
|
||||||
|
execution_date,
|
||||||
|
ProcessEventKind::PostSettlement,
|
||||||
|
"settlement:post",
|
||||||
|
);
|
||||||
|
|
||||||
let benchmark =
|
let benchmark =
|
||||||
self.data
|
self.data
|
||||||
@@ -296,6 +395,7 @@ where
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(" | ");
|
.join(" | ");
|
||||||
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
let holdings_for_day = portfolio.holdings_summary(execution_date);
|
||||||
|
let day_process_events = process_events.clone();
|
||||||
|
|
||||||
result.equity_curve.push(DailyEquityPoint {
|
result.equity_curve.push(DailyEquityPoint {
|
||||||
date: execution_date,
|
date: execution_date,
|
||||||
@@ -335,7 +435,9 @@ where
|
|||||||
orders: day_orders,
|
orders: day_orders,
|
||||||
fills: day_fills,
|
fills: day_fills,
|
||||||
holdings: holdings_for_day,
|
holdings: holdings_for_day,
|
||||||
|
process_events: day_process_events,
|
||||||
});
|
});
|
||||||
|
result.process_events.extend(process_events);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(last_date) = execution_dates.last().copied() {
|
if let Some(last_date) = execution_dates.last().copied() {
|
||||||
@@ -676,6 +778,22 @@ fn collect_scheduled_decisions<S: Strategy>(
|
|||||||
Ok(combined)
|
Ok(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn push_phase_event(
|
||||||
|
events: &mut Vec<ProcessEvent>,
|
||||||
|
date: NaiveDate,
|
||||||
|
kind: ProcessEventKind,
|
||||||
|
detail: impl Into<String>,
|
||||||
|
) {
|
||||||
|
events.push(ProcessEvent {
|
||||||
|
date,
|
||||||
|
kind,
|
||||||
|
order_id: None,
|
||||||
|
symbol: None,
|
||||||
|
side: None,
|
||||||
|
detail: detail.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
mod date_format {
|
mod date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::Serializer;
|
use serde::Serializer;
|
||||||
|
|||||||
@@ -89,3 +89,40 @@ pub struct AccountEvent {
|
|||||||
pub total_equity: f64,
|
pub total_equity: f64,
|
||||||
pub note: String,
|
pub note: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub enum ProcessEventKind {
|
||||||
|
PreBeforeTrading,
|
||||||
|
BeforeTrading,
|
||||||
|
PostBeforeTrading,
|
||||||
|
PreOpenAuction,
|
||||||
|
OpenAuction,
|
||||||
|
PostOpenAuction,
|
||||||
|
PreOnDay,
|
||||||
|
OnDay,
|
||||||
|
PostOnDay,
|
||||||
|
PreAfterTrading,
|
||||||
|
AfterTrading,
|
||||||
|
PostAfterTrading,
|
||||||
|
PreSettlement,
|
||||||
|
Settlement,
|
||||||
|
PostSettlement,
|
||||||
|
OrderPendingNew,
|
||||||
|
OrderCreationPass,
|
||||||
|
OrderUnsolicitedUpdate,
|
||||||
|
Trade,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProcessEvent {
|
||||||
|
#[serde(with = "date_format")]
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub kind: ProcessEventKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub order_id: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub symbol: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub side: Option<OrderSide>,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ pub use engine::{
|
|||||||
BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult,
|
BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult,
|
||||||
DailyEquityPoint,
|
DailyEquityPoint,
|
||||||
};
|
};
|
||||||
pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
pub use events::{
|
||||||
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
|
ProcessEventKind,
|
||||||
|
};
|
||||||
pub use instrument::Instrument;
|
pub use instrument::Instrument;
|
||||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||||
pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig};
|
pub use platform_expr_strategy::{PlatformExprStrategy, PlatformExprStrategyConfig};
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use chrono::NaiveDate;
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
Instrument, PriceField, ScheduleRule, ScheduleStage, Strategy, StrategyContext,
|
Instrument, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy,
|
||||||
StrategyDecision,
|
StrategyContext, StrategyDecision,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||||
@@ -302,7 +302,7 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
engine.run().expect("backtest succeeds");
|
let result = engine.run().expect("backtest succeeds");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
log.borrow().as_slice(),
|
log.borrow().as_slice(),
|
||||||
@@ -319,6 +319,30 @@ fn engine_runs_strategy_hooks_in_daily_order() {
|
|||||||
"settlement:2025-01-03",
|
"settlement:2025-01-03",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(result.process_events.len(), 30);
|
||||||
|
assert_eq!(
|
||||||
|
result.process_events[..15]
|
||||||
|
.iter()
|
||||||
|
.map(|event| &event.kind)
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
&ProcessEventKind::PreBeforeTrading,
|
||||||
|
&ProcessEventKind::BeforeTrading,
|
||||||
|
&ProcessEventKind::PostBeforeTrading,
|
||||||
|
&ProcessEventKind::PreOpenAuction,
|
||||||
|
&ProcessEventKind::OpenAuction,
|
||||||
|
&ProcessEventKind::PostOpenAuction,
|
||||||
|
&ProcessEventKind::PreOnDay,
|
||||||
|
&ProcessEventKind::OnDay,
|
||||||
|
&ProcessEventKind::PostOnDay,
|
||||||
|
&ProcessEventKind::PreAfterTrading,
|
||||||
|
&ProcessEventKind::AfterTrading,
|
||||||
|
&ProcessEventKind::PostAfterTrading,
|
||||||
|
&ProcessEventKind::PreSettlement,
|
||||||
|
&ProcessEventKind::Settlement,
|
||||||
|
&ProcessEventKind::PostSettlement,
|
||||||
|
]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use chrono::NaiveDate;
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
|
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
|
||||||
IntradayExecutionQuote, OrderIntent, PortfolioState, PriceField, SlippageModel,
|
IntradayExecutionQuote, OrderIntent, PortfolioState, PriceField, ProcessEventKind,
|
||||||
StrategyDecision,
|
SlippageModel, StrategyDecision,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
@@ -416,6 +416,11 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
|
|||||||
.reason
|
.reason
|
||||||
.contains("open at or above upper limit")
|
.contains("open at or above upper limit")
|
||||||
);
|
);
|
||||||
|
assert!(report.process_events.iter().any(|event| {
|
||||||
|
event.kind == ProcessEventKind::OrderUnsolicitedUpdate
|
||||||
|
&& event.symbol.as_deref() == Some("000002.SZ")
|
||||||
|
&& event.side == Some(fidc_core::OrderSide::Buy)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -989,6 +994,22 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|item| item.contains("order_split_fill symbol=000002.SZ side=buy"))
|
.any(|item| item.contains("order_split_fill symbol=000002.SZ side=buy"))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
report
|
||||||
|
.process_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.kind == ProcessEventKind::Trade)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
assert!(report.process_events.iter().any(|event| {
|
||||||
|
event.kind == ProcessEventKind::OrderPendingNew
|
||||||
|
&& event.symbol.as_deref() == Some("000002.SZ")
|
||||||
|
}));
|
||||||
|
assert!(report.process_events.iter().any(|event| {
|
||||||
|
event.kind == ProcessEventKind::OrderCreationPass
|
||||||
|
&& event.symbol.as_deref() == Some("000002.SZ")
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user