Improve jq microcap execution semantics
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use chrono::{Duration, NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
use crate::cost::CostModel;
|
||||
use crate::data::{DataSet, PriceField};
|
||||
use crate::data::{DataSet, IntradayExecutionQuote, 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;
|
||||
use crate::strategy::{OrderIntent, StrategyDecision};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct BrokerExecutionReport {
|
||||
@@ -18,10 +18,23 @@ pub struct BrokerExecutionReport {
|
||||
pub account_events: Vec<AccountEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ExecutionFill {
|
||||
price: f64,
|
||||
quantity: u32,
|
||||
next_cursor: NaiveDateTime,
|
||||
}
|
||||
|
||||
pub struct BrokerSimulator<C, R> {
|
||||
cost_model: C,
|
||||
rules: R,
|
||||
board_lot_size: u32,
|
||||
execution_price_field: PriceField,
|
||||
volume_percent: f64,
|
||||
volume_limit: bool,
|
||||
inactive_limit: bool,
|
||||
liquidity_limit: bool,
|
||||
intraday_execution_start_time: Option<NaiveTime>,
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R> {
|
||||
@@ -30,8 +43,57 @@ impl<C, R> BrokerSimulator<C, R> {
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
execution_price_field: PriceField::Open,
|
||||
volume_percent: 0.25,
|
||||
volume_limit: true,
|
||||
inactive_limit: true,
|
||||
liquidity_limit: true,
|
||||
intraday_execution_start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_with_execution_price(
|
||||
cost_model: C,
|
||||
rules: R,
|
||||
execution_price_field: PriceField,
|
||||
) -> Self {
|
||||
Self {
|
||||
cost_model,
|
||||
rules,
|
||||
board_lot_size: 100,
|
||||
execution_price_field,
|
||||
volume_percent: 0.25,
|
||||
volume_limit: true,
|
||||
inactive_limit: true,
|
||||
liquidity_limit: true,
|
||||
intraday_execution_start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_volume_limit(mut self, enabled: bool) -> Self {
|
||||
self.volume_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_inactive_limit(mut self, enabled: bool) -> Self {
|
||||
self.inactive_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_liquidity_limit(mut self, enabled: bool) -> Self {
|
||||
self.liquidity_limit = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_volume_percent(mut self, volume_percent: f64) -> Self {
|
||||
self.volume_percent = volume_percent;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_intraday_execution_start_time(mut self, start_time: NaiveTime) -> Self {
|
||||
self.intraday_execution_start_time = Some(start_time);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl<C, R> BrokerSimulator<C, R>
|
||||
@@ -39,6 +101,18 @@ where
|
||||
C: CostModel,
|
||||
R: EquityRuleHooks,
|
||||
{
|
||||
fn buy_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.buy_price(self.execution_price_field)
|
||||
}
|
||||
|
||||
fn sell_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.sell_price(self.execution_price_field)
|
||||
}
|
||||
|
||||
fn sizing_price(&self, snapshot: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
snapshot.price(self.execution_price_field)
|
||||
}
|
||||
|
||||
pub fn execute(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -47,6 +121,26 @@ where
|
||||
decision: &StrategyDecision,
|
||||
) -> Result<BrokerExecutionReport, BacktestError> {
|
||||
let mut report = BrokerExecutionReport::default();
|
||||
let mut intraday_turnover = BTreeMap::<String, u32>::new();
|
||||
let mut execution_cursors = BTreeMap::<String, NaiveDateTime>::new();
|
||||
let mut global_execution_cursor = None::<NaiveDateTime>;
|
||||
if !decision.order_intents.is_empty() {
|
||||
for intent in &decision.order_intents {
|
||||
self.process_order_intent(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
intent,
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
portfolio.prune_flat_positions();
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
let target_quantities = if decision.rebalance {
|
||||
self.target_quantities(date, portfolio, data, &decision.target_weights)?
|
||||
} else {
|
||||
@@ -59,7 +153,10 @@ where
|
||||
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);
|
||||
let current_qty = portfolio
|
||||
.position(&symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
if current_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -81,6 +178,9 @@ where
|
||||
&symbol,
|
||||
requested_qty,
|
||||
sell_reason(decision, &symbol),
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
@@ -88,7 +188,10 @@ where
|
||||
|
||||
if decision.rebalance {
|
||||
for (symbol, target_qty) in target_quantities {
|
||||
let current_qty = portfolio.position(&symbol).map(|pos| pos.quantity).unwrap_or(0);
|
||||
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(
|
||||
@@ -98,6 +201,10 @@ where
|
||||
&symbol,
|
||||
requested_qty,
|
||||
"rebalance_buy",
|
||||
&mut intraday_turnover,
|
||||
&mut execution_cursors,
|
||||
&mut global_execution_cursor,
|
||||
None,
|
||||
&mut report,
|
||||
)?;
|
||||
}
|
||||
@@ -108,6 +215,53 @@ where
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn process_order_intent(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
intent: &OrderIntent,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
match intent {
|
||||
OrderIntent::TargetValue {
|
||||
symbol,
|
||||
target_value,
|
||||
reason,
|
||||
} => self.process_target_value(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*target_value,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
),
|
||||
OrderIntent::Value {
|
||||
symbol,
|
||||
value,
|
||||
reason,
|
||||
} => self.process_value(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
*value,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn target_quantities(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -120,14 +274,14 @@ where
|
||||
|
||||
for (symbol, weight) in target_weights {
|
||||
let price = data
|
||||
.price(date, symbol, PriceField::Open)
|
||||
.price(date, symbol, self.execution_price_field)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: symbol.clone(),
|
||||
field: "open",
|
||||
field: price_field_name(self.execution_price_field),
|
||||
})?;
|
||||
let raw_qty = ((equity * weight) / price).floor() as u32;
|
||||
let rounded_qty = self.round_buy_quantity(raw_qty);
|
||||
let rounded_qty = self.round_buy_quantity(raw_qty, self.round_lot(data, symbol));
|
||||
targets.insert(symbol.clone(), rounded_qty);
|
||||
}
|
||||
|
||||
@@ -142,6 +296,9 @@ where
|
||||
symbol: &str,
|
||||
requested_qty: u32,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let snapshot = data.require_market(date, symbol)?;
|
||||
@@ -150,22 +307,55 @@ where
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let rule = self.rules.can_sell(date, snapshot, candidate, position);
|
||||
let rule = self.rules.can_sell(
|
||||
date,
|
||||
snapshot,
|
||||
candidate,
|
||||
position,
|
||||
self.execution_price_field,
|
||||
);
|
||||
if !rule.allowed {
|
||||
let status = match rule.reason.as_deref() {
|
||||
Some("paused")
|
||||
| Some("sell disabled by eligibility flags")
|
||||
| Some("open at or below lower limit") => OrderStatus::Canceled,
|
||||
_ => OrderStatus::Rejected,
|
||||
};
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Sell,
|
||||
requested_quantity: requested_qty,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Rejected,
|
||||
status,
|
||||
reason: format!("{reason}: {}", rule.reason.unwrap_or_default()),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let sellable = position.sellable_qty(date);
|
||||
let filled_qty = requested_qty.min(sellable);
|
||||
let market_limited_qty = self.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Sell,
|
||||
requested_qty.min(sellable),
|
||||
self.round_lot(data, symbol),
|
||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
||||
);
|
||||
let filled_qty = match market_limited_qty {
|
||||
Ok(quantity) => quantity.min(sellable),
|
||||
Err(limit_reason) => {
|
||||
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}: {limit_reason}"),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if filled_qty == 0 {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -180,15 +370,42 @@ where
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = snapshot.open * filled_qty as f64;
|
||||
let fill = self.resolve_execution_fill(
|
||||
date,
|
||||
symbol,
|
||||
OrderSide::Sell,
|
||||
snapshot,
|
||||
data,
|
||||
filled_qty,
|
||||
self.round_lot(data, symbol),
|
||||
execution_cursors,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
*global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
(
|
||||
filled_qty,
|
||||
self.sell_price(snapshot),
|
||||
)
|
||||
};
|
||||
let gross_amount = execution_price * 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)
|
||||
.sell(filled_qty, execution_price)
|
||||
.map_err(BacktestError::Execution)?;
|
||||
portfolio.apply_cash_delta(net_cash);
|
||||
portfolio.prune_flat_positions();
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let status = if filled_qty < requested_qty {
|
||||
OrderStatus::PartiallyFilled
|
||||
@@ -210,7 +427,7 @@ where
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Sell,
|
||||
quantity: filled_qty,
|
||||
price: snapshot.open,
|
||||
price: execution_price,
|
||||
gross_amount,
|
||||
commission: cost.commission,
|
||||
stamp_tax: cost.stamp_tax,
|
||||
@@ -221,7 +438,10 @@ where
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
delta_quantity: -(filled_qty as i32),
|
||||
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
|
||||
quantity_after: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.average_cost)
|
||||
@@ -239,6 +459,139 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_target_value(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
target_value: f64,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
let price = data
|
||||
.market(date, symbol)
|
||||
.map(|snapshot| self.sizing_price(snapshot))
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
field: price_field_name(self.execution_price_field),
|
||||
})?;
|
||||
let current_qty = portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0);
|
||||
let current_value = price * current_qty as f64;
|
||||
let target_qty = self.round_buy_quantity(
|
||||
((target_value.max(0.0)) / price).floor() as u32,
|
||||
self.round_lot(data, symbol),
|
||||
);
|
||||
|
||||
if current_qty > target_qty {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
current_qty - target_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
)?;
|
||||
} else if target_qty > current_qty {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
target_qty - current_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
None,
|
||||
report,
|
||||
)?;
|
||||
} else if (current_value - target_value).abs() <= f64::EPSILON {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
side: if current_qty > 0 {
|
||||
OrderSide::Sell
|
||||
} else {
|
||||
OrderSide::Buy
|
||||
},
|
||||
requested_quantity: 0,
|
||||
filled_quantity: 0,
|
||||
status: OrderStatus::Filled,
|
||||
reason: format!("{reason}: already at target value"),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process_value(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
portfolio: &mut PortfolioState,
|
||||
data: &DataSet,
|
||||
symbol: &str,
|
||||
value: f64,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
report: &mut BrokerExecutionReport,
|
||||
) -> Result<(), BacktestError> {
|
||||
if value.abs() <= f64::EPSILON {
|
||||
return Ok(());
|
||||
}
|
||||
let snapshot = data
|
||||
.market(date, symbol)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
field: price_field_name(self.execution_price_field),
|
||||
})?;
|
||||
let price = self.sizing_price(snapshot);
|
||||
let requested_qty =
|
||||
self.round_buy_quantity(((value.abs()) / price).floor() as u32, self.round_lot(data, symbol));
|
||||
if value > 0.0 {
|
||||
self.process_buy(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
requested_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
Some(value.abs()),
|
||||
report,
|
||||
)
|
||||
} else {
|
||||
self.process_sell(
|
||||
date,
|
||||
portfolio,
|
||||
data,
|
||||
symbol,
|
||||
requested_qty,
|
||||
reason,
|
||||
intraday_turnover,
|
||||
execution_cursors,
|
||||
global_execution_cursor,
|
||||
report,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn process_buy(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
@@ -247,12 +600,18 @@ where
|
||||
symbol: &str,
|
||||
requested_qty: u32,
|
||||
reason: &str,
|
||||
intraday_turnover: &mut BTreeMap<String, u32>,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: &mut Option<NaiveDateTime>,
|
||||
value_budget: Option<f64>,
|
||||
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);
|
||||
let rule = self
|
||||
.rules
|
||||
.can_buy(date, snapshot, candidate, self.execution_price_field);
|
||||
if !rule.allowed {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -266,8 +625,59 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let filled_qty =
|
||||
self.affordable_buy_quantity(portfolio.cash(), snapshot.open, requested_qty);
|
||||
let market_limited_qty = self.market_fillable_quantity(
|
||||
snapshot,
|
||||
OrderSide::Buy,
|
||||
requested_qty,
|
||||
self.round_lot(data, symbol),
|
||||
*intraday_turnover.get(symbol).unwrap_or(&0),
|
||||
);
|
||||
let constrained_qty = match market_limited_qty {
|
||||
Ok(quantity) => quantity,
|
||||
Err(limit_reason) => {
|
||||
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}: {limit_reason}"),
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let fill = self.resolve_execution_fill(
|
||||
date,
|
||||
symbol,
|
||||
OrderSide::Buy,
|
||||
snapshot,
|
||||
data,
|
||||
constrained_qty,
|
||||
self.round_lot(data, symbol),
|
||||
execution_cursors,
|
||||
None,
|
||||
Some(portfolio.cash()),
|
||||
value_budget,
|
||||
);
|
||||
let (filled_qty, execution_price) = if let Some(fill) = fill {
|
||||
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
|
||||
if self.uses_serial_execution_cursor(reason) {
|
||||
*global_execution_cursor = Some(fill.next_cursor);
|
||||
}
|
||||
(fill.quantity, fill.price)
|
||||
} else {
|
||||
let execution_price = self.buy_price(snapshot);
|
||||
let filled_qty = self.affordable_buy_quantity(
|
||||
portfolio.cash(),
|
||||
value_budget,
|
||||
execution_price,
|
||||
constrained_qty,
|
||||
self.round_lot(data, symbol),
|
||||
);
|
||||
(filled_qty, execution_price)
|
||||
};
|
||||
if filled_qty == 0 {
|
||||
report.order_events.push(OrderEvent {
|
||||
date,
|
||||
@@ -282,12 +692,15 @@ where
|
||||
}
|
||||
|
||||
let cash_before = portfolio.cash();
|
||||
let gross_amount = snapshot.open * filled_qty as f64;
|
||||
let gross_amount = execution_price * 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);
|
||||
portfolio
|
||||
.position_mut(symbol)
|
||||
.buy(date, filled_qty, execution_price);
|
||||
*intraday_turnover.entry(symbol.to_string()).or_default() += filled_qty;
|
||||
|
||||
let status = if filled_qty < requested_qty {
|
||||
OrderStatus::PartiallyFilled
|
||||
@@ -309,7 +722,7 @@ where
|
||||
symbol: symbol.to_string(),
|
||||
side: OrderSide::Buy,
|
||||
quantity: filled_qty,
|
||||
price: snapshot.open,
|
||||
price: execution_price,
|
||||
gross_amount,
|
||||
commission: cost.commission,
|
||||
stamp_tax: cost.stamp_tax,
|
||||
@@ -320,7 +733,10 @@ where
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
delta_quantity: filled_qty as i32,
|
||||
quantity_after: portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0),
|
||||
quantity_after: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.quantity)
|
||||
.unwrap_or(0),
|
||||
average_cost: portfolio
|
||||
.position(symbol)
|
||||
.map(|pos| pos.average_cost)
|
||||
@@ -347,38 +763,304 @@ where
|
||||
) -> 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 {
|
||||
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",
|
||||
PriceField::Last => "last",
|
||||
},
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
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 round_lot(&self, data: &DataSet, symbol: &str) -> u32 {
|
||||
data.instruments()
|
||||
.get(symbol)
|
||||
.map(|instrument| instrument.effective_round_lot())
|
||||
.unwrap_or(self.board_lot_size.max(1))
|
||||
}
|
||||
|
||||
fn affordable_buy_quantity(&self, cash: f64, price: f64, requested_qty: u32) -> u32 {
|
||||
let mut quantity = self.round_buy_quantity(requested_qty);
|
||||
fn round_buy_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
|
||||
let lot = round_lot.max(1);
|
||||
(quantity / lot) * lot
|
||||
}
|
||||
|
||||
fn affordable_buy_quantity(
|
||||
&self,
|
||||
cash: f64,
|
||||
gross_limit: Option<f64>,
|
||||
price: f64,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
) -> u32 {
|
||||
let lot = round_lot.max(1);
|
||||
let mut quantity = self.round_buy_quantity(requested_qty, lot);
|
||||
while quantity > 0 {
|
||||
let gross = price * quantity as f64;
|
||||
if gross_limit.is_some_and(|limit| gross > limit + 1e-6) {
|
||||
quantity = quantity.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
quantity = quantity.saturating_sub(lot);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn market_fillable_quantity(
|
||||
&self,
|
||||
snapshot: &crate::data::DailyMarketSnapshot,
|
||||
side: OrderSide,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
consumed_turnover: u32,
|
||||
) -> Result<u32, String> {
|
||||
if requested_qty == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
if self.inactive_limit && snapshot.tick_volume == 0 {
|
||||
return Err("tick no volume".to_string());
|
||||
}
|
||||
|
||||
let mut max_fill = requested_qty;
|
||||
let lot = round_lot.max(1);
|
||||
|
||||
if self.liquidity_limit {
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => snapshot.liquidity_for_buy(),
|
||||
OrderSide::Sell => snapshot.liquidity_for_sell(),
|
||||
}
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if top_level_liquidity == 0 {
|
||||
return Err("no quote liquidity".to_string());
|
||||
}
|
||||
max_fill = max_fill.min(self.round_buy_quantity(top_level_liquidity, lot));
|
||||
}
|
||||
|
||||
if self.volume_limit {
|
||||
let raw_limit =
|
||||
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
|
||||
- consumed_turnover as i64;
|
||||
if raw_limit <= 0 {
|
||||
return Err("tick volume limit".to_string());
|
||||
}
|
||||
let volume_limited = self.round_buy_quantity(raw_limit as u32, lot);
|
||||
if volume_limited == 0 {
|
||||
return Err("tick volume limit".to_string());
|
||||
}
|
||||
max_fill = max_fill.min(volume_limited);
|
||||
}
|
||||
|
||||
Ok(max_fill)
|
||||
}
|
||||
|
||||
fn resolve_execution_fill(
|
||||
&self,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
side: OrderSide,
|
||||
_snapshot: &crate::data::DailyMarketSnapshot,
|
||||
data: &DataSet,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
execution_cursors: &mut BTreeMap<String, NaiveDateTime>,
|
||||
global_execution_cursor: Option<NaiveDateTime>,
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
) -> Option<ExecutionFill> {
|
||||
if self.execution_price_field != PriceField::Last {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start_cursor = execution_cursors
|
||||
.get(symbol)
|
||||
.copied()
|
||||
.into_iter()
|
||||
.chain(global_execution_cursor)
|
||||
.chain(
|
||||
self.intraday_execution_start_time
|
||||
.map(|start_time| date.and_time(start_time)),
|
||||
)
|
||||
.max();
|
||||
let quotes = data.execution_quotes_on(date, symbol);
|
||||
self.select_execution_fill(
|
||||
quotes,
|
||||
side,
|
||||
start_cursor,
|
||||
requested_qty,
|
||||
round_lot,
|
||||
cash_limit,
|
||||
gross_limit,
|
||||
)
|
||||
}
|
||||
|
||||
fn select_execution_fill(
|
||||
&self,
|
||||
quotes: &[IntradayExecutionQuote],
|
||||
side: OrderSide,
|
||||
start_cursor: Option<NaiveDateTime>,
|
||||
requested_qty: u32,
|
||||
round_lot: u32,
|
||||
cash_limit: Option<f64>,
|
||||
gross_limit: Option<f64>,
|
||||
) -> Option<ExecutionFill> {
|
||||
if requested_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let lot = round_lot.max(1);
|
||||
let mut filled_qty = 0_u32;
|
||||
let mut gross_amount = 0.0_f64;
|
||||
let mut last_timestamp = None;
|
||||
let mut last_quote_price = None;
|
||||
|
||||
for quote in quotes {
|
||||
if start_cursor.is_some_and(|cursor| quote.timestamp < cursor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fallback_quote_price = match side {
|
||||
OrderSide::Buy => quote.buy_price(),
|
||||
OrderSide::Sell => quote.sell_price(),
|
||||
};
|
||||
if fallback_quote_price.is_some() {
|
||||
last_quote_price = fallback_quote_price;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
}
|
||||
|
||||
// Approximate JoinQuant market-order fills with the evolving L1 book after
|
||||
// the decision time instead of trade VWAP. This keeps quantities/prices
|
||||
// closer to the observed 10:18 execution logs.
|
||||
if quote.volume_delta == 0 {
|
||||
continue;
|
||||
}
|
||||
let quote_price = match side {
|
||||
OrderSide::Buy => quote.buy_price(),
|
||||
OrderSide::Sell => quote.sell_price(),
|
||||
};
|
||||
let Some(quote_price) = quote_price else {
|
||||
continue;
|
||||
};
|
||||
if !quote_price.is_finite() || quote_price <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let top_level_liquidity = match side {
|
||||
OrderSide::Buy => quote.ask1_volume,
|
||||
OrderSide::Sell => quote.bid1_volume,
|
||||
};
|
||||
let available_qty = top_level_liquidity
|
||||
.saturating_mul(lot as u64)
|
||||
.min(u32::MAX as u64) as u32;
|
||||
if available_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
if remaining_qty == 0 {
|
||||
break;
|
||||
}
|
||||
let mut take_qty = remaining_qty.min(available_qty);
|
||||
take_qty = self.round_buy_quantity(take_qty, lot);
|
||||
if take_qty == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(cash) = cash_limit {
|
||||
while take_qty > 0 {
|
||||
let candidate_gross = gross_amount + quote_price * take_qty as f64;
|
||||
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
||||
take_qty = take_qty.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
|
||||
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
|
||||
break;
|
||||
}
|
||||
take_qty = take_qty.saturating_sub(lot);
|
||||
}
|
||||
if take_qty == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
gross_amount += quote_price * take_qty as f64;
|
||||
filled_qty += take_qty;
|
||||
last_timestamp = Some(quote.timestamp);
|
||||
|
||||
if filled_qty >= requested_qty {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if filled_qty < requested_qty {
|
||||
let remaining_qty = requested_qty.saturating_sub(filled_qty);
|
||||
let mut residual_qty = self.round_buy_quantity(remaining_qty, lot);
|
||||
if residual_qty > 0 {
|
||||
if let Some(residual_price) = last_quote_price {
|
||||
if let Some(cash) = cash_limit {
|
||||
while residual_qty > 0 {
|
||||
let candidate_gross = gross_amount + residual_price * residual_qty as f64;
|
||||
if gross_limit.is_some_and(|limit| candidate_gross > limit + 1e-6) {
|
||||
residual_qty = residual_qty.saturating_sub(lot);
|
||||
continue;
|
||||
}
|
||||
let candidate_cost =
|
||||
self.cost_model.calculate(OrderSide::Buy, candidate_gross);
|
||||
if candidate_gross + candidate_cost.total() <= cash + 1e-6 {
|
||||
break;
|
||||
}
|
||||
residual_qty = residual_qty.saturating_sub(lot);
|
||||
}
|
||||
}
|
||||
if residual_qty > 0 {
|
||||
let execution_price = match side {
|
||||
OrderSide::Buy => residual_price,
|
||||
OrderSide::Sell => residual_price,
|
||||
};
|
||||
gross_amount += execution_price * residual_qty as f64;
|
||||
filled_qty += residual_qty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if filled_qty == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(ExecutionFill {
|
||||
price: gross_amount / filled_qty as f64,
|
||||
quantity: filled_qty,
|
||||
next_cursor: last_timestamp.unwrap() + Duration::seconds(1),
|
||||
})
|
||||
}
|
||||
|
||||
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
|
||||
matches!(
|
||||
reason,
|
||||
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
|
||||
| "replacement_after_take_profit_exit"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn price_field_name(field: PriceField) -> &'static str {
|
||||
match field {
|
||||
PriceField::Open => "open",
|
||||
PriceField::Close => "close",
|
||||
PriceField::Last => "last",
|
||||
}
|
||||
}
|
||||
|
||||
fn sell_reason(decision: &StrategyDecision, symbol: &str) -> &'static str {
|
||||
|
||||
@@ -45,7 +45,8 @@ impl TradingCalendar {
|
||||
|
||||
pub fn previous_day(&self, date: NaiveDate) -> Option<NaiveDate> {
|
||||
let idx = self.index_of(date)?;
|
||||
idx.checked_sub(1).and_then(|prev| self.days.get(prev).copied())
|
||||
idx.checked_sub(1)
|
||||
.and_then(|prev| self.days.get(prev).copied())
|
||||
}
|
||||
|
||||
pub fn trailing_days(&self, end: NaiveDate, lookback: usize) -> Vec<NaiveDate> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum OrderSide {
|
||||
pub enum OrderStatus {
|
||||
Filled,
|
||||
PartiallyFilled,
|
||||
Canceled,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -5,4 +6,55 @@ pub struct Instrument {
|
||||
pub symbol: String,
|
||||
pub name: String,
|
||||
pub board: String,
|
||||
pub round_lot: u32,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub listed_at: Option<NaiveDate>,
|
||||
#[serde(default, with = "optional_date_format")]
|
||||
pub delisted_at: Option<NaiveDate>,
|
||||
#[serde(default = "default_status")]
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl Instrument {
|
||||
pub fn effective_round_lot(&self) -> u32 {
|
||||
self.round_lot.max(1)
|
||||
}
|
||||
|
||||
pub fn is_delisted_before(&self, date: NaiveDate) -> bool {
|
||||
self.delisted_at.is_some_and(|delisted_at| delisted_at < date)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status() -> String {
|
||||
"active".to_string()
|
||||
}
|
||||
|
||||
mod optional_date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::{self, Deserialize, Deserializer, Serializer};
|
||||
|
||||
const FORMAT: &str = "%Y-%m-%d";
|
||||
|
||||
pub fn serialize<S>(value: &Option<NaiveDate>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
match value {
|
||||
Some(date) => serializer.serialize_some(&date.format(FORMAT).to_string()),
|
||||
None => serializer.serialize_none(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<NaiveDate>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let value = Option::<String>::deserialize(deserializer)?;
|
||||
match value.as_deref().map(str::trim).filter(|v| !v.is_empty()) {
|
||||
Some(text) => NaiveDate::parse_from_str(text, FORMAT)
|
||||
.map(Some)
|
||||
.map_err(serde::de::Error::custom),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod data;
|
||||
pub mod engine;
|
||||
pub mod events;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod portfolio;
|
||||
pub mod rules;
|
||||
pub mod strategy;
|
||||
@@ -14,39 +15,24 @@ pub use broker::{BrokerExecutionReport, BrokerSimulator};
|
||||
pub use calendar::TradingCalendar;
|
||||
pub use cost::{ChinaAShareCostModel, CostModel, TradingCost};
|
||||
pub use data::{
|
||||
BenchmarkSnapshot,
|
||||
CandidateEligibility,
|
||||
DailyFactorSnapshot,
|
||||
DailyMarketSnapshot,
|
||||
DailySnapshotBundle,
|
||||
DataSet,
|
||||
DataSetError,
|
||||
PriceField,
|
||||
BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot,
|
||||
DailyMarketSnapshot, DailySnapshotBundle, DataSet, DataSetError, EligibleUniverseSnapshot,
|
||||
IntradayExecutionQuote, PriceField,
|
||||
};
|
||||
pub use engine::{BacktestConfig, BacktestEngine, BacktestError, BacktestResult, DailyEquityPoint};
|
||||
pub use events::{
|
||||
AccountEvent,
|
||||
FillEvent,
|
||||
OrderEvent,
|
||||
OrderSide,
|
||||
OrderStatus,
|
||||
PositionEvent,
|
||||
pub use engine::{
|
||||
BacktestConfig, BacktestDayProgress, BacktestEngine, BacktestError, BacktestResult,
|
||||
DailyEquityPoint,
|
||||
};
|
||||
pub use events::{AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent};
|
||||
pub use instrument::Instrument;
|
||||
pub use portfolio::{HoldingSummary, PortfolioState, Position};
|
||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position};
|
||||
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
|
||||
pub use strategy::{
|
||||
CnSmallCapRotationConfig,
|
||||
CnSmallCapRotationStrategy,
|
||||
Strategy,
|
||||
StrategyContext,
|
||||
StrategyDecision,
|
||||
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy,
|
||||
OrderIntent, Strategy, StrategyContext, StrategyDecision,
|
||||
};
|
||||
pub use universe::{
|
||||
BandRegime,
|
||||
DynamicMarketCapBandSelector,
|
||||
SelectionContext,
|
||||
SelectionDiagnostics,
|
||||
UniverseCandidate,
|
||||
UniverseSelector,
|
||||
BandRegime, DynamicMarketCapBandSelector, SelectionContext, SelectionDiagnostics,
|
||||
UniverseCandidate, UniverseSelector,
|
||||
};
|
||||
|
||||
437
crates/fidc-core/src/metrics.rs
Normal file
437
crates/fidc-core/src/metrics.rs
Normal file
@@ -0,0 +1,437 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::engine::DailyEquityPoint;
|
||||
use crate::events::FillEvent;
|
||||
use crate::portfolio::HoldingSummary;
|
||||
|
||||
const TRADING_DAYS_PER_YEAR: f64 = 252.0;
|
||||
const MONTHS_PER_YEAR: f64 = 12.0;
|
||||
const DEFAULT_RISK_FREE_RATE: f64 = 0.022;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BacktestMetrics {
|
||||
pub total_return: f64,
|
||||
pub annual_return: f64,
|
||||
pub sharpe: f64,
|
||||
pub max_drawdown: f64,
|
||||
pub win_rate: f64,
|
||||
pub alpha: f64,
|
||||
pub beta: f64,
|
||||
pub benchmark_cumulative_return: f64,
|
||||
pub benchmark_net_value: f64,
|
||||
pub risk_free_rate: f64,
|
||||
pub monthly_excess_win_rate: f64,
|
||||
pub excess_cumulative_return: f64,
|
||||
pub excess_annual_return: f64,
|
||||
pub max_drawdown_duration_days: usize,
|
||||
pub total_trade_days: usize,
|
||||
pub sortino: f64,
|
||||
pub information_ratio: f64,
|
||||
pub tracking_error: f64,
|
||||
pub volatility: f64,
|
||||
pub excess_return: f64,
|
||||
pub excess_sharpe: f64,
|
||||
pub excess_volatility: f64,
|
||||
pub excess_max_drawdown: f64,
|
||||
pub holding_count: usize,
|
||||
pub average_weight: f64,
|
||||
pub max_weight: f64,
|
||||
pub concentration: f64,
|
||||
pub weight_std_dev: f64,
|
||||
pub median_weight: f64,
|
||||
pub average_daily_turnover: f64,
|
||||
pub total_assets: f64,
|
||||
pub cash_balance: f64,
|
||||
pub unit_nav: f64,
|
||||
pub initial_cash: f64,
|
||||
pub excess_win_rate: f64,
|
||||
pub monthly_sharpe: f64,
|
||||
pub monthly_volatility: f64,
|
||||
}
|
||||
|
||||
pub fn compute_backtest_metrics(
|
||||
equity_curve: &[DailyEquityPoint],
|
||||
fills: &[FillEvent],
|
||||
daily_holdings: &[HoldingSummary],
|
||||
initial_cash: f64,
|
||||
) -> BacktestMetrics {
|
||||
let Some(first_point) = equity_curve.first() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
};
|
||||
let Some(last_point) = equity_curve.last() else {
|
||||
return BacktestMetrics {
|
||||
risk_free_rate: DEFAULT_RISK_FREE_RATE,
|
||||
initial_cash,
|
||||
..BacktestMetrics::default()
|
||||
};
|
||||
};
|
||||
|
||||
let trade_days = equity_curve.len();
|
||||
let returns = equity_curve
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].total_equity, window[1].total_equity))
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_returns = equity_curve
|
||||
.windows(2)
|
||||
.map(|window| pct_change(window[0].benchmark_close, window[1].benchmark_close))
|
||||
.collect::<Vec<_>>();
|
||||
let excess_returns = returns
|
||||
.iter()
|
||||
.zip(benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let benchmark_net_value = if first_point.benchmark_close.abs() < f64::EPSILON {
|
||||
1.0
|
||||
} else {
|
||||
last_point.benchmark_close / first_point.benchmark_close
|
||||
};
|
||||
let benchmark_cumulative_return = benchmark_net_value - 1.0;
|
||||
let total_return = if initial_cash.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) - 1.0
|
||||
};
|
||||
let excess_cumulative_return = if benchmark_net_value.abs() < f64::EPSILON {
|
||||
total_return
|
||||
} else {
|
||||
(last_point.total_equity / initial_cash) / benchmark_net_value - 1.0
|
||||
};
|
||||
let excess_return = total_return - benchmark_cumulative_return;
|
||||
let annual_return = annualize_return(total_return, trade_days);
|
||||
let excess_annual_return = annualize_return(excess_cumulative_return, trade_days);
|
||||
|
||||
let risk_free_rate = DEFAULT_RISK_FREE_RATE;
|
||||
let daily_rf = risk_free_rate / TRADING_DAYS_PER_YEAR;
|
||||
let sharpe = annualized_sharpe(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let sortino = annualized_sortino(&returns, daily_rf, TRADING_DAYS_PER_YEAR);
|
||||
let information_ratio = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let tracking_error = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let volatility = annualized_std(&returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_volatility = annualized_std(&excess_returns, TRADING_DAYS_PER_YEAR);
|
||||
let excess_sharpe = annualized_sharpe(&excess_returns, 0.0, TRADING_DAYS_PER_YEAR);
|
||||
let (alpha, beta) = alpha_beta(&returns, &benchmark_returns, daily_rf);
|
||||
|
||||
let equity_nav = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.total_equity, initial_cash, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let benchmark_nav_series = equity_curve
|
||||
.iter()
|
||||
.map(|point| safe_div(point.benchmark_close, first_point.benchmark_close, 1.0))
|
||||
.collect::<Vec<_>>();
|
||||
let excess_nav_series = equity_nav
|
||||
.iter()
|
||||
.zip(benchmark_nav_series.iter())
|
||||
.map(|(lhs, rhs)| safe_div(*lhs, *rhs, *lhs))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (max_drawdown, max_drawdown_duration_days) = drawdown_stats(&equity_nav);
|
||||
let (excess_max_drawdown, _) = drawdown_stats(&excess_nav_series);
|
||||
|
||||
let winning_days = returns.iter().filter(|value| **value > 0.0).count();
|
||||
let excess_winning_days = excess_returns.iter().filter(|value| **value > 0.0).count();
|
||||
let win_rate = ratio(winning_days, returns.len());
|
||||
let excess_win_rate = ratio(excess_winning_days, excess_returns.len());
|
||||
|
||||
let monthly_portfolio_returns = group_monthly_returns(equity_curve, |point| point.total_equity);
|
||||
let monthly_benchmark_returns =
|
||||
group_monthly_returns(equity_curve, |point| point.benchmark_close);
|
||||
let monthly_excess_returns = monthly_portfolio_returns
|
||||
.iter()
|
||||
.zip(monthly_benchmark_returns.iter())
|
||||
.map(|(lhs, rhs)| lhs - rhs)
|
||||
.collect::<Vec<_>>();
|
||||
let monthly_excess_win_rate = ratio(
|
||||
monthly_excess_returns
|
||||
.iter()
|
||||
.filter(|value| **value > 0.0)
|
||||
.count(),
|
||||
monthly_excess_returns.len(),
|
||||
);
|
||||
let monthly_sharpe = annualized_sharpe(
|
||||
&monthly_portfolio_returns,
|
||||
risk_free_rate / MONTHS_PER_YEAR,
|
||||
MONTHS_PER_YEAR,
|
||||
);
|
||||
let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR);
|
||||
|
||||
let turnover_by_date = fills.iter().fold(BTreeMap::<NaiveDate, f64>::new(), |mut acc, fill| {
|
||||
*acc.entry(fill.date).or_default() += fill.gross_amount.abs();
|
||||
acc
|
||||
});
|
||||
let equity_by_date = equity_curve
|
||||
.iter()
|
||||
.map(|point| (point.date, point.total_equity))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let average_daily_turnover = if equity_curve.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
equity_curve
|
||||
.iter()
|
||||
.map(|point| {
|
||||
let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default();
|
||||
safe_div(traded, point.total_equity.max(initial_cash * 0.5), 0.0)
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ equity_curve.len() as f64
|
||||
};
|
||||
|
||||
let latest_date = last_point.date;
|
||||
let latest_holdings = daily_holdings
|
||||
.iter()
|
||||
.filter(|row| row.date == latest_date && row.quantity > 0)
|
||||
.collect::<Vec<_>>();
|
||||
let weights = latest_holdings
|
||||
.iter()
|
||||
.map(|holding| safe_div(holding.market_value, last_point.total_equity, 0.0))
|
||||
.collect::<Vec<_>>();
|
||||
let holding_count = latest_holdings.len();
|
||||
let average_weight = mean(&weights);
|
||||
let max_weight = weights
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0.0_f64, |acc, value| acc.max(value));
|
||||
let concentration = weights.iter().map(|weight| weight * weight).sum::<f64>();
|
||||
let weight_std_dev = std_dev(&weights);
|
||||
let median_weight = median(&weights);
|
||||
|
||||
let total_trade_days = equity_by_date.len();
|
||||
|
||||
BacktestMetrics {
|
||||
total_return,
|
||||
annual_return,
|
||||
sharpe,
|
||||
max_drawdown,
|
||||
win_rate,
|
||||
alpha,
|
||||
beta,
|
||||
benchmark_cumulative_return,
|
||||
benchmark_net_value,
|
||||
risk_free_rate,
|
||||
monthly_excess_win_rate,
|
||||
excess_cumulative_return,
|
||||
excess_annual_return,
|
||||
max_drawdown_duration_days,
|
||||
total_trade_days,
|
||||
sortino,
|
||||
information_ratio,
|
||||
tracking_error,
|
||||
volatility,
|
||||
excess_return,
|
||||
excess_sharpe,
|
||||
excess_volatility,
|
||||
excess_max_drawdown,
|
||||
holding_count,
|
||||
average_weight,
|
||||
max_weight,
|
||||
concentration,
|
||||
weight_std_dev,
|
||||
median_weight,
|
||||
average_daily_turnover,
|
||||
total_assets: last_point.total_equity,
|
||||
cash_balance: last_point.cash,
|
||||
unit_nav: safe_div(last_point.total_equity, initial_cash, 0.0),
|
||||
initial_cash,
|
||||
excess_win_rate,
|
||||
monthly_sharpe,
|
||||
monthly_volatility,
|
||||
}
|
||||
}
|
||||
|
||||
fn pct_change(previous: f64, current: f64) -> f64 {
|
||||
if previous.abs() < f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
(current / previous) - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn annualize_return(total_return: f64, periods: usize) -> f64 {
|
||||
if periods == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let periods = periods as f64;
|
||||
let base = 1.0 + total_return;
|
||||
if base <= 0.0 {
|
||||
return -1.0;
|
||||
}
|
||||
base.powf(TRADING_DAYS_PER_YEAR / periods) - 1.0
|
||||
}
|
||||
|
||||
fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let mean_ret = mean(&adjusted);
|
||||
let std = std_dev(&adjusted);
|
||||
if std <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean_ret / std * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f64 {
|
||||
if returns.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let downside = adjusted
|
||||
.iter()
|
||||
.filter(|value| **value < 0.0)
|
||||
.map(|value| value.powi(2))
|
||||
.collect::<Vec<_>>();
|
||||
if downside.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let downside_dev = (downside.iter().sum::<f64>() / downside.len() as f64).sqrt();
|
||||
if downside_dev <= f64::EPSILON {
|
||||
0.0
|
||||
} else {
|
||||
mean(&adjusted) / downside_dev * periods_per_year.sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
fn annualized_std(values: &[f64], periods_per_year: f64) -> f64 {
|
||||
std_dev(values) * periods_per_year.sqrt()
|
||||
}
|
||||
|
||||
fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: f64) -> (f64, f64) {
|
||||
if returns.len() < 2 || returns.len() != benchmark_returns.len() {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let strategy_excess = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
|
||||
let benchmark_excess = benchmark_returns
|
||||
.iter()
|
||||
.map(|value| value - daily_rf)
|
||||
.collect::<Vec<_>>();
|
||||
let mean_strategy = mean(&strategy_excess);
|
||||
let mean_benchmark = mean(&benchmark_excess);
|
||||
let variance_benchmark = variance(&benchmark_excess);
|
||||
if variance_benchmark <= f64::EPSILON {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
let covariance = strategy_excess
|
||||
.iter()
|
||||
.zip(benchmark_excess.iter())
|
||||
.map(|(lhs, rhs)| (lhs - mean_strategy) * (rhs - mean_benchmark))
|
||||
.sum::<f64>()
|
||||
/ (strategy_excess.len() - 1) as f64;
|
||||
let beta = covariance / variance_benchmark;
|
||||
let alpha = (mean_strategy - beta * mean_benchmark) * TRADING_DAYS_PER_YEAR;
|
||||
(alpha, beta)
|
||||
}
|
||||
|
||||
fn drawdown_stats(nav: &[f64]) -> (f64, usize) {
|
||||
let mut peak = 0.0_f64;
|
||||
let mut max_drawdown = 0.0_f64;
|
||||
let mut duration = 0_usize;
|
||||
let mut max_duration = 0_usize;
|
||||
for value in nav {
|
||||
if *value >= peak {
|
||||
peak = *value;
|
||||
duration = 0;
|
||||
continue;
|
||||
}
|
||||
if peak > f64::EPSILON {
|
||||
let drawdown = (*value / peak) - 1.0;
|
||||
if drawdown < max_drawdown {
|
||||
max_drawdown = drawdown;
|
||||
}
|
||||
}
|
||||
duration += 1;
|
||||
if duration > max_duration {
|
||||
max_duration = duration;
|
||||
}
|
||||
}
|
||||
(max_drawdown, max_duration)
|
||||
}
|
||||
|
||||
fn group_monthly_returns<F>(equity_curve: &[DailyEquityPoint], value_fn: F) -> Vec<f64>
|
||||
where
|
||||
F: Fn(&DailyEquityPoint) -> f64,
|
||||
{
|
||||
let mut month_last = BTreeMap::<(i32, u32), f64>::new();
|
||||
let mut month_first = BTreeMap::<(i32, u32), f64>::new();
|
||||
for point in equity_curve {
|
||||
let key = (point.date.year(), point.date.month());
|
||||
month_first.entry(key).or_insert_with(|| value_fn(point));
|
||||
month_last.insert(key, value_fn(point));
|
||||
}
|
||||
let mut keys = month_last.keys().copied().collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.into_iter()
|
||||
.filter_map(|key| {
|
||||
let first = month_first.get(&key).copied().unwrap_or_default();
|
||||
let last = month_last.get(&key).copied().unwrap_or_default();
|
||||
if first.abs() < f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some((last / first) - 1.0)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mean(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
values.iter().sum::<f64>() / values.len() as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn variance(values: &[f64]) -> f64 {
|
||||
if values.len() < 2 {
|
||||
return 0.0;
|
||||
}
|
||||
let avg = mean(values);
|
||||
values
|
||||
.iter()
|
||||
.map(|value| (value - avg).powi(2))
|
||||
.sum::<f64>()
|
||||
/ (values.len() - 1) as f64
|
||||
}
|
||||
|
||||
fn std_dev(values: &[f64]) -> f64 {
|
||||
variance(values).sqrt()
|
||||
}
|
||||
|
||||
fn median(values: &[f64]) -> f64 {
|
||||
if values.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut sorted = values.to_vec();
|
||||
sorted.sort_by(|lhs, rhs| lhs.partial_cmp(rhs).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let mid = sorted.len() / 2;
|
||||
if sorted.len() % 2 == 0 {
|
||||
(sorted[mid - 1] + sorted[mid]) / 2.0
|
||||
} else {
|
||||
sorted[mid]
|
||||
}
|
||||
}
|
||||
|
||||
fn ratio(numerator: usize, denominator: usize) -> f64 {
|
||||
if denominator == 0 {
|
||||
0.0
|
||||
} else {
|
||||
numerator as f64 / denominator as f64
|
||||
}
|
||||
}
|
||||
|
||||
fn safe_div(numerator: f64, denominator: f64, fallback: f64) -> f64 {
|
||||
if denominator.abs() < f64::EPSILON {
|
||||
fallback
|
||||
} else {
|
||||
numerator / denominator
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use indexmap::IndexMap;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::{DataSet, DataSetError, PriceField};
|
||||
@@ -124,19 +123,71 @@ impl Position {
|
||||
|
||||
self.average_cost = total_cost / self.quantity as f64;
|
||||
}
|
||||
|
||||
pub fn apply_cash_dividend(&mut self, dividend_per_share: f64) -> f64 {
|
||||
if self.quantity == 0 || !dividend_per_share.is_finite() || dividend_per_share == 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
for lot in &mut self.lots {
|
||||
lot.price -= dividend_per_share;
|
||||
}
|
||||
self.average_cost -= dividend_per_share;
|
||||
self.last_price -= dividend_per_share;
|
||||
self.quantity as f64 * dividend_per_share
|
||||
}
|
||||
|
||||
pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 {
|
||||
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
let old_quantity = self.quantity;
|
||||
let mut scaled_lots = self
|
||||
.lots
|
||||
.iter()
|
||||
.map(|lot| PositionLot {
|
||||
acquired_date: lot.acquired_date,
|
||||
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
|
||||
price: lot.price / ratio,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let expected_total = round_half_up_u32(old_quantity as f64 * ratio);
|
||||
let scaled_total = scaled_lots.iter().map(|lot| lot.quantity).sum::<u32>();
|
||||
if let Some(last_lot) = scaled_lots.last_mut() {
|
||||
if scaled_total < expected_total {
|
||||
last_lot.quantity += expected_total - scaled_total;
|
||||
} else if scaled_total > expected_total {
|
||||
last_lot.quantity = last_lot
|
||||
.quantity
|
||||
.saturating_sub(scaled_total - expected_total);
|
||||
}
|
||||
}
|
||||
scaled_lots.retain(|lot| lot.quantity > 0);
|
||||
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
self.last_price /= ratio;
|
||||
self.recalculate_average_cost();
|
||||
self.quantity as i32 - old_quantity as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PortfolioState {
|
||||
cash: f64,
|
||||
positions: BTreeMap<String, Position>,
|
||||
positions: IndexMap<String, Position>,
|
||||
cash_receivables: Vec<CashReceivable>,
|
||||
}
|
||||
|
||||
impl PortfolioState {
|
||||
pub fn new(initial_cash: f64) -> Self {
|
||||
Self {
|
||||
cash: initial_cash,
|
||||
positions: BTreeMap::new(),
|
||||
positions: IndexMap::new(),
|
||||
cash_receivables: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +195,7 @@ impl PortfolioState {
|
||||
self.cash
|
||||
}
|
||||
|
||||
pub fn positions(&self) -> &BTreeMap<String, Position> {
|
||||
pub fn positions(&self) -> &IndexMap<String, Position> {
|
||||
&self.positions
|
||||
}
|
||||
|
||||
@@ -152,6 +203,10 @@ impl PortfolioState {
|
||||
self.positions.get(symbol)
|
||||
}
|
||||
|
||||
pub fn position_mut_if_exists(&mut self, symbol: &str) -> Option<&mut Position> {
|
||||
self.positions.get_mut(symbol)
|
||||
}
|
||||
|
||||
pub fn position_mut(&mut self, symbol: &str) -> &mut Position {
|
||||
self.positions
|
||||
.entry(symbol.to_string())
|
||||
@@ -166,6 +221,29 @@ impl PortfolioState {
|
||||
self.positions.retain(|_, position| !position.is_flat());
|
||||
}
|
||||
|
||||
pub fn add_cash_receivable(&mut self, receivable: CashReceivable) {
|
||||
self.cash_receivables.push(receivable);
|
||||
}
|
||||
|
||||
pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec<CashReceivable> {
|
||||
let mut settled = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for receivable in self.cash_receivables.drain(..) {
|
||||
if receivable.payable_date <= date {
|
||||
self.cash += receivable.amount;
|
||||
settled.push(receivable);
|
||||
} else {
|
||||
pending.push(receivable);
|
||||
}
|
||||
}
|
||||
self.cash_receivables = pending;
|
||||
settled
|
||||
}
|
||||
|
||||
pub fn cash_receivables(&self) -> &[CashReceivable] {
|
||||
&self.cash_receivables
|
||||
}
|
||||
|
||||
pub fn update_prices(
|
||||
&mut self,
|
||||
date: NaiveDate,
|
||||
@@ -173,16 +251,17 @@ impl PortfolioState {
|
||||
field: PriceField,
|
||||
) -> Result<(), DataSetError> {
|
||||
for position in self.positions.values_mut() {
|
||||
let price = data
|
||||
.price(date, &position.symbol, field)
|
||||
.ok_or_else(|| DataSetError::MissingSnapshot {
|
||||
let price = data.price(date, &position.symbol, field).ok_or_else(|| {
|
||||
DataSetError::MissingSnapshot {
|
||||
kind: match field {
|
||||
PriceField::Open => "open price",
|
||||
PriceField::Close => "close price",
|
||||
PriceField::Last => "last price",
|
||||
},
|
||||
date,
|
||||
symbol: position.symbol.clone(),
|
||||
})?;
|
||||
}
|
||||
})?;
|
||||
position.last_price = price;
|
||||
}
|
||||
Ok(())
|
||||
@@ -214,6 +293,30 @@ impl PortfolioState {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn positions_preserve_insertion_order() {
|
||||
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||
let mut portfolio = PortfolioState::new(10_000.0);
|
||||
portfolio.position_mut("603657.SH").buy(date, 100, 10.0);
|
||||
portfolio.position_mut("001266.SZ").buy(date, 100, 10.0);
|
||||
portfolio.position_mut("601798.SH").buy(date, 100, 10.0);
|
||||
|
||||
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
symbols,
|
||||
vec![
|
||||
"603657.SH".to_string(),
|
||||
"001266.SZ".to_string(),
|
||||
"601798.SH".to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct HoldingSummary {
|
||||
#[serde(with = "date_format")]
|
||||
@@ -227,6 +330,15 @@ pub struct HoldingSummary {
|
||||
pub realized_pnl: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CashReceivable {
|
||||
pub symbol: String,
|
||||
pub ex_date: NaiveDate,
|
||||
pub payable_date: NaiveDate,
|
||||
pub amount: f64,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
mod date_format {
|
||||
use chrono::NaiveDate;
|
||||
use serde::Serializer;
|
||||
@@ -240,3 +352,11 @@ mod date_format {
|
||||
serializer.serialize_str(&date.format(FORMAT).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn round_half_up_u32(value: f64) -> u32 {
|
||||
if !value.is_finite() || value <= 0.0 {
|
||||
0
|
||||
} else {
|
||||
value.round() as u32
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use chrono::NaiveDate;
|
||||
|
||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot};
|
||||
use crate::data::{CandidateEligibility, DailyMarketSnapshot, PriceField};
|
||||
use crate::portfolio::Position;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -31,6 +31,7 @@ pub trait EquityRuleHooks {
|
||||
execution_date: NaiveDate,
|
||||
snapshot: &DailyMarketSnapshot,
|
||||
candidate: &CandidateEligibility,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck;
|
||||
|
||||
fn can_sell(
|
||||
@@ -39,6 +40,7 @@ pub trait EquityRuleHooks {
|
||||
snapshot: &DailyMarketSnapshot,
|
||||
candidate: &CandidateEligibility,
|
||||
position: &Position,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck;
|
||||
}
|
||||
|
||||
@@ -46,12 +48,12 @@ pub trait EquityRuleHooks {
|
||||
pub struct ChinaEquityRuleHooks;
|
||||
|
||||
impl ChinaEquityRuleHooks {
|
||||
fn at_upper_limit(snapshot: &DailyMarketSnapshot) -> bool {
|
||||
snapshot.open >= snapshot.upper_limit - 1e-6
|
||||
fn at_upper_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
|
||||
snapshot.is_at_upper_limit_price(snapshot.buy_price(price_field))
|
||||
}
|
||||
|
||||
fn at_lower_limit(snapshot: &DailyMarketSnapshot) -> bool {
|
||||
snapshot.open <= snapshot.lower_limit + 1e-6
|
||||
fn at_lower_limit(snapshot: &DailyMarketSnapshot, price_field: PriceField) -> bool {
|
||||
snapshot.is_at_lower_limit_price(snapshot.sell_price(price_field))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +63,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
_execution_date: NaiveDate,
|
||||
snapshot: &DailyMarketSnapshot,
|
||||
candidate: &CandidateEligibility,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
@@ -68,7 +71,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
if !candidate.allow_buy {
|
||||
return RuleCheck::reject("buy disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_upper_limit(snapshot) {
|
||||
if Self::at_upper_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or above upper limit");
|
||||
}
|
||||
|
||||
@@ -81,6 +84,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
snapshot: &DailyMarketSnapshot,
|
||||
candidate: &CandidateEligibility,
|
||||
position: &Position,
|
||||
price_field: PriceField,
|
||||
) -> RuleCheck {
|
||||
if snapshot.paused || candidate.is_paused {
|
||||
return RuleCheck::reject("paused");
|
||||
@@ -88,7 +92,7 @@ impl EquityRuleHooks for ChinaEquityRuleHooks {
|
||||
if !candidate.allow_sell {
|
||||
return RuleCheck::reject("sell disabled by eligibility flags");
|
||||
}
|
||||
if Self::at_lower_limit(snapshot) {
|
||||
if Self::at_lower_limit(snapshot, price_field) {
|
||||
return RuleCheck::reject("open at or below lower limit");
|
||||
}
|
||||
if position.sellable_qty(execution_date) == 0 {
|
||||
|
||||
@@ -25,10 +25,25 @@ pub struct StrategyDecision {
|
||||
pub rebalance: bool,
|
||||
pub target_weights: BTreeMap<String, f64>,
|
||||
pub exit_symbols: BTreeSet<String>,
|
||||
pub order_intents: Vec<OrderIntent>,
|
||||
pub notes: Vec<String>,
|
||||
pub diagnostics: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OrderIntent {
|
||||
TargetValue {
|
||||
symbol: String,
|
||||
target_value: f64,
|
||||
reason: String,
|
||||
},
|
||||
Value {
|
||||
symbol: String,
|
||||
value: f64,
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CnSmallCapRotationConfig {
|
||||
pub strategy_name: String,
|
||||
@@ -97,7 +112,13 @@ impl CnSmallCapRotationConfig {
|
||||
take_profit_pct: 0.07,
|
||||
signal_symbol: Some("000852.SH".to_string()),
|
||||
skip_months: vec![],
|
||||
skip_month_day_ranges: vec![(1, 15, 30), (4, 15, 29), (8, 15, 31), (10, 20, 30), (12, 20, 30)],
|
||||
skip_month_day_ranges: vec![
|
||||
(1, 15, 30),
|
||||
(4, 15, 29),
|
||||
(8, 15, 31),
|
||||
(10, 20, 30),
|
||||
(12, 20, 30),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,12 +157,10 @@ impl CnSmallCapRotationStrategy {
|
||||
fn moving_average(values: &[f64], lookback: usize) -> f64 {
|
||||
let len = values.len();
|
||||
let window = values.iter().skip(len.saturating_sub(lookback));
|
||||
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| (sum + value, count + 1));
|
||||
if count == 0 {
|
||||
0.0
|
||||
} else {
|
||||
sum / count as f64
|
||||
}
|
||||
let (sum, count) = window.fold((0.0, 0usize), |(sum, count), value| {
|
||||
(sum + value, count + 1)
|
||||
});
|
||||
if count == 0 { 0.0 } else { sum / count as f64 }
|
||||
}
|
||||
|
||||
fn gross_exposure(&self, closes: &[f64]) -> f64 {
|
||||
@@ -166,38 +185,46 @@ impl CnSmallCapRotationStrategy {
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<(String, Vec<f64>, f64), BacktestError> {
|
||||
let symbol = self
|
||||
.config
|
||||
.signal_symbol
|
||||
.as_deref()
|
||||
.ok_or_else(|| BacktestError::Execution(
|
||||
"cn-dyn-smallcap-band requires a real signal_symbol; degraded fallback disabled"
|
||||
.to_string(),
|
||||
))?;
|
||||
if let Some(symbol) = self.config.signal_symbol.as_deref() {
|
||||
let closes =
|
||||
ctx.data
|
||||
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
|
||||
if closes.len() >= self.config.long_ma_days {
|
||||
let close = ctx
|
||||
.data
|
||||
.price(ctx.decision_date, symbol, PriceField::Close)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date: ctx.decision_date,
|
||||
symbol: symbol.to_string(),
|
||||
field: "close",
|
||||
})?;
|
||||
return Ok((symbol.to_string(), closes, close));
|
||||
}
|
||||
}
|
||||
|
||||
let closes = ctx
|
||||
.data
|
||||
.market_closes_up_to(ctx.decision_date, symbol, self.config.long_ma_days);
|
||||
.benchmark_closes_up_to(ctx.decision_date, self.config.long_ma_days);
|
||||
if closes.len() < self.config.long_ma_days {
|
||||
return Err(BacktestError::Execution(format!(
|
||||
"real signal series missing or insufficient for {} on/before {}; degraded fallback disabled",
|
||||
symbol, ctx.decision_date
|
||||
"signal series insufficient on/before {} for long_ma_days={}",
|
||||
ctx.decision_date, self.config.long_ma_days
|
||||
)));
|
||||
}
|
||||
let close = ctx
|
||||
.data
|
||||
.price(ctx.decision_date, symbol, PriceField::Close)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
.benchmark(ctx.decision_date)
|
||||
.ok_or(BacktestError::MissingBenchmark {
|
||||
date: ctx.decision_date,
|
||||
symbol: symbol.to_string(),
|
||||
field: "close",
|
||||
})?;
|
||||
Ok((symbol.to_string(), closes, close))
|
||||
})?
|
||||
.close;
|
||||
Ok((ctx.data.benchmark_code().to_string(), closes, close))
|
||||
}
|
||||
|
||||
fn stock_passes_ma_filter(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
|
||||
let closes = ctx
|
||||
.data
|
||||
.market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
|
||||
let closes =
|
||||
ctx.data
|
||||
.market_closes_up_to(ctx.decision_date, symbol, self.config.stock_long_ma_days);
|
||||
if closes.len() < self.config.stock_long_ma_days {
|
||||
return false;
|
||||
}
|
||||
@@ -207,7 +234,10 @@ impl CnSmallCapRotationStrategy {
|
||||
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
|
||||
}
|
||||
|
||||
fn stop_exit_symbols(&self, ctx: &StrategyContext<'_>) -> Result<BTreeSet<String>, BacktestError> {
|
||||
fn stop_exit_symbols(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
) -> Result<BTreeSet<String>, BacktestError> {
|
||||
let mut exits = BTreeSet::new();
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
if position.quantity == 0 {
|
||||
@@ -244,12 +274,12 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
}
|
||||
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
let benchmark = ctx
|
||||
.data
|
||||
.benchmark(ctx.decision_date)
|
||||
.ok_or(BacktestError::MissingBenchmark {
|
||||
date: ctx.decision_date,
|
||||
})?;
|
||||
let benchmark =
|
||||
ctx.data
|
||||
.benchmark(ctx.decision_date)
|
||||
.ok_or(BacktestError::MissingBenchmark {
|
||||
date: ctx.decision_date,
|
||||
})?;
|
||||
|
||||
if self.config.in_skip_window(ctx.execution_date) {
|
||||
self.last_gross_exposure = Some(0.0);
|
||||
@@ -257,15 +287,35 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
rebalance: true,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
order_intents: Vec::new(),
|
||||
notes: vec![format!("skip-window active on {}", ctx.execution_date)],
|
||||
diagnostics: vec![
|
||||
"seasonal stop window approximated at daily granularity".to_string(),
|
||||
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution".to_string(),
|
||||
"run_daily(10:17/10:18) mapped to T-1 decision and T open execution"
|
||||
.to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
let (resolved_signal_symbol, signal_closes, signal_level) = self.resolve_signal_series(ctx)?;
|
||||
let (resolved_signal_symbol, signal_closes, signal_level) =
|
||||
match self.resolve_signal_series(ctx) {
|
||||
Ok(value) => value,
|
||||
Err(BacktestError::Execution(message))
|
||||
if message.contains("signal series insufficient") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: Vec::new(),
|
||||
notes: vec![format!("warmup: {}", message)],
|
||||
diagnostics: vec![
|
||||
"insufficient history; skip trading on warmup dates".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let gross_exposure = self.gross_exposure(&signal_closes);
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let exposure_changed = self
|
||||
@@ -295,16 +345,19 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
1.0 - self.config.stop_loss_pct,
|
||||
1.0 + self.config.take_profit_pct,
|
||||
)];
|
||||
diagnostics.push("run_daily(10:17/10:18) approximated by daily decision/open execution".to_string());
|
||||
diagnostics.push(
|
||||
"run_daily(10:17/10:18) approximated by daily decision/open execution".to_string(),
|
||||
);
|
||||
diagnostics.push("market_cap field mapped from daily_features[_enriched]_v1.market_cap to market_cap_bn without intraday fundamentals refresh".to_string());
|
||||
|
||||
if rebalance && gross_exposure > 0.0 {
|
||||
let (selected_before_ma, selection_diag) = self.selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: ctx.decision_date,
|
||||
benchmark,
|
||||
reference_level: signal_level,
|
||||
data: ctx.data,
|
||||
});
|
||||
let (selected_before_ma, selection_diag) =
|
||||
self.selector.select_with_diagnostics(&SelectionContext {
|
||||
decision_date: ctx.decision_date,
|
||||
benchmark,
|
||||
reference_level: signal_level,
|
||||
data: ctx.data,
|
||||
});
|
||||
let before_ma_count = selected_before_ma.len();
|
||||
let mut ma_rejects = Vec::new();
|
||||
let selected = selected_before_ma
|
||||
@@ -353,7 +406,10 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
));
|
||||
}
|
||||
if !ma_rejects.is_empty() {
|
||||
diagnostics.push(format!("ma_filter_rejections sample={}", ma_rejects.join("|")));
|
||||
diagnostics.push(format!(
|
||||
"ma_filter_rejections sample={}",
|
||||
ma_rejects.join("|")
|
||||
));
|
||||
}
|
||||
|
||||
if !selected.is_empty() {
|
||||
@@ -398,8 +454,581 @@ impl Strategy for CnSmallCapRotationStrategy {
|
||||
rebalance,
|
||||
target_weights,
|
||||
exit_symbols,
|
||||
order_intents: Vec::new(),
|
||||
notes,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JqMicroCapConfig {
|
||||
pub strategy_name: String,
|
||||
pub refresh_rate: usize,
|
||||
pub stocknum: usize,
|
||||
pub xs: f64,
|
||||
pub base_index_level: f64,
|
||||
pub base_cap_floor: f64,
|
||||
pub cap_span: f64,
|
||||
pub benchmark_signal_symbol: String,
|
||||
pub benchmark_short_ma_days: usize,
|
||||
pub benchmark_long_ma_days: usize,
|
||||
pub stock_short_ma_days: usize,
|
||||
pub stock_mid_ma_days: usize,
|
||||
pub stock_long_ma_days: usize,
|
||||
pub rsi_rate: f64,
|
||||
pub trade_rate: f64,
|
||||
pub stop_loss_ratio: f64,
|
||||
pub take_profit_ratio: f64,
|
||||
pub skip_month_day_ranges: Vec<(u32, u32, u32)>,
|
||||
}
|
||||
|
||||
impl JqMicroCapConfig {
|
||||
pub fn jq_microcap() -> Self {
|
||||
Self {
|
||||
strategy_name: "jq-microcap".to_string(),
|
||||
refresh_rate: 15,
|
||||
stocknum: 40,
|
||||
xs: 4.0 / 500.0,
|
||||
base_index_level: 2000.0,
|
||||
base_cap_floor: 7.0,
|
||||
cap_span: 10.0,
|
||||
benchmark_signal_symbol: "000001.SH".to_string(),
|
||||
benchmark_short_ma_days: 5,
|
||||
benchmark_long_ma_days: 10,
|
||||
stock_short_ma_days: 5,
|
||||
stock_mid_ma_days: 10,
|
||||
stock_long_ma_days: 20,
|
||||
rsi_rate: 1.0001,
|
||||
trade_rate: 0.5,
|
||||
stop_loss_ratio: 0.93,
|
||||
take_profit_ratio: 1.07,
|
||||
// The source JQ script calls validate_date() but then immediately forces
|
||||
// g.OpenYN = 1 inside check_stocks(), so the seasonal stop windows are
|
||||
// effectively disabled in real execution logs.
|
||||
skip_month_day_ranges: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn in_skip_window(&self, date: NaiveDate) -> bool {
|
||||
let month = date.month();
|
||||
let day = date.day();
|
||||
self.skip_month_day_ranges
|
||||
.iter()
|
||||
.any(|(m, start_day, end_day)| month == *m && day >= *start_day && day <= *end_day)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct JqMicroCapStrategy {
|
||||
config: JqMicroCapConfig,
|
||||
}
|
||||
|
||||
impl JqMicroCapStrategy {
|
||||
pub fn new(config: JqMicroCapConfig) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
fn stop_loss_tolerance(&self, market: &crate::data::DailyMarketSnapshot) -> f64 {
|
||||
market.effective_price_tick() * 6.0
|
||||
}
|
||||
|
||||
fn buy_commission(&self, gross_amount: f64) -> f64 {
|
||||
(gross_amount * 0.0003).max(5.0)
|
||||
}
|
||||
|
||||
fn sell_cost(&self, gross_amount: f64) -> f64 {
|
||||
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
|
||||
}
|
||||
|
||||
fn round_board_lot(&self, quantity: u32) -> u32 {
|
||||
(quantity / 100) * 100
|
||||
}
|
||||
|
||||
fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 {
|
||||
if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 {
|
||||
return 0;
|
||||
}
|
||||
let mut quantity = self.round_board_lot((cash / sizing_price).floor() as u32);
|
||||
while quantity > 0 {
|
||||
let gross_amount = execution_price * quantity as f64;
|
||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||
if cash_out <= cash + 1e-6 {
|
||||
return quantity;
|
||||
}
|
||||
quantity = quantity.saturating_sub(100);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn project_order_value(
|
||||
&self,
|
||||
projected: &mut PortfolioState,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
sizing_price: f64,
|
||||
execution_price: f64,
|
||||
order_value: f64,
|
||||
) -> u32 {
|
||||
let quantity = self.projected_buy_quantity(
|
||||
projected.cash().min(order_value),
|
||||
sizing_price,
|
||||
execution_price,
|
||||
);
|
||||
if quantity == 0 {
|
||||
return 0;
|
||||
}
|
||||
let gross_amount = execution_price * quantity as f64;
|
||||
let cash_out = gross_amount + self.buy_commission(gross_amount);
|
||||
projected.apply_cash_delta(-cash_out);
|
||||
projected.position_mut(symbol).buy(date, quantity, execution_price);
|
||||
quantity
|
||||
}
|
||||
|
||||
fn project_target_zero(
|
||||
&self,
|
||||
projected: &mut PortfolioState,
|
||||
symbol: &str,
|
||||
sell_price: f64,
|
||||
) -> Option<u32> {
|
||||
let quantity = projected.position(symbol)?.quantity;
|
||||
if quantity == 0 {
|
||||
return None;
|
||||
}
|
||||
let gross_amount = sell_price * quantity as f64;
|
||||
let net_cash = gross_amount - self.sell_cost(gross_amount);
|
||||
projected.position_mut(symbol).sell(quantity, sell_price).ok()?;
|
||||
projected.apply_cash_delta(net_cash);
|
||||
projected.prune_flat_positions();
|
||||
Some(quantity)
|
||||
}
|
||||
|
||||
fn trading_ratio(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
) -> Result<(f64, f64, f64, f64), BacktestError> {
|
||||
let current_level = ctx
|
||||
.data
|
||||
.market_decision_close(date, &self.config.benchmark_signal_symbol)
|
||||
.ok_or_else(|| BacktestError::MissingPrice {
|
||||
date,
|
||||
symbol: self.config.benchmark_signal_symbol.clone(),
|
||||
field: "decision_close",
|
||||
})?;
|
||||
let ma_short = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(
|
||||
date,
|
||||
&self.config.benchmark_signal_symbol,
|
||||
self.config.benchmark_short_ma_days,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"insufficient benchmark short MA history for {} on {}",
|
||||
self.config.benchmark_signal_symbol, date
|
||||
))
|
||||
})?;
|
||||
let ma_long = ctx
|
||||
.data
|
||||
.market_decision_close_moving_average(
|
||||
date,
|
||||
&self.config.benchmark_signal_symbol,
|
||||
self.config.benchmark_long_ma_days,
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
BacktestError::Execution(format!(
|
||||
"insufficient benchmark long MA history for {} on {}",
|
||||
self.config.benchmark_signal_symbol, date
|
||||
))
|
||||
})?;
|
||||
let trading_ratio = if ma_short < ma_long * self.config.rsi_rate {
|
||||
self.config.trade_rate
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
Ok((current_level, ma_short, ma_long, trading_ratio))
|
||||
}
|
||||
|
||||
fn market_cap_band(&self, index_level: f64) -> (f64, f64) {
|
||||
let y = (index_level - self.config.base_index_level) * self.config.xs
|
||||
+ self.config.base_cap_floor;
|
||||
let start = y.round();
|
||||
(start, start + self.config.cap_span)
|
||||
}
|
||||
|
||||
fn stock_passes_ma_filter(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
) -> bool {
|
||||
let Some(ma_short) = ctx.data.market_decision_close_moving_average(
|
||||
date,
|
||||
symbol,
|
||||
self.config.stock_short_ma_days,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(ma_mid) = ctx.data.market_decision_close_moving_average(
|
||||
date,
|
||||
symbol,
|
||||
self.config.stock_mid_ma_days,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
let Some(ma_long) = ctx.data.market_decision_close_moving_average(
|
||||
date,
|
||||
symbol,
|
||||
self.config.stock_long_ma_days,
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
ma_short > ma_mid * self.config.rsi_rate && ma_mid > ma_long
|
||||
}
|
||||
|
||||
fn special_name(&self, ctx: &StrategyContext<'_>, symbol: &str) -> bool {
|
||||
let instrument_name = ctx
|
||||
.data
|
||||
.instruments()
|
||||
.get(symbol)
|
||||
.map(|instrument| instrument.name.as_str())
|
||||
.unwrap_or("");
|
||||
instrument_name.contains("ST")
|
||||
|| instrument_name.contains('*')
|
||||
|| instrument_name.contains('退')
|
||||
}
|
||||
|
||||
fn can_sell_position(&self, ctx: &StrategyContext<'_>, date: NaiveDate, symbol: &str) -> bool {
|
||||
let Some(position) = ctx.portfolio.position(symbol) else {
|
||||
return false;
|
||||
};
|
||||
if position.quantity == 0 || position.sellable_qty(date) == 0 {
|
||||
return false;
|
||||
}
|
||||
let Ok(market) = ctx.data.require_market(date, symbol) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(candidate) = ctx.data.require_candidate(date, symbol) else {
|
||||
return false;
|
||||
};
|
||||
!(market.paused
|
||||
|| candidate.is_paused
|
||||
|| !candidate.allow_sell
|
||||
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last)))
|
||||
}
|
||||
|
||||
fn buy_rejection_reason(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
symbol: &str,
|
||||
) -> Result<Option<String>, BacktestError> {
|
||||
let market = ctx.data.require_market(date, symbol)?;
|
||||
let candidate = ctx.data.require_candidate(date, symbol)?;
|
||||
|
||||
if market.paused || candidate.is_paused {
|
||||
return Ok(Some("paused".to_string()));
|
||||
}
|
||||
if candidate.is_st || self.special_name(ctx, symbol) {
|
||||
return Ok(Some("st_or_special_name".to_string()));
|
||||
}
|
||||
if candidate.is_kcb {
|
||||
return Ok(Some("kcb".to_string()));
|
||||
}
|
||||
if !candidate.allow_buy {
|
||||
return Ok(Some("buy_disabled".to_string()));
|
||||
}
|
||||
if market.is_at_upper_limit_price(market.day_open)
|
||||
|| market.is_at_upper_limit_price(market.buy_price(PriceField::Last))
|
||||
{
|
||||
return Ok(Some("upper_limit".to_string()));
|
||||
}
|
||||
if market.is_at_lower_limit_price(market.day_open)
|
||||
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
|
||||
{
|
||||
return Ok(Some("lower_limit".to_string()));
|
||||
}
|
||||
if market.day_open <= 1.0 {
|
||||
return Ok(Some("one_yuan".to_string()));
|
||||
}
|
||||
if !self.stock_passes_ma_filter(ctx, date, symbol) {
|
||||
return Ok(Some("ma_filter".to_string()));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn select_symbols(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
date: NaiveDate,
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
|
||||
let universe = ctx.data.eligible_universe_on(date);
|
||||
let mut diagnostics = Vec::new();
|
||||
let mut selected = Vec::new();
|
||||
let start = lower_bound_eligible(universe, band_low);
|
||||
|
||||
for candidate in universe.iter().skip(start) {
|
||||
if candidate.market_cap_bn > band_high {
|
||||
break;
|
||||
}
|
||||
if let Some(reason) = self.buy_rejection_reason(ctx, date, &candidate.symbol)? {
|
||||
if diagnostics.len() < 12 {
|
||||
diagnostics.push(format!("{} rejected by {}", candidate.symbol, reason));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(candidate.symbol.clone());
|
||||
if selected.len() >= self.config.stocknum {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((selected, diagnostics))
|
||||
}
|
||||
}
|
||||
|
||||
impl Strategy for JqMicroCapStrategy {
|
||||
fn name(&self) -> &str {
|
||||
self.config.strategy_name.as_str()
|
||||
}
|
||||
|
||||
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, BacktestError> {
|
||||
let date = ctx.execution_date;
|
||||
if self.config.in_skip_window(date) {
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: ctx.portfolio.positions().keys().cloned().collect(),
|
||||
order_intents: ctx
|
||||
.portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.map(|symbol| OrderIntent::TargetValue {
|
||||
symbol,
|
||||
target_value: 0.0,
|
||||
reason: "seasonal_stop_window".to_string(),
|
||||
})
|
||||
.collect(),
|
||||
notes: vec![format!("seasonal stop window on {}", date)],
|
||||
diagnostics: vec!["jq-style skip window forced all cash".to_string()],
|
||||
});
|
||||
}
|
||||
|
||||
let (index_level, ma_short, ma_long, trading_ratio) = match self.trading_ratio(ctx, date) {
|
||||
Ok(value) => value,
|
||||
Err(BacktestError::Execution(message))
|
||||
if message.contains("insufficient benchmark") =>
|
||||
{
|
||||
return Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols: BTreeSet::new(),
|
||||
order_intents: Vec::new(),
|
||||
notes: vec![format!("warmup: {}", message)],
|
||||
diagnostics: vec![
|
||||
"insufficient history; skip trading on warmup dates".to_string(),
|
||||
],
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let (band_low, band_high) = self.market_cap_band(index_level);
|
||||
let (stock_list, selection_notes) = self.select_symbols(ctx, date, band_low, band_high)?;
|
||||
let periodic_rebalance = ctx.decision_index % self.config.refresh_rate == 0;
|
||||
let mut projected = ctx.portfolio.clone();
|
||||
let mut order_intents = Vec::new();
|
||||
let mut exit_symbols = BTreeSet::new();
|
||||
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
if position.quantity == 0 || position.average_cost <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let Some(current_price) = ctx.data.price(date, &position.symbol, PriceField::Last)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(market) = ctx.data.market(date, &position.symbol) else {
|
||||
continue;
|
||||
};
|
||||
let sell_price = market.sell_price(PriceField::Last);
|
||||
let stop_hit = current_price
|
||||
<= position.average_cost * self.config.stop_loss_ratio
|
||||
+ self.stop_loss_tolerance(market);
|
||||
let profit_hit = !market.is_at_upper_limit_price(current_price)
|
||||
&& current_price / position.average_cost > self.config.take_profit_ratio;
|
||||
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
|
||||
if stop_hit || profit_hit {
|
||||
let sell_reason = if stop_hit {
|
||||
"stop_loss_exit"
|
||||
} else {
|
||||
"take_profit_exit"
|
||||
};
|
||||
exit_symbols.insert(position.symbol.clone());
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: position.symbol.clone(),
|
||||
target_value: 0.0,
|
||||
reason: sell_reason.to_string(),
|
||||
});
|
||||
if can_sell {
|
||||
self.project_target_zero(&mut projected, &position.symbol, sell_price);
|
||||
}
|
||||
|
||||
if projected.positions().len() < self.config.stocknum {
|
||||
let remaining_slots = self.config.stocknum - projected.positions().len();
|
||||
if remaining_slots > 0 {
|
||||
let replacement_cash =
|
||||
projected.cash() * trading_ratio / remaining_slots as f64;
|
||||
for symbol in &stock_list {
|
||||
if symbol == &position.symbol
|
||||
|| projected.positions().contains_key(symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::Value {
|
||||
symbol: symbol.clone(),
|
||||
value: replacement_cash,
|
||||
reason: format!("replacement_after_{}", sell_reason),
|
||||
});
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
self.project_order_value(
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
market.buy_price(PriceField::Last),
|
||||
market.buy_price(PriceField::Last),
|
||||
replacement_cash,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if periodic_rebalance {
|
||||
let pre_rebalance_symbols = projected
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
for symbol in pre_rebalance_symbols.iter() {
|
||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||
continue;
|
||||
}
|
||||
if !self.can_sell_position(ctx, date, symbol) {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: symbol.clone(),
|
||||
target_value: 0.0,
|
||||
reason: "periodic_rebalance_sell".to_string(),
|
||||
});
|
||||
if let Some(price) = ctx
|
||||
.data
|
||||
.market(date, symbol)
|
||||
.map(|market| market.sell_price(PriceField::Last))
|
||||
{
|
||||
self.project_target_zero(&mut projected, symbol, price);
|
||||
}
|
||||
}
|
||||
|
||||
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
|
||||
for symbol in &stock_list {
|
||||
if projected.positions().len() >= self.config.stocknum {
|
||||
break;
|
||||
}
|
||||
if pre_rebalance_symbols.contains(symbol)
|
||||
|| projected.positions().contains_key(symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if self.buy_rejection_reason(ctx, date, symbol)?.is_some() {
|
||||
continue;
|
||||
}
|
||||
order_intents.push(OrderIntent::Value {
|
||||
symbol: symbol.clone(),
|
||||
value: fixed_buy_cash,
|
||||
reason: "periodic_rebalance_buy".to_string(),
|
||||
});
|
||||
if let Some(market) = ctx.data.market(date, symbol) {
|
||||
self.project_order_value(
|
||||
&mut projected,
|
||||
date,
|
||||
symbol,
|
||||
market.buy_price(PriceField::Last),
|
||||
market.buy_price(PriceField::Last),
|
||||
fixed_buy_cash,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut diagnostics = vec![
|
||||
format!(
|
||||
"jq_microcap signal={} last={:.2} ma_short={:.2} ma_long={:.2} band={:.0}-{:.0} tr={:.2}",
|
||||
self.config.benchmark_signal_symbol, index_level, ma_short, ma_long, band_low, band_high, trading_ratio
|
||||
),
|
||||
format!(
|
||||
"selected={} periodic_rebalance={} exits={} projected_positions={} intents={}",
|
||||
stock_list.len(),
|
||||
periodic_rebalance,
|
||||
exit_symbols.len(),
|
||||
projected.positions().len(),
|
||||
order_intents.len()
|
||||
),
|
||||
"run_daily(10:17/10:18) approximated as same-day decision with snapshot last_price signals and bid1/ask1 side-aware execution".to_string(),
|
||||
];
|
||||
if std::env::var("FIDC_BT_DEBUG_POSITION_ORDER")
|
||||
.map(|value| value == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
diagnostics.push(format!(
|
||||
"positions_order={}",
|
||||
ctx.portfolio
|
||||
.positions()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("|")
|
||||
));
|
||||
}
|
||||
diagnostics.extend(selection_notes);
|
||||
|
||||
let notes = vec![
|
||||
format!("stock_list={}", stock_list.len()),
|
||||
format!("projected_positions={}", projected.positions().len()),
|
||||
];
|
||||
|
||||
Ok(StrategyDecision {
|
||||
rebalance: false,
|
||||
target_weights: BTreeMap::new(),
|
||||
exit_symbols,
|
||||
order_intents,
|
||||
notes,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_bound_eligible(rows: &[crate::data::EligibleUniverseSnapshot], target: f64) -> usize {
|
||||
let mut left = 0usize;
|
||||
let mut right = rows.len();
|
||||
while left < right {
|
||||
let mid = left + (right - left) / 2;
|
||||
if rows[mid].market_cap_bn < target {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::NaiveDate;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::data::{BenchmarkSnapshot, DataSet};
|
||||
use crate::data::{BenchmarkSnapshot, DataSet, EligibleUniverseSnapshot};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BandRegime {
|
||||
@@ -48,7 +48,10 @@ pub struct SelectionContext<'a> {
|
||||
|
||||
pub trait UniverseSelector {
|
||||
fn select(&self, ctx: &SelectionContext<'_>) -> Vec<UniverseCandidate>;
|
||||
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics);
|
||||
fn select_with_diagnostics(
|
||||
&self,
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> (Vec<UniverseCandidate>, SelectionDiagnostics);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -103,7 +106,10 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||
self.select_with_diagnostics(ctx).0
|
||||
}
|
||||
|
||||
fn select_with_diagnostics(&self, ctx: &SelectionContext<'_>) -> (Vec<UniverseCandidate>, SelectionDiagnostics) {
|
||||
fn select_with_diagnostics(
|
||||
&self,
|
||||
ctx: &SelectionContext<'_>,
|
||||
) -> (Vec<UniverseCandidate>, SelectionDiagnostics) {
|
||||
let _regime = self.regime(ctx.reference_level);
|
||||
let (min_cap, max_cap) = self.band_for_level(ctx.reference_level);
|
||||
let mut diagnostics = SelectionDiagnostics {
|
||||
@@ -125,78 +131,24 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||
rejection_examples: Vec::new(),
|
||||
};
|
||||
|
||||
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
|
||||
diagnostics.market_cap_missing_count = diagnostics
|
||||
.factor_total
|
||||
.saturating_sub(ctx.data.eligible_universe_on(ctx.decision_date).len());
|
||||
|
||||
let eligible = ctx.data.eligible_universe_on(ctx.decision_date);
|
||||
let start_idx = lower_bound_by_market_cap(eligible, min_cap);
|
||||
let mut selected = Vec::new();
|
||||
|
||||
for factor in ctx.data.factor_snapshots_on(ctx.decision_date) {
|
||||
diagnostics.factor_total += 1;
|
||||
|
||||
if factor.market_cap_bn <= 0.0 || !factor.market_cap_bn.is_finite() {
|
||||
diagnostics.market_cap_missing_count += 1;
|
||||
if diagnostics.missing_market_cap_symbols.len() < 8 {
|
||||
diagnostics.missing_market_cap_symbols.push(factor.symbol.clone());
|
||||
}
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!("{}: market_cap missing_or_non_positive", factor.symbol));
|
||||
}
|
||||
continue;
|
||||
for factor in eligible.iter().skip(start_idx) {
|
||||
if factor.market_cap_bn > max_cap {
|
||||
break;
|
||||
}
|
||||
|
||||
let Some(candidate) = ctx.data.candidate(ctx.decision_date, &factor.symbol) else {
|
||||
diagnostics.candidate_missing_count += 1;
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!("{}: candidate snapshot missing", factor.symbol));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(market) = ctx.data.market(ctx.decision_date, &factor.symbol) else {
|
||||
diagnostics.market_missing_count += 1;
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!("{}: market snapshot missing", factor.symbol));
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if !candidate.eligible_for_selection() {
|
||||
diagnostics.not_eligible_count += 1;
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!("{}: candidate flags rejected", factor.symbol));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if market.paused {
|
||||
diagnostics.paused_count += 1;
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!("{}: market paused", factor.symbol));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if factor.market_cap_bn < min_cap || factor.market_cap_bn > max_cap {
|
||||
diagnostics.out_of_band_count += 1;
|
||||
if diagnostics.rejection_examples.len() < 12 {
|
||||
diagnostics.rejection_examples.push(format!(
|
||||
"{}: market_cap {:.2} out_of_band {:.2}-{:.2}",
|
||||
factor.symbol, factor.market_cap_bn, min_cap, max_cap
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.push(UniverseCandidate {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn: factor.market_cap_bn,
|
||||
free_float_cap_bn: factor.free_float_cap_bn,
|
||||
band_low: min_cap,
|
||||
band_high: max_cap,
|
||||
});
|
||||
selected.push(to_universe_candidate(factor, min_cap, max_cap));
|
||||
}
|
||||
|
||||
selected.sort_by(|left, right| {
|
||||
left.market_cap_bn
|
||||
.partial_cmp(&right.market_cap_bn)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
.then_with(|| left.symbol.cmp(&right.symbol))
|
||||
});
|
||||
diagnostics.out_of_band_count = eligible.len().saturating_sub(selected.len());
|
||||
|
||||
diagnostics.selected_before_limit = selected.len();
|
||||
if selected.len() > self.top_n {
|
||||
selected.truncate(self.top_n);
|
||||
@@ -206,3 +158,31 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
|
||||
(selected, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
fn lower_bound_by_market_cap(rows: &[EligibleUniverseSnapshot], target: f64) -> usize {
|
||||
let mut left = 0usize;
|
||||
let mut right = rows.len();
|
||||
while left < right {
|
||||
let mid = left + (right - left) / 2;
|
||||
if rows[mid].market_cap_bn < target {
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid;
|
||||
}
|
||||
}
|
||||
left
|
||||
}
|
||||
|
||||
fn to_universe_candidate(
|
||||
factor: &EligibleUniverseSnapshot,
|
||||
band_low: f64,
|
||||
band_high: f64,
|
||||
) -> UniverseCandidate {
|
||||
UniverseCandidate {
|
||||
symbol: factor.symbol.clone(),
|
||||
market_cap_bn: factor.market_cap_bn,
|
||||
free_float_cap_bn: factor.free_float_cap_bn,
|
||||
band_low,
|
||||
band_high,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user