From 1d1c93f8e2ef8e80125b4a3f4137a42f807c6010 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 05:53:30 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=8C=81=E4=BB=93=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E5=B9=B3=E5=9D=87=E6=88=90=E4=BA=A4=E4=BB=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fidc-core/src/platform_expr_strategy.rs | 16 ++-- crates/fidc-core/src/portfolio.rs | 83 +++++++++++++++++-- 2 files changed, 87 insertions(+), 12 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 87e16b6..6f40854 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -11664,8 +11664,11 @@ impl PlatformExprStrategy { return Ok((false, false)); } let entry_avg_price = position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) + .average_price + .is_finite() + .then_some(position.average_price) + .filter(|value| *value > 0.0) + .or_else(|| position.average_entry_price()) .unwrap_or(position.average_cost); let stop_take_base_price = match self.config.stop_take_reference_price_mode { PlatformStopTakeReferencePriceMode::PositionCostBasis => { @@ -12997,8 +13000,11 @@ impl Strategy for PlatformExprStrategy { } let projected_position = projected.position(&position.symbol).unwrap_or(position); let avg_price = projected_position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) + .average_price + .is_finite() + .then_some(projected_position.average_price) + .filter(|value| *value > 0.0) + .or_else(|| projected_position.average_entry_price()) .unwrap_or(projected_position.average_cost); if projected_position.quantity == 0 || avg_price <= 0.0 { continue; @@ -34937,7 +34943,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 == 8.5 && avg_cost == 8.5", " && 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 1d828e7..5419e6f 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -60,6 +60,8 @@ pub struct PositionLot { pub struct Position { pub symbol: String, pub quantity: u32, + // ALV-compatible moving average execution price; partial sells do not rebase it. + pub average_price: f64, // ALV-compatible moving average including buy costs; partial sells do not rebase it. pub average_cost: f64, pub last_price: f64, @@ -86,6 +88,7 @@ impl Position { Self { symbol: symbol.into(), quantity: 0, + average_price: 0.0, average_cost: 0.0, last_price: 0.0, realized_pnl: FixedMoney::ZERO, @@ -127,6 +130,7 @@ impl Position { } let previous_quantity = self.quantity; + let previous_average_price = self.average_price; let previous_average_cost = self.average_cost; let gross_amount = fixed_money_or_panic( execution_price * quantity as f64, @@ -146,6 +150,18 @@ impl Position { .day_buy_value .checked_add(gross_amount) .expect("fixed-point day buy value overflow"); + if previous_quantity > 0 + && previous_average_price.is_finite() + && previous_average_price > 0.0 + && execution_price.is_finite() + && execution_price > 0.0 + { + self.average_price = (previous_average_price * previous_quantity as f64 + + execution_price * quantity as f64) + / self.quantity as f64; + } else { + self.average_price = execution_price; + } if previous_quantity > 0 && previous_average_cost.is_finite() && previous_average_cost > 0.0 @@ -186,6 +202,7 @@ impl Position { let mut remaining_proceeds = total_proceeds; let mut realized = FixedMoney::ZERO; let mut realized_entry = FixedMoney::ZERO; + let average_price_before_sell = self.average_price; let average_cost_before_sell = self.average_cost; while remaining > 0 { @@ -250,11 +267,19 @@ impl Position { .checked_add(total_proceeds) .ok_or_else(|| "fixed-point day sell value overflow".to_string())?; if self.quantity == 0 { + self.average_price = 0.0; self.recalculate_average_cost(); - } else if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 { - self.average_cost = average_cost_before_sell; } else { - self.recalculate_average_cost(); + if average_price_before_sell.is_finite() && average_price_before_sell > 0.0 { + self.average_price = average_price_before_sell; + } else { + self.average_price = self.average_entry_price().unwrap_or(0.0); + } + if average_cost_before_sell.is_finite() && average_cost_before_sell > 0.0 { + self.average_cost = average_cost_before_sell; + } else { + self.recalculate_average_cost(); + } } self.refresh_day_pnl(); Ok(realized.to_f64()) @@ -421,9 +446,12 @@ impl Position { } pub fn holding_return(&self, price: f64) -> Option { - let Some(avg_price) = self.average_entry_price() else { - return None; - }; + let avg_price = self + .average_price + .is_finite() + .then_some(self.average_price) + .filter(|value| *value > 0.0) + .or_else(|| self.average_entry_price())?; if avg_price <= 0.0 { None } else { @@ -503,6 +531,7 @@ impl Position { if adjust_cost_basis { self.average_cost -= dividend_per_share; } + self.average_price -= dividend_per_share; self.last_price -= dividend_per_share; self.day_dividend_cash = self .day_dividend_cash @@ -545,6 +574,11 @@ impl Position { self.lots = scaled_lots; self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.last_price /= ratio; + if self.average_price.is_finite() && self.average_price > 0.0 { + self.average_price /= ratio; + } else { + self.average_price = self.average_entry_price().unwrap_or(0.0); + } if self.average_cost.is_finite() && self.average_cost > 0.0 { self.average_cost /= ratio; } else { @@ -1128,8 +1162,11 @@ impl PortfolioState { .map(|position| { let market_value = position.market_value(); let entry_average_cost = position - .average_entry_price() + .average_price + .is_finite() + .then_some(position.average_price) .filter(|value| value.is_finite() && *value > 0.0) + .or_else(|| position.average_entry_price()) .unwrap_or(position.average_cost); HoldingSummary { date, @@ -1181,6 +1218,7 @@ impl PortfolioState { let old_quantity = old_position.quantity; let last_price = old_position.last_price; + let old_average_price = old_position.average_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; @@ -1218,6 +1256,7 @@ impl PortfolioState { .entry(new_symbol.to_string()) .or_insert_with(|| Position::new(new_symbol)); let successor_quantity_before = successor.quantity; + let successor_average_price_before = successor.average_price; let successor_average_cost_before = successor.average_cost; successor.lots.extend(converted_lots); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); @@ -1232,6 +1271,30 @@ impl PortfolioState { if converted_last_price > 0.0 { successor.last_price = converted_last_price; } + let converted_average_price = if old_average_price.is_finite() + && old_average_price > 0.0 + && ratio.is_finite() + && ratio > 0.0 + { + Some(old_average_price / ratio) + } else { + None + }; + if let Some(converted_average_price) = converted_average_price { + if successor_quantity_before > 0 + && successor_average_price_before.is_finite() + && successor_average_price_before > 0.0 + { + successor.average_price = (successor_average_price_before + * successor_quantity_before as f64 + + converted_average_price * converted_quantity as f64) + / successor.quantity as f64; + } else { + successor.average_price = converted_average_price; + } + } else { + successor.average_price = successor.average_entry_price().unwrap_or(0.0); + } let converted_average_cost = if old_average_cost.is_finite() && old_average_cost > 0.0 && ratio.is_finite() @@ -1375,6 +1438,7 @@ mod tests { let realized = position.sell(100, 6.0).expect("partial FIFO sell"); assert_eq!(position.quantity, 100); + assert!((position.average_price - 7.5).abs() < 1e-12); assert!((position.average_cost - 7.55).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); assert!((realized + 405.0).abs() < 1e-12); @@ -1409,6 +1473,7 @@ mod tests { position.record_buy_trade_cost(22_200, 100.0); assert!(position.average_cost > 5.66); + assert!((position.average_price - 5.66).abs() < 1e-12); 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); } @@ -1430,6 +1495,7 @@ mod tests { position.sell(2700, 16.8331).expect("partial sell"); assert_eq!(position.quantity, 100); + assert!((position.average_price - 18.94711428571429).abs() < 1e-12); assert!((position.average_cost - average_cost_before).abs() < 1e-12); } @@ -1443,11 +1509,13 @@ mod tests { position.sell(100, 6.0).expect("partial sell"); assert_eq!(position.quantity, 100); + assert!((position.average_price - 7.5).abs() < 1e-12); 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_price - 6.25).abs() < 1e-12); assert!((position.average_cost - 6.25).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12); } @@ -1489,6 +1557,7 @@ mod tests { let cash = position.apply_cash_dividend_preserve_cost_basis(0.6); assert!((cash - 600.0).abs() < 1e-12); + assert!((position.average_price - 45.85).abs() < 1e-12); assert!((position.average_cost - cost_before).abs() < 1e-12); assert!((position.average_entry_price().unwrap() - (entry_before - 0.6)).abs() < 1e-12); assert!((position.last_price - 45.85).abs() < 1e-12);