Expose account runtime metrics

This commit is contained in:
boris
2026-04-23 20:03:49 -07:00
parent 9f10afddec
commit e0a5d0c945
7 changed files with 367 additions and 19 deletions

View File

@@ -46,8 +46,8 @@ pub use scheduler::{
};
pub use strategy::{
AlgoOrderStyle, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig,
JqMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, Strategy, StrategyContext,
StrategyDecision, TargetPortfolioOrderPricing,
JqMicroCapStrategy, OpenOrderView, OrderIntent, OrderRuntimeView, PortfolioRuntimeView,
Strategy, StrategyContext, StrategyDecision, TargetPortfolioOrderPricing,
};
pub use strategy_ai::{
ManualExample, ManualFactorSource, ManualField, ManualFieldGroup, ManualFunction,

View File

@@ -281,8 +281,22 @@ struct DayExpressionState {
signal_ma20: f64,
signal_ma30: f64,
cash: f64,
available_cash: f64,
frozen_cash: f64,
market_value: f64,
total_equity: f64,
total_value: f64,
portfolio_value: f64,
starting_cash: f64,
unit_net_value: f64,
static_unit_net_value: f64,
daily_pnl: f64,
daily_returns: f64,
total_returns: f64,
transaction_cost: f64,
trading_pnl: f64,
position_pnl: f64,
cash_liabilities: f64,
current_exposure: f64,
position_count: i64,
max_positions: i64,
@@ -455,8 +469,18 @@ impl PlatformExprStrategy {
"benchmark_ma_long",
"cash",
"available_cash",
"frozen_cash",
"market_value",
"total_equity",
"total_value",
"portfolio_value",
"starting_cash",
"unit_net_value",
"static_unit_net_value",
"daily_pnl",
"daily_returns",
"total_returns",
"cash_liabilities",
"current_exposure",
"position_count",
"max_positions",
@@ -1033,14 +1057,10 @@ impl PlatformExprStrategy {
.data
.market_decision_close_moving_average(date, &self.config.signal_symbol, 30)
.unwrap_or(benchmark_ma20);
let cash = ctx.portfolio.cash();
let market_value = ctx
.portfolio
.positions()
.values()
.map(|position| position.market_value())
.sum::<f64>();
let total_equity = cash + market_value;
let account = ctx.account();
let cash = account.cash;
let market_value = account.market_value;
let total_equity = account.total_equity;
let current_exposure = if total_equity > 0.0 {
market_value / total_equity
} else {
@@ -1066,8 +1086,22 @@ impl PlatformExprStrategy {
signal_ma20: benchmark_ma20,
signal_ma30: benchmark_ma30,
cash,
available_cash: account.available_cash,
frozen_cash: account.frozen_cash,
market_value,
total_equity,
total_value: account.total_value,
portfolio_value: account.portfolio_value,
starting_cash: account.starting_cash,
unit_net_value: account.unit_net_value,
static_unit_net_value: account.static_unit_net_value,
daily_pnl: account.daily_pnl,
daily_returns: account.daily_returns,
total_returns: account.total_returns,
transaction_cost: account.transaction_cost,
trading_pnl: account.trading_pnl,
position_pnl: account.position_pnl,
cash_liabilities: account.cash_liabilities,
current_exposure,
position_count: ctx.portfolio.positions().len() as i64,
max_positions: self.config.max_positions as i64,
@@ -1231,9 +1265,22 @@ impl PlatformExprStrategy {
scope.push("benchmark_ma_short", day.benchmark_ma_short);
scope.push("benchmark_ma_long", day.benchmark_ma_long);
scope.push("cash", day.cash);
scope.push("available_cash", day.cash);
scope.push("available_cash", day.available_cash);
scope.push("frozen_cash", day.frozen_cash);
scope.push("market_value", day.market_value);
scope.push("total_equity", day.total_equity);
scope.push("total_value", day.total_value);
scope.push("portfolio_value", day.portfolio_value);
scope.push("starting_cash", day.starting_cash);
scope.push("unit_net_value", day.unit_net_value);
scope.push("static_unit_net_value", day.static_unit_net_value);
scope.push("daily_pnl", day.daily_pnl);
scope.push("daily_returns", day.daily_returns);
scope.push("total_returns", day.total_returns);
scope.push("transaction_cost", day.transaction_cost);
scope.push("trading_pnl", day.trading_pnl);
scope.push("position_pnl", day.position_pnl);
scope.push("cash_liabilities", day.cash_liabilities);
scope.push("current_exposure", day.current_exposure);
scope.push("position_count", day.position_count);
scope.push("max_positions", day.max_positions);
@@ -1343,8 +1390,31 @@ impl PlatformExprStrategy {
Dynamic::from(day.benchmark_ma_long),
);
day_factors.insert("cash".into(), Dynamic::from(day.cash));
day_factors.insert("available_cash".into(), Dynamic::from(day.available_cash));
day_factors.insert("frozen_cash".into(), Dynamic::from(day.frozen_cash));
day_factors.insert("market_value".into(), Dynamic::from(day.market_value));
day_factors.insert("total_equity".into(), Dynamic::from(day.total_equity));
day_factors.insert("total_value".into(), Dynamic::from(day.total_value));
day_factors.insert("portfolio_value".into(), Dynamic::from(day.portfolio_value));
day_factors.insert("starting_cash".into(), Dynamic::from(day.starting_cash));
day_factors.insert("unit_net_value".into(), Dynamic::from(day.unit_net_value));
day_factors.insert(
"static_unit_net_value".into(),
Dynamic::from(day.static_unit_net_value),
);
day_factors.insert("daily_pnl".into(), Dynamic::from(day.daily_pnl));
day_factors.insert("daily_returns".into(), Dynamic::from(day.daily_returns));
day_factors.insert("total_returns".into(), Dynamic::from(day.total_returns));
day_factors.insert(
"transaction_cost".into(),
Dynamic::from(day.transaction_cost),
);
day_factors.insert("trading_pnl".into(), Dynamic::from(day.trading_pnl));
day_factors.insert("position_pnl".into(), Dynamic::from(day.position_pnl));
day_factors.insert(
"cash_liabilities".into(),
Dynamic::from(day.cash_liabilities),
);
day_factors.insert(
"current_exposure".into(),
Dynamic::from(day.current_exposure),
@@ -5267,7 +5337,13 @@ mod tests {
" && 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"
" && day_trade_quantity_delta == 50 && trading_pnl > 90.0",
" && available_cash == cash && frozen_cash == 0.0",
" && total_value == total_equity && portfolio_value == total_equity",
" && starting_cash == 1000000.0 && unit_net_value > 1.0",
" && static_unit_net_value > 1.0 && daily_pnl > 290.0",
" && daily_returns > 0.0 && total_returns > 0.0",
" && cash_liabilities == 0.0"
)
.to_string();
let mut strategy = PlatformExprStrategy::new(cfg);

View File

@@ -307,6 +307,7 @@ impl Position {
#[derive(Debug, Clone)]
pub struct PortfolioState {
initial_cash: f64,
cash: f64,
positions: IndexMap<String, Position>,
cash_receivables: Vec<CashReceivable>,
@@ -326,12 +327,21 @@ pub(crate) struct SuccessorConversionOutcome {
impl PortfolioState {
pub fn new(initial_cash: f64) -> Self {
Self {
initial_cash,
cash: initial_cash,
positions: IndexMap::new(),
cash_receivables: Vec::new(),
}
}
pub fn starting_cash(&self) -> f64 {
self.initial_cash
}
pub fn units(&self) -> f64 {
self.initial_cash
}
pub fn cash(&self) -> f64 {
self.cash
}
@@ -423,10 +433,72 @@ impl PortfolioState {
self.positions.values().map(Position::market_value).sum()
}
pub fn transaction_cost(&self) -> f64 {
self.positions
.values()
.map(Position::transaction_cost)
.sum()
}
pub fn trading_pnl(&self) -> f64 {
self.positions
.values()
.map(|position| position.trading_pnl)
.sum()
}
pub fn position_pnl(&self) -> f64 {
self.positions
.values()
.map(|position| position.position_pnl)
.sum()
}
pub fn daily_pnl(&self) -> f64 {
self.trading_pnl() + self.position_pnl()
}
pub fn total_equity(&self) -> f64 {
self.cash + self.market_value()
}
pub fn total_value(&self) -> f64 {
self.total_equity()
}
pub fn portfolio_value(&self) -> f64 {
self.total_equity()
}
pub fn unit_net_value(&self) -> f64 {
if self.initial_cash.abs() < f64::EPSILON {
0.0
} else {
self.total_equity() / self.initial_cash
}
}
pub fn static_unit_net_value(&self) -> f64 {
if self.initial_cash.abs() < f64::EPSILON {
0.0
} else {
(self.total_equity() - self.daily_pnl()) / self.initial_cash
}
}
pub fn daily_returns(&self) -> f64 {
let previous_value = self.total_equity() - self.daily_pnl();
if previous_value.abs() < f64::EPSILON {
0.0
} else {
self.daily_pnl() / previous_value
}
}
pub fn total_returns(&self) -> f64 {
self.unit_net_value() - 1.0
}
pub fn holdings_summary(&self, date: NaiveDate) -> Vec<HoldingSummary> {
let total_equity = self.total_equity();
self.positions
@@ -819,6 +891,47 @@ mod tests {
assert!((summary[0].transaction_cost - 3.0).abs() < 1e-6);
assert!(summary[0].value_percent > 0.0);
}
#[test]
fn portfolio_exposes_rqalpha_style_account_metrics() {
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);
portfolio
.position_mut("000001.SZ")
.sell(40, 12.0)
.expect("sell");
portfolio.position_mut("000001.SZ").record_trade_cost(3.0);
assert!((portfolio.starting_cash() - 10_000.0).abs() < 1e-6);
assert!((portfolio.units() - 10_000.0).abs() < 1e-6);
assert!((portfolio.transaction_cost() - 3.0).abs() < 1e-6);
assert!((portfolio.trading_pnl() - 47.0).abs() < 1e-6);
assert!((portfolio.position_pnl() - 200.0).abs() < 1e-6);
assert!((portfolio.daily_pnl() - 247.0).abs() < 1e-6);
assert!((portfolio.total_value() - portfolio.total_equity()).abs() < 1e-6);
assert!((portfolio.portfolio_value() - portfolio.total_equity()).abs() < 1e-6);
assert!((portfolio.unit_net_value() - portfolio.total_equity() / 10_000.0).abs() < 1e-6);
assert!(
(portfolio.static_unit_net_value()
- (portfolio.total_equity() - portfolio.daily_pnl()) / 10_000.0)
.abs()
< 1e-6
);
assert!(
(portfolio.daily_returns()
- portfolio.daily_pnl() / (portfolio.total_equity() - portfolio.daily_pnl()))
.abs()
< 1e-6
);
assert!((portfolio.total_returns() - (portfolio.unit_net_value() - 1.0)).abs() < 1e-6);
assert_eq!(portfolio.cash_receivables().len(), 0);
}
}
#[derive(Debug, Clone, Serialize)]

View File

@@ -95,6 +95,28 @@ pub struct OrderRuntimeView {
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct PortfolioRuntimeView {
pub starting_cash: f64,
pub units: f64,
pub cash: f64,
pub available_cash: f64,
pub frozen_cash: f64,
pub market_value: f64,
pub total_value: f64,
pub portfolio_value: f64,
pub total_equity: f64,
pub unit_net_value: f64,
pub static_unit_net_value: f64,
pub daily_pnl: f64,
pub daily_returns: f64,
pub total_returns: f64,
pub transaction_cost: f64,
pub trading_pnl: f64,
pub position_pnl: f64,
pub cash_liabilities: f64,
}
pub struct StrategyContext<'a> {
pub execution_date: NaiveDate,
pub decision_date: NaiveDate,
@@ -323,6 +345,55 @@ impl StrategyContext<'_> {
.unwrap_or(0.0)
}
pub fn portfolio_view(&self) -> PortfolioRuntimeView {
let frozen_cash = self.frozen_cash();
let cash = self.portfolio.cash();
let total_equity = self.portfolio.total_equity();
PortfolioRuntimeView {
starting_cash: self.portfolio.starting_cash(),
units: self.portfolio.units(),
cash,
available_cash: (cash - frozen_cash).max(0.0),
frozen_cash,
market_value: self.portfolio.market_value(),
total_value: self.portfolio.total_value(),
portfolio_value: self.portfolio.portfolio_value(),
total_equity,
unit_net_value: self.portfolio.unit_net_value(),
static_unit_net_value: self.portfolio.static_unit_net_value(),
daily_pnl: self.portfolio.daily_pnl(),
daily_returns: self.portfolio.daily_returns(),
total_returns: self.portfolio.total_returns(),
transaction_cost: self.portfolio.transaction_cost(),
trading_pnl: self.portfolio.trading_pnl(),
position_pnl: self.portfolio.position_pnl(),
cash_liabilities: 0.0,
}
}
pub fn account(&self) -> PortfolioRuntimeView {
self.portfolio_view()
}
pub fn frozen_cash(&self) -> f64 {
self.open_orders
.iter()
.filter(|order| order.side == OrderSide::Buy)
.map(|order| {
let price = if order.limit_price.is_finite() {
order.limit_price.max(0.0)
} else {
0.0
};
order.remaining_quantity as f64 * price
})
.sum()
}
pub fn available_cash(&self) -> f64 {
(self.portfolio.cash() - self.frozen_cash()).max(0.0)
}
pub fn available_sellable_qty(&self, symbol: &str, raw_sellable_qty: u32) -> u32 {
raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol))
}

View File

@@ -140,7 +140,9 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() },
ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() },
ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() },
ManualField { name: "cash/available_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户资金与总资产".to_string() },
ManualField { name: "cash/available_cash/frozen_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户可用资金、挂单冻结资金、市值与总权益available_cash 会扣减当前买入挂单冻结估算".to_string() },
ManualField { name: "total_value/portfolio_value/starting_cash/unit_net_value/static_unit_net_value".to_string(), field_type: "float".to_string(), detail: "组合总权益别名、初始资金、实时净值和昨日静态净值,对齐 RQAlpha Portfolio 常用字段。".to_string() },
ManualField { name: "daily_pnl/daily_returns/total_returns/transaction_cost/trading_pnl/position_pnl/cash_liabilities".to_string(), field_type: "float".to_string(), detail: "账户当日盈亏、日收益率、累计收益率、当日交易成本、交易盈亏、持仓盈亏和现金负债;股票账户现金负债默认为 0。".to_string() },
ManualField { name: "position_count/max_positions/refresh_rate".to_string(), field_type: "int".to_string(), detail: "仓位计数与调仓周期。".to_string() },
ManualField { name: "has_open_orders/open_order_count/open_buy_order_count/open_sell_order_count".to_string(), field_type: "bool/int".to_string(), detail: "当前阶段挂单簿摘要。".to_string() },
ManualField { name: "open_buy_qty/open_sell_qty/latest_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前阶段未成交买卖挂单的剩余数量汇总,以及最近一笔挂单 id。".to_string() },
@@ -205,6 +207,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "is_suspended/is_st_stock".to_string(), signature: "ctx.is_suspended(symbol, count)".to_string(), detail: "读取指定证券截至当前交易日最近 count 个交易日的停牌或 ST 标记,返回 bool 序列,顺序从旧到新;对应 RQAlpha 的 is_suspended/is_st_stock 数据源能力。".to_string() },
ManualFunction { name: "get_price".to_string(), signature: "ctx.get_price(symbol, start_date, end_date, \"1d\" | \"1m\" | \"tick\")".to_string(), detail: "按日期区间读取统一 PriceBar 序列。日线返回 open/high/low/close/last/volume/盘口字段;分钟或 tick 返回按 timestamp 排序的 last/bid1/ask1/volume_delta/amount_delta 映射,便于服务层转成表格或前端明细。".to_string() },
ManualFunction { name: "order/order_status/order_avg_price/order_transaction_cost".to_string(), signature: "ctx.order(order_id)".to_string(), detail: "按订单 id 查询运行时订单对象,支持已结束订单和当前挂单。返回字段包括 status、filled_quantity、unfilled_quantity、avg_price、transaction_cost、symbol、side、reason可用便捷函数读取状态、成交均价和费用对齐 RQAlpha Order 的核心属性。".to_string() },
ManualFunction { name: "account/portfolio_view".to_string(), signature: "ctx.account()".to_string(), detail: "返回当前股票账户/组合运行时视图,字段包括 cash、available_cash、frozen_cash、market_value、total_value、unit_net_value、daily_pnl、daily_returns、total_returns、transaction_cost、trading_pnl、position_pnl 等DSL 中同名字段可直接使用。".to_string() },
ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。任意成交量窗口推荐用它,比如 rolling_mean(\"volume\", 15)。".to_string() },
ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。任意价格均线窗口推荐用它,比如 sma(\"close\", 15)。".to_string() },
ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() },