Expose position lifecycle fields

This commit is contained in:
boris
2026-04-23 19:34:47 -07:00
parent c3ef0bd49a
commit 6106297a97
4 changed files with 339 additions and 7 deletions

View File

@@ -352,6 +352,20 @@ struct PositionExpressionState {
holding_return: f64, holding_return: f64,
quantity: i64, quantity: i64,
sellable_qty: 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, trading_pnl: f64,
position_pnl: f64, position_pnl: f64,
dividend_receivable: f64, dividend_receivable: f64,
@@ -518,6 +532,22 @@ impl PlatformExprStrategy {
"holding_return", "holding_return",
"quantity", "quantity",
"sellable_qty", "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", "profit_pct",
"trading_pnl", "trading_pnl",
"position_pnl", "position_pnl",
@@ -1622,6 +1652,25 @@ impl PlatformExprStrategy {
scope.push("holding_return", position.holding_return); scope.push("holding_return", position.holding_return);
scope.push("quantity", position.quantity); scope.push("quantity", position.quantity);
scope.push("sellable_qty", position.sellable_qty); 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("trading_pnl", position.trading_pnl);
scope.push("position_pnl", position.position_pnl); scope.push("position_pnl", position.position_pnl);
scope.push("dividend_receivable", position.dividend_receivable); scope.push("dividend_receivable", position.dividend_receivable);
@@ -3260,12 +3309,32 @@ impl PlatformExprStrategy {
} else { } else {
0.0 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 { let position_state = PositionExpressionState {
avg_cost: position.average_cost, avg_cost: position.average_cost,
current_price, current_price,
holding_return, holding_return,
quantity: position.quantity as i64, quantity: position.quantity as i64,
sellable_qty: position.sellable_qty(date) 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, trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl, position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable, 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] #[test]
fn platform_strategy_exposes_process_event_runtime_fields() { fn platform_strategy_exposes_process_event_runtime_fields() {
let date = d(2025, 2, 3); let date = d(2025, 2, 3);

View File

@@ -28,6 +28,10 @@ pub struct Position {
day_dividend_cash: f64, day_dividend_cash: f64,
day_trade_quantity_delta: i32, day_trade_quantity_delta: i32,
day_trade_cost: f64, day_trade_cost: f64,
day_buy_quantity: u32,
day_sell_quantity: u32,
day_buy_value: f64,
day_sell_value: f64,
lots: Vec<PositionLot>, lots: Vec<PositionLot>,
} }
@@ -48,6 +52,10 @@ impl Position {
day_dividend_cash: 0.0, day_dividend_cash: 0.0,
day_trade_quantity_delta: 0, day_trade_quantity_delta: 0,
day_trade_cost: 0.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(), lots: Vec::new(),
} }
} }
@@ -69,6 +77,8 @@ impl Position {
self.quantity += quantity; self.quantity += quantity;
self.last_price = price; self.last_price = price;
self.day_trade_quantity_delta += quantity as i32; 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.recalculate_average_cost();
self.refresh_day_pnl(); self.refresh_day_pnl();
} }
@@ -103,6 +113,8 @@ impl Position {
self.last_price = price; self.last_price = price;
self.realized_pnl += realized; self.realized_pnl += realized;
self.day_trade_quantity_delta -= quantity as i32; 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.recalculate_average_cost();
self.refresh_day_pnl(); self.refresh_day_pnl();
Ok(realized) Ok(realized)
@@ -124,6 +136,54 @@ impl Position {
(self.last_price - self.average_cost) * self.quantity as f64 (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) { pub fn begin_trading_day(&mut self) {
self.day_start_quantity = self.quantity; self.day_start_quantity = self.quantity;
self.day_start_price = self.last_price; self.day_start_price = self.last_price;
@@ -131,6 +191,10 @@ impl Position {
self.day_dividend_cash = 0.0; self.day_dividend_cash = 0.0;
self.day_trade_quantity_delta = 0; self.day_trade_quantity_delta = 0;
self.day_trade_cost = 0.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(); self.refresh_day_pnl();
} }
@@ -235,8 +299,9 @@ impl Position {
* (self.last_price - (self.day_start_price / self.day_split_ratio)) * (self.last_price - (self.day_start_price / self.day_split_ratio))
+ self.day_dividend_cash + self.day_dividend_cash
}; };
self.trading_pnl = self.trading_pnl = (self.day_buy_quantity as f64 * self.last_price - self.day_buy_value)
(self.day_trade_quantity_delta as f64 * self.last_price) - self.day_trade_cost; + (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<HoldingSummary> { pub fn holdings_summary(&self, date: NaiveDate) -> Vec<HoldingSummary> {
let total_equity = self.total_equity();
self.positions self.positions
.values() .values()
.filter(|position| position.quantity > 0) .filter(|position| position.quantity > 0)
@@ -373,11 +439,26 @@ impl PortfolioState {
average_cost: position.average_cost, average_cost: position.average_cost,
last_price: position.last_price, last_price: position.last_price,
market_value: position.market_value(), 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(), unrealized_pnl: position.unrealized_pnl(),
realized_pnl: position.realized_pnl, realized_pnl: position.realized_pnl,
pnl: position.pnl(),
trading_pnl: position.trading_pnl, trading_pnl: position.trading_pnl,
position_pnl: position.position_pnl, position_pnl: position.position_pnl,
dividend_receivable: position.dividend_receivable, 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() .collect()
} }
@@ -692,6 +773,52 @@ mod tests {
assert!((position.position_pnl - 70.0).abs() < 1e-6); assert!((position.position_pnl - 70.0).abs() < 1e-6);
assert!((position.trading_pnl + 5.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)] #[derive(Debug, Clone, Serialize)]
@@ -703,11 +830,22 @@ pub struct HoldingSummary {
pub average_cost: f64, pub average_cost: f64,
pub last_price: f64, pub last_price: f64,
pub market_value: f64, pub market_value: f64,
pub value_percent: f64,
pub unrealized_pnl: f64, pub unrealized_pnl: f64,
pub realized_pnl: f64, pub realized_pnl: f64,
pub pnl: f64,
pub trading_pnl: f64, pub trading_pnl: f64,
pub position_pnl: f64, pub position_pnl: f64,
pub dividend_receivable: 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)] #[derive(Debug, Clone)]

View File

@@ -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: "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: "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: "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: "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: "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() }, ManualField { name: "available_sellable_qty/reserved_open_sell_qty".to_string(), field_type: "int".to_string(), detail: "扣掉未成交卖单占用后的可卖数量,以及当前 symbol 已占用的卖出挂单数量。".to_string() },

View File

@@ -44,7 +44,7 @@ current alignment pass.
- [x] `trading_pnl` - [x] `trading_pnl`
- [x] `position_pnl` - [x] `position_pnl`
- [x] `dividend_receivable` - [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 ### Phase 6: Strategy data API parity
@@ -64,10 +64,10 @@ current alignment pass.
4. Add dynamic universe APIs. 4. Add dynamic universe APIs.
5. Add algo-order styles. 5. Add algo-order styles.
6. Finish position accounting parity. 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 ## Current Step
Active implementation target: Phase 5 follow-up: expose richer position Active implementation target: continue parity audit for remaining account,
lifecycle fields to strategy runtime beyond quantity, sellable quantity, order, and data-source APIs after the stock strategy API, scheduler, universe,
average cost, trading pnl, position pnl, and dividend receivable. algo-order, position accounting, and core strategy data helpers are covered.