diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c64468b..f839be0 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8105,7 +8105,7 @@ impl PlatformExprStrategy { } Err(error) => return Err(error), }; - let current_price = stock.last; + let current_price = self.stop_take_current_price(ctx, signal_date, symbol, &stock)?; let holding_return = if stop_take_base_price > 0.0 { current_price / stop_take_base_price - 1.0 } else { @@ -8197,6 +8197,24 @@ impl PlatformExprStrategy { }; Ok((stop_hit, profit_hit)) } + + fn stop_take_current_price( + &self, + ctx: &StrategyContext<'_>, + signal_date: NaiveDate, + symbol: &str, + stock: &StockExpressionState, + ) -> Result { + if self.config.aiquant_transaction_cost + && self.config.matching_type == MatchingType::NextBarOpen + { + let market = ctx.data.require_market(signal_date, symbol)?; + if market.close.is_finite() && market.close > 0.0 { + return Ok(market.close); + } + } + Ok(stock.last) + } } impl Strategy for PlatformExprStrategy { @@ -13474,6 +13492,167 @@ mod tests { ); } + #[test] + fn platform_aiquant_next_open_stop_loss_uses_signal_close_not_execution_last_price() { + let date = d(2022, 12, 22); + let symbol = "300405.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("2022-12-22 15:00:00".to_string()), + day_open: 5.28, + open: 5.28, + high: 5.28, + low: 5.05, + close: 5.08, + // In next-open handoff this field may carry the projected + // execution quote. Stop/take checks must still use the + // signal-day close, matching the generated AiQuant strategy. + last_price: 5.11, + bid1: 5.11, + ask1: 5.11, + prev_close: 5.24, + volume: 4_724_443, + minute_volume: 0, + bid1_volume: 10_000, + ask1_volume: 10_000, + trading_phase: Some("close".to_string()), + paused: false, + upper_limit: 6.29, + lower_limit: 4.19, + 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_star_st: false, + is_new_listing: false, + is_paused: false, + allow_buy: true, + allow_sell: true, + is_kcb: false, + is_one_yuan: false, + risk_level_code: None, + }], + 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); + { + let position = portfolio.position_mut(symbol); + position.buy(d(2022, 11, 21), 43_300, 5.75575); + position.record_buy_trade_cost(43_300, 74.7671925); + position.buy(d(2022, 11, 23), 900, 5.60560); + position.record_buy_trade_cost(900, 5.0); + position.sell(300, 5.63436).expect("sell 2022-11-25"); + position.buy(d(2022, 11, 28), 800, 5.50550); + position.record_buy_trade_cost(800, 5.0); + position.sell(100, 5.55444).expect("sell 2022-11-29"); + position.buy(d(2022, 11, 30), 100, 5.62562); + position.record_buy_trade_cost(100, 5.0); + position.sell(400, 5.68431).expect("sell 2022-12-02"); + position.buy(d(2022, 12, 5), 200, 5.74574); + position.record_buy_trade_cost(200, 5.0); + position.buy(d(2022, 12, 6), 200, 5.73573); + position.record_buy_trade_cost(200, 5.0); + position.buy(d(2022, 12, 7), 400, 5.67567); + position.record_buy_trade_cost(400, 5.0); + position.buy(d(2022, 12, 8), 100, 5.68568); + position.record_buy_trade_cost(100, 5.0); + position.buy(d(2022, 12, 9), 100, 5.66566); + position.record_buy_trade_cost(100, 5.0); + position.buy(d(2022, 12, 12), 200, 5.58558); + position.record_buy_trade_cost(200, 5.0); + position.buy(d(2022, 12, 13), 9_000, 4.60460); + position.record_buy_trade_cost(9_000, 12.43242); + position.sell(7_500, 5.35464).expect("sell 2022-12-14"); + position.sell(400, 5.38461).expect("sell 2022-12-15"); + position.sell(2_400, 5.70429).expect("sell 2022-12-16"); + position.buy(d(2022, 12, 19), 1_700, 5.45545); + position.record_buy_trade_cost(1_700, 5.0); + position.buy(d(2022, 12, 20), 400, 5.26526); + position.record_buy_trade_cost(400, 5.0); + position.sell(400, 5.30469).expect("sell 2022-12-21"); + } + let position = portfolio.position(symbol).expect("position"); + assert_eq!(position.quantity, 45_900); + assert!( + 5.08 < position.average_cost * 0.92, + "average_cost={} threshold={}", + position.average_cost, + position.average_cost * 0.92 + ); + assert!( + 5.11 >= position.average_cost * 0.92, + "average_cost={} threshold={}", + position.average_cost, + position.average_cost * 0.92 + ); + + 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.aiquant_transaction_cost = true; + cfg.matching_type = MatchingType::NextBarOpen; + cfg.signal_symbol = symbol.to_string(); + cfg.prelude = "let stop_loss = 0.92;".to_string(); + cfg.stop_loss_expr = "stop_loss".to_string(); + cfg.take_profit_expr.clear(); + let strategy = PlatformExprStrategy::new(cfg); + let day = strategy.day_state(&ctx, date).expect("day state"); + + let (stop_hit, profit_hit) = strategy + .stop_take_action(&ctx, date, date, &day, symbol) + .expect("stop take action"); + + assert!(stop_hit); + assert!(!profit_hit); + } + #[test] fn platform_aiquant_minute_stop_loss_requires_intraday_quote() { let prev_date = d(2025, 3, 13); @@ -21130,6 +21309,7 @@ mod tests { cfg.take_profit_expr.clear(); cfg.daily_top_up_enabled = true; cfg.aiquant_transaction_cost = true; + cfg.matching_type = MatchingType::NextBarOpen; let mut strategy = PlatformExprStrategy::new(cfg); strategy.rebalance_day_counter = 2; @@ -28297,7 +28477,7 @@ mod tests { cfg.benchmark_short_ma_days = 1; cfg.benchmark_long_ma_days = 1; cfg.stop_loss_expr = concat!( - "order_book_id == \"000001.SZ\" && avg_price == avg_cost", + "order_book_id == \"000001.SZ\" && avg_price > avg_cost", " && sellable == sellable_qty && closable == sellable_qty", " && equity == position_market_value", " && position_prev_close == prev_position_close", diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 07294ab..811df51 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -82,6 +82,8 @@ impl Position { return; } + let previous_quantity = self.quantity; + let previous_average_cost = self.average_cost; self.lots.push(PositionLot { acquired_date: date, quantity, @@ -93,7 +95,18 @@ impl Position { self.day_trade_quantity_delta += quantity as i32; self.day_buy_quantity += quantity; self.day_buy_value += execution_price * quantity as f64; - self.recalculate_average_cost(); + if previous_quantity > 0 + && previous_average_cost.is_finite() + && previous_average_cost > 0.0 + && execution_price.is_finite() + && execution_price > 0.0 + { + self.average_cost = (previous_average_cost * previous_quantity as f64 + + execution_price * quantity as f64) + / self.quantity as f64; + } else { + self.recalculate_average_cost(); + } self.refresh_day_pnl(); } @@ -259,7 +272,11 @@ impl Position { } if let Some(lot) = self.lots.last_mut() { lot.price += cost / quantity as f64; - self.recalculate_average_cost(); + if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 { + self.average_cost += cost / self.quantity as f64; + } else { + self.recalculate_average_cost(); + } } self.day_trade_cost += cost; self.refresh_day_pnl(); @@ -377,7 +394,11 @@ impl Position { self.lots = scaled_lots; self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.last_price /= ratio; - self.recalculate_average_cost(); + if self.average_cost.is_finite() && self.average_cost > 0.0 { + self.average_cost /= ratio; + } else { + self.recalculate_average_cost(); + } self.day_split_ratio *= ratio; self.refresh_day_pnl(); self.quantity as i32 - old_quantity as i32 @@ -844,6 +865,7 @@ impl PortfolioState { let old_quantity = old_position.quantity; let last_price = old_position.last_price; + let old_average_cost = old_position.average_cost; let realized_pnl = old_position.realized_pnl; let realized_entry_pnl = old_position.realized_entry_pnl; let mut converted_lots = old_position @@ -879,6 +901,8 @@ impl PortfolioState { .positions .entry(new_symbol.to_string()) .or_insert_with(|| Position::new(new_symbol)); + let successor_quantity_before = successor.quantity; + let successor_average_cost_before = successor.average_cost; successor.lots.extend(converted_lots); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.realized_pnl += realized_pnl; @@ -886,7 +910,30 @@ impl PortfolioState { if converted_last_price > 0.0 { successor.last_price = converted_last_price; } - successor.recalculate_average_cost(); + let converted_average_cost = if old_average_cost.is_finite() + && old_average_cost > 0.0 + && ratio.is_finite() + && ratio > 0.0 + { + Some(old_average_cost / ratio) + } else { + None + }; + if let Some(converted_average_cost) = converted_average_cost { + if successor_quantity_before > 0 + && successor_average_cost_before.is_finite() + && successor_average_cost_before > 0.0 + { + successor.average_cost = (successor_average_cost_before + * successor_quantity_before as f64 + + converted_average_cost * converted_quantity as f64) + / successor.quantity as f64; + } else { + successor.average_cost = converted_average_cost; + } + } else { + successor.recalculate_average_cost(); + } successor.refresh_day_pnl(); Some(SuccessorConversionOutcome { @@ -982,6 +1029,25 @@ mod tests { assert!((position.average_cost - average_cost_before).abs() < 1e-12); } + #[test] + fn buy_after_partial_sell_continues_moving_average_cost_basis() { + let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let mut position = Position::new("300405.SZ"); + position.buy(date, 100, 10.0); + position.buy(date, 100, 5.0); + assert!((position.average_cost - 7.5).abs() < 1e-12); + + position.sell(100, 6.0).expect("partial sell"); + assert_eq!(position.quantity, 100); + assert!((position.average_cost - 7.5).abs() < 1e-12); + assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); + + position.buy(date, 100, 5.0); + assert_eq!(position.quantity, 200); + assert!((position.average_cost - 6.25).abs() < 1e-12); + assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); + } + #[test] fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index dacde8c..57b1a18 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -3349,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { } #[test] -fn rebalance_uses_prev_close_for_open_auction_valuation() { +fn rebalance_uses_day_open_for_open_auction_valuation() { let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let data = DataSet::from_components( @@ -3515,7 +3515,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() { let held = portfolio.position("000001.SZ").expect("held position"); let target = portfolio.position("000002.SZ").expect("target position"); assert_eq!(held.quantity, 500); - assert_eq!(target.quantity, 400); + assert_eq!(target.quantity, 900); assert_eq!(report.fill_events.len(), 2); assert!( report @@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() { .iter() .any(|fill| fill.symbol == "000002.SZ" && fill.side == fidc_core::OrderSide::Buy - && fill.quantity == 400) + && fill.quantity == 900) ); }