Align jq microcap execution with intraday snapshots

This commit is contained in:
boris
2026-04-20 12:13:59 +08:00
parent 0e2c25e4c4
commit 0fe681ff5f
10 changed files with 761 additions and 94 deletions

View File

@@ -113,6 +113,39 @@ where
snapshot.price(self.execution_price_field)
}
fn snapshot_execution_price(
&self,
snapshot: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
if self.execution_price_field == PriceField::Last
&& self.intraday_execution_start_time.is_some()
{
let tick = snapshot.effective_price_tick();
let base_price = snapshot.price(PriceField::Last);
let adjusted = match side {
OrderSide::Buy => base_price + tick * 2.0,
OrderSide::Sell => base_price - tick,
};
let lower = if snapshot.lower_limit.is_finite() && snapshot.lower_limit > 0.0 {
snapshot.lower_limit
} else {
tick
};
let upper = if snapshot.upper_limit.is_finite() && snapshot.upper_limit > 0.0 {
snapshot.upper_limit
} else {
f64::INFINITY
};
return adjusted.clamp(lower, upper);
}
match side {
OrderSide::Buy => self.buy_price(snapshot),
OrderSide::Sell => self.sell_price(snapshot),
}
}
pub fn execute(
&self,
date: NaiveDate,
@@ -390,10 +423,7 @@ where
}
(fill.quantity, fill.price)
} else {
(
filled_qty,
self.sell_price(snapshot),
)
(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);
@@ -559,16 +589,17 @@ where
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 {
let round_lot = self.round_lot(data, symbol);
let price = self.sizing_price(snapshot);
let snapshot_requested_qty =
self.round_buy_quantity(((value.abs()) / price).floor() as u32, round_lot);
self.process_buy(
date,
portfolio,
data,
symbol,
requested_qty,
snapshot_requested_qty,
reason,
intraday_turnover,
execution_cursors,
@@ -577,6 +608,11 @@ where
report,
)
} else {
let price = self.sizing_price(snapshot);
let requested_qty = self.round_buy_quantity(
((value.abs()) / price).floor() as u32,
self.round_lot(data, symbol),
);
self.process_sell(
date,
portfolio,
@@ -592,6 +628,236 @@ where
}
}
fn estimate_value_buy_quantity(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
round_lot: u32,
value_budget: f64,
intraday_turnover: &BTreeMap<String, u32>,
execution_cursors: &BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
) -> Option<u32> {
if self.execution_price_field != PriceField::Last {
return None;
}
let snapshot = data.market(date, symbol)?;
let market_limited_qty = self
.market_fillable_quantity(
snapshot,
OrderSide::Buy,
u32::MAX,
round_lot,
*intraday_turnover.get(symbol).unwrap_or(&0),
)
.ok()?;
let max_requested_qty = market_limited_qty;
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);
let estimated = self.select_buy_sizing_fill(
quotes,
start_cursor,
max_requested_qty,
round_lot,
Some(portfolio.cash()),
Some(value_budget),
)?;
Some(estimated.quantity)
}
fn maybe_expand_periodic_value_buy_quantity(
&self,
date: NaiveDate,
portfolio: &PortfolioState,
data: &DataSet,
symbol: &str,
requested_qty: u32,
round_lot: u32,
value_budget: f64,
reason: &str,
execution_cursors: &BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
) -> u32 {
const PERIODIC_BUY_OVERSHOOT_TOLERANCE: f64 = 400.0;
if requested_qty == 0 || reason != "periodic_rebalance_buy" {
return requested_qty;
}
let candidate_qty = requested_qty.saturating_add(round_lot.max(1));
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);
let Some(fill) = self.select_execution_fill(
quotes,
OrderSide::Buy,
start_cursor,
candidate_qty,
round_lot,
Some(portfolio.cash()),
None,
) else {
return requested_qty;
};
if fill.quantity < candidate_qty {
return requested_qty;
}
let candidate_gross = fill.price * fill.quantity as f64;
let candidate_cost = self.cost_model.calculate(OrderSide::Buy, candidate_gross);
let candidate_cash_out = candidate_gross + candidate_cost.total();
if candidate_cash_out <= value_budget + PERIODIC_BUY_OVERSHOOT_TOLERANCE
&& candidate_cash_out <= portfolio.cash() + 1e-6
{
candidate_qty
} else {
requested_qty
}
}
fn select_buy_sizing_fill(
&self,
quotes: &[IntradayExecutionQuote],
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 = if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
quote.buy_price()
};
if fallback_quote_price.is_some() {
last_quote_price = fallback_quote_price;
last_timestamp = Some(quote.timestamp);
}
if quote.volume_delta == 0 {
continue;
}
let Some(quote_price) = fallback_quote_price else {
continue;
};
let available_qty = quote
.ask1_volume
.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 {
gross_amount += residual_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 process_buy(
&self,
date: NaiveDate,
@@ -659,7 +925,7 @@ where
execution_cursors,
None,
Some(portfolio.cash()),
value_budget,
None,
);
let (filled_qty, execution_price) = if let Some(fill) = fill {
execution_cursors.insert(symbol.to_string(), fill.next_cursor);
@@ -668,7 +934,7 @@ where
}
(fill.quantity, fill.price)
} else {
let execution_price = self.buy_price(snapshot);
let execution_price = self.snapshot_execution_price(snapshot, OrderSide::Buy);
let filled_qty = self.affordable_buy_quantity(
portfolio.cash(),
value_budget,
@@ -849,8 +1115,7 @@ where
}
if self.volume_limit {
let raw_limit =
((snapshot.tick_volume as f64) * self.volume_percent).round() as i64
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());
@@ -870,7 +1135,7 @@ where
date: NaiveDate,
symbol: &str,
side: OrderSide,
_snapshot: &crate::data::DailyMarketSnapshot,
snapshot: &crate::data::DailyMarketSnapshot,
data: &DataSet,
requested_qty: u32,
round_lot: u32,
@@ -883,6 +1148,32 @@ where
return None;
}
if self.intraday_execution_start_time.is_some() {
let execution_price = self.snapshot_execution_price(snapshot, side);
let quantity = match side {
OrderSide::Buy => self.affordable_buy_quantity(
cash_limit.unwrap_or(f64::INFINITY),
gross_limit,
execution_price,
requested_qty,
round_lot,
),
OrderSide::Sell => requested_qty,
};
if quantity == 0 {
return None;
}
let next_cursor = self
.intraday_execution_start_time
.map(|start_time| date.and_time(start_time) + Duration::seconds(1))
.unwrap_or_else(|| date.and_hms_opt(0, 0, 1).expect("valid midnight"));
return Some(ExecutionFill {
price: execution_price,
quantity,
next_cursor,
});
}
let start_cursor = execution_cursors
.get(symbol)
.copied()
@@ -1010,7 +1301,8 @@ where
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;
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;
@@ -1049,7 +1341,9 @@ where
fn uses_serial_execution_cursor(&self, reason: &str) -> bool {
matches!(
reason,
"stop_loss_exit" | "take_profit_exit" | "replacement_after_stop_loss_exit"
"stop_loss_exit"
| "take_profit_exit"
| "replacement_after_stop_loss_exit"
| "replacement_after_take_profit_exit"
)
}

View File

@@ -1280,7 +1280,11 @@ mod optional_date_format {
D: Deserializer<'de>,
{
let text = Option::<String>::deserialize(deserializer)?;
match text.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
match text
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(text) => NaiveDate::parse_from_str(text, FORMAT)
.map(Some)
.map_err(serde::de::Error::custom),

View File

@@ -116,7 +116,10 @@ where
self.run_with_progress(|_| {})
}
pub fn run_with_progress<F>(&mut self, mut on_progress: F) -> Result<BacktestResult, BacktestError>
pub fn run_with_progress<F>(
&mut self,
mut on_progress: F,
) -> Result<BacktestResult, BacktestError>
where
F: FnMut(&BacktestDayProgress),
{
@@ -287,7 +290,9 @@ where
) -> 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
.position_events
.extend(report.position_events.clone());
result.account_events.extend(report.account_events.clone());
report
}
@@ -338,7 +343,11 @@ where
});
format!(
"cash_dividend_receivable {} share_cash={:.6} quantity={} payable_date={} cash={:.2}",
action.symbol, action.share_cash, quantity_after, payable_date, cash_delta
action.symbol,
action.share_cash,
quantity_after,
payable_date,
cash_delta
)
};
notes.push(note.clone());
@@ -457,7 +466,10 @@ where
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))
.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 {

View File

@@ -21,7 +21,8 @@ impl Instrument {
}
pub fn is_delisted_before(&self, date: NaiveDate) -> bool {
self.delisted_at.is_some_and(|delisted_at| delisted_at < date)
self.delisted_at
.is_some_and(|delisted_at| delisted_at < date)
}
}

View File

@@ -163,7 +163,9 @@ pub fn compute_backtest_metrics(
);
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| {
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
});
@@ -177,7 +179,10 @@ pub fn compute_backtest_metrics(
equity_curve
.iter()
.map(|point| {
let traded = turnover_by_date.get(&point.date).copied().unwrap_or_default();
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>()
@@ -270,7 +275,10 @@ fn annualized_sharpe(returns: &[f64], daily_rf: f64, periods_per_year: f64) -> f
if returns.len() < 2 {
return 0.0;
}
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
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 {
@@ -284,7 +292,10 @@ fn annualized_sortino(returns: &[f64], daily_rf: f64, periods_per_year: f64) ->
if returns.is_empty() {
return 0.0;
}
let adjusted = returns.iter().map(|value| value - daily_rf).collect::<Vec<_>>();
let adjusted = returns
.iter()
.map(|value| value - daily_rf)
.collect::<Vec<_>>();
let downside = adjusted
.iter()
.filter(|value| **value < 0.0)
@@ -309,7 +320,10 @@ fn alpha_beta(returns: &[f64], benchmark_returns: &[f64], daily_rf: 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 strategy_excess = returns
.iter()
.map(|value| value - daily_rf)
.collect::<Vec<_>>();
let benchmark_excess = benchmark_returns
.iter()
.map(|value| value - daily_rf)

View File

@@ -138,8 +138,7 @@ impl Position {
}
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
{
if self.quantity == 0 || !ratio.is_finite() || ratio <= 0.0 || (ratio - 1.0).abs() < 1e-9 {
return 0;
}

View File

@@ -1,9 +1,10 @@
use std::collections::{BTreeMap, BTreeSet};
use chrono::{Datelike, NaiveDate};
use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::data::{DataSet, PriceField};
use crate::engine::BacktestError;
use crate::events::OrderSide;
use crate::portfolio::PortfolioState;
use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSelector};
@@ -523,6 +524,20 @@ pub struct JqMicroCapStrategy {
config: JqMicroCapConfig,
}
#[derive(Default)]
struct ProjectedExecutionState {
execution_cursors: BTreeMap<String, NaiveDateTime>,
global_execution_cursor: Option<NaiveDateTime>,
intraday_turnover: BTreeMap<String, u32>,
}
#[derive(Debug, Clone, Copy)]
struct ProjectedExecutionFill {
price: f64,
quantity: u32,
next_cursor: NaiveDateTime,
}
impl JqMicroCapStrategy {
pub fn new(config: JqMicroCapConfig) -> Self {
Self { config }
@@ -540,15 +555,28 @@ impl JqMicroCapStrategy {
(gross_amount * 0.0003).max(5.0) + (gross_amount * 0.001)
}
fn round_board_lot(&self, quantity: u32) -> u32 {
(quantity / 100) * 100
fn round_lot_quantity(&self, quantity: u32, round_lot: u32) -> u32 {
let lot = round_lot.max(1);
(quantity / lot) * lot
}
fn intraday_execution_start_time(&self) -> NaiveTime {
NaiveTime::from_hms_opt(10, 18, 0).expect("valid 10:18")
}
fn projected_round_lot(&self, ctx: &StrategyContext<'_>, symbol: &str) -> u32 {
ctx.data
.instrument(symbol)
.map(|instrument| instrument.effective_round_lot())
.unwrap_or(100)
.max(1)
}
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);
let mut quantity = self.round_lot_quantity((cash / sizing_price).floor() as u32, 100);
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
@@ -560,46 +588,342 @@ impl JqMicroCapStrategy {
0
}
fn projected_execution_price(
&self,
market: &crate::data::DailyMarketSnapshot,
side: OrderSide,
) -> f64 {
let tick = market.effective_price_tick();
let base_price = market.price(PriceField::Last);
let adjusted = match side {
OrderSide::Buy => base_price + tick * 2.0,
OrderSide::Sell => base_price - tick,
};
let lower = if market.lower_limit.is_finite() && market.lower_limit > 0.0 {
market.lower_limit
} else {
tick
};
let upper = if market.upper_limit.is_finite() && market.upper_limit > 0.0 {
market.upper_limit
} else {
f64::INFINITY
};
adjusted.clamp(lower, upper)
}
fn project_order_value(
&self,
ctx: &StrategyContext<'_>,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
sizing_price: f64,
execution_price: f64,
order_value: f64,
reason: &str,
execution_state: &mut ProjectedExecutionState,
) -> u32 {
let quantity = self.projected_buy_quantity(
projected.cash().min(order_value),
sizing_price,
execution_price,
if order_value <= 0.0 {
return 0;
}
let round_lot = self.projected_round_lot(ctx, symbol);
let market = match ctx.data.market(date, symbol) {
Some(market) => market,
None => return 0,
};
let sizing_price = market.price(PriceField::Last);
if !sizing_price.is_finite() || sizing_price <= 0.0 {
return 0;
}
let requested_qty = self.round_lot_quantity(
((projected.cash().min(order_value)) / sizing_price).floor() as u32,
round_lot,
);
if requested_qty == 0 {
return 0;
}
let execution_price = self.projected_execution_price(market, OrderSide::Buy);
let mut quantity = requested_qty;
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out <= projected.cash() + 1e-6 {
break;
}
quantity = quantity.saturating_sub(round_lot);
}
if quantity == 0 {
return 0;
}
let gross_amount = execution_price * quantity as f64;
let fill = ProjectedExecutionFill {
price: execution_price,
quantity,
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > projected.cash() + 1e-6 {
return 0;
}
projected.apply_cash_delta(-cash_out);
projected.position_mut(symbol).buy(date, quantity, execution_price);
quantity
projected
.position_mut(symbol)
.buy(date, fill.quantity, fill.price);
*execution_state
.intraday_turnover
.entry(symbol.to_string())
.or_default() += fill.quantity;
execution_state
.execution_cursors
.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
execution_state.global_execution_cursor = Some(fill.next_cursor);
}
fill.quantity
}
fn project_target_zero(
&self,
ctx: &StrategyContext<'_>,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
sell_price: f64,
reason: &str,
execution_state: &mut ProjectedExecutionState,
) -> Option<u32> {
let quantity = projected.position(symbol)?.quantity;
if quantity == 0 {
return None;
}
let gross_amount = sell_price * quantity as f64;
let market = ctx.data.market(date, symbol)?;
let fill = ProjectedExecutionFill {
price: self.projected_execution_price(market, OrderSide::Sell),
quantity,
next_cursor: date.and_time(self.intraday_execution_start_time()) + Duration::seconds(1),
};
let gross_amount = fill.price * fill.quantity as f64;
let net_cash = gross_amount - self.sell_cost(gross_amount);
projected.position_mut(symbol).sell(quantity, sell_price).ok()?;
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(net_cash);
*execution_state
.intraday_turnover
.entry(symbol.to_string())
.or_default() += fill.quantity;
execution_state
.execution_cursors
.insert(symbol.to_string(), fill.next_cursor);
if self.uses_serial_execution_cursor(reason) {
execution_state.global_execution_cursor = Some(fill.next_cursor);
}
projected.prune_flat_positions();
Some(quantity)
Some(fill.quantity)
}
fn projected_market_fillable_quantity(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
side: OrderSide,
requested_qty: u32,
round_lot: u32,
execution_state: &ProjectedExecutionState,
) -> Option<u32> {
if requested_qty == 0 {
return Some(0);
}
let snapshot = ctx.data.market(date, symbol)?;
if snapshot.tick_volume == 0 {
return None;
}
let lot = round_lot.max(1);
let mut max_fill = requested_qty;
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 None;
}
max_fill = max_fill.min(self.round_lot_quantity(top_level_liquidity, lot));
let consumed_turnover = *execution_state.intraday_turnover.get(symbol).unwrap_or(&0);
let raw_limit =
((snapshot.tick_volume as f64) * 0.25).round() as i64 - consumed_turnover as i64;
if raw_limit <= 0 {
return None;
}
let volume_limited = self.round_lot_quantity(raw_limit as u32, lot);
if volume_limited == 0 {
return None;
}
Some(max_fill.min(volume_limited))
}
fn projected_execution_start_cursor(
&self,
date: NaiveDate,
symbol: &str,
execution_state: &ProjectedExecutionState,
) -> Option<NaiveDateTime> {
execution_state
.execution_cursors
.get(symbol)
.copied()
.into_iter()
.chain(execution_state.global_execution_cursor)
.chain(Some(date.and_time(self.intraday_execution_start_time())))
.max()
}
fn projected_select_execution_fill(
&self,
ctx: &StrategyContext<'_>,
date: NaiveDate,
symbol: &str,
side: OrderSide,
requested_qty: u32,
round_lot: u32,
cash_limit: Option<f64>,
gross_limit: Option<f64>,
execution_state: &ProjectedExecutionState,
) -> Option<ProjectedExecutionFill> {
if requested_qty == 0 {
return None;
}
let lot = round_lot.max(1);
let start_cursor = self.projected_execution_start_cursor(date, symbol, execution_state);
let quotes = ctx.data.execution_quotes_on(date, symbol);
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 => {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
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);
}
if quote.volume_delta == 0 {
continue;
}
let Some(quote_price) = fallback_quote_price else {
continue;
};
let available_qty = match side {
OrderSide::Buy => quote.ask1_volume,
OrderSide::Sell => quote.bid1_volume,
}
.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 = self.round_lot_quantity(remaining_qty.min(available_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_cash_out = candidate_gross + self.buy_commission(candidate_gross);
if candidate_cash_out <= 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_lot_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_cash_out =
candidate_gross + self.buy_commission(candidate_gross);
if candidate_cash_out <= cash + 1e-6 {
break;
}
residual_qty = residual_qty.saturating_sub(lot);
}
}
if residual_qty > 0 {
gross_amount += residual_price * residual_qty as f64;
filled_qty += residual_qty;
}
}
}
}
if filled_qty == 0 {
return None;
}
Some(ProjectedExecutionFill {
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 trading_ratio(
@@ -841,6 +1165,7 @@ impl Strategy for JqMicroCapStrategy {
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 projected_execution_state = ProjectedExecutionState::default();
let mut order_intents = Vec::new();
let mut exit_symbols = BTreeSet::new();
@@ -855,7 +1180,6 @@ impl Strategy for JqMicroCapStrategy {
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);
@@ -875,7 +1199,14 @@ impl Strategy for JqMicroCapStrategy {
reason: sell_reason.to_string(),
});
if can_sell {
self.project_target_zero(&mut projected, &position.symbol, sell_price);
self.project_target_zero(
ctx,
&mut projected,
date,
&position.symbol,
sell_reason,
&mut projected_execution_state,
);
}
if projected.positions().len() < self.config.stocknum {
@@ -897,16 +1228,15 @@ impl Strategy for JqMicroCapStrategy {
value: replacement_cash,
reason: format!("replacement_after_{}", sell_reason),
});
if let Some(market) = ctx.data.market(date, symbol) {
self.project_order_value(
ctx,
&mut projected,
date,
symbol,
market.buy_price(PriceField::Last),
market.buy_price(PriceField::Last),
replacement_cash,
&format!("replacement_after_{}", sell_reason),
&mut projected_execution_state,
);
}
break;
}
}
@@ -932,13 +1262,14 @@ impl Strategy for JqMicroCapStrategy {
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);
}
self.project_target_zero(
ctx,
&mut projected,
date,
symbol,
"periodic_rebalance_sell",
&mut projected_execution_state,
);
}
let fixed_buy_cash = projected.cash() * trading_ratio / self.config.stocknum as f64;
@@ -959,18 +1290,17 @@ impl Strategy for JqMicroCapStrategy {
value: fixed_buy_cash,
reason: "periodic_rebalance_buy".to_string(),
});
if let Some(market) = ctx.data.market(date, symbol) {
self.project_order_value(
ctx,
&mut projected,
date,
symbol,
market.buy_price(PriceField::Last),
market.buy_price(PriceField::Last),
fixed_buy_cash,
"periodic_rebalance_buy",
&mut projected_execution_state,
);
}
}
}
let mut diagnostics = vec![
format!(

View File

@@ -17,7 +17,10 @@ impl Strategy for BuyThenHoldStrategy {
"buy-then-hold"
}
fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result<StrategyDecision, fidc_core::BacktestError> {
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,
@@ -238,12 +241,17 @@ fn engine_settles_delisted_position_before_missing_market_snapshot_breaks_run()
let result = engine.run().expect("backtest succeeds");
assert_eq!(result.fills.len(), 2);
assert!(result
assert!(
result
.fills
.iter()
.any(|fill| fill.reason.contains("delisted_cash_settlement") && fill.symbol == "000001.SZ"));
assert!(result
.any(|fill| fill.reason.contains("delisted_cash_settlement")
&& fill.symbol == "000001.SZ")
);
assert!(
result
.holdings_summary
.iter()
.all(|holding| holding.symbol != "000001.SZ"));
.all(|holding| holding.symbol != "000001.SZ")
);
}

View File

@@ -289,9 +289,15 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
.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);
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(),

View File

@@ -60,10 +60,9 @@ fn can_load_partitioned_snapshot_dir() {
.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");
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);