diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index a55ddb8..bab0571 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -352,6 +352,20 @@ struct PositionExpressionState { holding_return: f64, quantity: i64, sellable_qty: i64, + old_quantity: i64, + bought_quantity: i64, + sold_quantity: i64, + buy_avg_price: f64, + sell_avg_price: f64, + bought_value: f64, + sold_value: f64, + transaction_cost: f64, + market_value: f64, + value_percent: f64, + unrealized_pnl: f64, + realized_pnl: f64, + pnl: f64, + day_trade_quantity_delta: i64, trading_pnl: f64, position_pnl: f64, dividend_receivable: f64, @@ -518,6 +532,22 @@ impl PlatformExprStrategy { "holding_return", "quantity", "sellable_qty", + "old_quantity", + "buy_quantity", + "sell_quantity", + "bought_quantity", + "sold_quantity", + "buy_avg_price", + "sell_avg_price", + "bought_value", + "sold_value", + "transaction_cost", + "position_market_value", + "value_percent", + "unrealized_pnl", + "realized_pnl", + "pnl", + "day_trade_quantity_delta", "profit_pct", "trading_pnl", "position_pnl", @@ -1622,6 +1652,25 @@ impl PlatformExprStrategy { scope.push("holding_return", position.holding_return); scope.push("quantity", position.quantity); scope.push("sellable_qty", position.sellable_qty); + scope.push("old_quantity", position.old_quantity); + scope.push("buy_quantity", position.bought_quantity); + scope.push("sell_quantity", position.sold_quantity); + scope.push("bought_quantity", position.bought_quantity); + scope.push("sold_quantity", position.sold_quantity); + scope.push("buy_avg_price", position.buy_avg_price); + scope.push("sell_avg_price", position.sell_avg_price); + scope.push("bought_value", position.bought_value); + scope.push("sold_value", position.sold_value); + scope.push("transaction_cost", position.transaction_cost); + scope.push("position_market_value", position.market_value); + scope.push("value_percent", position.value_percent); + scope.push("unrealized_pnl", position.unrealized_pnl); + scope.push("realized_pnl", position.realized_pnl); + scope.push("pnl", position.pnl); + scope.push( + "day_trade_quantity_delta", + position.day_trade_quantity_delta, + ); scope.push("trading_pnl", position.trading_pnl); scope.push("position_pnl", position.position_pnl); scope.push("dividend_receivable", position.dividend_receivable); @@ -3260,12 +3309,32 @@ impl PlatformExprStrategy { } else { 0.0 }; + let market_value = position.market_value(); + let value_percent = if ctx.portfolio.total_equity() > 0.0 { + market_value / ctx.portfolio.total_equity() + } else { + 0.0 + }; let position_state = PositionExpressionState { avg_cost: position.average_cost, current_price, holding_return, quantity: position.quantity as i64, sellable_qty: position.sellable_qty(date) as i64, + old_quantity: position.day_start_quantity() as i64, + bought_quantity: position.bought_quantity() as i64, + sold_quantity: position.sold_quantity() as i64, + 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(), + market_value, + value_percent, + unrealized_pnl: position.unrealized_pnl(), + realized_pnl: position.realized_pnl, + pnl: position.pnl(), + day_trade_quantity_delta: position.day_trade_quantity_delta() as i64, trading_pnl: position.trading_pnl, position_pnl: position.position_pnl, dividend_receivable: position.dividend_receivable, @@ -5004,6 +5073,127 @@ mod tests { } } + #[test] + fn platform_strategy_exposes_position_lifecycle_runtime_fields() { + let prev_date = d(2025, 2, 2); + let date = d(2025, 2, 3); + let data = DataSet::from_components( + vec![Instrument { + symbol: "000001.SZ".to_string(), + name: "Ping An Bank".to_string(), + board: "SZSE".to_string(), + round_lot: 100, + listed_at: Some(d(2010, 1, 1)), + delisted_at: None, + status: "active".to_string(), + }], + vec![DailyMarketSnapshot { + date, + symbol: "000001.SZ".to_string(), + timestamp: Some("10:18:00".to_string()), + day_open: 10.0, + open: 10.0, + high: 10.2, + low: 9.9, + close: 10.1, + last_price: 10.05, + bid1: 10.04, + ask1: 10.05, + prev_close: 9.95, + volume: 1_000_000, + tick_volume: 5_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 10.94, + lower_limit: 8.96, + price_tick: 0.01, + }], + vec![DailyFactorSnapshot { + date, + symbol: "000001.SZ".to_string(), + market_cap_bn: 12.0, + free_float_cap_bn: 10.0, + pe_ttm: 8.0, + turnover_ratio: Some(22.0), + effective_turnover_ratio: Some(18.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: 1002.0, + prev_close: 998.0, + volume: 1_000_000, + }], + ) + .expect("dataset"); + let mut portfolio = PortfolioState::new(1_000_000.0); + portfolio.position_mut("000001.SZ").buy(prev_date, 100, 8.0); + portfolio.begin_trading_day(); + portfolio.position_mut("000001.SZ").buy(date, 100, 9.0); + portfolio + .position_mut("000001.SZ") + .sell(50, 10.0) + .expect("sell"); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .record_trade_cost(2.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, + decision_date: date, + decision_index: 0, + data: &data, + portfolio: &portfolio, + open_orders: &[], + dynamic_universe: None, + subscriptions: &subscriptions, + process_events: &[], + active_process_event: None, + active_datetime: None, + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = "000001.SZ".to_string(); + cfg.rotation_enabled = false; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.stop_loss_expr = concat!( + "old_quantity == 100 && buy_quantity == 100 && sell_quantity == 50", + " && bought_quantity == 100 && sold_quantity == 50", + " && buy_avg_price == 9.0 && sell_avg_price == 10.0", + " && bought_value == 900.0 && sold_value == 500.0", + " && transaction_cost == 2.0 && position_market_value > 0.0", + " && value_percent > 0.0 && unrealized_pnl > 0.0", + " && realized_pnl > 0.0 && pnl > 0.0", + " && day_trade_quantity_delta == 50 && trading_pnl > 90.0" + ) + .to_string(); + let mut strategy = PlatformExprStrategy::new(cfg); + + let decision = strategy.on_day(&ctx).expect("platform decision"); + + assert!(decision.order_intents.iter().any(|intent| matches!( + intent, + crate::strategy::OrderIntent::TargetValue { symbol, target_value, reason } + if symbol == "000001.SZ" && *target_value == 0.0 && reason == "stop_loss_exit" + ))); + } + #[test] fn platform_strategy_exposes_process_event_runtime_fields() { let date = d(2025, 2, 3); diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 0b8146f..3cb23d3 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -28,6 +28,10 @@ pub struct Position { day_dividend_cash: f64, day_trade_quantity_delta: i32, day_trade_cost: f64, + day_buy_quantity: u32, + day_sell_quantity: u32, + day_buy_value: f64, + day_sell_value: f64, lots: Vec, } @@ -48,6 +52,10 @@ impl Position { day_dividend_cash: 0.0, day_trade_quantity_delta: 0, day_trade_cost: 0.0, + day_buy_quantity: 0, + day_sell_quantity: 0, + day_buy_value: 0.0, + day_sell_value: 0.0, lots: Vec::new(), } } @@ -69,6 +77,8 @@ impl Position { self.quantity += quantity; self.last_price = price; self.day_trade_quantity_delta += quantity as i32; + self.day_buy_quantity += quantity; + self.day_buy_value += price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); } @@ -103,6 +113,8 @@ impl Position { self.last_price = price; self.realized_pnl += realized; self.day_trade_quantity_delta -= quantity as i32; + self.day_sell_quantity += quantity; + self.day_sell_value += price * quantity as f64; self.recalculate_average_cost(); self.refresh_day_pnl(); Ok(realized) @@ -124,6 +136,54 @@ impl Position { (self.last_price - self.average_cost) * self.quantity as f64 } + pub fn pnl(&self) -> f64 { + self.realized_pnl + self.unrealized_pnl() + } + + pub fn day_start_quantity(&self) -> u32 { + self.day_start_quantity + } + + pub fn day_trade_quantity_delta(&self) -> i32 { + self.day_trade_quantity_delta + } + + pub fn bought_quantity(&self) -> u32 { + self.day_buy_quantity + } + + pub fn sold_quantity(&self) -> u32 { + self.day_sell_quantity + } + + pub fn bought_value(&self) -> f64 { + self.day_buy_value + } + + pub fn sold_value(&self) -> f64 { + self.day_sell_value + } + + pub fn buy_avg_price(&self) -> f64 { + if self.day_buy_quantity == 0 { + 0.0 + } else { + self.day_buy_value / self.day_buy_quantity as f64 + } + } + + pub fn sell_avg_price(&self) -> f64 { + if self.day_sell_quantity == 0 { + 0.0 + } else { + self.day_sell_value / self.day_sell_quantity as f64 + } + } + + pub fn transaction_cost(&self) -> f64 { + self.day_trade_cost + } + pub fn begin_trading_day(&mut self) { self.day_start_quantity = self.quantity; self.day_start_price = self.last_price; @@ -131,6 +191,10 @@ impl Position { self.day_dividend_cash = 0.0; self.day_trade_quantity_delta = 0; self.day_trade_cost = 0.0; + self.day_buy_quantity = 0; + self.day_sell_quantity = 0; + self.day_buy_value = 0.0; + self.day_sell_value = 0.0; self.refresh_day_pnl(); } @@ -235,8 +299,9 @@ impl Position { * (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; + self.trading_pnl = (self.day_buy_quantity as f64 * self.last_price - self.day_buy_value) + + (self.day_sell_value - self.day_sell_quantity as f64 * self.last_price) + - self.day_trade_cost; } } @@ -363,6 +428,7 @@ impl PortfolioState { } pub fn holdings_summary(&self, date: NaiveDate) -> Vec { + let total_equity = self.total_equity(); self.positions .values() .filter(|position| position.quantity > 0) @@ -373,11 +439,26 @@ impl PortfolioState { 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(), }) .collect() } @@ -692,6 +773,52 @@ mod tests { assert!((position.position_pnl - 70.0).abs() < 1e-6); assert!((position.trading_pnl + 5.0).abs() < 1e-6); } + + #[test] + fn position_tracks_day_lifecycle_fields() { + 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.begin_trading_day(); + + portfolio.position_mut("000001.SZ").buy(date, 50, 11.0); + let realized = portfolio + .position_mut("000001.SZ") + .sell(40, 12.0) + .expect("sell"); + portfolio + .position_mut_if_exists("000001.SZ") + .expect("position") + .record_trade_cost(3.0); + + let position = portfolio.position("000001.SZ").expect("position"); + assert_eq!(position.day_start_quantity(), 100); + assert_eq!(position.bought_quantity(), 50); + assert_eq!(position.sold_quantity(), 40); + assert_eq!(position.day_trade_quantity_delta(), 10); + assert!((position.bought_value() - 550.0).abs() < 1e-6); + assert!((position.sold_value() - 480.0).abs() < 1e-6); + assert!((position.buy_avg_price() - 11.0).abs() < 1e-6); + assert!((position.sell_avg_price() - 12.0).abs() < 1e-6); + assert!((position.transaction_cost() - 3.0).abs() < 1e-6); + assert!((realized - 80.0).abs() < 1e-6); + assert!((position.realized_pnl - 80.0).abs() < 1e-6); + assert!((position.position_pnl - 200.0).abs() < 1e-6); + assert!((position.trading_pnl - 47.0).abs() < 1e-6); + assert!((position.pnl() - (80.0 + position.unrealized_pnl())).abs() < 1e-6); + + let summary = portfolio.holdings_summary(date); + assert_eq!(summary[0].old_quantity, 100); + assert_eq!(summary[0].bought_quantity, 50); + assert_eq!(summary[0].sold_quantity, 40); + assert!((summary[0].buy_avg_price - 11.0).abs() < 1e-6); + assert!((summary[0].sell_avg_price - 12.0).abs() < 1e-6); + assert!((summary[0].transaction_cost - 3.0).abs() < 1e-6); + assert!(summary[0].value_percent > 0.0); + } } #[derive(Debug, Clone, Serialize)] @@ -703,11 +830,22 @@ pub struct HoldingSummary { pub average_cost: f64, pub last_price: f64, pub market_value: f64, + pub value_percent: f64, pub unrealized_pnl: f64, pub realized_pnl: f64, + pub pnl: f64, pub trading_pnl: f64, pub position_pnl: f64, pub dividend_receivable: f64, + pub old_quantity: u32, + pub bought_quantity: u32, + pub sold_quantity: u32, + pub buy_avg_price: f64, + pub sell_avg_price: f64, + pub bought_value: f64, + pub sold_value: f64, + pub transaction_cost: f64, + pub day_trade_quantity_delta: i32, } #[derive(Debug, Clone)] diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 41be802..deca831 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -182,6 +182,10 @@ 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: "old_quantity/buy_quantity/sell_quantity".to_string(), field_type: "int".to_string(), detail: "交易日开始时老仓数量、当日买入数量、当日卖出数量。buy_quantity/sell_quantity 也可写成 bought_quantity/sold_quantity。".to_string() }, + ManualField { name: "buy_avg_price/sell_avg_price/bought_value/sold_value".to_string(), field_type: "float".to_string(), detail: "当日买入均价、卖出均价、买入成交额、卖出成交额。".to_string() }, + ManualField { name: "position_market_value/value_percent".to_string(), field_type: "float".to_string(), detail: "当前持仓市值,以及该持仓市值占账户总权益比例。".to_string() }, + ManualField { name: "unrealized_pnl/realized_pnl/pnl/transaction_cost".to_string(), field_type: "float".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() }, diff --git a/docs/rqalpha-gap-roadmap.md b/docs/rqalpha-gap-roadmap.md index d131b01..d6a01ab 100644 --- a/docs/rqalpha-gap-roadmap.md +++ b/docs/rqalpha-gap-roadmap.md @@ -44,7 +44,7 @@ current alignment pass. - [x] `trading_pnl` - [x] `position_pnl` - [x] `dividend_receivable` -- [ ] richer position lifecycle fields exposed to strategy runtime +- [x] richer position lifecycle fields exposed to strategy runtime ### Phase 6: Strategy data API parity @@ -64,10 +64,10 @@ current alignment pass. 4. Add dynamic universe APIs. 5. Add algo-order styles. 6. Finish position accounting parity. -7. Expose richer position lifecycle fields to strategy runtime. +7. Continue parity audit for remaining account, order, and data-source APIs. ## Current Step -Active implementation target: Phase 5 follow-up: expose richer position -lifecycle fields to strategy runtime beyond quantity, sellable quantity, -average cost, trading pnl, position pnl, and dividend receivable. +Active implementation target: continue parity audit for remaining account, +order, and data-source APIs after the stock strategy API, scheduler, universe, +algo-order, position accounting, and core strategy data helpers are covered.