Add order runtime lookup

This commit is contained in:
boris
2026-04-23 19:57:02 -07:00
parent c12a883d28
commit 9f10afddec
8 changed files with 351 additions and 8 deletions

View File

@@ -410,6 +410,8 @@ where
execution_date,
default_stage_time(ScheduleStage::BeforeTrading),
),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
})?;
publish_phase_event(
&mut self.strategy,
@@ -443,6 +445,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::BeforeTrading),
result.order_events.as_slice(),
result.fills.as_slice(),
)?;
self.apply_strategy_directives(
execution_date,
@@ -501,6 +505,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OpenAuction),
result.order_events.as_slice(),
result.fills.as_slice(),
)?;
auction_decision.merge_from(self.strategy.open_auction(&StrategyContext {
execution_date,
@@ -517,6 +523,8 @@ where
execution_date,
default_stage_time(ScheduleStage::OpenAuction),
),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
})?);
publish_phase_event(
&mut self.strategy,
@@ -615,6 +623,8 @@ where
execution_date,
default_stage_time(ScheduleStage::OnDay),
),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
})
})
.transpose()?
@@ -635,6 +645,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OnDay),
result.order_events.as_slice(),
result.fills.as_slice(),
)?);
publish_phase_event(
&mut self.strategy,
@@ -685,6 +697,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::Bar),
result.order_events.as_slice(),
result.fills.as_slice(),
)?);
decision.merge_from(self.strategy.on_bar(&StrategyContext {
execution_date,
@@ -701,6 +715,8 @@ where
execution_date,
default_stage_time(ScheduleStage::Bar),
),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
})?);
publish_phase_event(
&mut self.strategy,
@@ -831,6 +847,8 @@ where
&mut process_events,
&mut self.process_event_bus,
Some(tick_time),
result.order_events.as_slice(),
result.fills.as_slice(),
)?;
tick_decision.merge_from(self.strategy.on_tick(
&StrategyContext {
@@ -845,6 +863,8 @@ where
process_events: &process_events,
active_process_event: None,
active_datetime: Some(quote.timestamp),
order_events: result.order_events.as_slice(),
fills: result.fills.as_slice(),
},
&quote,
)?);
@@ -919,6 +939,18 @@ where
portfolio.update_prices(execution_date, &self.data, PriceField::Close)?;
let post_trade_open_orders = self.broker.open_order_views();
let visible_order_events = result
.order_events
.iter()
.cloned()
.chain(report.order_events.iter().cloned())
.collect::<Vec<_>>();
let visible_fills = result
.fills
.iter()
.cloned()
.chain(report.fill_events.iter().cloned())
.collect::<Vec<_>>();
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,
@@ -950,6 +982,8 @@ where
execution_date,
default_stage_time(ScheduleStage::AfterTrading),
),
order_events: visible_order_events.as_slice(),
fills: visible_fills.as_slice(),
})?;
publish_phase_event(
&mut self.strategy,
@@ -983,6 +1017,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::AfterTrading),
visible_order_events.as_slice(),
visible_fills.as_slice(),
)?;
self.apply_strategy_directives(
execution_date,
@@ -1014,6 +1050,18 @@ where
report.account_events.extend(close_report.account_events);
report.diagnostics.extend(close_report.diagnostics);
let post_close_open_orders = self.broker.open_order_views();
let visible_order_events_after_close = result
.order_events
.iter()
.cloned()
.chain(report.order_events.iter().cloned())
.collect::<Vec<_>>();
let visible_fills_after_close = result
.fills
.iter()
.cloned()
.chain(report.fill_events.iter().cloned())
.collect::<Vec<_>>();
publish_phase_event(
&mut self.strategy,
&mut self.process_event_bus,
@@ -1061,6 +1109,8 @@ where
execution_date,
default_stage_time(ScheduleStage::Settlement),
),
order_events: visible_order_events_after_close.as_slice(),
fills: visible_fills_after_close.as_slice(),
})?;
publish_phase_event(
&mut self.strategy,
@@ -1094,6 +1144,8 @@ where
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::Settlement),
visible_order_events_after_close.as_slice(),
visible_fills_after_close.as_slice(),
)?;
self.apply_strategy_directives(
execution_date,
@@ -1620,6 +1672,8 @@ fn collect_scheduled_decisions<S: Strategy>(
process_events: &mut Vec<ProcessEvent>,
process_event_bus: &mut ProcessEventBus,
current_time: Option<chrono::NaiveTime>,
order_events: &[OrderEvent],
fills: &[FillEvent],
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
let mut combined = crate::strategy::StrategyDecision::default();
for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) {
@@ -1652,6 +1706,8 @@ fn collect_scheduled_decisions<S: Strategy>(
process_events: process_events.as_slice(),
active_process_event: None,
active_datetime: stage_datetime(execution_date, current_time),
order_events,
fills,
},
rule,
)?);
@@ -1713,6 +1769,8 @@ fn publish_phase_event<S: Strategy>(
process_events,
active_process_event: Some(&event),
active_datetime: None,
order_events: &[],
fills: &[],
};
strategy.on_process_event(&event_ctx, &event)?;
events.push(event);
@@ -1748,6 +1806,8 @@ fn publish_process_events<S: Strategy>(
process_events,
active_process_event: Some(&event),
active_datetime: None,
order_events: &[],
fills: &[],
};
strategy.on_process_event(&event_ctx, &event)?;
target.push(event);
@@ -1783,6 +1843,8 @@ fn publish_custom_process_event<S: Strategy>(
process_events,
active_process_event: Some(&event),
active_datetime: None,
order_events: &[],
fills: &[],
};
strategy.on_process_event(&event_ctx, &event)?;
target.push(event);

View File

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

View File

@@ -3912,6 +3912,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4050,6 +4052,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4166,6 +4170,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4287,6 +4293,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SH".to_string();
@@ -4391,6 +4399,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SH".to_string();
@@ -4490,6 +4500,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SH".to_string();
@@ -4607,6 +4619,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4727,6 +4741,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4852,6 +4868,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -4987,6 +5005,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -5094,6 +5114,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -5229,6 +5251,8 @@ mod tests {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();
@@ -5346,6 +5370,8 @@ mod tests {
process_events: &process_events,
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.signal_symbol = "000001.SZ".to_string();

View File

@@ -9,7 +9,7 @@ use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime};
use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField};
use crate::engine::BacktestError;
use crate::events::{OrderSide, OrderStatus, ProcessEvent};
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::scheduler::ScheduleRule;
@@ -80,6 +80,21 @@ pub struct OpenOrderView {
pub reason: String,
}
#[derive(Debug, Clone)]
pub struct OrderRuntimeView {
pub order_id: u64,
pub symbol: String,
pub side: OrderSide,
pub requested_quantity: u32,
pub filled_quantity: u32,
pub unfilled_quantity: u32,
pub status: OrderStatus,
pub avg_price: f64,
pub transaction_cost: f64,
pub limit_price: f64,
pub reason: String,
}
pub struct StrategyContext<'a> {
pub execution_date: NaiveDate,
pub decision_date: NaiveDate,
@@ -92,6 +107,8 @@ pub struct StrategyContext<'a> {
pub process_events: &'a [ProcessEvent],
pub active_process_event: Option<&'a ProcessEvent>,
pub active_datetime: Option<NaiveDateTime>,
pub order_events: &'a [OrderEvent],
pub fills: &'a [FillEvent],
}
impl StrategyContext<'_> {
@@ -215,6 +232,97 @@ impl StrategyContext<'_> {
.unwrap_or(0)
}
pub fn order(&self, order_id: u64) -> Option<OrderRuntimeView> {
let fills = self
.fills
.iter()
.filter(|fill| fill.order_id == Some(order_id))
.collect::<Vec<_>>();
let filled_quantity = fills.iter().map(|fill| fill.quantity).sum::<u32>();
let gross_amount = fills.iter().map(|fill| fill.gross_amount).sum::<f64>();
let transaction_cost = fills
.iter()
.map(|fill| fill.commission + fill.stamp_tax)
.sum::<f64>();
let avg_price = if filled_quantity == 0 {
0.0
} else {
gross_amount / filled_quantity as f64
};
if let Some(order) = self
.open_orders
.iter()
.find(|order| order.order_id == order_id)
{
let filled_quantity = order.filled_quantity.max(filled_quantity);
return Some(OrderRuntimeView {
order_id,
symbol: order.symbol.clone(),
side: order.side,
requested_quantity: order.requested_quantity,
filled_quantity,
unfilled_quantity: order
.unfilled_quantity
.min(order.requested_quantity.saturating_sub(filled_quantity)),
status: order.status,
avg_price: if avg_price > 0.0 {
avg_price
} else {
order.avg_price
},
transaction_cost: if transaction_cost > 0.0 {
transaction_cost
} else {
order.transaction_cost
},
limit_price: order.limit_price,
reason: order.reason.clone(),
});
}
let latest_event = self
.order_events
.iter()
.rev()
.filter(|event| event.order_id == Some(order_id))
.next()?;
let filled_quantity = latest_event.filled_quantity.max(filled_quantity);
Some(OrderRuntimeView {
order_id,
symbol: latest_event.symbol.clone(),
side: latest_event.side,
requested_quantity: latest_event.requested_quantity,
filled_quantity,
unfilled_quantity: latest_event
.requested_quantity
.saturating_sub(filled_quantity),
status: latest_event.status,
avg_price,
transaction_cost,
limit_price: 0.0,
reason: latest_event.reason.clone(),
})
}
pub fn order_status(&self, order_id: u64) -> &'static str {
self.order(order_id)
.map(|order| order.status.as_str())
.unwrap_or("")
}
pub fn order_avg_price(&self, order_id: u64) -> f64 {
self.order(order_id)
.map(|order| order.avg_price)
.unwrap_or(0.0)
}
pub fn order_transaction_cost(&self, order_id: u64) -> f64 {
self.order(order_id)
.map(|order| order.transaction_cost)
.unwrap_or(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

@@ -204,6 +204,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
ManualFunction { name: "get_trading_dates/get_previous_trading_date/get_next_trading_date".to_string(), signature: "ctx.get_previous_trading_date(date, n)".to_string(), detail: "交易日历 API。get_trading_dates 返回闭区间交易日previous/next 返回相对某日向前或向后的第 n 个交易日,当前日自身不计入。".to_string() },
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: "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() },

View File

@@ -157,6 +157,10 @@ struct DataApiProbeStrategy {
snapshots: Rc<RefCell<Vec<String>>>,
}
struct OrderInspectionStrategy {
observed: Rc<RefCell<Vec<String>>>,
}
impl Strategy for ScheduledProbeStrategy {
fn name(&self) -> &str {
"scheduled-probe"
@@ -448,6 +452,45 @@ impl Strategy for DataApiProbeStrategy {
}
}
impl Strategy for OrderInspectionStrategy {
fn name(&self) -> &str {
"order-inspection"
}
fn on_day(
&mut self,
_ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Shares {
symbol: "000001.SZ".to_string(),
quantity: 100,
reason: "inspect_buy".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
})
}
fn after_trading(&mut self, ctx: &StrategyContext<'_>) -> Result<(), fidc_core::BacktestError> {
let order = ctx.order(1).expect("order 1 visible after trading");
self.observed.borrow_mut().push(format!(
"status={};filled={};unfilled={};avg={:.2};cost={:.2};symbol={};side={}",
order.status.as_str(),
order.filled_quantity,
order.unfilled_quantity,
order.avg_price,
order.transaction_cost,
order.symbol,
order.side.as_str()
));
Ok(())
}
}
#[test]
fn engine_runs_strategy_hooks_in_daily_order() {
let date1 = d(2025, 1, 2);
@@ -1084,6 +1127,105 @@ fn strategy_context_exposes_rqalpha_style_data_helpers() {
);
}
#[test]
fn strategy_context_exposes_final_order_runtime_view() {
let date = d(2025, 1, 2);
let data = DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.4,
low: 9.9,
close: 10.2,
last_price: 10.2,
bid1: 10.19,
ask1: 10.2,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: "000001.SZ".to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let observed = Rc::new(RefCell::new(Vec::new()));
let strategy = OrderInspectionStrategy {
observed: observed.clone(),
};
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Close,
);
let mut engine = BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
);
engine.run().expect("backtest run");
assert_eq!(
observed.borrow().as_slice(),
["status=filled;filled=100;unfilled=0;avg=10.20;cost=5.00;symbol=000001.SZ;side=buy"]
);
}
#[test]
fn engine_rejects_pending_limit_orders_at_market_close() {
let date1 = d(2025, 1, 2);

View File

@@ -33,6 +33,8 @@ fn strategy_emits_target_weights_and_diagnostics() {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
})
.expect("decision");
@@ -77,6 +79,8 @@ fn jq_strategy_emits_same_day_decision() {
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
})
.expect("jq decision");