From 0fe681ff5f68808129ba1870b482478e72735e8d Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 20 Apr 2026 12:13:59 +0800 Subject: [PATCH] Align jq microcap execution with intraday snapshots --- crates/fidc-core/src/broker.rs | 326 +++++++++++++- crates/fidc-core/src/data.rs | 6 +- crates/fidc-core/src/engine.rs | 20 +- crates/fidc-core/src/instrument.rs | 3 +- crates/fidc-core/src/metrics.rs | 30 +- crates/fidc-core/src/portfolio.rs | 3 +- crates/fidc-core/src/strategy.rs | 422 ++++++++++++++++-- crates/fidc-core/tests/delisting.rs | 26 +- crates/fidc-core/tests/explicit_order_flow.rs | 12 +- crates/fidc-core/tests/partitioned_loader.rs | 7 +- 10 files changed, 761 insertions(+), 94 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 85fec29..3d08480 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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, + execution_cursors: &BTreeMap, + global_execution_cursor: Option, + ) -> Option { + 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, + global_execution_cursor: Option, + ) -> 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, + requested_qty: u32, + round_lot: u32, + cash_limit: Option, + gross_limit: Option, + ) -> Option { + 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,9 +1115,8 @@ where } if self.volume_limit { - let raw_limit = - ((snapshot.tick_volume as f64) * self.volume_percent).round() as i64 - - consumed_turnover 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" ) } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 167906d..820e1ca 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -1280,7 +1280,11 @@ mod optional_date_format { D: Deserializer<'de>, { let text = Option::::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), diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 89928da..080d06c 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -116,7 +116,10 @@ where self.run_with_progress(|_| {}) } - pub fn run_with_progress(&mut self, mut on_progress: F) -> Result + pub fn run_with_progress( + &mut self, + mut on_progress: F, + ) -> Result 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 { diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index 4c24dec..df46f0f 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -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) } } diff --git a/crates/fidc-core/src/metrics.rs b/crates/fidc-core/src/metrics.rs index 88bc6ba..390ae9d 100644 --- a/crates/fidc-core/src/metrics.rs +++ b/crates/fidc-core/src/metrics.rs @@ -163,10 +163,12 @@ pub fn compute_backtest_metrics( ); let monthly_volatility = annualized_std(&monthly_portfolio_returns, MONTHS_PER_YEAR); - let turnover_by_date = fills.iter().fold(BTreeMap::::new(), |mut acc, fill| { - *acc.entry(fill.date).or_default() += fill.gross_amount.abs(); - acc - }); + let turnover_by_date = fills + .iter() + .fold(BTreeMap::::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)) @@ -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::() @@ -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::>(); + let adjusted = returns + .iter() + .map(|value| value - daily_rf) + .collect::>(); 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::>(); + let adjusted = returns + .iter() + .map(|value| value - daily_rf) + .collect::>(); 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::>(); + let strategy_excess = returns + .iter() + .map(|value| value - daily_rf) + .collect::>(); let benchmark_excess = benchmark_returns .iter() .map(|value| value - daily_rf) diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 110701b..9a48bb4 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -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; } diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 3d65a6c..51b758c 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -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, + global_execution_cursor: Option, + intraday_turnover: BTreeMap, +} + +#[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 { 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 { + 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 { + 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, + gross_limit: Option, + execution_state: &ProjectedExecutionState, + ) -> Option { + 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( - &mut projected, - date, - symbol, - market.buy_price(PriceField::Last), - market.buy_price(PriceField::Last), - replacement_cash, - ); - } + self.project_order_value( + ctx, + &mut projected, + date, + symbol, + 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,16 +1290,15 @@ 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( - &mut projected, - date, - symbol, - market.buy_price(PriceField::Last), - market.buy_price(PriceField::Last), - fixed_buy_cash, - ); - } + self.project_order_value( + ctx, + &mut projected, + date, + symbol, + fixed_buy_cash, + "periodic_rebalance_buy", + &mut projected_execution_state, + ); } } diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 537542f..ab4d8ec 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -17,7 +17,10 @@ impl Strategy for BuyThenHoldStrategy { "buy-then-hold" } - fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + fn on_day( + &mut self, + ctx: &StrategyContext<'_>, + ) -> Result { 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 - .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")); + 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") + ); } diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index f27759e..46d0b46 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -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(), diff --git a/crates/fidc-core/tests/partitioned_loader.rs b/crates/fidc-core/tests/partitioned_loader.rs index 6c294f8..2228296 100644 --- a/crates/fidc-core/tests/partitioned_loader.rs +++ b/crates/fidc-core/tests/partitioned_loader.rs @@ -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);