Add dynamic universe and subscription controls

This commit is contained in:
boris
2026-04-23 07:12:56 -07:00
parent 5265f82fef
commit 152b5c3141
12 changed files with 963 additions and 24 deletions

View File

@@ -787,6 +787,30 @@ where
self.cancel_all_open_orders(date, reason, report);
Ok(())
}
OrderIntent::UpdateUniverse { symbols, reason } => {
report.diagnostics.push(format!(
"engine_control_intent_skipped kind=update_universe count={} reason={}",
symbols.len(),
reason
));
Ok(())
}
OrderIntent::Subscribe { symbols, reason } => {
report.diagnostics.push(format!(
"engine_control_intent_skipped kind=subscribe count={} reason={}",
symbols.len(),
reason
));
Ok(())
}
OrderIntent::Unsubscribe { symbols, reason } => {
report.diagnostics.push(format!(
"engine_control_intent_skipped kind=unsubscribe count={} reason={}",
symbols.len(),
reason
));
Ok(())
}
}
}

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeSet;
use chrono::NaiveDate;
use serde::Serialize;
use thiserror::Error;
@@ -97,6 +99,8 @@ pub struct BacktestEngine<S, C, R> {
config: BacktestConfig,
dividend_reinvestment: bool,
process_event_bus: ProcessEventBus,
dynamic_universe: Option<BTreeSet<String>>,
subscriptions: BTreeSet<String>,
}
impl<S, C, R> BacktestEngine<S, C, R> {
@@ -113,6 +117,8 @@ impl<S, C, R> BacktestEngine<S, C, R> {
config,
dividend_reinvestment: false,
process_event_bus: ProcessEventBus::new(),
dynamic_universe: None,
subscriptions: BTreeSet::new(),
}
}
@@ -146,6 +152,143 @@ where
C: CostModel,
R: EquityRuleHooks,
{
fn apply_strategy_directives(
&mut self,
execution_date: NaiveDate,
decision_date: NaiveDate,
decision_index: usize,
portfolio: &PortfolioState,
open_orders: &[crate::strategy::OpenOrderView],
process_events: &mut Vec<ProcessEvent>,
decision: &mut crate::strategy::StrategyDecision,
) -> Result<(), BacktestError> {
if decision.order_intents.is_empty() {
return Ok(());
}
let mut retained = Vec::with_capacity(decision.order_intents.len());
for intent in decision.order_intents.drain(..) {
match intent {
crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => {
let symbol_count = symbols.len();
self.dynamic_universe = Some(symbols.clone());
decision
.diagnostics
.push(format!("dynamic_universe_updated count={symbol_count}"));
publish_custom_process_event(
&mut self.strategy,
&mut self.process_event_bus,
execution_date,
decision_date,
decision_index,
&self.data,
portfolio,
open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
process_events,
ProcessEvent {
date: execution_date,
kind: ProcessEventKind::UniverseUpdated,
order_id: None,
symbol: (symbol_count == 1)
.then(|| symbols.iter().next().cloned())
.flatten(),
side: None,
detail: format!(
"reason={reason} count={symbol_count} symbols={}",
symbols.iter().cloned().collect::<Vec<_>>().join(",")
),
},
)?;
}
crate::strategy::OrderIntent::Subscribe { symbols, reason } => {
let mut added = Vec::new();
for symbol in symbols {
if self.subscriptions.insert(symbol.clone()) {
added.push(symbol);
}
}
if !added.is_empty() {
decision.diagnostics.push(format!(
"subscriptions_added count={} total={}",
added.len(),
self.subscriptions.len()
));
publish_custom_process_event(
&mut self.strategy,
&mut self.process_event_bus,
execution_date,
decision_date,
decision_index,
&self.data,
portfolio,
open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
process_events,
ProcessEvent {
date: execution_date,
kind: ProcessEventKind::UniverseSubscribed,
order_id: None,
symbol: (added.len() == 1).then(|| added[0].clone()),
side: None,
detail: format!(
"reason={reason} count={} symbols={}",
added.len(),
added.join(",")
),
},
)?;
}
}
crate::strategy::OrderIntent::Unsubscribe { symbols, reason } => {
let mut removed = Vec::new();
for symbol in symbols {
if self.subscriptions.remove(&symbol) {
removed.push(symbol);
}
}
if !removed.is_empty() {
decision.diagnostics.push(format!(
"subscriptions_removed count={} total={}",
removed.len(),
self.subscriptions.len()
));
publish_custom_process_event(
&mut self.strategy,
&mut self.process_event_bus,
execution_date,
decision_date,
decision_index,
&self.data,
portfolio,
open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
process_events,
ProcessEvent {
date: execution_date,
kind: ProcessEventKind::UniverseUnsubscribed,
order_id: None,
symbol: (removed.len() == 1).then(|| removed[0].clone()),
side: None,
detail: format!(
"reason={reason} count={} symbols={}",
removed.len(),
removed.join(",")
),
},
)?;
}
}
other => retained.push(other),
}
}
decision.order_intents = retained;
Ok(())
}
pub fn run(&mut self) -> Result<BacktestResult, BacktestError> {
self.run_with_progress(|_| {})
}
@@ -245,6 +388,8 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PreBeforeTrading,
@@ -257,6 +402,8 @@ where
data: &self.data,
portfolio: &portfolio,
open_orders: &pre_open_orders,
dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
})?;
@@ -269,12 +416,14 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::BeforeTrading,
"before_trading",
)?;
let _ = collect_scheduled_decisions(
let mut before_trading_decision = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
@@ -285,10 +434,21 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::BeforeTrading),
)?;
self.apply_strategy_directives(
execution_date,
decision_date,
decision_index,
&portfolio,
&pre_open_orders,
&mut process_events,
&mut before_trading_decision,
)?;
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,
@@ -298,6 +458,8 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PostBeforeTrading,
@@ -312,6 +474,8 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PreOpenAuction,
@@ -328,6 +492,8 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OpenAuction),
@@ -339,6 +505,8 @@ where
data: &self.data,
portfolio: &portfolio,
open_orders: &pre_open_orders,
dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
})?);
@@ -351,11 +519,22 @@ where
&self.data,
&portfolio,
&pre_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::OpenAuction,
"open_auction",
)?;
self.apply_strategy_directives(
execution_date,
decision_date,
decision_index,
&portfolio,
&pre_open_orders,
&mut process_events,
&mut auction_decision,
)?;
let mut report = self.broker.execute(
execution_date,
&mut portfolio,
@@ -372,6 +551,8 @@ where
&self.data,
&portfolio,
&post_auction_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut report.process_events,
)?;
@@ -384,6 +565,8 @@ where
&self.data,
&portfolio,
&post_auction_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PostOpenAuction,
@@ -399,6 +582,8 @@ where
&self.data,
&portfolio,
&post_auction_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PreOnDay,
@@ -414,6 +599,8 @@ where
data: &self.data,
portfolio: &portfolio,
open_orders: &on_day_open_orders,
dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
})
@@ -431,6 +618,8 @@ where
&self.data,
&portfolio,
&on_day_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OnDay),
@@ -444,11 +633,22 @@ where
&self.data,
&portfolio,
&on_day_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::OnDay,
"on_day",
)?;
self.apply_strategy_directives(
execution_date,
decision_date,
decision_index,
&portfolio,
&on_day_open_orders,
&mut process_events,
&mut decision,
)?;
let mut intraday_report =
self.broker
@@ -463,6 +663,8 @@ where
&self.data,
&portfolio,
&post_intraday_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut intraday_report.process_events,
)?;
@@ -482,6 +684,8 @@ where
&self.data,
&portfolio,
&post_intraday_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PostOnDay,
@@ -500,6 +704,8 @@ where
&self.data,
&portfolio,
&post_trade_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PreAfterTrading,
@@ -512,6 +718,8 @@ where
data: &self.data,
portfolio: &portfolio,
open_orders: &post_trade_open_orders,
dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
})?;
@@ -524,12 +732,14 @@ where
&self.data,
&portfolio,
&post_trade_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::AfterTrading,
"after_trading",
)?;
let _ = collect_scheduled_decisions(
let mut after_trading_decision = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
@@ -540,10 +750,21 @@ where
&self.data,
&portfolio,
&post_trade_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::AfterTrading),
)?;
self.apply_strategy_directives(
execution_date,
decision_date,
decision_index,
&portfolio,
&post_trade_open_orders,
&mut process_events,
&mut after_trading_decision,
)?;
let mut close_report = self.broker.after_trading(execution_date);
publish_process_events(
&mut self.strategy,
@@ -554,6 +775,8 @@ where
&self.data,
&portfolio,
&post_trade_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut close_report.process_events,
)?;
@@ -572,6 +795,8 @@ where
&self.data,
&portfolio,
&post_close_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PostAfterTrading,
@@ -586,6 +811,8 @@ where
&self.data,
&portfolio,
&post_close_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PreSettlement,
@@ -598,6 +825,8 @@ where
data: &self.data,
portfolio: &portfolio,
open_orders: &post_close_open_orders,
dynamic_universe: self.dynamic_universe.as_ref(),
subscriptions: &self.subscriptions,
process_events: &process_events,
active_process_event: None,
})?;
@@ -610,12 +839,14 @@ where
&self.data,
&portfolio,
&post_close_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::Settlement,
"settlement",
)?;
let _ = collect_scheduled_decisions(
let mut settlement_decision = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
@@ -626,10 +857,21 @@ where
&self.data,
&portfolio,
&post_close_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::Settlement),
)?;
self.apply_strategy_directives(
execution_date,
decision_date,
decision_index,
&portfolio,
&post_close_open_orders,
&mut process_events,
&mut settlement_decision,
)?;
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,
@@ -639,6 +881,8 @@ where
&self.data,
&portfolio,
&post_close_open_orders,
self.dynamic_universe.as_ref(),
&self.subscriptions,
&mut process_events,
execution_date,
ProcessEventKind::PostSettlement,
@@ -1139,6 +1383,8 @@ fn collect_scheduled_decisions<S: Strategy>(
data: &crate::data::DataSet,
portfolio: &PortfolioState,
open_orders: &[crate::strategy::OpenOrderView],
dynamic_universe: Option<&BTreeSet<String>>,
subscriptions: &BTreeSet<String>,
process_events: &mut Vec<ProcessEvent>,
process_event_bus: &mut ProcessEventBus,
current_time: Option<chrono::NaiveTime>,
@@ -1154,6 +1400,8 @@ fn collect_scheduled_decisions<S: Strategy>(
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events,
execution_date,
ProcessEventKind::PreScheduled,
@@ -1167,6 +1415,8 @@ fn collect_scheduled_decisions<S: Strategy>(
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events: process_events.as_slice(),
active_process_event: None,
},
@@ -1181,6 +1431,8 @@ fn collect_scheduled_decisions<S: Strategy>(
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events,
execution_date,
ProcessEventKind::PostScheduled,
@@ -1199,6 +1451,8 @@ fn publish_phase_event<S: Strategy>(
data: &crate::data::DataSet,
portfolio: &PortfolioState,
open_orders: &[crate::strategy::OpenOrderView],
dynamic_universe: Option<&BTreeSet<String>>,
subscriptions: &BTreeSet<String>,
events: &mut Vec<ProcessEvent>,
date: NaiveDate,
kind: ProcessEventKind,
@@ -1221,6 +1475,8 @@ fn publish_phase_event<S: Strategy>(
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events,
active_process_event: Some(&event),
};
@@ -1238,6 +1494,8 @@ fn publish_process_events<S: Strategy>(
data: &crate::data::DataSet,
portfolio: &PortfolioState,
open_orders: &[crate::strategy::OpenOrderView],
dynamic_universe: Option<&BTreeSet<String>>,
subscriptions: &BTreeSet<String>,
target: &mut Vec<ProcessEvent>,
incoming: &mut Vec<ProcessEvent>,
) -> Result<(), BacktestError> {
@@ -1251,6 +1509,8 @@ fn publish_process_events<S: Strategy>(
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events,
active_process_event: Some(&event),
};
@@ -1260,6 +1520,39 @@ fn publish_process_events<S: Strategy>(
Ok(())
}
fn publish_custom_process_event<S: Strategy>(
strategy: &mut S,
process_event_bus: &mut ProcessEventBus,
execution_date: NaiveDate,
decision_date: NaiveDate,
decision_index: usize,
data: &crate::data::DataSet,
portfolio: &PortfolioState,
open_orders: &[crate::strategy::OpenOrderView],
dynamic_universe: Option<&BTreeSet<String>>,
subscriptions: &BTreeSet<String>,
target: &mut Vec<ProcessEvent>,
event: ProcessEvent,
) -> Result<(), BacktestError> {
process_event_bus.publish(&event);
let process_events = target.as_slice();
let event_ctx = StrategyContext {
execution_date,
decision_date,
decision_index,
data,
portfolio,
open_orders,
dynamic_universe,
subscriptions,
process_events,
active_process_event: Some(&event),
};
strategy.on_process_event(&event_ctx, &event)?;
target.push(event);
Ok(())
}
fn stage_label(stage: ScheduleStage) -> &'static str {
match stage {
ScheduleStage::BeforeTrading => "before_trading",

View File

@@ -127,6 +127,9 @@ pub enum ProcessEventKind {
OrderCancellationReject,
OrderUnsolicitedUpdate,
Trade,
UniverseUpdated,
UniverseSubscribed,
UniverseUnsubscribed,
}
impl ProcessEventKind {
@@ -157,6 +160,9 @@ impl ProcessEventKind {
Self::OrderCancellationReject => "order_cancellation_reject",
Self::OrderUnsolicitedUpdate => "order_unsolicited_update",
Self::Trade => "trade",
Self::UniverseUpdated => "universe_updated",
Self::UniverseSubscribed => "universe_subscribed",
Self::UniverseUnsubscribed => "universe_unsubscribed",
}
}
}

View File

@@ -37,7 +37,7 @@ pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{
PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind,
PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule,
PlatformScheduleFrequency, PlatformTradeAction,
PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind,
};
pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position};
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};

View File

@@ -100,6 +100,13 @@ pub enum PlatformExplicitCancelKind {
All,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformUniverseActionKind {
UpdateUniverse,
Subscribe,
Unsubscribe,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlatformTradeAction {
Order {
@@ -117,6 +124,12 @@ pub enum PlatformTradeAction {
when_expr: Option<String>,
reason: String,
},
Universe {
kind: PlatformUniverseActionKind,
symbols_expr: String,
when_expr: Option<String>,
reason: String,
},
Cancel {
kind: PlatformExplicitCancelKind,
symbol: Option<String>,
@@ -1186,6 +1199,13 @@ impl PlatformExprStrategy {
scope.push("open_buy_qty", ctx.open_buy_quantity() as i64);
scope.push("open_sell_qty", ctx.open_sell_quantity() as i64);
scope.push("latest_open_order_id", ctx.latest_open_order_id() as i64);
scope.push("has_dynamic_universe", ctx.has_dynamic_universe());
scope.push(
"dynamic_universe_count",
ctx.dynamic_universe_count() as i64,
);
scope.push("has_subscriptions", ctx.has_subscriptions());
scope.push("subscription_count", ctx.subscription_count() as i64);
scope.push("has_process_events", ctx.has_process_events());
scope.push("process_event_count", ctx.process_event_count() as i64);
scope.push(
@@ -1301,6 +1321,22 @@ impl PlatformExprStrategy {
"latest_open_order_id".into(),
Dynamic::from(ctx.latest_open_order_id() as i64),
);
day_factors.insert(
"has_dynamic_universe".into(),
Dynamic::from(ctx.has_dynamic_universe()),
);
day_factors.insert(
"dynamic_universe_count".into(),
Dynamic::from(ctx.dynamic_universe_count() as i64),
);
day_factors.insert(
"has_subscriptions".into(),
Dynamic::from(ctx.has_subscriptions()),
);
day_factors.insert(
"subscription_count".into(),
Dynamic::from(ctx.subscription_count() as i64),
);
day_factors.insert(
"has_process_events".into(),
Dynamic::from(ctx.has_process_events()),
@@ -1410,6 +1446,11 @@ impl PlatformExprStrategy {
"latest_symbol_open_order_id",
ctx.latest_symbol_open_order_id(&stock.symbol) as i64,
);
scope.push(
"in_dynamic_universe",
ctx.dynamic_universe_contains(&stock.symbol),
);
scope.push("is_subscribed", ctx.is_subscribed(&stock.symbol));
scope.push("stock_ma_short", stock.stock_ma_short);
scope.push("stock_ma_mid", stock.stock_ma_mid);
scope.push("stock_ma_long", stock.stock_ma_long);
@@ -1501,6 +1542,14 @@ impl PlatformExprStrategy {
"latest_symbol_open_order_id".into(),
Dynamic::from(ctx.latest_symbol_open_order_id(&stock.symbol) as i64),
);
factors.insert(
"in_dynamic_universe".into(),
Dynamic::from(ctx.dynamic_universe_contains(&stock.symbol)),
);
factors.insert(
"is_subscribed".into(),
Dynamic::from(ctx.is_subscribed(&stock.symbol)),
);
factors.insert("stock_ma5".into(), Dynamic::from(stock.stock_ma5));
factors.insert("stock_ma10".into(), Dynamic::from(stock.stock_ma10));
factors.insert("stock_ma20".into(), Dynamic::from(stock.stock_ma20));
@@ -2193,6 +2242,50 @@ impl PlatformExprStrategy {
Ok(output)
}
fn eval_symbol_set_expr(
&self,
ctx: &StrategyContext<'_>,
expr: &str,
day: &DayExpressionState,
stock: Option<&StockExpressionState>,
position: Option<&PositionExpressionState>,
) -> Result<BTreeSet<String>, BacktestError> {
let trimmed = expr.trim();
if trimmed.is_empty() {
return Ok(BTreeSet::new());
}
let inner = trimmed
.strip_prefix('[')
.and_then(|value| value.strip_suffix(']'))
.ok_or_else(|| {
BacktestError::Execution(format!(
"platform symbol list expr must use [...] array literal syntax: {trimmed}"
))
})?;
let mut output = BTreeSet::new();
for entry in Self::split_top_level_args(inner) {
let raw = entry.trim();
if raw.is_empty() {
continue;
}
let symbol = if raw.starts_with('"') || raw.starts_with('\'') {
Self::parse_string_literal_key(raw)?
} else {
let value = self.eval_dynamic(ctx, raw, day, stock, position)?;
value.try_cast::<String>().ok_or_else(|| {
BacktestError::Execution(format!(
"platform symbol list entry must evaluate to string: {raw}"
))
})?
};
let normalized = symbol.trim().to_ascii_uppercase();
if !normalized.is_empty() {
output.insert(normalized);
}
}
Ok(output)
}
fn split_top_level_key_value(input: &str) -> Option<(&str, &str)> {
let mut paren_depth = 0i32;
let mut brace_depth = 0i32;
@@ -2553,6 +2646,34 @@ impl PlatformExprStrategy {
}
}
}
PlatformTradeAction::Universe {
kind,
symbols_expr,
when_expr,
reason,
} => {
if !self.action_when_matches(ctx, day, None, when_expr.as_deref())? {
continue;
}
let symbols = self.eval_symbol_set_expr(ctx, symbols_expr, day, None, None)?;
if symbols.is_empty() {
continue;
}
intents.push(match kind {
PlatformUniverseActionKind::UpdateUniverse => OrderIntent::UpdateUniverse {
symbols,
reason: reason.clone(),
},
PlatformUniverseActionKind::Subscribe => OrderIntent::Subscribe {
symbols,
reason: reason.clone(),
},
PlatformUniverseActionKind::Unsubscribe => OrderIntent::Unsubscribe {
symbols,
reason: reason.clone(),
},
});
}
PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr,
order_prices_expr,
@@ -2805,10 +2926,10 @@ impl PlatformExprStrategy {
band_high: f64,
limit: usize,
) -> Result<(Vec<String>, Vec<String>), BacktestError> {
let universe = ctx.data.eligible_universe_on(date);
let universe = ctx.eligible_universe_on(date);
let mut diagnostics = Vec::new();
let mut candidates = Vec::new();
for candidate in universe.iter().cloned() {
for candidate in universe {
let stock = self.stock_state(ctx, date, &candidate.symbol)?;
let field_value = self.selection_field_value(&candidate, &stock);
if field_value < band_low || field_value > band_high {
@@ -3235,14 +3356,14 @@ impl Strategy for PlatformExprStrategy {
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use chrono::{NaiveDate, NaiveTime};
use super::{
PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind,
PlatformExprStrategy, PlatformExprStrategyConfig, PlatformRebalanceSchedule,
PlatformScheduleFrequency, PlatformTradeAction,
PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind,
};
use crate::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
@@ -3401,6 +3522,7 @@ mod tests {
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3408,6 +3530,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -3533,6 +3657,7 @@ mod tests {
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3540,6 +3665,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -3616,6 +3743,7 @@ mod tests {
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3623,10 +3751,13 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SH".to_string();
cfg.rotation_enabled = false;
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
@@ -3742,6 +3873,7 @@ mod tests {
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3749,6 +3881,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -3856,6 +3990,7 @@ mod tests {
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3863,6 +3998,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -3971,6 +4108,7 @@ mod tests {
limit_price: 10.2,
reason: "pending_limit_sell".to_string(),
}];
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -3978,6 +4116,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &open_orders,
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -4092,6 +4232,7 @@ mod tests {
reason: "pending_limit_sell".to_string(),
},
];
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -4099,6 +4240,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &open_orders,
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
};
@@ -4127,6 +4270,128 @@ mod tests {
}
}
#[test]
fn platform_strategy_emits_universe_management_actions() {
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 portfolio = PortfolioState::new(1_000_000.0);
let subscriptions = BTreeSet::from(["000001.SZ".to_string()]);
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,
};
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.explicit_actions = vec![
PlatformTradeAction::Universe {
kind: PlatformUniverseActionKind::UpdateUniverse,
symbols_expr: "[\"000001.SZ\"]".to_string(),
when_expr: Some("subscription_count == 1 && has_subscriptions".to_string()),
reason: "dynamic_focus".to_string(),
},
PlatformTradeAction::Universe {
kind: PlatformUniverseActionKind::Unsubscribe,
symbols_expr: "[\"000001.SZ\"]".to_string(),
when_expr: Some("has_subscriptions".to_string()),
reason: "drop_subscription".to_string(),
},
];
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision");
assert_eq!(decision.order_intents.len(), 2);
match &decision.order_intents[0] {
crate::strategy::OrderIntent::UpdateUniverse { symbols, reason } => {
assert_eq!(reason, "dynamic_focus");
assert!(symbols.contains("000001.SZ"));
}
other => panic!("unexpected universe update intent: {other:?}"),
}
match &decision.order_intents[1] {
crate::strategy::OrderIntent::Unsubscribe { symbols, reason } => {
assert_eq!(reason, "drop_subscription");
assert_eq!(symbols.len(), 1);
assert!(symbols.contains("000001.SZ"));
}
other => panic!("unexpected unsubscribe intent: {other:?}"),
}
}
#[test]
fn platform_strategy_exposes_process_event_runtime_fields() {
let date = d(2025, 2, 3);
@@ -4203,6 +4468,7 @@ mod tests {
side: Some(crate::OrderSide::Buy),
detail: "open at or above upper limit".to_string(),
}];
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
@@ -4210,6 +4476,8 @@ mod tests {
data: &data,
portfolio: &portfolio,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &process_events,
active_process_event: None,
};

View File

@@ -70,6 +70,8 @@ pub struct StrategyContext<'a> {
pub data: &'a DataSet,
pub portfolio: &'a PortfolioState,
pub open_orders: &'a [OpenOrderView],
pub dynamic_universe: Option<&'a BTreeSet<String>>,
pub subscriptions: &'a BTreeSet<String>,
pub process_events: &'a [ProcessEvent],
pub active_process_event: Option<&'a ProcessEvent>,
}
@@ -157,6 +159,47 @@ impl StrategyContext<'_> {
raw_sellable_qty.saturating_sub(self.symbol_open_sell_quantity(symbol))
}
pub fn has_dynamic_universe(&self) -> bool {
self.dynamic_universe
.is_some_and(|symbols| !symbols.is_empty())
}
pub fn dynamic_universe_count(&self) -> usize {
self.dynamic_universe.map_or(0, BTreeSet::len)
}
pub fn dynamic_universe_contains(&self, symbol: &str) -> bool {
self.dynamic_universe
.is_some_and(|symbols| symbols.contains(symbol))
}
pub fn eligible_universe_on(
&self,
date: NaiveDate,
) -> Vec<crate::data::EligibleUniverseSnapshot> {
let eligible = self.data.eligible_universe_on(date);
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
.iter()
.filter(|row| symbols.contains(&row.symbol))
.cloned()
.collect(),
_ => eligible.to_vec(),
}
}
pub fn has_subscriptions(&self) -> bool {
!self.subscriptions.is_empty()
}
pub fn subscription_count(&self) -> usize {
self.subscriptions.len()
}
pub fn is_subscribed(&self, symbol: &str) -> bool {
self.subscriptions.contains(symbol)
}
pub fn has_process_events(&self) -> bool {
!self.process_events.is_empty() || self.active_process_event.is_some()
}
@@ -381,6 +424,18 @@ pub enum OrderIntent {
CancelAll {
reason: String,
},
UpdateUniverse {
symbols: BTreeSet<String>,
reason: String,
},
Subscribe {
symbols: BTreeSet<String>,
reason: String,
},
Unsubscribe {
symbols: BTreeSet<String>,
reason: String,
},
}
#[derive(Debug, Clone)]
@@ -696,6 +751,7 @@ impl Strategy for CnSmallCapRotationStrategy {
benchmark,
reference_level: signal_level,
data: ctx.data,
dynamic_universe: ctx.dynamic_universe,
});
let before_ma_count = selected_before_ma.len();
let mut ma_rejects = Vec::new();
@@ -1576,6 +1632,13 @@ impl JqMicroCapStrategy {
if !selected_set.insert(symbol.clone()) {
continue;
}
if ctx.has_dynamic_universe() && !ctx.dynamic_universe_contains(symbol) {
selected_set.remove(symbol);
if diagnostics.len() < 14 {
diagnostics.push(format!("truth {} rejected by dynamic_universe", symbol));
}
continue;
}
if let Some(reason) = self.buy_rejection_reason(ctx, date, symbol)? {
selected_set.remove(symbol);
if diagnostics.len() < 14 {
@@ -1588,8 +1651,8 @@ impl JqMicroCapStrategy {
}
if selected.len() < self.config.stocknum {
let universe = ctx.data.eligible_universe_on(date);
let start = lower_bound_eligible(universe, band_low);
let universe = ctx.eligible_universe_on(date);
let start = lower_bound_eligible(&universe, band_low);
for candidate in universe.iter().skip(start) {
if candidate.market_cap_bn > band_high {
break;
@@ -1623,10 +1686,10 @@ impl JqMicroCapStrategy {
return Ok((selected, diagnostics));
}
let universe = ctx.data.eligible_universe_on(date);
let universe = ctx.eligible_universe_on(date);
let mut diagnostics = Vec::new();
let mut selected = Vec::new();
let start = lower_bound_eligible(universe, band_low);
let start = lower_bound_eligible(&universe, band_low);
for candidate in universe.iter().skip(start) {
if candidate.market_cap_bn > band_high {

View File

@@ -119,8 +119,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
detail: "设置撮合模式和滑点。支持 execution.matching_type(\"next_tick_last\" | \"next_tick_best_own\" | \"next_tick_best_counterparty\" | \"counterparty_offer\" | \"vwap\" | \"current_bar_close\" | \"next_bar_open\" | \"open_auction\")。其中 next_tick_last 使用 tick 的 last_pricenext_tick_best_own / next_tick_best_counterparty 会按 L1 买一卖一近似 rqalpha 的 tick 最优价语义counterparty_offer 当前也按 L1 对手方报价近似实现vwap 会在盘中执行价链路上聚合多笔成交为单条 VWAP 成交open_auction 使用当日集合竞价开盘价 day_open 进行撮合,且不额外施加滑点,并按竞价成交量而不是盘口一档流动性限制成交;滑点支持 execution.slippage(\"none\") / execution.slippage(\"price_ratio\", 0.001) / execution.slippage(\"tick_size\", 1) / execution.slippage(\"limit_price\"),其中 limit_price 会在限价单成交时按挂单价模拟 rqalpha 的最坏成交价。".to_string(),
},
ManualSection {
title: "trading.rotation / order.* / cancel.*".to_string(),
detail: "支持显式下单撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()。其中 order.target_shares(...) 对应 rqalpha 的 order_toorder.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义。symbol 使用标准证券代码;数量、金额、仓位、限价order_id 都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
title: "trading.rotation / order.* / cancel.* / update_universe / subscribe".to_string(),
detail: "支持显式下单撤单和动态 universe 管理。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily().at([\"10:18\"]) / trading.schedule.weekly(weekday=5).at([\"10:18\"]) / trading.schedule.weekly(tradingday=-1).at([\"10:18\"]) / trading.schedule.monthly(tradingday=1).at([\"10:18\"]) 指定触发频率和分钟级 time_rule然后写 order.shares(\"600000.SH\", 1000)、order.target_shares(\"600000.SH\", 2000)、order.value(\"600000.SH\", cash * 0.25)、order.target_percent(\"600000.SH\", 0.05)、order.limit_value(\"600000.SH\", cash * 0.25, open * 0.99)、order.target_portfolio_smart(weights={\"600000.SH\": 0.3, \"000001.SZ\": 0.2}, order_prices={\"600000.SH\": open * 0.99}, valuation_prices={\"600000.SH\": prev_close})、cancel.order(12345)、cancel.symbol(\"600000.SH\")、cancel.all()、update_universe([\"600000.SH\", \"000001.SZ\"])、subscribe([\"000001.SZ\"])、unsubscribe([\"000001.SZ\"])。其中 order.target_shares(...) 对应 rqalpha 的 order_toorder.target_portfolio_smart(...) 对应 rqalpha 的 order_target_portfolio_smart 批量目标权重语义,而 update_universe/subscribe/unsubscribe 对应 rqalpha 的动态 universe 与订阅接口。symbol 使用标准证券代码;数量、金额、仓位、限价order_id 和 symbol 列表都支持表达式;这些语句也支持放进 when/unless 条件块。".to_string(),
},
ManualSection {
title: "when / unless / else".to_string(),
@@ -140,6 +140,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
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() },
ManualField { name: "has_dynamic_universe/dynamic_universe_count".to_string(), field_type: "bool/int".to_string(), detail: "当前策略上下文是否存在动态 universe以及动态 universe 内证券数量。".to_string() },
ManualField { name: "has_subscriptions/subscription_count".to_string(), field_type: "bool/int".to_string(), detail: "当前订阅集合是否为空,以及订阅证券数量。".to_string() },
ManualField { name: "has_process_events/process_event_count/process_event_counts".to_string(), field_type: "bool/int/map".to_string(), detail: "当前阶段可见的过程事件摘要process_event_counts[\"trade\"] 这类写法可直接读取当天事件计数。".to_string() },
ManualField { name: "current_process_kind/current_process_order_id/current_process_symbol/current_process_side/current_process_detail".to_string(), field_type: "string/int".to_string(), detail: "当前正在回调的过程事件上下文;没有活动事件时为空字符串或 0。".to_string() },
ManualField { name: "latest_process_kind/latest_process_order_id/latest_process_symbol/latest_process_side/latest_process_detail".to_string(), field_type: "string/int".to_string(), detail: "当前阶段最近一条过程事件的摘要,可用于让 on_day/open_auction 逻辑响应 earlier lifecycle 或订单事件。".to_string() },
@@ -160,6 +162,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualField { name: "allow_buy/allow_sell/at_upper_limit/at_lower_limit".to_string(), field_type: "bool".to_string(), detail: "盘中买卖与涨跌停状态。".to_string() },
ManualField { name: "touched_upper_limit/touched_lower_limit/hit_upper_limit/hit_lower_limit".to_string(), field_type: "bool".to_string(), detail: "当日 tick 曾经触达涨跌停。".to_string() },
ManualField { name: "symbol_open_order_count/symbol_open_buy_qty/symbol_open_sell_qty/latest_symbol_open_order_id".to_string(), field_type: "int".to_string(), detail: "当前证券在挂单簿中的未成交挂单摘要和最近挂单 id。".to_string() },
ManualField { name: "in_dynamic_universe/is_subscribed".to_string(), field_type: "bool".to_string(), detail: "当前证券是否在动态 universe 内,以及是否仍在订阅集合中。".to_string() },
ManualField { name: "stock_ma5/stock_ma10/stock_ma20/stock_ma30".to_string(), field_type: "float".to_string(), detail: "个股价格均线内建别名。只内建这几个窗口15 日、45 日等任意窗口请改用 sma(\"close\", n)。".to_string() },
ManualField { name: "stock_volume_ma5/stock_volume_ma10/stock_volume_ma20/stock_volume_ma60".to_string(), field_type: "float".to_string(), detail: "个股成交量均线内建别名。只内建这几个窗口;任意窗口请改用 rolling_mean(\"volume\", n)。".to_string() },
ManualField { name: "listed_days".to_string(), field_type: "int".to_string(), detail: "上市天数。".to_string() },
@@ -219,6 +222,10 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
title: "next tick 撮合 + tick 滑点".to_string(),
code: "execution.matching_type(\"next_tick_last\")\nexecution.slippage(\"tick_size\", 1)".to_string(),
},
ManualExample {
title: "动态 universe 和订阅".to_string(),
code: "when(!has_dynamic_universe) { update_universe([\"000001.SZ\", \"000002.SZ\"]) }\nwhen(subscription_count == 0) { subscribe([\"000001.SZ\"]) }".to_string(),
},
ManualExample {
title: "显式下单并关闭默认轮动".to_string(),
code: "trading.rotation(false)\norder.value(\"600000.SH\", cash * 0.25, \"manual_entry\")\ncancel.symbol(\"600000.SH\", \"manual_cancel\")".to_string(),

View File

@@ -1,3 +1,5 @@
use std::collections::BTreeSet;
use chrono::NaiveDate;
use serde::Serialize;
@@ -44,6 +46,21 @@ pub struct SelectionContext<'a> {
pub benchmark: &'a BenchmarkSnapshot,
pub reference_level: f64,
pub data: &'a DataSet,
pub dynamic_universe: Option<&'a BTreeSet<String>>,
}
impl SelectionContext<'_> {
fn eligible_universe(&self) -> Vec<EligibleUniverseSnapshot> {
let eligible = self.data.eligible_universe_on(self.decision_date);
match self.dynamic_universe {
Some(symbols) if !symbols.is_empty() => eligible
.iter()
.filter(|row| symbols.contains(&row.symbol))
.cloned()
.collect(),
_ => eligible.to_vec(),
}
}
}
pub trait UniverseSelector {
@@ -132,12 +149,10 @@ impl UniverseSelector for DynamicMarketCapBandSelector {
};
diagnostics.factor_total = ctx.data.factor_snapshots_on(ctx.decision_date).len();
diagnostics.market_cap_missing_count = diagnostics
.factor_total
.saturating_sub(ctx.data.eligible_universe_on(ctx.decision_date).len());
let eligible = ctx.data.eligible_universe_on(ctx.decision_date);
let start_idx = lower_bound_by_market_cap(eligible, min_cap);
let eligible = ctx.eligible_universe();
diagnostics.market_cap_missing_count =
diagnostics.factor_total.saturating_sub(eligible.len());
let start_idx = lower_bound_by_market_cap(&eligible, min_cap);
let mut selected = Vec::new();
for factor in eligible.iter().skip(start_idx) {