diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index f0ea7cb..3b2a843 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -6190,17 +6190,10 @@ impl PlatformExprStrategy { { return Ok((false, false)); } - let avg_price = if self.config.aiquant_transaction_cost - && position.average_cost.is_finite() - && position.average_cost > 0.0 - { - position.average_cost - } else { - position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(position.average_cost) - }; + 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)); } @@ -6696,17 +6689,10 @@ impl Strategy for PlatformExprStrategy { continue; } let projected_position = projected.position(&position.symbol).unwrap_or(position); - let avg_price = if self.config.aiquant_transaction_cost - && projected_position.average_cost.is_finite() - && projected_position.average_cost > 0.0 - { - projected_position.average_cost - } else { - projected_position - .average_entry_price() - .filter(|value| value.is_finite() && *value > 0.0) - .unwrap_or(projected_position.average_cost) - }; + let avg_price = projected_position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(projected_position.average_cost); if projected_position.quantity == 0 || avg_price <= 0.0 { continue; } diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 385c8ba..1616adc 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -20,6 +20,7 @@ pub struct Position { pub average_cost: f64, pub last_price: f64, pub realized_pnl: f64, + realized_entry_pnl: f64, pub trading_pnl: f64, pub position_pnl: f64, pub dividend_receivable: f64, @@ -44,6 +45,7 @@ impl Position { average_cost: 0.0, last_price: 0.0, realized_pnl: 0.0, + realized_entry_pnl: 0.0, trading_pnl: 0.0, position_pnl: 0.0, dividend_receivable: 0.0, @@ -114,6 +116,7 @@ impl Position { let mut remaining = quantity; let mut realized = 0.0; + let mut realized_entry = 0.0; while remaining > 0 { let Some(first_lot) = self.lots.first_mut() else { @@ -122,6 +125,7 @@ impl Position { let lot_sell = remaining.min(first_lot.quantity); realized += (execution_price - first_lot.price) * lot_sell as f64; + realized_entry += (execution_price - first_lot.entry_price) * lot_sell as f64; first_lot.quantity -= lot_sell; remaining -= lot_sell; @@ -133,6 +137,7 @@ impl Position { self.quantity -= quantity; self.last_price = normalized_mark_price(mark_price, execution_price); self.realized_pnl += realized; + self.realized_entry_pnl += realized_entry; self.day_trade_quantity_delta -= quantity as i32; self.day_sell_quantity += quantity; self.day_sell_value += execution_price * quantity as f64; @@ -157,10 +162,21 @@ impl Position { (self.last_price - self.average_cost) * self.quantity as f64 } + pub fn unrealized_entry_pnl(&self) -> f64 { + let Some(avg_price) = self.average_entry_price() else { + return 0.0; + }; + (self.last_price - avg_price) * self.quantity as f64 + } + pub fn pnl(&self) -> f64 { self.realized_pnl + self.unrealized_pnl() } + pub fn entry_pnl(&self) -> f64 { + self.realized_entry_pnl + self.unrealized_entry_pnl() + } + pub fn day_start_quantity(&self) -> u32 { self.day_start_quantity } @@ -765,33 +781,40 @@ impl PortfolioState { self.positions .values() .filter(|position| position.quantity > 0) - .map(|position| HoldingSummary { - date, - symbol: position.symbol.clone(), - quantity: position.quantity, - average_cost: position.average_cost, - last_price: position.last_price, - market_value: position.market_value(), - value_percent: if total_equity > 0.0 { - position.market_value() / total_equity - } else { - 0.0 - }, - unrealized_pnl: position.unrealized_pnl(), - realized_pnl: position.realized_pnl, - pnl: position.pnl(), - trading_pnl: position.trading_pnl, - position_pnl: position.position_pnl, - dividend_receivable: position.dividend_receivable, - old_quantity: position.day_start_quantity(), - bought_quantity: position.bought_quantity(), - sold_quantity: position.sold_quantity(), - buy_avg_price: position.buy_avg_price(), - sell_avg_price: position.sell_avg_price(), - bought_value: position.bought_value(), - sold_value: position.sold_value(), - transaction_cost: position.transaction_cost(), - day_trade_quantity_delta: position.day_trade_quantity_delta(), + .map(|position| { + let market_value = position.market_value(); + let entry_average_cost = position + .average_entry_price() + .filter(|value| value.is_finite() && *value > 0.0) + .unwrap_or(position.average_cost); + HoldingSummary { + date, + symbol: position.symbol.clone(), + quantity: position.quantity, + average_cost: entry_average_cost, + last_price: position.last_price, + market_value, + value_percent: if total_equity > 0.0 { + market_value / total_equity + } else { + 0.0 + }, + unrealized_pnl: position.unrealized_entry_pnl(), + realized_pnl: position.realized_entry_pnl, + pnl: position.entry_pnl(), + trading_pnl: position.trading_pnl, + position_pnl: position.position_pnl, + dividend_receivable: position.dividend_receivable, + old_quantity: position.day_start_quantity(), + bought_quantity: position.bought_quantity(), + sold_quantity: position.sold_quantity(), + buy_avg_price: position.buy_avg_price(), + sell_avg_price: position.sell_avg_price(), + bought_value: position.bought_value(), + sold_value: position.sold_value(), + transaction_cost: position.transaction_cost(), + day_trade_quantity_delta: position.day_trade_quantity_delta(), + } }) .collect() } @@ -815,6 +838,7 @@ impl PortfolioState { let old_quantity = old_position.quantity; let last_price = old_position.last_price; let realized_pnl = old_position.realized_pnl; + let realized_entry_pnl = old_position.realized_entry_pnl; let mut converted_lots = old_position .lots .into_iter() @@ -851,6 +875,7 @@ impl PortfolioState { successor.lots.extend(converted_lots); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.realized_pnl += realized_pnl; + successor.realized_entry_pnl += realized_entry_pnl; if converted_last_price > 0.0 { successor.last_price = converted_last_price; } @@ -930,6 +955,31 @@ mod tests { assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.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(); + let mut portfolio = PortfolioState::new(10_000.0); + { + let position = portfolio.position_mut("600561.SH"); + position.buy(date, 100, 10.0); + position.record_buy_trade_cost(100, 5.0); + position.last_price = 10.5; + } + + let summary = portfolio.holdings_summary(date); + assert_eq!(summary.len(), 1); + assert!((summary[0].average_cost - 10.0).abs() < 1e-12); + assert!((summary[0].unrealized_pnl - 50.0).abs() < 1e-12); + assert!((summary[0].realized_pnl - 0.0).abs() < 1e-12); + assert!( + portfolio + .position("600561.SH") + .expect("position") + .average_cost + > summary[0].average_cost + ); + } + #[test] fn cash_dividend_can_preserve_avg_cost_for_aiquant_compatibility() { let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();