diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 7047e00..2a7e43a 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -485,13 +485,12 @@ struct SymbolPriceSeries { closes: Vec, prev_closes: Vec, last_prices: Vec, + volumes: Vec, open_prefix: Vec, close_prefix: Vec, prev_close_prefix: Vec, last_prefix: Vec, - unpaused_volumes: Vec, - unpaused_volume_prefix: Vec, - unpaused_count_prefix: Vec, + volume_prefix: Vec, } impl SymbolPriceSeries { @@ -504,20 +503,12 @@ 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 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 mut unpaused_volumes = Vec::new(); - let mut unpaused_count_prefix = Vec::with_capacity(sorted.len() + 1); - unpaused_count_prefix.push(0); - for row in &sorted { - if !row.paused { - unpaused_volumes.push(row.volume as f64); - } - unpaused_count_prefix.push(unpaused_volumes.len()); - } - let unpaused_volume_prefix = prefix_sums(&unpaused_volumes); + let volume_prefix = prefix_sums(&volumes); Self { snapshots: sorted, @@ -526,13 +517,12 @@ impl SymbolPriceSeries { closes, prev_closes, last_prices, + volumes, open_prefix, close_prefix, prev_close_prefix, last_prefix, - unpaused_volumes, - unpaused_volume_prefix, - unpaused_count_prefix, + volume_prefix, } } @@ -633,12 +623,11 @@ impl SymbolPriceSeries { return None; } let end = self.previous_completed_end_index(date)?; - let end_count = *self.unpaused_count_prefix.get(end)?; - if end_count < lookback { + if end < lookback { return None; } - let start_count = end_count - lookback; - let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count]; + let start = end - lookback; + let sum = self.volume_prefix[end] - self.volume_prefix[start]; Some(sum / lookback as f64) } @@ -647,12 +636,11 @@ impl SymbolPriceSeries { return None; } let end = self.end_index(date)?; - let end_count = *self.unpaused_count_prefix.get(end)?; - if end_count < lookback { + if end < lookback { return None; } - let start_count = end_count - lookback; - let sum = self.unpaused_volume_prefix[end_count] - self.unpaused_volume_prefix[start_count]; + let start = end - lookback; + let sum = self.volume_prefix[end] - self.volume_prefix[start]; Some(sum / lookback as f64) } @@ -661,23 +649,11 @@ impl SymbolPriceSeries { return None; } let end = self.previous_completed_end_index(date)?; - let values = self.trailing_unpaused_volumes(end, lookback)?; - if values.len() < lookback { + if end < lookback { return None; } - Some(values) - } - - fn trailing_unpaused_volumes(&self, end: usize, lookback: usize) -> Option> { - if lookback == 0 || end == 0 { - return None; - } - let end_count = *self.unpaused_count_prefix.get(end)?; - if end_count < lookback { - return None; - } - let start_count = end_count - lookback; - Some(self.unpaused_volumes[start_count..end_count].to_vec()) + let start = end - lookback; + Some(self.volumes[start..end].to_vec()) } fn end_index(&self, date: NaiveDate) -> Option { @@ -3647,7 +3623,7 @@ mod tests { } #[test] - fn decision_volume_average_skips_paused_days_before_counting_window() { + fn decision_volume_average_includes_paused_zero_volume_days() { let mut paused = market_row("2025-01-03", 11.0, 0); paused.paused = true; let series = SymbolPriceSeries::new(&[ @@ -3662,14 +3638,14 @@ mod tests { NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), 2 ), - Some(200.0) + Some(150.0) ); assert_eq!( series.decision_volume_moving_average( NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(), 3 ), - None + Some((100.0 + 0.0 + 300.0) / 3.0) ); } diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 865ac02..bccc92f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -5904,6 +5904,28 @@ impl Strategy for PlatformExprStrategy { projected.cash() }; + if self.config.aiquant_transaction_cost + && self.config.rotation_enabled + && trading_ratio > 0.0 + && trading_ratio < 1.0 + && selection_limit > 0 + && !ctx.portfolio.positions().is_empty() + { + let target_value = aiquant_total_value * trading_ratio / selection_limit as f64; + if target_value.is_finite() && target_value > 0.0 { + for position in ctx.portfolio.positions().values() { + if position.quantity == 0 || delayed_sold_symbols.contains(&position.symbol) { + continue; + } + order_intents.push(OrderIntent::TargetValue { + symbol: position.symbol.clone(), + target_value, + reason: "daily_position_target_adjust".to_string(), + }); + } + } + } + for position in ctx.portfolio.positions().values() { if delayed_sold_symbols.contains(&position.symbol) { continue; @@ -7460,6 +7482,238 @@ mod tests { assert!(selected.is_empty()); } + #[test] + fn platform_stock_expr_rejects_when_volume_ma5_exceeds_market_volume_ma100() { + let current = d(2023, 5, 4); + let start = current - chrono::Duration::days(100); + let symbol = "000153.SZ"; + let market_rows: Vec = (0..=100) + .map(|idx| { + let date = start + chrono::Duration::days(idx); + let volume = if idx >= 95 { 200_000 } else { 190_000 }; + DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some(format!("{date} 10:40: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, + tick_volume: 1_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(); + let benchmark_rows: Vec = market_rows + .iter() + .map(|row| BenchmarkSnapshot { + date: row.date, + benchmark: "000852.SH".to_string(), + open: 1000.0, + close: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }) + .collect(); + 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(), + }], + market_rows, + vec![DailyFactorSnapshot { + date: current, + symbol: symbol.to_string(), + market_cap_bn: 33.64, + free_float_cap_bn: 33.64, + pe_ttm: 8.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::from([ + ("ma5".to_string(), 11.0), + ("ma10".to_string(), 10.0), + ("ma30".to_string(), 9.0), + ("avg_volume5".to_string(), 200_000.0), + ]), + }], + vec![CandidateEligibility { + date: current, + 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, + }], + benchmark_rows, + ) + .expect("dataset"); + let portfolio = PortfolioState::new(1_000_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: current, + decision_date: current, + decision_index: 100, + 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 = symbol.to_string(); + cfg.prelude = "let ma_ratio = 1.00001;".to_string(); + cfg.stock_filter_expr = "rolling_mean(\"close\", 5) > rolling_mean(\"close\", 10) * ma_ratio && rolling_mean(\"close\", 10) > rolling_mean(\"close\", 30) * ma_ratio && rolling_mean(\"volume\", 5) < rolling_mean(\"volume\", 100) && close > 1.0 && !at_upper_limit && !at_lower_limit".to_string(); + let strategy = PlatformExprStrategy::new(cfg); + let day = strategy.day_state(&ctx, current).expect("day state"); + let stock = strategy + .stock_state_with_factor_date(&ctx, current, current, symbol) + .expect("stock state"); + + assert!( + !strategy + .stock_passes_expr(&ctx, &day, &stock) + .expect("stock expr") + ); + } + + #[test] + fn platform_aiquant_weak_market_emits_daily_position_target_adjustment() { + let date = d(2023, 5, 5); + let symbol = "300621.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(format!("{date} 10:40:00")), + day_open: 10.0, + open: 10.0, + high: 11.0, + low: 9.8, + close: 10.8, + last_price: 10.8, + bid1: 10.79, + ask1: 10.81, + prev_close: 10.5, + volume: 200_000, + tick_volume: 1_000, + bid1_volume: 2_000, + ask1_volume: 2_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.55, + lower_limit: 9.45, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: symbol.to_string(), + market_cap_bn: 20.0, + free_float_cap_bn: 20.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: 1000.0, + prev_close: 999.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut(symbol).buy(date, 11_800, 10.52); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 1, + 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.aiquant_transaction_cost = true; + cfg.signal_symbol = symbol.to_string(); + cfg.exposure_expr = "0.5".to_string(); + cfg.selection_limit_expr = "40".to_string(); + cfg.stock_filter_expr = "false".to_string(); + cfg.stop_loss_expr.clear(); + cfg.take_profit_expr.clear(); + 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 + && reason == "daily_position_target_adjust" + && target_value.is_finite() + && *target_value > 0.0 + ))); + } + #[test] fn platform_aiquant_stock_expr_uses_scheduled_tick_price_for_limit_check() { let date = d(2024, 1, 30);