diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index fd9bfeb..e952141 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2129,6 +2129,9 @@ where .position_mut(symbol) .sell(leg.quantity, leg.price) .map_err(BacktestError::Execution)?; + if let Some(position) = portfolio.position_mut_if_exists(symbol) { + position.record_trade_cost(cost.total()); + } portfolio.apply_cash_delta(net_cash); report.fill_events.push(FillEvent { @@ -3332,6 +3335,9 @@ where portfolio .position_mut(symbol) .buy(date, leg.quantity, leg.price); + if let Some(position) = portfolio.position_mut_if_exists(symbol) { + position.record_trade_cost(cost.total()); + } report.fill_events.push(FillEvent { date, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 3acba64..6ddae2b 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -208,6 +208,7 @@ where for (execution_idx, execution_date) in execution_dates.iter().copied().enumerate() { let mut corporate_action_notes = Vec::new(); + portfolio.begin_trading_day(); let receivable_report = self.settle_cash_receivables( execution_date, &mut portfolio, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3e1744b..0b49da6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -326,6 +326,9 @@ struct PositionExpressionState { holding_return: f64, quantity: i64, sellable_qty: i64, + trading_pnl: f64, + position_pnl: f64, + dividend_receivable: f64, } pub struct PlatformExprStrategy { @@ -490,6 +493,9 @@ impl PlatformExprStrategy { "quantity", "sellable_qty", "profit_pct", + "trading_pnl", + "position_pnl", + "dividend_receivable", "at_upper_limit", "at_lower_limit", ]) @@ -1546,6 +1552,9 @@ impl PlatformExprStrategy { scope.push("holding_return", position.holding_return); scope.push("quantity", position.quantity); scope.push("sellable_qty", position.sellable_qty); + scope.push("trading_pnl", position.trading_pnl); + scope.push("position_pnl", position.position_pnl); + scope.push("dividend_receivable", position.dividend_receivable); let available_sellable_qty = stock .map(|stock| { ctx.available_sellable_qty(&stock.symbol, position.sellable_qty as u32) @@ -2873,6 +2882,9 @@ impl PlatformExprStrategy { holding_return, quantity: position.quantity as i64, sellable_qty: position.sellable_qty(date) as i64, + trading_pnl: position.trading_pnl, + position_pnl: position.position_pnl, + dividend_receivable: position.dividend_receivable, }; let stop_hit = if self.config.stop_loss_expr.trim().is_empty() { false diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 98b2937..3d7a1b6 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -1,6 +1,7 @@ use chrono::NaiveDate; use indexmap::IndexMap; use serde::Serialize; +use std::collections::BTreeMap; use crate::data::{DataSet, DataSetError, PriceField}; @@ -18,6 +19,15 @@ pub struct Position { pub average_cost: f64, pub last_price: f64, pub realized_pnl: f64, + pub trading_pnl: f64, + pub position_pnl: f64, + pub dividend_receivable: f64, + day_start_quantity: u32, + day_start_price: f64, + day_split_ratio: f64, + day_dividend_cash: f64, + day_trade_quantity_delta: i32, + day_trade_cost: f64, lots: Vec, } @@ -29,6 +39,15 @@ impl Position { average_cost: 0.0, last_price: 0.0, realized_pnl: 0.0, + trading_pnl: 0.0, + position_pnl: 0.0, + dividend_receivable: 0.0, + day_start_quantity: 0, + day_start_price: 0.0, + day_split_ratio: 1.0, + day_dividend_cash: 0.0, + day_trade_quantity_delta: 0, + day_trade_cost: 0.0, lots: Vec::new(), } } @@ -49,7 +68,9 @@ impl Position { }); self.quantity += quantity; self.last_price = price; + self.day_trade_quantity_delta += quantity as i32; self.recalculate_average_cost(); + self.refresh_day_pnl(); } pub fn sell(&mut self, quantity: u32, price: f64) -> Result { @@ -81,7 +102,9 @@ impl Position { self.quantity -= quantity; self.last_price = price; self.realized_pnl += realized; + self.day_trade_quantity_delta -= quantity as i32; self.recalculate_average_cost(); + self.refresh_day_pnl(); Ok(realized) } @@ -101,6 +124,27 @@ impl Position { (self.last_price - self.average_cost) * self.quantity as f64 } + pub fn begin_trading_day(&mut self) { + self.day_start_quantity = self.quantity; + self.day_start_price = self.last_price; + self.day_split_ratio = 1.0; + self.day_dividend_cash = 0.0; + self.day_trade_quantity_delta = 0; + self.day_trade_cost = 0.0; + self.refresh_day_pnl(); + } + + pub fn record_trade_cost(&mut self, value: f64) { + if value.is_finite() { + self.day_trade_cost += value.max(0.0); + self.refresh_day_pnl(); + } + } + + pub fn set_dividend_receivable(&mut self, value: f64) { + self.dividend_receivable = if value.is_finite() { value.max(0.0) } else { 0.0 }; + } + pub fn holding_return(&self, price: f64) -> Option { if self.quantity == 0 || self.average_cost <= 0.0 { None @@ -134,7 +178,10 @@ impl Position { } self.average_cost -= dividend_per_share; self.last_price -= dividend_per_share; - self.quantity as f64 * dividend_per_share + let cash_delta = self.quantity as f64 * dividend_per_share; + self.day_dividend_cash += cash_delta; + self.refresh_day_pnl(); + cash_delta } pub fn apply_split_ratio(&mut self, ratio: f64) -> i32 { @@ -170,8 +217,22 @@ impl Position { self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.last_price /= ratio; self.recalculate_average_cost(); + self.day_split_ratio *= ratio; + self.refresh_day_pnl(); self.quantity as i32 - old_quantity as i32 } + + fn refresh_day_pnl(&mut self) { + let adjusted_old_quantity = self.day_start_quantity as f64 * self.day_split_ratio; + self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { + 0.0 + } else { + adjusted_old_quantity * (self.last_price - (self.day_start_price / self.day_split_ratio)) + + self.day_dividend_cash + }; + self.trading_pnl = (self.day_trade_quantity_delta as f64 * self.last_price) + - self.day_trade_cost; + } } #[derive(Debug, Clone)] @@ -233,6 +294,7 @@ impl PortfolioState { pub fn add_cash_receivable(&mut self, receivable: CashReceivable) { self.cash_receivables.push(receivable); + self.refresh_dividend_receivables(); } pub fn settle_cash_receivables(&mut self, date: NaiveDate) -> Vec { @@ -247,6 +309,7 @@ impl PortfolioState { } } self.cash_receivables = pending; + self.refresh_dividend_receivables(); settled } @@ -254,6 +317,13 @@ impl PortfolioState { &self.cash_receivables } + pub fn begin_trading_day(&mut self) { + for position in self.positions.values_mut() { + position.begin_trading_day(); + } + self.refresh_dividend_receivables(); + } + pub fn update_prices( &mut self, date: NaiveDate, @@ -274,6 +344,7 @@ impl PortfolioState { } })?; position.last_price = price; + position.refresh_day_pnl(); } Ok(()) } @@ -299,6 +370,9 @@ impl PortfolioState { market_value: position.market_value(), unrealized_pnl: position.unrealized_pnl(), realized_pnl: position.realized_pnl, + trading_pnl: position.trading_pnl, + position_pnl: position.position_pnl, + dividend_receivable: position.dividend_receivable, }) .collect() } @@ -361,6 +435,7 @@ impl PortfolioState { successor.last_price = converted_last_price; } successor.recalculate_average_cost(); + successor.refresh_day_pnl(); Some(SuccessorConversionOutcome { old_symbol: old_symbol_owned, @@ -376,11 +451,24 @@ impl PortfolioState { }, }) } + + fn refresh_dividend_receivables(&mut self) { + let mut per_symbol = BTreeMap::::new(); + for receivable in &self.cash_receivables { + *per_symbol.entry(receivable.symbol.clone()).or_insert(0.0) += receivable.amount; + } + for (symbol, position) in &mut self.positions { + position.set_dividend_receivable(per_symbol.get(symbol).copied().unwrap_or(0.0)); + } + } } #[cfg(test)] mod tests { use super::*; + use crate::data::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField}; + use crate::Instrument; + use std::collections::BTreeMap; #[test] fn positions_preserve_insertion_order() { @@ -400,6 +488,198 @@ mod tests { ] ); } + + #[test] + fn portfolio_tracks_dividend_receivable_and_day_pnl() { + let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); + let mut portfolio = PortfolioState::new(10_000.0); + portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0); + portfolio.update_prices( + prev_date, + &DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![ + DailyMarketSnapshot { + date: prev_date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.0, + open: 10.0, + high: 10.0, + low: 10.0, + close: 10.0, + last_price: 10.0, + bid1: 9.99, + ask1: 10.01, + prev_close: 9.8, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.5, + open: 10.5, + high: 10.5, + low: 10.5, + close: 10.5, + last_price: 10.5, + bid1: 10.49, + ask1: 10.51, + prev_close: 10.0, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }, + ], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".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: 1000, + }], + ) + .expect("dataset"), + PriceField::Close, + ) + .expect("prev close"); + portfolio.begin_trading_day(); + portfolio.add_cash_receivable(CashReceivable { + symbol: "000001.SZ".to_string(), + ex_date: prev_date, + payable_date: date.succ_opt().unwrap(), + amount: 25.0, + reason: "cash_dividend".to_string(), + }); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .apply_cash_dividend(0.2); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .record_trade_cost(5.0); + portfolio.update_prices( + date, + &DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Test".to_string(), + board: "SZ".to_string(), + round_lot: 100, + listed_at: None, + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: None, + day_open: 10.5, + open: 10.5, + high: 10.5, + low: 10.5, + close: 10.5, + last_price: 10.5, + bid1: 10.49, + ask1: 10.51, + prev_close: 10.0, + volume: 1000, + tick_volume: 1000, + bid1_volume: 1000, + ask1_volume: 1000, + trading_phase: None, + paused: false, + upper_limit: 11.0, + lower_limit: 9.0, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 50.0, + free_float_cap_bn: 45.0, + pe_ttm: 10.0, + turnover_ratio: Some(1.0), + effective_turnover_ratio: Some(1.0), + extra_factors: BTreeMap::new(), + }], + vec![CandidateEligibility { + date, + symbol: "000001.SZ".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: 1000, + }], + ) + .expect("dataset"), + PriceField::Close, + ) + .expect("close"); + + let position = portfolio.position("000001.SZ").expect("position"); + assert!((position.dividend_receivable - 25.0).abs() < 1e-6); + assert!((position.position_pnl - 70.0).abs() < 1e-6); + assert!((position.trading_pnl + 5.0).abs() < 1e-6); + } } #[derive(Debug, Clone, Serialize)] @@ -413,6 +693,9 @@ pub struct HoldingSummary { pub market_value: f64, pub unrealized_pnl: f64, pub realized_pnl: f64, + pub trading_pnl: f64, + pub position_pnl: f64, + pub dividend_receivable: f64, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 8b0e5d9..70be213 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -174,6 +174,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { ManualField { name: "holding_return".to_string(), field_type: "float".to_string(), detail: "持仓收益率,小数。".to_string() }, ManualField { name: "profit_pct".to_string(), field_type: "float".to_string(), detail: "持仓收益率,百分比。".to_string() }, ManualField { name: "quantity/sellable_qty".to_string(), field_type: "int".to_string(), detail: "持仓数量与可卖数量。".to_string() }, + ManualField { name: "trading_pnl/position_pnl".to_string(), field_type: "float".to_string(), detail: "当日交易收益和昨仓持有收益,口径更接近 rqalpha StockPosition。".to_string() }, + ManualField { name: "dividend_receivable".to_string(), field_type: "float".to_string(), detail: "当前 symbol 尚未到账的应收分红。".to_string() }, ManualField { name: "available_sellable_qty/reserved_open_sell_qty".to_string(), field_type: "int".to_string(), detail: "扣掉未成交卖单占用后的可卖数量,以及当前 symbol 已占用的卖出挂单数量。".to_string() }, ], },