Improve jq microcap execution semantics

This commit is contained in:
boris
2026-04-18 18:02:50 +08:00
parent 9f4165e689
commit 0e2c25e4c4
26 changed files with 5058 additions and 362 deletions

View File

@@ -7,5 +7,6 @@ authors.workspace = true
[dependencies]
chrono.workspace = true
indexmap.workspace = true
serde.workspace = true
thiserror.workspace = true

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -33,6 +33,7 @@ pub enum OrderSide {
pub enum OrderStatus {
Filled,
PartiallyFilled,
Canceled,
Rejected,
}

View File

@@ -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),
}
}
}

View File

@@ -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,
};

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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,
}
}

View File

@@ -2,12 +2,8 @@ use chrono::NaiveDate;
use fidc_core::cost::CostModel;
use fidc_core::rules::EquityRuleHooks;
use fidc_core::{
CandidateEligibility,
ChinaAShareCostModel,
ChinaEquityRuleHooks,
DailyMarketSnapshot,
OrderSide,
Position,
CandidateEligibility, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyMarketSnapshot,
OrderSide, Position, PriceField,
};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -32,15 +28,25 @@ fn snapshot(open: f64, upper_limit: f64, lower_limit: f64) -> DailyMarketSnapsho
DailyMarketSnapshot {
date: d(2024, 1, 3),
symbol: "000001.SZ".to_string(),
timestamp: Some("2024-01-03 10:18:00".to_string()),
day_open: open,
open,
high: open,
low: open,
close: open,
last_price: open,
bid1: open,
ask1: open,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 100_000,
bid1_volume: 50_000,
ask1_volume: 50_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit,
lower_limit,
price_tick: 0.01,
}
}
@@ -69,14 +75,11 @@ fn china_rule_hooks_block_same_day_sell_under_t_plus_one() {
&snapshot(10.1, 11.0, 9.0),
&candidate(),
&position,
PriceField::Open,
);
assert!(!check.allowed);
assert!(check
.reason
.as_deref()
.unwrap_or_default()
.contains("t+1"));
assert!(check.reason.as_deref().unwrap_or_default().contains("t+1"));
}
#[test]
@@ -86,20 +89,62 @@ fn china_rule_hooks_block_buy_at_limit_up_and_sell_at_limit_down() {
let mut position = Position::new("000001.SZ");
position.buy(d(2024, 1, 2), 1_000, 10.0);
let buy_check = hooks.can_buy(d(2024, 1, 3), &snapshot(11.0, 11.0, 9.0), &candidate);
let buy_check = hooks.can_buy(
d(2024, 1, 3),
&snapshot(11.0, 11.0, 9.0),
&candidate,
PriceField::Open,
);
assert!(!buy_check.allowed);
assert!(buy_check
.reason
.as_deref()
.unwrap_or_default()
.contains("upper limit"));
assert!(
buy_check
.reason
.as_deref()
.unwrap_or_default()
.contains("upper limit")
);
let sell_check =
hooks.can_sell(d(2024, 1, 3), &snapshot(9.0, 11.0, 9.0), &candidate, &position);
let sell_check = hooks.can_sell(
d(2024, 1, 3),
&snapshot(9.0, 11.0, 9.0),
&candidate,
&position,
PriceField::Open,
);
assert!(!sell_check.allowed);
assert!(
sell_check
.reason
.as_deref()
.unwrap_or_default()
.contains("lower limit")
);
}
#[test]
fn china_rule_hooks_use_tick_size_tolerance_for_price_limits() {
let hooks = ChinaEquityRuleHooks;
let candidate = candidate();
let near_upper = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(10.9995, 11.0, 9.0)
};
let buy_check = hooks.can_buy(d(2024, 1, 3), &near_upper, &candidate, PriceField::Open);
assert!(!buy_check.allowed);
let near_lower = DailyMarketSnapshot {
price_tick: 0.001,
..snapshot(9.0005, 11.0, 9.0)
};
let mut position = Position::new("000001.SZ");
position.buy(d(2024, 1, 2), 1_000, 10.0);
let sell_check = hooks.can_sell(
d(2024, 1, 3),
&near_lower,
&candidate,
&position,
PriceField::Open,
);
assert!(!sell_check.allowed);
assert!(sell_check
.reason
.as_deref()
.unwrap_or_default()
.contains("lower limit"));
}

View File

@@ -0,0 +1,54 @@
use chrono::NaiveDate;
use fidc_core::{CashReceivable, PortfolioState, Position};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
#[test]
fn cash_dividend_adjusts_cost_basis_and_returns_cash_delta() {
let mut position = Position::new("000001.SZ");
position.buy(d(2025, 1, 2), 1_000, 10.0);
let cash_delta = position.apply_cash_dividend(0.5);
assert!((cash_delta - 500.0).abs() < 1e-9);
assert_eq!(position.quantity, 1_000);
assert!((position.average_cost - 9.5).abs() < 1e-9);
assert!((position.last_price - 9.5).abs() < 1e-9);
}
#[test]
fn stock_split_scales_lots_quantity_and_average_cost() {
let mut position = Position::new("000001.SZ");
position.buy(d(2025, 1, 2), 1_000, 10.0);
let delta_quantity = position.apply_split_ratio(1.2);
assert_eq!(delta_quantity, 200);
assert_eq!(position.quantity, 1_200);
assert!((position.average_cost - (10.0 / 1.2)).abs() < 1e-9);
assert!((position.last_price - (10.0 / 1.2)).abs() < 1e-9);
assert_eq!(position.sellable_qty(d(2025, 1, 3)), 1_200);
}
#[test]
fn portfolio_settles_cash_receivable_on_payable_date() {
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".to_string(),
ex_date: d(2025, 1, 2),
payable_date: d(2025, 1, 5),
amount: 500.0,
reason: "cash_dividend 0.5".to_string(),
});
let settled_early = portfolio.settle_cash_receivables(d(2025, 1, 4));
assert!(settled_early.is_empty());
assert!((portfolio.cash() - 1_000_000.0).abs() < 1e-9);
let settled = portfolio.settle_cash_receivables(d(2025, 1, 5));
assert_eq!(settled.len(), 1);
assert!((portfolio.cash() - 1_000_500.0).abs() < 1e-9);
assert!(portfolio.cash_receivables().is_empty());
}

View File

@@ -0,0 +1,249 @@
use chrono::NaiveDate;
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
Instrument, OrderIntent, PriceField, Strategy, StrategyContext, StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
struct BuyThenHoldStrategy;
impl Strategy for BuyThenHoldStrategy {
fn name(&self) -> &str {
"buy-then-hold"
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() {
return Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "000001.SZ".to_string(),
value: 10_000.0,
reason: "seed_position".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
});
}
Ok(StrategyDecision::default())
}
}
#[test]
fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let data = DataSet::from_components(
vec![
Instrument {
symbol: "000001.SZ".to_string(),
name: "Delisted".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: Some(date1),
status: "delisted".to_string(),
},
Instrument {
symbol: "000002.SZ".to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
},
],
vec![
DailyMarketSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 5.0,
open: 5.0,
high: 5.1,
low: 4.9,
close: 5.0,
last_price: 5.0,
bid1: 4.99,
ask1: 5.01,
prev_close: 5.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.5,
lower_limit: 4.5,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
timestamp: Some("2025-01-03 10:18:00".to_string()),
day_open: 5.1,
open: 5.1,
high: 5.2,
low: 5.0,
close: 5.1,
last_price: 5.1,
bid1: 5.09,
ask1: 5.11,
prev_close: 5.0,
volume: 120_000,
tick_volume: 120_000,
bid1_volume: 120_000,
ask1_volume: 120_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 5.5,
lower_limit: 4.5,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: date1,
symbol: "000001.SZ".to_string(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
DailyFactorSnapshot {
date: date1,
symbol: "000002.SZ".to_string(),
market_cap_bn: 30.0,
free_float_cap_bn: 28.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
DailyFactorSnapshot {
date: date2,
symbol: "000002.SZ".to_string(),
market_cap_bn: 31.0,
free_float_cap_bn: 29.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
},
],
vec![
CandidateEligibility {
date: date1,
symbol: "000001.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
CandidateEligibility {
date: date1,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
CandidateEligibility {
date: date2,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
},
],
vec![
BenchmarkSnapshot {
date: date1,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: date2,
benchmark: "000300.SH".to_string(),
open: 101.0,
close: 101.0,
prev_close: 100.0,
volume: 1_100_000,
},
],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
data,
BuyThenHoldStrategy,
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date1),
end_date: Some(date2),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
);
let result = engine.run().expect("backtest succeeds");
assert_eq!(result.fills.len(), 2);
assert!(result
.fills
.iter()
.any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ"));
assert!(result
.holdings_summary
.iter()
.all(|holding| holding.symbol != "000001.SZ"));
}

View File

@@ -0,0 +1,338 @@
use chrono::NaiveDate;
use fidc_core::{
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
OrderIntent, PortfolioState, PriceField, StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
#[test]
fn broker_executes_explicit_order_value_buy() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "000002.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
}],
vec![CandidateEligibility {
date,
symbol: "000002.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 100_000.0,
reason: "test_order_value".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert!(!report.fill_events.is_empty());
assert_eq!(report.fill_events[0].symbol, "000002.SZ");
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Buy);
assert!(portfolio.position("000002.SZ").is_some());
assert!(portfolio.cash() < 1_000_000.0);
}
#[test]
fn broker_uses_instrument_round_lot_for_buy_sizing() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components(
vec![Instrument {
symbol: "688001.SH".to_string(),
name: "KSH".to_string(),
board: "KSH".to_string(),
round_lot: 200,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "688001.SH".to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "688001.SH".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 20.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
}],
vec![CandidateEligibility {
date,
symbol: "688001.SH".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(10_500.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "688001.SH".to_string(),
value: 10_500.0,
reason: "round_lot".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].quantity, 1000);
}
#[test]
fn same_day_sell_then_rebuy_reinserts_position_at_end() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let market = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
date,
symbol: (*symbol).to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
})
.collect::<Vec<_>>();
let factors = symbols
.iter()
.map(|symbol| DailyFactorSnapshot {
date,
symbol: (*symbol).to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: (*symbol).to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
})
.collect::<Vec<_>>();
let data = DataSet::from_components(
instruments,
market,
factors,
candidates,
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0);
portfolio.position_mut("000002.SZ").buy(prev_date, 100, 10.0);
portfolio.position_mut("000003.SZ").buy(prev_date, 100, 10.0);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 0.0,
reason: "sell_then_rebuy".to_string(),
},
OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 10_000.0,
reason: "sell_then_rebuy".to_string(),
},
],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("broker execution");
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec![
"000001.SZ".to_string(),
"000003.SZ".to_string(),
"000002.SZ".to_string(),
]
);
}

View File

@@ -20,10 +20,11 @@ fn can_load_partitioned_snapshot_dir() {
fs::create_dir_all(dir.join("market/2024/01")).unwrap();
fs::create_dir_all(dir.join("factors/2024/01")).unwrap();
fs::create_dir_all(dir.join("candidates/2024/01")).unwrap();
fs::create_dir_all(dir.join("corporate_actions/2024/01")).unwrap();
fs::write(
dir.join("instruments.csv"),
"symbol,name,exchange,lot_size\n000001.SZ,PingAn,SZ,100\n",
"symbol,name,board,round_lot,listed_at,delisted_at,status\n000001.SZ,PingAn,SZ,100,2020-01-01,,active\n",
)
.unwrap();
fs::write(
@@ -33,7 +34,7 @@ fn can_load_partitioned_snapshot_dir() {
.unwrap();
fs::write(
dir.join("market/2024/01/2024-01-02.csv"),
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9\n",
"date,symbol,open,high,low,close,prev_close,volume,paused,upper_limit,lower_limit,day_open,last_price,bid1,ask1,price_tick\n2024-01-02,000001.SZ,10,10.5,9.9,10.2,10,100000,false,11,9,10.1,10.15,10.14,10.16,0.01\n",
)
.unwrap();
fs::write(
@@ -46,10 +47,48 @@ fn can_load_partitioned_snapshot_dir() {
"date,symbol,is_st,is_new_listing,is_paused,allow_buy,allow_sell,is_kcb,is_one_yuan\n2024-01-02,000001.SZ,false,false,false,true,true,false,false\n",
)
.unwrap();
fs::write(
dir.join("corporate_actions/2024/01/2024-01-02.csv"),
"date,symbol,payable_date,share_cash,share_bonus,share_gift,issue_quantity,issue_price,reform,adjust_factor\n2024-01-02,000001.SZ,2024-01-05,0.5,0.1,0.0,0,0,false,1.05\n",
)
.unwrap();
let data = DataSet::from_partitioned_dir(&dir).expect("partitioned dataset");
assert_eq!(data.benchmark_code(), "CSI300.DEMO");
assert!(data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()).len() == 1);
assert!(
data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())
.len()
== 1
);
let market_rows = data.market_snapshots_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
let snapshot = market_rows
.first()
.expect("market snapshot");
assert_eq!(snapshot.day_open, 10.1);
assert_eq!(snapshot.last_price, 10.15);
assert_eq!(snapshot.price_tick, 0.01);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.round_lot,
100
);
assert_eq!(
data.instruments()
.get("000001.SZ")
.expect("instrument")
.listed_at,
Some(chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap())
);
let actions = data.corporate_actions_on(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap());
assert_eq!(actions.len(), 1);
assert_eq!(
actions[0].payable_date,
Some(chrono::NaiveDate::from_ymd_opt(2024, 1, 5).unwrap())
);
assert!((actions[0].share_cash - 0.5).abs() < 1e-9);
assert!((actions[0].split_ratio() - 1.1).abs() < 1e-9);
let _ = fs::remove_dir_all(&dir);
}

View File

@@ -1,5 +1,8 @@
use chrono::NaiveDate;
use fidc_core::{CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, Strategy, StrategyContext, PortfolioState};
use fidc_core::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, DataSet, JqMicroCapConfig,
JqMicroCapStrategy, PortfolioState, Strategy, StrategyContext,
};
use std::path::PathBuf;
#[test]
@@ -28,9 +31,51 @@ fn strategy_emits_target_weights_and_diagnostics() {
assert!(decision.rebalance);
assert!(decision.rebalance);
assert!(!decision.diagnostics.is_empty());
assert!(decision
.diagnostics
.iter()
.any(|line| line.contains("signal_symbol=")));
assert!(
decision
.diagnostics
.iter()
.any(|line| line.contains("signal_symbol="))
);
assert_eq!(strategy.name(), "cn-dyn-smallcap-band");
}
#[test]
fn jq_strategy_emits_same_day_decision() {
let data_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../data/demo");
let data = DataSet::from_csv_dir(&data_dir).expect("demo data");
let execution_date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let portfolio = PortfolioState::new(1_000_000.0);
let mut cfg = JqMicroCapConfig::jq_microcap();
cfg.benchmark_signal_symbol = "000001.SZ".to_string();
cfg.benchmark_short_ma_days = 3;
cfg.benchmark_long_ma_days = 5;
cfg.stock_short_ma_days = 3;
cfg.stock_mid_ma_days = 4;
cfg.stock_long_ma_days = 5;
let mut strategy = JqMicroCapStrategy::new(cfg);
let decision = strategy
.on_day(&StrategyContext {
execution_date,
decision_date: execution_date,
decision_index: 0,
data: &data,
portfolio: &portfolio,
})
.expect("jq decision");
assert!(!decision.rebalance);
assert!(
decision
.diagnostics
.iter()
.any(|line| line.contains("jq_microcap signal="))
);
assert!(
decision
.diagnostics
.iter()
.any(|line| line.contains("selected="))
);
}