diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 5164a08..dc43add 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -111,6 +111,7 @@ pub struct BrokerSimulator { inactive_limit: bool, liquidity_limit: bool, strict_value_budget: bool, + same_day_buy_close_mark_at_fill: bool, intraday_execution_start_time: Option, runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, @@ -132,6 +133,7 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, strict_value_budget: false, + same_day_buy_close_mark_at_fill: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -157,6 +159,7 @@ impl BrokerSimulator { inactive_limit: true, liquidity_limit: true, strict_value_budget: false, + same_day_buy_close_mark_at_fill: false, intraday_execution_start_time: None, runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), @@ -185,6 +188,15 @@ impl BrokerSimulator { self } + pub fn with_same_day_buy_close_mark_at_fill(mut self, enabled: bool) -> Self { + self.same_day_buy_close_mark_at_fill = enabled; + self + } + + pub fn same_day_buy_close_mark_at_fill(&self) -> bool { + self.same_day_buy_close_mark_at_fill + } + pub fn with_volume_percent(mut self, volume_percent: f64) -> Self { self.volume_percent = volume_percent; self @@ -252,6 +264,34 @@ where snapshot.price(self.execution_price_field) } + fn value_buy_sizing_price( + &self, + date: NaiveDate, + data: &DataSet, + symbol: &str, + snapshot: &crate::data::DailyMarketSnapshot, + ) -> f64 { + let start_cursor = self + .runtime_intraday_start_time + .get() + .or(self.intraday_execution_start_time) + .map(|start_time| date.and_time(start_time)); + data.execution_quotes_on(date, symbol) + .iter() + .filter(|quote| { + start_cursor + .map(|cursor| quote.timestamp >= cursor) + .unwrap_or(true) + }) + .next() + .and_then(|quote| match self.execution_price_field { + PriceField::Last => (quote.last_price.is_finite() && quote.last_price > 0.0) + .then_some(quote.last_price), + _ => quote.buy_price(), + }) + .unwrap_or_else(|| self.sizing_price(snapshot)) + } + fn snapshot_execution_price( &self, snapshot: &crate::data::DailyMarketSnapshot, @@ -2917,7 +2957,7 @@ where let round_lot = self.round_lot(data, symbol); let minimum_order_quantity = self.minimum_order_quantity(data, symbol); let order_step_size = self.order_step_size(data, symbol); - let price = self.sizing_price(snapshot); + let price = self.value_buy_sizing_price(date, data, symbol, snapshot); let snapshot_requested_qty = self.value_buy_quantity( date, value.abs(), @@ -3408,13 +3448,10 @@ where requested_qty: u32, reference_price: f64, ) -> Option { - value_budget.map(|budget| { - if self.strict_value_budget { - budget.max(reference_price * requested_qty as f64) - } else { - budget + 400.0 - } - }) + if !self.strict_value_budget { + return None; + } + value_budget.map(|budget| budget.max(reference_price * requested_qty as f64)) } fn process_buy( @@ -3733,7 +3770,7 @@ where .position_mut(symbol) .buy(date, leg.quantity, leg.price); if let Some(position) = portfolio.position_mut_if_exists(symbol) { - position.record_trade_cost(cost.total()); + position.record_buy_trade_cost(leg.quantity, cost.total()); } report.fill_events.push(FillEvent { @@ -4372,7 +4409,8 @@ where return None; } - let quote_quantity_limited = self.quote_quantity_limited(matching_type); + let quote_quantity_limited = + self.quote_quantity_limited_for_window(matching_type, start_cursor, end_cursor); let lot = round_lot.max(1); let eligible_quotes: Vec<&IntradayExecutionQuote> = quotes .iter() @@ -4533,6 +4571,23 @@ where self.volume_limit || self.liquidity_limit || matching_type != MatchingType::NextTickLast } + fn quote_quantity_limited_for_window( + &self, + matching_type: MatchingType, + start_cursor: Option, + end_cursor: Option, + ) -> bool { + if matching_type == MatchingType::Twap + && !self.volume_limit + && !self.liquidity_limit + && start_cursor.is_some() + && start_cursor == end_cursor + { + return false; + } + self.quote_quantity_limited(matching_type) + } + fn uses_serial_execution_cursor(&self, reason: &str) -> bool { let _ = reason; false @@ -4629,4 +4684,26 @@ mod tests { assert!(volume_limited.quote_quantity_limited(MatchingType::NextTickLast)); assert!(liquidity_limited.quote_quantity_limited(MatchingType::NextTickLast)); } + + #[test] + fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_volume_limit(false) + .with_liquidity_limit(false); + let cursor = chrono::NaiveDate::from_ymd_opt(2025, 11, 3) + .unwrap() + .and_hms_opt(9, 31, 0) + .unwrap(); + + assert!(!broker.quote_quantity_limited_for_window( + MatchingType::Twap, + Some(cursor), + Some(cursor) + )); + assert!(broker.quote_quantity_limited_for_window( + MatchingType::Twap, + Some(cursor), + Some(cursor + chrono::Duration::minutes(1)) + )); + } } diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index e8a2945..4d324fc 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -654,6 +654,14 @@ impl SymbolPriceSeries { self.values_for(field).get(end - 1).copied() } + fn snapshot_before(&self, date: NaiveDate) -> Option<&DailyMarketSnapshot> { + let end = self.previous_completed_end_index(date)?; + if end == 0 { + return None; + } + self.snapshots.get(end - 1) + } + fn prefix_for(&self, field: PriceField) -> &[f64] { match field { PriceField::DayOpen => &self.open_prefix, @@ -1828,6 +1836,12 @@ impl DataSet { .and_then(|series| series.price_on_or_before(date, field)) } + pub fn market_before(&self, date: NaiveDate, symbol: &str) -> Option<&DailyMarketSnapshot> { + self.market_series_by_symbol + .get(symbol) + .and_then(|series| series.snapshot_before(date)) + } + pub fn factor_snapshots_on(&self, date: NaiveDate) -> Vec<&DailyFactorSnapshot> { self.factor_by_date .get(&date) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 8063cb7..8d8e7f8 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -2127,7 +2127,12 @@ where } } - portfolio.update_prices(execution_date, &self.data, PriceField::Close)?; + portfolio.update_prices_with_options( + execution_date, + &self.data, + PriceField::Close, + self.broker.same_day_buy_close_mark_at_fill(), + )?; let post_trade_open_orders = self.open_order_views(); let visible_order_events = result diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 6e57f8d..25eac59 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -202,6 +202,7 @@ pub struct PlatformExprStrategyConfig { pub daily_top_up_enabled: bool, pub retry_empty_rebalance: bool, pub strict_value_budget: bool, + pub quote_quantity_limit: bool, pub explicit_action_stage: PlatformExplicitActionStage, pub explicit_action_schedule: Option, pub subscription_guard_required: bool, @@ -255,6 +256,7 @@ fn band_low(index_close) { daily_top_up_enabled: false, retry_empty_rebalance: false, strict_value_budget: false, + quote_quantity_limit: true, explicit_action_stage: PlatformExplicitActionStage::OnDay, explicit_action_schedule: None, subscription_guard_required: false, @@ -428,6 +430,8 @@ struct PositionExpressionState { pub struct PlatformExprStrategy { config: PlatformExprStrategyConfig, engine: Engine, + rebalance_day_counter: usize, + pending_highlimit_holdings: BTreeSet, /// 已编译表达式 AST 缓存。 /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, /// Value 是 Rhai 编译产物。命中后 eval 走 eval_ast_with_scope,避免重复 @@ -491,6 +495,8 @@ impl PlatformExprStrategy { Self { config, engine, + rebalance_day_counter: 0, + pending_highlimit_holdings: BTreeSet::new(), compiled_cache: RefCell::new(HashMap::new()), cache_hits: RefCell::new(0), cache_misses: RefCell::new(0), @@ -796,6 +802,33 @@ impl PlatformExprStrategy { + model.stamp_tax_for(date, OrderSide::Sell, gross_amount) } + fn marked_total_value(&self, ctx: &StrategyContext<'_>, date: NaiveDate) -> f64 { + let mut total = ctx.portfolio.cash(); + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 { + continue; + } + let mark_price = ctx + .data + .price(date, &position.symbol, PriceField::Close) + .or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last)) + .or_else(|| { + ctx.data + .price_on_or_before(date, &position.symbol, PriceField::Close) + }) + .or_else(|| { + ctx.data + .price_on_or_before(date, &position.symbol, PriceField::Last) + }) + .filter(|price| price.is_finite() && *price > 0.0) + .unwrap_or(position.last_price); + if mark_price.is_finite() && mark_price > 0.0 { + total += mark_price * position.quantity as f64; + } + } + total + } + fn round_lot_quantity( &self, quantity: u32, @@ -927,12 +960,16 @@ impl PlatformExprStrategy { 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(round_lot.max(1) as u64) - .min(u32::MAX as u64) as u32; + let available_qty = if self.config.quote_quantity_limit { + match side { + OrderSide::Buy => quote.ask1_volume, + OrderSide::Sell => quote.bid1_volume, + } + .saturating_mul(round_lot.max(1) as u64) + .min(u32::MAX as u64) as u32 + } else { + requested_qty.saturating_sub(filled_qty) + }; if available_qty == 0 { continue; } @@ -1005,6 +1042,9 @@ impl PlatformExprStrategy { if quantity == 0 { return None; } + if !self.can_sell_position(ctx, date, symbol) { + return None; + } let market = ctx.data.market(date, symbol)?; let round_lot = self.projected_round_lot(ctx, symbol); let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol); @@ -1054,6 +1094,20 @@ impl PlatformExprStrategy { Some(fill.quantity) } + fn projected_position_count_excluding( + projected: &PortfolioState, + excluded_symbols: &BTreeSet, + ) -> usize { + if excluded_symbols.is_empty() { + return projected.positions().len(); + } + projected + .positions() + .keys() + .filter(|symbol| !excluded_symbols.contains(*symbol)) + .count() + } + fn project_order_value( &self, ctx: &StrategyContext<'_>, @@ -1073,7 +1127,8 @@ impl PlatformExprStrategy { Some(market) => market, None => return 0, }; - let sizing_price = market.price(PriceField::Last); + let execution_price = self.projected_execution_price(market, OrderSide::Buy); + let sizing_price = execution_price; if !sizing_price.is_finite() || sizing_price <= 0.0 { return 0; } @@ -1083,16 +1138,15 @@ impl PlatformExprStrategy { minimum_order_quantity, order_step_size, ); - let execution_price = self.projected_execution_price(market, OrderSide::Buy); let mut quantity = snapshot_requested_qty; let gross_limit = if self.config.strict_value_budget { - order_value.max(execution_price * quantity as f64) + Some(order_value.max(execution_price * quantity as f64)) } else { - order_value + 400.0 + None }; while quantity > 0 { let gross_amount = execution_price * quantity as f64; - if gross_amount <= gross_limit + if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6) && gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6 { break; @@ -1115,7 +1169,7 @@ impl PlatformExprStrategy { order_step_size, false, Some(projected.cash()), - Some(gross_limit), + gross_limit, execution_state, ) .or_else(|| { @@ -1152,18 +1206,6 @@ impl PlatformExprStrategy { fill.quantity } - 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 day_state( &self, ctx: &StrategyContext<'_>, @@ -1430,7 +1472,7 @@ impl PlatformExprStrategy { .unwrap_or(100) as i64, order_step_size: instrument.map(|item| item.order_step_size()).unwrap_or(100) as i64, paused: market.paused || candidate.is_paused, - is_st: candidate.is_st || self.special_name(ctx, symbol), + is_st: candidate.is_st, is_kcb: candidate.is_kcb, is_one_yuan: candidate.is_one_yuan || market.day_open <= 1.0, is_new_listing: candidate.is_new_listing, @@ -4315,11 +4357,7 @@ impl PlatformExprStrategy { if market.paused { continue; } - if !self.stock_passes_universe_exclude( - candidate, - market, - self.special_name(ctx, &factor.symbol), - ) { + if !self.stock_passes_universe_exclude(candidate, market, false) { continue; } rows.push(EligibleUniverseSnapshot { @@ -4780,6 +4818,14 @@ impl Strategy for PlatformExprStrategy { } else { 0.0 }; + let marked_total_value = self.marked_total_value(ctx, execution_date); + let aiquant_total_value = if marked_total_value.is_finite() && marked_total_value > 0.0 { + marked_total_value + } else if day.total_value.is_finite() && day.total_value > 0.0 { + day.total_value + } else { + ctx.portfolio.total_value() + }; let (band_low, band_high) = if self.config.rotation_enabled { self.market_cap_band(ctx, &day)? } else { @@ -4817,7 +4863,7 @@ impl Strategy for PlatformExprStrategy { default_stage_time(ScheduleStage::OnDay), ) || empty_rebalance_retry } else { - ctx.decision_index % self.config.refresh_rate == 0 || empty_rebalance_retry + self.rebalance_day_counter % self.config.refresh_rate == 0 || empty_rebalance_retry } } else { false @@ -4827,8 +4873,106 @@ impl Strategy for PlatformExprStrategy { let mut order_intents = Vec::new(); let mut exit_symbols = BTreeSet::new(); let mut intraday_attempted_buys = BTreeSet::::new(); + let mut delayed_sold_symbols = BTreeSet::::new(); + let mut unresolved_stop_loss_symbols = BTreeSet::::new(); + + let mut pending_symbols = self + .pending_highlimit_holdings + .iter() + .cloned() + .collect::>(); + let take_profit_multiplier = self.config.take_profit_expr.trim().parse::().ok(); + if let Some(multiplier) = take_profit_multiplier { + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 + || position.average_cost <= 0.0 + || pending_symbols.contains(&position.symbol) + { + continue; + } + let Some(previous) = ctx.data.market_before(execution_date, &position.symbol) + else { + continue; + }; + let tick = previous.effective_price_tick().abs().max(1e-6); + let closed_at_upper_limit = + previous.upper_limit > 0.0 && previous.close >= previous.upper_limit - tick; + let closed_at_day_high = + previous.high > 0.0 && (previous.high - previous.close).abs() <= tick; + let mut recent_pause_before_previous = false; + let mut cursor = ctx.data.market_before(previous.date, &position.symbol); + for _ in 0..3 { + let Some(snapshot) = cursor else { + break; + }; + if snapshot.paused { + recent_pause_before_previous = true; + break; + } + cursor = ctx.data.market_before(snapshot.date, &position.symbol); + } + if (closed_at_upper_limit || closed_at_day_high) + && previous.close / position.average_cost > multiplier + && recent_pause_before_previous + { + pending_symbols.insert(position.symbol.clone()); + } + } + } + let pending_symbols = pending_symbols.into_iter().collect::>(); + for symbol in pending_symbols { + if !ctx.portfolio.positions().contains_key(&symbol) { + self.pending_highlimit_holdings.remove(&symbol); + continue; + } + let stock = match self.stock_state(ctx, execution_date, &symbol) { + Ok(stock) => stock, + Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { .. })) => { + continue; + } + Err(error) => return Err(error), + }; + if stock.upper_limit > 0.0 && stock.last >= stock.upper_limit { + continue; + } + let quantity = ctx + .portfolio + .position(&symbol) + .map(|position| position.quantity as f64) + .unwrap_or(0.0); + if quantity <= 0.0 { + self.pending_highlimit_holdings.remove(&symbol); + continue; + } + order_intents.push(OrderIntent::AlgoValue { + symbol: symbol.clone(), + value: -(quantity * stock.last.max(stock.upper_limit).max(0.01) * 2.0), + style: AlgoOrderStyle::Twap, + start_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), + end_time: Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")), + reason: "delayed_limit_open_sell".to_string(), + }); + self.project_target_zero( + ctx, + &mut projected, + execution_date, + &symbol, + &mut projected_execution_state, + ); + delayed_sold_symbols.insert(symbol.clone()); + self.pending_highlimit_holdings.remove(&symbol); + } + + let mut aiquant_available_cash = if delayed_sold_symbols.is_empty() { + ctx.portfolio.cash() + } else { + projected.cash() + }; for position in ctx.portfolio.positions().values() { + if delayed_sold_symbols.contains(&position.symbol) { + continue; + } if position.quantity == 0 || position.average_cost <= 0.0 { continue; } @@ -4855,17 +4999,33 @@ impl Strategy for PlatformExprStrategy { &position.symbol, &mut projected_execution_state, ); + } else if stop_hit { + unresolved_stop_loss_symbols.insert(position.symbol.clone()); + } + } else { + let stock = match self.stock_state(ctx, execution_date, &position.symbol) { + Ok(stock) => stock, + Err(BacktestError::Data(crate::data::DataSetError::MissingSnapshot { + .. + })) => continue, + Err(error) => return Err(error), + }; + if stock.upper_limit > 0.0 && stock.last >= stock.upper_limit { + self.pending_highlimit_holdings + .insert(position.symbol.clone()); } } if self.config.daily_top_up_enabled && self.config.rotation_enabled && trading_ratio > 0.0 - && projected.positions().len() < selection_limit + && Self::projected_position_count_excluding( + &projected, + &unresolved_stop_loss_symbols, + ) < selection_limit { - let fixed_buy_cash = - ctx.portfolio.total_value() * trading_ratio / selection_limit as f64; - let available_buy_cash = fixed_buy_cash.min(projected.cash()); + let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; + let available_buy_cash = fixed_buy_cash.min(aiquant_available_cash); if available_buy_cash >= fixed_buy_cash * 0.5 { for symbol in &stock_list { if symbol == &position.symbol @@ -4890,6 +5050,7 @@ impl Strategy for PlatformExprStrategy { value: buy_cash, reason: "daily_top_up_buy".to_string(), }); + let cash_before_buy = projected.cash(); let filled_qty = self.project_order_value( ctx, &mut projected, @@ -4899,6 +5060,8 @@ impl Strategy for PlatformExprStrategy { &mut projected_execution_state, ); if filled_qty > 0 { + let spent = (cash_before_buy - projected.cash()).max(0.0); + aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); intraday_attempted_buys.insert(symbol.clone()); } break; @@ -4934,9 +5097,13 @@ impl Strategy for PlatformExprStrategy { ); } - let fixed_buy_cash = projected.cash() * trading_ratio / selection_limit as f64; + let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; for symbol in stock_list.iter().take(selection_limit) { - if projected.positions().len() >= selection_limit { + if Self::projected_position_count_excluding( + &projected, + &unresolved_stop_loss_symbols, + ) >= selection_limit + { break; } if pre_rebalance_symbols.contains(symbol) @@ -4944,6 +5111,10 @@ impl Strategy for PlatformExprStrategy { { continue; } + let available_rebalance_cash = fixed_buy_cash.min(aiquant_available_cash); + if available_rebalance_cash < fixed_buy_cash * 0.5 { + break; + } let decision_stock = self.stock_state_with_factor_date( ctx, decision_date, @@ -4960,7 +5131,8 @@ impl Strategy for PlatformExprStrategy { if !self.stock_passes_expr(ctx, &day, &decision_stock)? { continue; } - let buy_cash = fixed_buy_cash * self.buy_scale(ctx, &day, &decision_stock)?; + let buy_cash = + available_rebalance_cash * self.buy_scale(ctx, &day, &decision_stock)?; if buy_cash <= 0.0 { continue; } @@ -4969,7 +5141,8 @@ impl Strategy for PlatformExprStrategy { value: buy_cash, reason: "periodic_rebalance_buy".to_string(), }); - self.project_order_value( + let cash_before_buy = projected.cash(); + let filled_qty = self.project_order_value( ctx, &mut projected, execution_date, @@ -4977,6 +5150,21 @@ impl Strategy for PlatformExprStrategy { buy_cash, &mut projected_execution_state, ); + if filled_qty > 0 { + let spent = (cash_before_buy - projected.cash()).max(0.0); + aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + } + } + } + if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() { + if periodic_rebalance { + self.rebalance_day_counter = if projected.positions().is_empty() { + 0 + } else { + 1 + }; + } else { + self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1); } } if !explicit_action_intents.is_empty() { @@ -5004,7 +5192,7 @@ impl Strategy for PlatformExprStrategy { ) }, format!( - "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} selection_factor_date={} execution_date={}", + "selected={} periodic_rebalance={} exits={} projected_positions={} intents={} limit={} decision_date={} selection_factor_date={} execution_date={} budget_total={:.2} marked_total={:.2} day_total={:.2}", stock_list.len(), periodic_rebalance, exit_symbols.len(), @@ -5013,7 +5201,10 @@ impl Strategy for PlatformExprStrategy { selection_limit, decision_date, selection_factor_date, - execution_date + execution_date, + aiquant_total_value, + marked_total_value, + day.total_value ), format!("selected_symbols={}", stock_list.join(",")), "platform strategy script executed through expression runtime + bid1/ask1 snapshot execution".to_string(), @@ -5081,9 +5272,9 @@ mod tests { use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FactorTextValue, FuturesCommissionType, - FuturesTradingParameter, Instrument, OpenOrderView, PortfolioState, ProcessEvent, - ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext, - TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, + FuturesTradingParameter, Instrument, IntradayExecutionQuote, OpenOrderView, OrderIntent, + PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage, ScheduleTimeRule, Strategy, + StrategyContext, TargetPortfolioOrderPricing, TradingCalendar, default_stage_time, }; fn d(year: i32, month: u32, day: u32) -> NaiveDate { @@ -5106,6 +5297,103 @@ mod tests { assert_eq!(strategy.value_buy_quantity(4_000.0, 15.20, 100, 100), 200); assert_eq!(strategy.value_buy_quantity(4_000.0, 28.85, 100, 100), 100); assert_eq!(strategy.value_buy_quantity(4_000.0, 37.40, 100, 100), 100); + assert_eq!(strategy.value_buy_quantity(4_776.0, 11.93, 100, 100), 300); + assert_eq!(strategy.value_buy_quantity(4_848.0, 11.93, 100, 100), 400); + } + + #[test] + fn platform_marked_total_value_uses_current_day_close() { + let prev_date = d(2025, 6, 20); + let date = d(2025, 6, 23); + let symbol = "605303.SH"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-06-23 09:33:00".to_string()), + day_open: 11.50, + open: 11.50, + high: 12.20, + low: 11.40, + close: 11.93, + last_price: 11.80, + bid1: 11.93, + ask1: 11.93, + prev_close: 11.40, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 12.54, + lower_limit: 10.26, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![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, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(100.0); + portfolio.position_mut(symbol).buy(prev_date, 100, 10.00); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + + let marked = strategy.marked_total_value(&ctx, date); + + assert!((marked - 1_293.0).abs() < 1e-6, "{marked}"); } fn sample_calendar() -> TradingCalendar { @@ -6547,6 +6835,710 @@ mod tests { ); } + #[test] + fn platform_refresh_rate_uses_stateful_aiquant_day_counter() { + let dates = [d(2025, 2, 5), d(2025, 2, 6), d(2025, 2, 7)]; + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(move |symbol| DailyMarketSnapshot { + date: *date, + symbol: (*symbol).to_string(), + timestamp: Some(format!("{date} 09:33:00")), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols + .iter() + .enumerate() + .map(move |(index, symbol)| DailyFactorSnapshot { + date: *date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0 + index as f64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + }) + .collect(), + dates + .iter() + .flat_map(|date| { + symbols.iter().map(move |symbol| CandidateEligibility { + date: *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(), + dates + .iter() + .map(|date| BenchmarkSnapshot { + date: *date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(), + ) + .expect("dataset"); + + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.retry_empty_rebalance = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 2; + let subscriptions = BTreeSet::new(); + + let mut held_portfolio = PortfolioState::new(30_000.0); + held_portfolio + .position_mut("000001.SZ") + .buy(dates[0], 100, 10.0); + held_portfolio + .position_mut("000002.SZ") + .buy(dates[0], 100, 10.0); + let third_ctx = StrategyContext { + execution_date: dates[2], + decision_date: dates[2], + decision_index: 20, + data: &data, + portfolio: &held_portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let third = strategy.on_day(&third_ctx).expect("third decision"); + assert!( + third + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=false")), + "{:?}", + third.diagnostics + ); + } + + #[test] + fn platform_daily_top_up_does_not_use_same_day_sell_cash() { + let prev_date = d(2025, 2, 25); + let date = d(2025, 2, 26); + let symbols = ["000001.SZ", "000002.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-02-26 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: 10.0 + index as f64, + free_float_cap_bn: 10.0 + index as f64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + 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![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(100.0); + portfolio.position_mut("000001.SZ").buy(prev_date, 100, 8.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.take_profit_expr = "1.1".to_string(); + cfg.daily_top_up_enabled = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 2; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + .. + } if symbol == "000001.SZ" && *target_value == 0.0 + )), + "{:?}", + decision.order_intents + ); + assert!( + !decision + .order_intents + .iter() + .any(|intent| matches!(intent, OrderIntent::Value { .. })), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_daily_top_up_excludes_unsellable_stop_loss_from_target_count() { + let prev_date = d(2026, 3, 31); + let date = d(2026, 4, 1); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let data = DataSet::from_components( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2026-04-01 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 10.0, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: match *symbol { + "000003.SZ" => 8.0, + "000002.SZ" => 9.0, + _ => 20.0, + }, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + 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: *symbol != "000001.SZ", + is_kcb: false, + is_one_yuan: false, + }) + .collect(), + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(20_000.0); + portfolio + .position_mut("000001.SZ") + .buy(prev_date, 100, 12.0); + portfolio + .position_mut("000002.SZ") + .buy(prev_date, 100, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 2, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 99; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.stop_loss_expr = "0.9".to_string(); + cfg.daily_top_up_enabled = true; + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 2; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + .. + } if symbol == "000001.SZ" && *target_value == 0.0 + )), + "{:?}", + decision.order_intents + ); + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::Value { + symbol, + reason, + .. + } if symbol == "000003.SZ" && reason == "daily_top_up_buy" + )), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_periodic_rebalance_does_not_use_same_day_sell_cash_for_later_buys() { + let prev_date = d(2025, 5, 13); + let date = d(2025, 5, 14); + let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"]; + let data = DataSet::from_components_with_actions_and_quotes( + symbols + .iter() + .map(|symbol| Instrument { + symbol: (*symbol).to_string(), + name: (*symbol).to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }) + .collect(), + symbols + .iter() + .map(|symbol| DailyMarketSnapshot { + date, + symbol: (*symbol).to_string(), + timestamp: Some("2025-05-14 09:33:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.5, + low: 9.8, + close: 10.0, + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + prev_close: 9.9, + volume: 1_000_000, + tick_volume: 10_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }) + .collect(), + symbols + .iter() + .enumerate() + .map(|(index, symbol)| DailyFactorSnapshot { + date, + symbol: (*symbol).to_string(), + market_cap_bn: if *symbol == "000003.SZ" { + 50.0 + } else { + 10.0 + index as f64 + }, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }) + .collect(), + 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![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + Vec::new(), + symbols + .iter() + .map(|symbol| IntradayExecutionQuote { + date, + symbol: (*symbol).to_string(), + timestamp: date.and_hms_opt(9, 33, 0).expect("valid timestamp"), + last_price: 10.0, + bid1: 10.0, + ask1: 10.0, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 100_000.0, + trading_phase: Some("continuous".to_string()), + }) + .collect(), + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(1_200.0); + portfolio + .position_mut("000003.SZ") + .buy(prev_date, 100, 10.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 20, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.refresh_rate = 20; + cfg.max_positions = 2; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "2".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.take_profit_expr.clear(); + cfg.stop_loss_expr.clear(); + let mut strategy = PlatformExprStrategy::new(cfg); + strategy.rebalance_day_counter = 20; + + let decision = strategy.on_day(&ctx).expect("platform decision"); + let periodic_buys = decision + .order_intents + .iter() + .filter(|intent| { + matches!( + intent, + OrderIntent::Value { reason, .. } if reason == "periodic_rebalance_buy" + ) + }) + .count(); + + assert_eq!(periodic_buys, 1, "{:?}", decision.order_intents); + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol, + target_value, + .. + } if symbol == "000003.SZ" && *target_value == 0.0 + )), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_projection_does_not_consume_cash_from_unfillable_sell() { + let prev_date = d(2025, 3, 18); + let date = d(2025, 3, 19); + let symbol = "603139.SH"; + let data = DataSet::from_components( + vec![Instrument { + symbol: symbol.to_string(), + name: symbol.to_string(), + board: "SH".to_string(), + round_lot: 100, + listed_at: Some(d(2020, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-03-19 09:31:00".to_string()), + day_open: 18.82, + open: 18.82, + high: 18.82, + low: 18.82, + close: 18.82, + last_price: 18.82, + bid1: 0.0, + ask1: 0.0, + prev_close: 17.11, + volume: 0, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: Some("paused".to_string()), + paused: true, + upper_limit: 18.82, + lower_limit: 15.40, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 10.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(0.0), + effective_turnover_ratio: Some(0.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: true, + allow_buy: false, + allow_sell: false, + is_kcb: false, + is_one_yuan: false, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + + let mut portfolio = PortfolioState::new(100.0); + portfolio.position_mut(symbol).buy(prev_date, 500, 15.05); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 40, + data: &data, + portfolio: &portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation()); + let mut projected = portfolio.clone(); + let mut execution_state = super::ProjectedExecutionState::default(); + + let filled = + strategy.project_target_zero(&ctx, &mut projected, date, symbol, &mut execution_state); + + assert_eq!(filled, None); + assert_eq!(projected.cash(), portfolio.cash()); + assert_eq!(projected.position(symbol).expect("position").quantity, 500); + } + #[test] fn platform_selection_ranks_with_previous_factor_date() { let prev = d(2025, 1, 2); diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 8cfa8a2..06af8e8 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -1,7 +1,7 @@ use chrono::NaiveDate; use indexmap::IndexMap; use serde::Serialize; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use crate::data::{DataSet, DataSetError, PriceField}; @@ -205,6 +205,22 @@ impl Position { } } + pub fn record_buy_trade_cost(&mut self, quantity: u32, value: f64) { + if quantity == 0 || !value.is_finite() { + return; + } + let cost = value.max(0.0); + if cost <= 0.0 { + return; + } + if let Some(lot) = self.lots.last_mut() { + lot.price += cost / quantity as f64; + self.recalculate_average_cost(); + } + self.day_trade_cost += cost; + self.refresh_day_pnl(); + } + pub fn set_dividend_receivable(&mut self, value: f64) { self.dividend_receivable = if value.is_finite() { value.max(0.0) @@ -316,6 +332,7 @@ pub struct PortfolioState { positions: IndexMap, cash_receivables: Vec, pending_cash_flows: Vec, + day_sold_symbols: BTreeSet, } #[derive(Debug, Clone)] @@ -348,6 +365,7 @@ impl PortfolioState { positions: IndexMap::new(), cash_receivables: Vec::new(), pending_cash_flows: Vec::new(), + day_sold_symbols: BTreeSet::new(), } } @@ -402,7 +420,18 @@ impl PortfolioState { } pub fn prune_flat_positions(&mut self) { - self.positions.retain(|_, position| !position.is_flat()); + let mut sold_symbols = Vec::new(); + self.positions.retain(|symbol, position| { + if position.is_flat() { + if position.sold_quantity() > 0 { + sold_symbols.push(symbol.clone()); + } + false + } else { + true + } + }); + self.day_sold_symbols.extend(sold_symbols); } pub fn add_cash_receivable(&mut self, receivable: CashReceivable) { @@ -538,6 +567,7 @@ impl PortfolioState { } pub fn begin_trading_day(&mut self) { + self.day_sold_symbols.clear(); for position in self.positions.values_mut() { position.begin_trading_day(); } @@ -550,9 +580,24 @@ impl PortfolioState { data: &DataSet, field: PriceField, ) -> Result<(), DataSetError> { + self.update_prices_with_options(date, data, field, false) + } + + pub fn update_prices_with_options( + &mut self, + date: NaiveDate, + data: &DataSet, + field: PriceField, + same_day_buy_close_mark_at_fill: bool, + ) -> Result<(), DataSetError> { + let day_sold_symbols = self.day_sold_symbols.clone(); for position in self.positions.values_mut() { - if field == PriceField::Close + let sold_today = + position.sold_quantity() > 0 || day_sold_symbols.contains(&position.symbol); + if same_day_buy_close_mark_at_fill + && field == PriceField::Close && position.day_buy_quantity > 0 + && !sold_today && position.sellable_qty(date) == 0 && position.last_price.is_finite() && position.last_price > 0.0 @@ -1165,7 +1210,7 @@ mod tests { .expect("dataset"); portfolio - .update_prices(buy_date, &dataset, PriceField::Close) + .update_prices_with_options(buy_date, &dataset, PriceField::Close, true) .expect("same day close"); let position = portfolio.position(symbol).expect("position"); assert!((position.last_price - 3.01).abs() < 1e-9); @@ -1178,6 +1223,27 @@ mod tests { let position = portfolio.position(symbol).expect("position"); assert!((position.last_price - 3.07).abs() < 1e-9); assert!((position.market_value() - 3991.0).abs() < 1e-6); + + let prev_date = NaiveDate::from_ymd_opt(2025, 2, 7).unwrap(); + let mut roundtrip_portfolio = PortfolioState::new(20_000.0); + roundtrip_portfolio + .position_mut(symbol) + .buy(prev_date, 2000, 2.90); + roundtrip_portfolio.begin_trading_day(); + roundtrip_portfolio + .position_mut(symbol) + .sell(2000, 3.01) + .expect("same day sell"); + roundtrip_portfolio.prune_flat_positions(); + roundtrip_portfolio + .position_mut(symbol) + .buy(buy_date, 1800, 3.01); + roundtrip_portfolio + .update_prices(buy_date, &dataset, PriceField::Close) + .expect("same day roundtrip close"); + let position = roundtrip_portfolio.position(symbol).expect("position"); + assert!((position.last_price - 3.06).abs() < 1e-9); + assert!((position.market_value() - 5508.0).abs() < 1e-6); } #[test] diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index f979c00..4cc4bd1 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1780,11 +1780,23 @@ impl OmniMicroCapStrategy { if !sizing_price.is_finite() || sizing_price <= 0.0 { return 0; } - let snapshot_requested_qty = self.round_lot_quantity( + let mut snapshot_requested_qty = self.round_lot_quantity( ((projected.cash().min(order_value)) / sizing_price).floor() as u32, minimum_order_quantity, order_step_size, ); + while snapshot_requested_qty > 0 { + let gross_amount = sizing_price * snapshot_requested_qty as f64; + let cash_out = gross_amount + self.buy_commission(gross_amount); + if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { + break; + } + snapshot_requested_qty = self.decrement_order_quantity( + snapshot_requested_qty, + minimum_order_quantity, + order_step_size, + ); + } let projected_execution_price = self.projected_execution_price(market, OrderSide::Buy); let projected_fill = self.projected_select_execution_fill( ctx, @@ -1796,14 +1808,15 @@ impl OmniMicroCapStrategy { minimum_order_quantity, order_step_size, false, - Some(projected.cash()), - Some(order_value + 400.0), + Some(projected.cash().min(order_value)), + Some(order_value), execution_state, ); let mut quantity = snapshot_requested_qty; while quantity > 0 { let gross_amount = projected_execution_price * quantity as f64; - if gross_amount <= order_value + 400.0 && gross_amount <= projected.cash() + 1e-6 { + let cash_out = gross_amount + self.buy_commission(gross_amount); + if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { break; } quantity = @@ -1818,7 +1831,8 @@ impl OmniMicroCapStrategy { .unwrap_or(projected_execution_price); while quantity > 0 { let gross_amount = execution_price * quantity as f64; - if gross_amount <= projected.cash() + 1e-6 { + let cash_out = gross_amount + self.buy_commission(gross_amount); + if cash_out <= order_value + 1e-6 && cash_out <= projected.cash() + 1e-6 { break; } quantity = @@ -1834,7 +1848,7 @@ impl OmniMicroCapStrategy { }; let gross_amount = fill.price * fill.quantity as f64; let cash_out = gross_amount + self.buy_commission(gross_amount); - if gross_amount > projected.cash() + 1e-6 { + if cash_out > projected.cash() + 1e-6 || cash_out > order_value + 1e-6 { return 0; } projected.apply_cash_delta(-cash_out); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 65c35f5..c94ce11 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -233,8 +233,148 @@ fn broker_order_value_rounds_to_nearest_lot_when_min_lot_is_affordable() { let (portfolio, report) = execute_single_value_order(date, &data, symbol, 3_938.13); assert_eq!(report.fill_events.len(), 1); - assert_eq!(report.fill_events[0].quantity, 200); - assert_eq!(portfolio.position(symbol).expect("position").quantity, 200); + assert_eq!(report.fill_events[0].quantity, 100); + assert_eq!(portfolio.position(symbol).expect("position").quantity, 100); +} + +#[test] +fn broker_order_value_budget_includes_buy_commission() { + let date = NaiveDate::from_ymd_opt(2025, 6, 23).unwrap(); + let symbol = "605303.SH"; + let data = order_value_rounding_data(date, symbol, 11.93); + + let (portfolio, report) = execute_single_value_order(date, &data, symbol, 4_776.0); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 300); + assert_eq!(portfolio.position(symbol).expect("position").quantity, 300); + + let (portfolio, report) = execute_single_value_order(date, &data, symbol, 4_848.0); + + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].quantity, 400); + assert_eq!(portfolio.position(symbol).expect("position").quantity, 400); +} + +#[test] +fn broker_delayed_limit_open_sell_uses_tick_price() { + let date = NaiveDate::from_ymd_opt(2025, 6, 27).unwrap(); + let prev_date = NaiveDate::from_ymd_opt(2025, 6, 26).unwrap(); + let symbol = "300635.SZ"; + let data = DataSet::from_components_with_actions_and_quotes( + vec![Instrument { + symbol: symbol.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: symbol.to_string(), + timestamp: Some("2025-06-27 09:31:00".to_string()), + day_open: 12.55, + open: 12.55, + high: 13.16, + low: 12.26, + close: 12.36, + last_price: 12.39, + bid1: 12.39, + ask1: 12.40, + prev_close: 13.24, + volume: 329_575, + tick_volume: 10_000, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 14.56, + lower_limit: 11.92, + price_tick: 0.01, + }], + vec![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), + extra_factors: BTreeMap::new(), + }], + vec![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, + }], + vec![BenchmarkSnapshot { + date, + benchmark: "000300.SH".to_string(), + open: 100.0, + close: 100.0, + prev_close: 99.0, + volume: 1_000_000, + }], + Vec::new(), + vec![IntradayExecutionQuote { + date, + symbol: symbol.to_string(), + timestamp: date.and_hms_opt(9, 31, 0).unwrap(), + last_price: 12.39, + bid1: 12.39, + ask1: 12.40, + bid1_volume: 10_000, + ask1_volume: 10_000, + volume_delta: 10_000, + amount_delta: 123_900.0, + trading_phase: Some("continuous".to_string()), + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000.0); + portfolio.position_mut(symbol).buy(prev_date, 800, 10.92); + let broker = BrokerSimulator::new_with_execution_price( + ChinaAShareCostModel::default(), + ChinaEquityRuleHooks::default(), + PriceField::Last, + ) + .with_matching_type(MatchingType::NextTickLast) + .with_intraday_execution_start_time(NaiveTime::from_hms_opt(9, 31, 0).unwrap()) + .with_volume_limit(false) + .with_liquidity_limit(false); + + let report = broker + .execute( + date, + &mut portfolio, + &data, + &StrategyDecision { + rebalance: false, + target_weights: BTreeMap::new(), + exit_symbols: BTreeSet::new(), + order_intents: vec![OrderIntent::TargetValue { + symbol: symbol.to_string(), + target_value: 0.0, + reason: "delayed_limit_open_sell".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, 800); + assert_eq!(report.fill_events[0].price, 12.39); + assert!(portfolio.position(symbol).is_none()); } #[test] @@ -905,7 +1045,7 @@ fn broker_executes_order_percent_and_target_percent() { ) .expect("percent execution"); assert_eq!(percent_report.fill_events.len(), 1); - assert_eq!(percent_report.fill_events[0].quantity, 10_000); + assert_eq!(percent_report.fill_events[0].quantity, 9_900); let mut target_percent_portfolio = PortfolioState::new(1_000_000.0); let target_percent_report = broker @@ -2498,7 +2638,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() { exit_symbols: BTreeSet::new(), order_intents: vec![OrderIntent::AlgoPercent { symbol: "000002.SZ".to_string(), - percent: 0.0036, + percent: 0.0037, style: AlgoOrderStyle::Twap, start_time: Some(NaiveTime::from_hms_opt(10, 0, 0).unwrap()), end_time: Some(NaiveTime::from_hms_opt(10, 30, 0).unwrap()),