From 6e54471e576d7a76a570c62636f170698250512a Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 18 May 2026 23:06:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=9E=E6=B5=8B=E6=92=AE?= =?UTF-8?q?=E5=90=88=E4=B8=8EAiQuant=E5=85=BC=E5=AE=B9=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 86 +- .../fidc-core/src/platform_expr_strategy.rs | 758 ++++++++++++++++-- crates/fidc-core/src/portfolio.rs | 36 +- crates/fidc-core/src/strategy.rs | 8 +- 4 files changed, 793 insertions(+), 95 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 4d324fc..6dd66d0 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -452,11 +452,11 @@ struct SymbolPriceSeries { closes: Vec, prev_closes: Vec, last_prices: Vec, + paused: Vec, open_prefix: Vec, close_prefix: Vec, prev_close_prefix: Vec, last_prefix: Vec, - volume_prefix: Vec, } impl SymbolPriceSeries { @@ -469,15 +469,11 @@ impl SymbolPriceSeries { let closes = sorted.iter().map(|row| row.close).collect::>(); let prev_closes = sorted.iter().map(|row| row.prev_close).collect::>(); let last_prices = sorted.iter().map(|row| row.last_price).collect::>(); - let volumes = sorted - .iter() - .map(|row| row.volume as f64) - .collect::>(); + let paused = sorted.iter().map(|row| row.paused).collect::>(); let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); let prev_close_prefix = prefix_sums(&prev_closes); let last_prefix = prefix_sums(&last_prices); - let volume_prefix = prefix_sums(&volumes); Self { snapshots: sorted, @@ -486,11 +482,11 @@ impl SymbolPriceSeries { closes, prev_closes, last_prices, + paused, open_prefix, close_prefix, prev_close_prefix, last_prefix, - volume_prefix, } } @@ -587,15 +583,11 @@ impl SymbolPriceSeries { } fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { - if lookback == 0 { + let values = self.decision_volume_values(date, lookback)?; + if values.len() < lookback { return None; } - let end = self.previous_completed_end_index(date)?; - if end < lookback { - return None; - } - let start = end - lookback; - let sum = self.volume_prefix[end] - self.volume_prefix[start]; + let sum = values.iter().sum::(); Some(sum / lookback as f64) } @@ -604,11 +596,11 @@ impl SymbolPriceSeries { return None; } let end = self.end_index(date)?; - if end < lookback { + let values = self.trailing_unpaused_volumes(end, lookback)?; + if values.len() < lookback { return None; } - let start = end - lookback; - let sum = self.volume_prefix[end] - self.volume_prefix[start]; + let sum = values.iter().sum::(); Some(sum / lookback as f64) } @@ -617,16 +609,33 @@ impl SymbolPriceSeries { return None; } let end = self.previous_completed_end_index(date)?; - if end < lookback { + let values = self.trailing_unpaused_volumes(end, lookback)?; + if values.len() < lookback { return None; } - let start = end - lookback; - Some( - self.snapshots[start..end] - .iter() - .map(|snapshot| snapshot.volume as f64) - .collect(), - ) + Some(values) + } + + fn trailing_unpaused_volumes(&self, end: usize, lookback: usize) -> Option> { + if lookback == 0 || end == 0 { + return None; + } + let mut values = Vec::with_capacity(lookback); + for idx in (0..end).rev() { + if self.paused.get(idx).copied().unwrap_or(false) { + continue; + } + values.push(self.snapshots[idx].volume as f64); + if values.len() == lookback { + break; + } + } + if values.len() < lookback { + None + } else { + values.reverse(); + Some(values) + } } fn end_index(&self, date: NaiveDate) -> Option { @@ -3385,6 +3394,33 @@ mod tests { ); } + #[test] + fn decision_volume_average_skips_paused_days_before_counting_window() { + let mut paused = market_row("2025-01-03", 11.0, 0); + paused.paused = true; + let series = SymbolPriceSeries::new(&[ + market_row("2025-01-02", 10.0, 100), + paused, + market_row("2025-01-06", 12.0, 300), + market_row("2025-01-07", 13.0, 10_000), + ]); + + assert_eq!( + series.decision_volume_moving_average( + NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), + 2 + ), + Some(200.0) + ); + assert_eq!( + series.decision_volume_moving_average( + NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), + 3 + ), + None + ); + } + #[test] fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() { let path = temp_csv_path("mixed_factor_maps"); diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 9d54c32..c969378 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1393,6 +1393,7 @@ impl PlatformExprStrategy { symbol: &str, ) -> Result { let market = ctx.data.require_market(date, symbol)?; + let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market); let factor = ctx.data.require_factor(factor_date, symbol)?; let candidate = ctx.data.require_candidate(date, symbol)?; let instrument = ctx.data.instrument(symbol); @@ -1440,22 +1441,14 @@ impl PlatformExprStrategy { .data .market_decision_volume_moving_average(date, symbol, 60) .unwrap_or(f64::NAN); - let touched_upper_limit = factor - .extra_factors - .get("touched_upper_limit") - .or_else(|| factor.extra_factors.get("hit_upper_limit")) - .or_else(|| factor.extra_factors.get("limit_up_touched")) - .copied() - .unwrap_or_default() - >= 0.5; - let touched_lower_limit = factor - .extra_factors - .get("touched_lower_limit") - .or_else(|| factor.extra_factors.get("hit_lower_limit")) - .or_else(|| factor.extra_factors.get("limit_down_touched")) - .copied() - .unwrap_or_default() - >= 0.5; + let touched_upper_limit = !market.paused + && (market.is_at_upper_limit_price(market.close) + || market.is_at_upper_limit_price(market.open) + || market.is_at_upper_limit_price(market.day_open)); + let touched_lower_limit = !market.paused + && (market.is_at_lower_limit_price(market.close) + || market.is_at_lower_limit_price(market.open) + || market.is_at_lower_limit_price(market.day_open)); let amount = factor.extra_factors.get("amount").copied().unwrap_or(0.0); Ok(StockExpressionState { @@ -1463,16 +1456,16 @@ impl PlatformExprStrategy { market_cap: factor.market_cap_bn, free_float_cap: factor.free_float_cap_bn, pe_ttm: factor.pe_ttm, - volume: market.volume as i64, + volume: feature_market.volume as i64, tick_volume: market.tick_volume as i64, bid1_volume: market.bid1_volume as i64, ask1_volume: market.ask1_volume as i64, turnover_ratio: factor.turnover_ratio.unwrap_or(0.0), effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0), - open: market.day_open, - high: market.high, - low: market.low, - close: market.close, + open: feature_market.day_open, + high: feature_market.high, + low: feature_market.low, + close: feature_market.close, last: market.last_price, prev_close: market.prev_close, amount, @@ -4563,11 +4556,6 @@ impl PlatformExprStrategy { { return Ok(Some("upper_limit".to_string())); } - if market.is_at_lower_limit_price(market.day_open) - || market.is_at_lower_limit_price(market.sell_price(PriceField::Last)) - { - return Ok(Some("lower_limit".to_string())); - } Ok(None) } @@ -4670,7 +4658,11 @@ impl PlatformExprStrategy { { return Ok((false, false)); } - if position.quantity == 0 || position.average_cost <= 0.0 { + let avg_price = position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost); + if position.quantity == 0 || avg_price <= 0.0 { return Ok((false, false)); } let stock = match self.stock_state(ctx, signal_date, symbol) { @@ -4681,8 +4673,8 @@ impl PlatformExprStrategy { Err(error) => return Err(error), }; let current_price = stock.last; - let holding_return = if position.average_cost > 0.0 { - current_price / position.average_cost - 1.0 + let holding_return = if avg_price > 0.0 { + current_price / avg_price - 1.0 } else { 0.0 }; @@ -4695,7 +4687,7 @@ impl PlatformExprStrategy { let position_state = PositionExpressionState { order_book_id: position.symbol.clone(), avg_cost: position.average_cost, - avg_price: position.average_cost, + avg_price, current_price, prev_close: stock.prev_close, holding_return, @@ -4735,9 +4727,9 @@ impl PlatformExprStrategy { if let Some(boolean) = stop_result.clone().try_cast::() { boolean } else if let Some(multiplier) = stop_result.clone().try_cast::() { - current_price <= position.average_cost * multiplier + current_price <= avg_price * multiplier } else if let Some(multiplier) = stop_result.try_cast::() { - current_price <= position.average_cost * multiplier as f64 + current_price <= avg_price * multiplier as f64 } else { false } @@ -4755,15 +4747,9 @@ impl PlatformExprStrategy { if let Some(boolean) = take_result.clone().try_cast::() { boolean } else if let Some(multiplier) = take_result.clone().try_cast::() { - !ctx.data - .require_market(signal_date, symbol)? - .is_at_upper_limit_price(current_price) - && current_price / position.average_cost > multiplier + current_price / avg_price > multiplier } else if let Some(multiplier) = take_result.try_cast::() { - !ctx.data - .require_market(signal_date, symbol)? - .is_at_upper_limit_price(current_price) - && current_price / position.average_cost > multiplier as f64 + current_price / avg_price > multiplier as f64 } else { false } @@ -4891,6 +4877,13 @@ impl Strategy for PlatformExprStrategy { let mut intraday_attempted_buys = BTreeSet::::new(); let mut delayed_sold_symbols = BTreeSet::::new(); let mut unresolved_stop_loss_symbols = BTreeSet::::new(); + let initial_position_symbols = ctx + .portfolio + .positions() + .values() + .filter(|position| position.quantity > 0) + .map(|position| position.symbol.clone()) + .collect::>(); let mut pending_symbols = self .pending_highlimit_holdings @@ -4900,8 +4893,12 @@ impl Strategy for PlatformExprStrategy { 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() { + let avg_price = position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost); if position.quantity == 0 - || position.average_cost <= 0.0 + || avg_price <= 0.0 || pending_symbols.contains(&position.symbol) { continue; @@ -4928,7 +4925,7 @@ impl Strategy for PlatformExprStrategy { cursor = ctx.data.market_before(snapshot.date, &position.symbol); } if (closed_at_upper_limit || closed_at_day_high) - && previous.close / position.average_cost > multiplier + && previous.close / avg_price > multiplier && recent_pause_before_previous { pending_symbols.insert(position.symbol.clone()); @@ -4994,7 +4991,11 @@ impl Strategy for PlatformExprStrategy { if delayed_sold_symbols.contains(&position.symbol) { continue; } - if position.quantity == 0 || position.average_cost <= 0.0 { + let avg_price = position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost); + if position.quantity == 0 || avg_price <= 0.0 { continue; } let (stop_hit, profit_hit) = @@ -5136,8 +5137,15 @@ impl Strategy for PlatformExprStrategy { } } + aiquant_available_cash = projected.cash(); let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64; + let max_periodic_buys = + selection_limit.saturating_sub(initial_position_symbols.len().min(selection_limit)); + let mut periodic_buy_count = 0_usize; for symbol in stock_list.iter().take(selection_limit) { + if periodic_buy_count >= max_periodic_buys { + break; + } if Self::projected_position_count_excluding( &projected, &unresolved_stop_loss_symbols, @@ -5151,16 +5159,24 @@ 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 slots_remaining = selection_limit + .saturating_sub(Self::projected_position_count_excluding( + &projected, + &unresolved_stop_loss_symbols, + )) + .max(1); let decision_stock = self.stock_state_with_factor_date( ctx, decision_date, selection_factor_date, symbol, )?; + let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?; + let buy_cash = (fixed_buy_cash * stock_scale) + .min(aiquant_available_cash / slots_remaining as f64); + if buy_cash <= 0.0 { + break; + } let execution_stock = self.stock_state(ctx, execution_date, symbol)?; if self .buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)? @@ -5171,11 +5187,6 @@ impl Strategy for PlatformExprStrategy { if !self.stock_passes_expr(ctx, &day, &decision_stock)? { continue; } - let buy_cash = - available_rebalance_cash * self.buy_scale(ctx, &day, &decision_stock)?; - if buy_cash <= 0.0 { - continue; - } order_intents.push(OrderIntent::Value { symbol: symbol.clone(), value: buy_cash, @@ -5193,16 +5204,13 @@ impl Strategy for PlatformExprStrategy { if filled_qty > 0 { let spent = (cash_before_buy - projected.cash()).max(0.0); aiquant_available_cash = (aiquant_available_cash - spent).max(0.0); + periodic_buy_count += 1; } } } 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 - }; + self.rebalance_day_counter = 1; } else { self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1); } @@ -5436,6 +5444,564 @@ mod tests { assert!((marked - 1_293.0).abs() < 1e-6, "{marked}"); } + #[test] + fn platform_take_profit_sells_position_at_upper_limit() { + let prev_date = d(2025, 2, 6); + let date = d(2025, 2, 7); + let symbol = "001368.SZ"; + let data = DataSet::from_components( + vec![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(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-02-07 10:18:00".to_string()), + day_open: 23.11, + open: 23.11, + high: 23.99, + low: 23.11, + close: 23.99, + last_price: 23.99, + bid1: 23.99, + ask1: 23.99, + prev_close: 21.81, + volume: 100_161, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 23.99, + lower_limit: 19.63, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 24.90, + free_float_cap_bn: 6.10, + pe_ttm: 8.0, + turnover_ratio: Some(35.77), + effective_turnover_ratio: Some(35.77), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: false, + 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(1_000_000.0); + portfolio.position_mut(symbol).buy(prev_date, 6_200, 19.86); + 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.rotation_enabled = false; + cfg.signal_symbol = symbol.to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr = "1.07".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol: intent_symbol, + target_value, + reason, + } if intent_symbol == symbol && *target_value == 0.0 && reason == "take_profit_exit" + )), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_take_profit_uses_strategy_entry_price_not_fee_cost_basis() { + let prev_date = d(2025, 3, 13); + let date = d(2025, 3, 14); + let symbol = "600561.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-14 10:18:00".to_string()), + day_open: 5.96, + open: 5.96, + high: 6.08, + low: 5.95, + close: 6.06, + last_price: 6.06, + bid1: 6.05, + ask1: 6.06, + prev_close: 5.86, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 6.45, + lower_limit: 5.27, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 3.2, + free_float_cap_bn: 2.1, + pe_ttm: 8.0, + turnover_ratio: Some(3.0), + effective_turnover_ratio: Some(3.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: symbol.to_string(), + is_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: false, + 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(1_000_000.0); + portfolio.position_mut(symbol).buy(prev_date, 22_200, 5.66); + portfolio + .position_mut(symbol) + .record_buy_trade_cost(22_200, 100.0); + assert!(portfolio.position(symbol).unwrap().average_cost > 5.66); + assert!(6.06 / portfolio.position(symbol).unwrap().average_cost <= 1.07); + + 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 mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.rotation_enabled = false; + cfg.signal_symbol = symbol.to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr = "1.07".to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!( + decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol: intent_symbol, + target_value, + reason, + } if intent_symbol == symbol && *target_value == 0.0 && reason == "take_profit_exit" + )), + "{:?}", + decision.order_intents + ); + } + + #[test] + fn platform_stock_state_uses_current_day_limit_status_for_buy_scale() { + let date = d(2025, 3, 14); + let symbol = "603813.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-14 10:18:00".to_string()), + day_open: 14.82, + open: 14.82, + high: 15.98, + low: 14.80, + close: 15.98, + last_price: 15.05, + bid1: 15.04, + ask1: 15.05, + prev_close: 14.53, + volume: 1_000_000, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 15.98, + lower_limit: 13.08, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 8.0, + free_float_cap_bn: 4.0, + pe_ttm: 8.0, + turnover_ratio: Some(3.0), + effective_turnover_ratio: Some(3.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 portfolio = PortfolioState::new(1_000_000.0); + 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 stock = strategy + .stock_state_with_factor_date(&ctx, date, date, symbol) + .expect("stock state"); + + assert!(stock.touched_upper_limit); + } + + #[test] + fn platform_stock_state_uses_factor_date_for_selection_market_fields() { + let factor_date = d(2025, 4, 3); + let date = d(2025, 4, 7); + let symbol = "003008.SZ"; + let data = DataSet::from_components( + vec![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(), + }], + vec![ + DailyMarketSnapshot { + date: factor_date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-03 15:00:00".to_string()), + day_open: 9.8, + open: 9.8, + high: 10.2, + low: 9.6, + close: 9.9, + last_price: 9.9, + bid1: 9.9, + ask1: 9.9, + prev_close: 9.7, + volume: 12_300, + tick_volume: 0, + bid1_volume: 0, + ask1_volume: 0, + trading_phase: Some("close".to_string()), + paused: false, + upper_limit: 10.67, + lower_limit: 8.73, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-07 10:18:00".to_string()), + day_open: 19.0, + open: 19.0, + high: 20.5, + low: 18.8, + close: 20.0, + last_price: 19.06, + bid1: 19.05, + ask1: 19.06, + prev_close: 9.9, + volume: 45_600, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 20.89, + lower_limit: 17.11, + price_tick: 0.01, + }, + ], + vec![ + DailyFactorSnapshot { + date: factor_date, + symbol: symbol.to_string(), + market_cap_bn: 8.0, + free_float_cap_bn: 4.0, + pe_ttm: 8.0, + turnover_ratio: Some(1.2), + effective_turnover_ratio: Some(1.2), + extra_factors: BTreeMap::from([("touched_upper_limit".to_string(), 1.0)]), + }, + DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 9.0, + free_float_cap_bn: 5.0, + pe_ttm: 8.0, + turnover_ratio: Some(2.4), + effective_turnover_ratio: Some(2.4), + 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 portfolio = PortfolioState::new(1_000_000.0); + 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 stock = strategy + .stock_state_with_factor_date(&ctx, date, factor_date, symbol) + .expect("stock state"); + + assert_eq!(stock.close, 9.9); + assert_eq!(stock.volume, 12_300); + assert_eq!(stock.last, 19.06); + assert_eq!(stock.market_cap, 8.0); + assert!(!stock.touched_upper_limit); + } + + #[test] + fn platform_buy_rejection_allows_lower_limit_buy_when_ask_is_available() { + let date = d(2025, 4, 7); + let symbol = "003008.SZ"; + let data = DataSet::from_components( + vec![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(), + }], + vec![DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-04-07 10:18:00".to_string()), + day_open: 20.0, + open: 20.0, + high: 20.3, + low: 19.05, + close: 19.05, + last_price: 19.06, + bid1: 19.05, + ask1: 19.06, + prev_close: 21.17, + volume: 16_173, + tick_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 23.29, + lower_limit: 19.05, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 8.0, + free_float_cap_bn: 4.0, + pe_ttm: 8.0, + turnover_ratio: Some(2.0), + effective_turnover_ratio: Some(2.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 portfolio = PortfolioState::new(1_000_000.0); + 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 stock = strategy + .stock_state(&ctx, date, symbol) + .expect("stock state"); + + let rejection = strategy + .buy_rejection_reason(&ctx, date, symbol, &stock) + .expect("rejection"); + + assert_eq!(rejection, None); + } + fn sample_calendar() -> TradingCalendar { TradingCalendar::new(vec![ d(2025, 1, 30), @@ -6627,7 +7193,7 @@ mod tests { last_price: 10.0 + index as f64, bid1: 10.0 + index as f64, ask1: 10.0 + index as f64, - prev_close: 9.8 + index as f64, + prev_close: if index == 0 { 9.8 } else { 9.0 + index as f64 }, volume: 1000 + index as u64 * 100, tick_volume: 0, bid1_volume: 0, @@ -7015,6 +7581,76 @@ mod tests { "{:?}", third.diagnostics ); + + let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation(); + no_retry_cfg.signal_symbol = "000001.SZ".to_string(); + no_retry_cfg.refresh_rate = 15; + no_retry_cfg.max_positions = 2; + no_retry_cfg.benchmark_short_ma_days = 1; + no_retry_cfg.benchmark_long_ma_days = 1; + no_retry_cfg.market_cap_lower_expr = "0".to_string(); + no_retry_cfg.market_cap_upper_expr = "100".to_string(); + no_retry_cfg.selection_limit_expr = "2".to_string(); + no_retry_cfg.stock_filter_expr = "close < 0".to_string(); + no_retry_cfg.retry_empty_rebalance = false; + let mut no_retry_strategy = PlatformExprStrategy::new(no_retry_cfg); + let empty_portfolio = PortfolioState::new(30_000.0); + let first_empty_ctx = StrategyContext { + execution_date: dates[0], + decision_date: dates[0], + decision_index: 20, + data: &data, + portfolio: &empty_portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let first_empty = no_retry_strategy + .on_day(&first_empty_ctx) + .expect("first empty decision"); + assert!( + first_empty + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=true")), + "{:?}", + first_empty.diagnostics + ); + assert_eq!(no_retry_strategy.rebalance_day_counter, 1); + + let second_empty_ctx = StrategyContext { + execution_date: dates[1], + decision_date: dates[1], + decision_index: 21, + data: &data, + portfolio: &empty_portfolio, + futures_account: None, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + order_events: &[], + fills: &[], + }; + let second_empty = no_retry_strategy + .on_day(&second_empty_ctx) + .expect("second empty decision"); + assert!( + second_empty + .diagnostics + .iter() + .any(|item| item.contains("periodic_rebalance=false")), + "{:?}", + second_empty.diagnostics + ); } #[test] @@ -7492,7 +8128,7 @@ mod tests { } #[test] - fn platform_periodic_rebalance_does_not_use_same_day_sell_cash_for_later_buys() { + fn platform_periodic_rebalance_uses_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"]; @@ -7595,7 +8231,7 @@ mod tests { ) .expect("dataset"); - let mut portfolio = PortfolioState::new(1_200.0); + let mut portfolio = PortfolioState::new(100.0); portfolio .position_mut("000003.SZ") .buy(prev_date, 100, 10.0); @@ -7643,7 +8279,7 @@ mod tests { }) .count(); - assert_eq!(periodic_buys, 1, "{:?}", decision.order_intents); + assert_eq!(periodic_buys, 2, "{:?}", decision.order_intents); assert!( decision.order_intents.iter().any(|intent| matches!( intent, diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 06af8e8..5cc430f 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -9,6 +9,7 @@ use crate::data::{DataSet, DataSetError, PriceField}; pub struct PositionLot { pub acquired_date: NaiveDate, pub quantity: u32, + pub entry_price: f64, pub price: f64, } @@ -72,6 +73,7 @@ impl Position { self.lots.push(PositionLot { acquired_date: date, quantity, + entry_price: price, price, }); self.quantity += quantity; @@ -230,13 +232,28 @@ impl Position { } pub fn holding_return(&self, price: f64) -> Option { - if self.quantity == 0 || self.average_cost <= 0.0 { + let Some(avg_price) = self.average_entry_price() else { + return None; + }; + if avg_price <= 0.0 { None } else { - Some((price / self.average_cost) - 1.0) + Some((price / avg_price) - 1.0) } } + pub fn average_entry_price(&self) -> Option { + if self.quantity == 0 { + return None; + } + let total = self + .lots + .iter() + .map(|lot| lot.entry_price * lot.quantity as f64) + .sum::(); + Some(total / self.quantity as f64) + } + fn recalculate_average_cost(&mut self) { if self.quantity == 0 { self.average_cost = 0.0; @@ -258,6 +275,7 @@ impl Position { } for lot in &mut self.lots { + lot.entry_price -= dividend_per_share; lot.price -= dividend_per_share; } self.average_cost -= dividend_per_share; @@ -280,6 +298,7 @@ impl Position { .map(|lot| PositionLot { acquired_date: lot.acquired_date, quantity: round_half_up_u32(lot.quantity as f64 * ratio), + entry_price: lot.entry_price / ratio, price: lot.price / ratio, }) .collect::>(); @@ -759,6 +778,7 @@ impl PortfolioState { .map(|lot| PositionLot { acquired_date: lot.acquired_date, quantity: round_half_up_u32(lot.quantity as f64 * ratio), + entry_price: lot.entry_price / ratio, price: lot.price / ratio, }) .collect::>(); @@ -855,6 +875,18 @@ mod tests { ); } + #[test] + fn strategy_entry_price_excludes_buy_commission_cost_basis() { + let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let mut position = Position::new("600561.SH"); + position.buy(date, 22_200, 5.66); + position.record_buy_trade_cost(22_200, 100.0); + + assert!(position.average_cost > 5.66); + assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12); + assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12); + } + #[test] fn portfolio_tracks_dividend_receivable_and_day_pnl() { let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 4cc4bd1..a0fbc94 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -2388,11 +2388,6 @@ impl OmniMicroCapStrategy { { return Ok(Some("upper_limit".to_string())); } - if market.is_at_lower_limit_price(market.day_open) - || market.is_at_lower_limit_price(market.sell_price(PriceField::Last)) - { - return Ok(Some("lower_limit".to_string())); - } if market.day_open <= 1.0 { return Ok(Some("one_yuan".to_string())); } @@ -2744,8 +2739,7 @@ impl Strategy for OmniMicroCapStrategy { let stop_hit = current_price <= position.average_cost * self.config.stop_loss_ratio + self.stop_loss_tolerance(market); - let profit_hit = !market.is_at_upper_limit_price(current_price) - && current_price / position.average_cost > self.config.take_profit_ratio; + let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio; let can_sell = self.can_sell_position(ctx, date, &position.symbol); if stop_hit || profit_hit { let sell_reason = if stop_hit {