Add rqalpha-style scheduler time rules

This commit is contained in:
boris
2026-04-23 06:34:07 -07:00
parent fae09afb86
commit 5265f82fef
10 changed files with 557 additions and 233 deletions

View File

@@ -1232,8 +1232,12 @@ where
let mut desired_targets = BTreeMap::new(); let mut desired_targets = BTreeMap::new();
let mut diagnostics = Vec::new(); let mut diagnostics = Vec::new();
for (symbol, weight) in target_weights { for (symbol, weight) in target_weights {
let price = let price = self.rebalance_valuation_price_with_overrides(
self.rebalance_valuation_price_with_overrides(date, symbol, data, valuation_prices)?; date,
symbol,
data,
valuation_prices,
)?;
let raw_qty = ((equity * weight) / price).floor() as u32; let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert( desired_targets.insert(
symbol.clone(), symbol.clone(),
@@ -1486,7 +1490,10 @@ where
symbols.extend(target_quantities.keys().cloned()); symbols.extend(target_quantities.keys().cloned());
for symbol in &symbols { for symbol in &symbols {
let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if current_qty <= target_qty { if current_qty <= target_qty {
continue; continue;
@@ -1529,7 +1536,10 @@ where
} }
for symbol in &symbols { for symbol in &symbols {
let current_qty = portfolio.position(symbol).map(|pos| pos.quantity).unwrap_or(0); let current_qty = portfolio
.position(symbol)
.map(|pos| pos.quantity)
.unwrap_or(0);
let target_qty = target_quantities.get(symbol).copied().unwrap_or(0); let target_qty = target_quantities.get(symbol).copied().unwrap_or(0);
if target_qty <= current_qty { if target_qty <= current_qty {
continue; continue;

View File

@@ -13,7 +13,7 @@ use crate::events::{
use crate::metrics::{BacktestMetrics, compute_backtest_metrics}; use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState}; use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::rules::EquityRuleHooks; use crate::rules::EquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler}; use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
use crate::strategy::{Strategy, StrategyContext}; use crate::strategy::{Strategy, StrategyContext};
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -274,6 +274,21 @@ where
ProcessEventKind::BeforeTrading, ProcessEventKind::BeforeTrading,
"before_trading", "before_trading",
)?; )?;
let _ = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
ScheduleStage::BeforeTrading,
&schedule_rules,
decision_date,
decision_index,
&self.data,
&portfolio,
&pre_open_orders,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::BeforeTrading),
)?;
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -315,6 +330,7 @@ where
&pre_open_orders, &pre_open_orders,
&mut process_events, &mut process_events,
&mut self.process_event_bus, &mut self.process_event_bus,
default_stage_time(ScheduleStage::OpenAuction),
)?; )?;
auction_decision.merge_from(self.strategy.open_auction(&StrategyContext { auction_decision.merge_from(self.strategy.open_auction(&StrategyContext {
execution_date, execution_date,
@@ -417,6 +433,7 @@ where
&on_day_open_orders, &on_day_open_orders,
&mut process_events, &mut process_events,
&mut self.process_event_bus, &mut self.process_event_bus,
default_stage_time(ScheduleStage::OnDay),
)?); )?);
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
@@ -512,6 +529,21 @@ where
ProcessEventKind::AfterTrading, ProcessEventKind::AfterTrading,
"after_trading", "after_trading",
)?; )?;
let _ = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
ScheduleStage::AfterTrading,
&schedule_rules,
decision_date,
decision_index,
&self.data,
&portfolio,
&post_trade_open_orders,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::AfterTrading),
)?;
let mut close_report = self.broker.after_trading(execution_date); let mut close_report = self.broker.after_trading(execution_date);
publish_process_events( publish_process_events(
&mut self.strategy, &mut self.strategy,
@@ -583,6 +615,21 @@ where
ProcessEventKind::Settlement, ProcessEventKind::Settlement,
"settlement", "settlement",
)?; )?;
let _ = collect_scheduled_decisions(
&mut self.strategy,
&scheduler,
execution_date,
ScheduleStage::Settlement,
&schedule_rules,
decision_date,
decision_index,
&self.data,
&portfolio,
&post_close_open_orders,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::Settlement),
)?;
publish_phase_event( publish_phase_event(
&mut self.strategy, &mut self.strategy,
&mut self.process_event_bus, &mut self.process_event_bus,
@@ -1094,9 +1141,10 @@ fn collect_scheduled_decisions<S: Strategy>(
open_orders: &[crate::strategy::OpenOrderView], open_orders: &[crate::strategy::OpenOrderView],
process_events: &mut Vec<ProcessEvent>, process_events: &mut Vec<ProcessEvent>,
process_event_bus: &mut ProcessEventBus, process_event_bus: &mut ProcessEventBus,
current_time: Option<chrono::NaiveTime>,
) -> Result<crate::strategy::StrategyDecision, BacktestError> { ) -> Result<crate::strategy::StrategyDecision, BacktestError> {
let mut combined = crate::strategy::StrategyDecision::default(); let mut combined = crate::strategy::StrategyDecision::default();
for rule in scheduler.triggered_rules(execution_date, stage, rules) { for rule in scheduler.triggered_rules_at(execution_date, stage, current_time, rules) {
publish_phase_event( publish_phase_event(
strategy, strategy,
process_event_bus, process_event_bus,
@@ -1214,8 +1262,11 @@ fn publish_process_events<S: Strategy>(
fn stage_label(stage: ScheduleStage) -> &'static str { fn stage_label(stage: ScheduleStage) -> &'static str {
match stage { match stage {
ScheduleStage::BeforeTrading => "before_trading",
ScheduleStage::OpenAuction => "open_auction", ScheduleStage::OpenAuction => "open_auction",
ScheduleStage::OnDay => "on_day", ScheduleStage::OnDay => "on_day",
ScheduleStage::AfterTrading => "after_trading",
ScheduleStage::Settlement => "settlement",
} }
} }

View File

@@ -41,7 +41,9 @@ pub use platform_expr_strategy::{
}; };
pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position}; pub use portfolio::{CashReceivable, HoldingSummary, PortfolioState, Position};
pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck};
pub use scheduler::{ScheduleFrequency, ScheduleRule, ScheduleStage, Scheduler}; pub use scheduler::{
ScheduleFrequency, ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
};
pub use strategy::{ pub use strategy::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy, CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy,
OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision, OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision,

View File

@@ -8,7 +8,9 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField};
use crate::engine::BacktestError; use crate::engine::BacktestError;
use crate::events::OrderSide; use crate::events::OrderSide;
use crate::portfolio::PortfolioState; use crate::portfolio::PortfolioState;
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler}; use crate::scheduler::{
ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler, default_stage_time,
};
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision}; use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,48 +27,48 @@ pub enum PlatformScheduleFrequency {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformRebalanceSchedule { pub struct PlatformRebalanceSchedule {
pub frequency: PlatformScheduleFrequency, pub frequency: PlatformScheduleFrequency,
pub time_rule: Option<ScheduleTimeRule>,
} }
impl PlatformRebalanceSchedule { impl PlatformRebalanceSchedule {
fn as_schedule_rule(&self) -> ScheduleRule { fn as_schedule_rule(&self, stage: ScheduleStage) -> ScheduleRule {
match self.frequency { let rule = match self.frequency {
PlatformScheduleFrequency::Weekly { PlatformScheduleFrequency::Weekly {
weekday: Some(weekday), weekday: Some(weekday),
.. ..
} => ScheduleRule::weekly_by_weekday( } => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", weekday, stage),
"platform_periodic_rebalance",
weekday,
ScheduleStage::OnDay,
),
PlatformScheduleFrequency::Weekly { PlatformScheduleFrequency::Weekly {
tradingday: Some(tradingday), tradingday: Some(tradingday),
.. ..
} => ScheduleRule::weekly_by_tradingday( } => {
"platform_periodic_rebalance", ScheduleRule::weekly_by_tradingday("platform_periodic_rebalance", tradingday, stage)
tradingday, }
ScheduleStage::OnDay, PlatformScheduleFrequency::Monthly { tradingday } => {
), ScheduleRule::monthly("platform_periodic_rebalance", tradingday, stage)
PlatformScheduleFrequency::Monthly { tradingday } => ScheduleRule::monthly( }
"platform_periodic_rebalance",
tradingday,
ScheduleStage::OnDay,
),
PlatformScheduleFrequency::Weekly { PlatformScheduleFrequency::Weekly {
weekday: None, weekday: None,
tradingday: None, tradingday: None,
} => ScheduleRule::weekly_by_weekday( } => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", 1, stage),
"platform_periodic_rebalance", };
1, if let Some(time_rule) = self.time_rule.clone() {
ScheduleStage::OnDay, rule.with_time_rule(time_rule)
), } else {
rule
} }
} }
fn matches(&self, calendar: &crate::calendar::TradingCalendar, date: NaiveDate) -> bool { fn matches(
&self,
calendar: &crate::calendar::TradingCalendar,
date: NaiveDate,
stage: ScheduleStage,
current_time: Option<NaiveTime>,
) -> bool {
let scheduler = Scheduler::new(calendar); let scheduler = Scheduler::new(calendar);
let rule = self.as_schedule_rule(); let rule = self.as_schedule_rule(stage);
scheduler scheduler
.triggered_rules(date, ScheduleStage::OnDay, std::slice::from_ref(&rule)) .triggered_rules_at(date, stage, current_time, std::slice::from_ref(&rule))
.into_iter() .into_iter()
.next() .next()
.is_some() .is_some()
@@ -2617,10 +2619,16 @@ impl PlatformExprStrategy {
calendar: &crate::calendar::TradingCalendar, calendar: &crate::calendar::TradingCalendar,
date: NaiveDate, date: NaiveDate,
) -> bool { ) -> bool {
let stage = match self.config.explicit_action_stage {
PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction,
PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay,
};
self.config self.config
.explicit_action_schedule .explicit_action_schedule
.as_ref() .as_ref()
.is_none_or(|schedule| schedule.matches(calendar, date)) .is_none_or(|schedule| {
schedule.matches(calendar, date, stage, default_stage_time(stage))
})
} }
fn stock_passes_expr( fn stock_passes_expr(
@@ -3013,7 +3021,12 @@ impl Strategy for PlatformExprStrategy {
}; };
let periodic_rebalance = if self.config.rotation_enabled { let periodic_rebalance = if self.config.rotation_enabled {
if let Some(schedule) = &self.config.rebalance_schedule { if let Some(schedule) = &self.config.rebalance_schedule {
schedule.matches(ctx.data.calendar(), date) schedule.matches(
ctx.data.calendar(),
date,
ScheduleStage::OnDay,
default_stage_time(ScheduleStage::OnDay),
)
} else { } else {
ctx.decision_index % self.config.refresh_rate == 0 ctx.decision_index % self.config.refresh_rate == 0
} }
@@ -3224,7 +3237,7 @@ impl Strategy for PlatformExprStrategy {
mod tests { mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use chrono::NaiveDate; use chrono::{NaiveDate, NaiveTime};
use super::{ use super::{
PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind,
@@ -3233,8 +3246,8 @@ mod tests {
}; };
use crate::{ use crate::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, Strategy, Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage,
StrategyContext, TradingCalendar, ScheduleTimeRule, Strategy, StrategyContext, TradingCalendar, default_stage_time,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -3259,9 +3272,20 @@ mod tests {
weekday: Some(5), weekday: Some(5),
tradingday: None, tradingday: None,
}, },
time_rule: None,
}; };
assert!(schedule.matches(&calendar, d(2025, 1, 31))); assert!(schedule.matches(
assert!(!schedule.matches(&calendar, d(2025, 2, 3))); &calendar,
d(2025, 1, 31),
ScheduleStage::OnDay,
default_stage_time(ScheduleStage::OnDay),
));
assert!(!schedule.matches(
&calendar,
d(2025, 2, 3),
ScheduleStage::OnDay,
default_stage_time(ScheduleStage::OnDay),
));
} }
#[test] #[test]
@@ -3269,9 +3293,44 @@ mod tests {
let calendar = sample_calendar(); let calendar = sample_calendar();
let schedule = PlatformRebalanceSchedule { let schedule = PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Monthly { tradingday: 1 }, frequency: PlatformScheduleFrequency::Monthly { tradingday: 1 },
time_rule: None,
}; };
assert!(schedule.matches(&calendar, d(2025, 2, 3))); assert!(schedule.matches(
assert!(!schedule.matches(&calendar, d(2025, 2, 4))); &calendar,
d(2025, 2, 3),
ScheduleStage::OnDay,
default_stage_time(ScheduleStage::OnDay),
));
assert!(!schedule.matches(
&calendar,
d(2025, 2, 4),
ScheduleStage::OnDay,
default_stage_time(ScheduleStage::OnDay),
));
}
#[test]
fn platform_rebalance_schedule_matches_physical_time() {
let calendar = sample_calendar();
let schedule = PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Weekly {
weekday: Some(5),
tradingday: None,
},
time_rule: Some(ScheduleTimeRule::physical_time(10, 18)),
};
assert!(schedule.matches(
&calendar,
d(2025, 1, 31),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
));
assert!(!schedule.matches(
&calendar,
d(2025, 1, 31),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(10, 17, 0).unwrap()),
));
} }
#[test] #[test]
@@ -3572,15 +3631,13 @@ mod tests {
cfg.benchmark_short_ma_days = 1; cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1; cfg.benchmark_long_ma_days = 1;
cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart { cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr: target_weights_expr: "{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(),
"{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(),
order_prices_expr: Some( order_prices_expr: Some(
"{\"000001.SZ\": signal_open * 1.01, \"000002.SZ\": benchmark_open / 100.0}" "{\"000001.SZ\": signal_open * 1.01, \"000002.SZ\": benchmark_open / 100.0}"
.to_string(), .to_string(),
), ),
valuation_prices_expr: Some( valuation_prices_expr: Some(
"{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}" "{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}".to_string(),
.to_string(),
), ),
when_expr: Some("benchmark_close > 0".to_string()), when_expr: Some("benchmark_close > 0".to_string()),
reason: "platform_target_portfolio_smart".to_string(), reason: "platform_target_portfolio_smart".to_string(),
@@ -3704,6 +3761,7 @@ mod tests {
weekday: Some(1), weekday: Some(1),
tradingday: None, tradingday: None,
}, },
time_rule: None,
}); });
cfg.benchmark_short_ma_days = 1; cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1; cfg.benchmark_long_ma_days = 1;
@@ -3817,6 +3875,7 @@ mod tests {
weekday: Some(5), weekday: Some(5),
tradingday: None, tradingday: None,
}, },
time_rule: None,
}); });
cfg.benchmark_short_ma_days = 1; cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1; cfg.benchmark_long_ma_days = 1;

View File

@@ -142,7 +142,11 @@ impl Position {
} }
pub fn set_dividend_receivable(&mut self, value: f64) { pub fn set_dividend_receivable(&mut self, value: f64) {
self.dividend_receivable = if value.is_finite() { value.max(0.0) } else { 0.0 }; self.dividend_receivable = if value.is_finite() {
value.max(0.0)
} else {
0.0
};
} }
pub fn holding_return(&self, price: f64) -> Option<f64> { pub fn holding_return(&self, price: f64) -> Option<f64> {
@@ -227,11 +231,12 @@ impl Position {
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 { self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
0.0 0.0
} else { } else {
adjusted_old_quantity * (self.last_price - (self.day_start_price / self.day_split_ratio)) adjusted_old_quantity
* (self.last_price - (self.day_start_price / self.day_split_ratio))
+ self.day_dividend_cash + self.day_dividend_cash
}; };
self.trading_pnl = (self.day_trade_quantity_delta as f64 * self.last_price) self.trading_pnl =
- self.day_trade_cost; (self.day_trade_quantity_delta as f64 * self.last_price) - self.day_trade_cost;
} }
} }
@@ -466,8 +471,11 @@ impl PortfolioState {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::data::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField};
use crate::Instrument; use crate::Instrument;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use std::collections::BTreeMap; use std::collections::BTreeMap;
#[test] #[test]
@@ -494,44 +502,134 @@ mod tests {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0); let mut portfolio = PortfolioState::new(10_000.0);
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0); portfolio
portfolio.update_prices( .position_mut("000001.SZ")
prev_date, .buy(prev_date, 100, 10.0);
&DataSet::from_components( portfolio
vec![Instrument { .update_prices(
symbol: "000001.SZ".to_string(), prev_date,
name: "Test".to_string(), &DataSet::from_components(
board: "SZ".to_string(), vec![Instrument {
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![
DailyMarketSnapshot {
date: prev_date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: None, name: "Test".to_string(),
day_open: 10.0, board: "SZ".to_string(),
open: 10.0, round_lot: 100,
high: 10.0, listed_at: None,
low: 10.0, delisted_at: None,
close: 10.0, status: "active".to_string(),
last_price: 10.0, }],
bid1: 9.99, vec![
ask1: 10.01, DailyMarketSnapshot {
prev_close: 9.8, date: prev_date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 9.8,
volume: 1000,
tick_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.5,
open: 10.5,
high: 10.5,
low: 10.5,
close: 10.5,
last_price: 10.5,
bid1: 10.49,
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
tick_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
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: 50.0,
free_float_cap_bn: 45.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: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000, volume: 1000,
tick_volume: 1000, }],
bid1_volume: 1000, )
ask1_volume: 1000, .expect("dataset"),
trading_phase: None, PriceField::Close,
paused: false, )
upper_limit: 11.0, .expect("prev close");
lower_limit: 9.0, portfolio.begin_trading_day();
price_tick: 0.01, portfolio.add_cash_receivable(CashReceivable {
}, symbol: "000001.SZ".to_string(),
DailyMarketSnapshot { ex_date: prev_date,
payable_date: date.succ_opt().unwrap(),
amount: 25.0,
reason: "cash_dividend".to_string(),
});
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.apply_cash_dividend(0.2);
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.record_trade_cost(5.0);
portfolio
.update_prices(
date,
&DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date, date,
symbol: "000001.SZ".to_string(), symbol: "000001.SZ".to_string(),
timestamp: None, timestamp: None,
@@ -553,127 +651,41 @@ mod tests {
upper_limit: 11.0, upper_limit: 11.0,
lower_limit: 9.0, lower_limit: 9.0,
price_tick: 0.01, price_tick: 0.01,
}, }],
], vec![DailyFactorSnapshot {
vec![DailyFactorSnapshot { date,
date, symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".to_string(), market_cap_bn: 50.0,
market_cap_bn: 50.0, free_float_cap_bn: 45.0,
free_float_cap_bn: 45.0, pe_ttm: 10.0,
pe_ttm: 10.0, turnover_ratio: Some(1.0),
turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0), extra_factors: BTreeMap::new(),
extra_factors: BTreeMap::new(), }],
}], vec![CandidateEligibility {
vec![CandidateEligibility { date,
date, symbol: "000001.SZ".to_string(),
symbol: "000001.SZ".to_string(), is_st: false,
is_st: false, is_new_listing: false,
is_new_listing: false, is_paused: false,
is_paused: false, allow_buy: true,
allow_buy: true, allow_sell: true,
allow_sell: true, is_kcb: false,
is_kcb: false, is_one_yuan: false,
is_one_yuan: false, }],
}], vec![BenchmarkSnapshot {
vec![BenchmarkSnapshot { date,
date, benchmark: "000852.SH".to_string(),
benchmark: "000852.SH".to_string(), open: 1000.0,
open: 1000.0, close: 1000.0,
close: 1000.0, prev_close: 999.0,
prev_close: 999.0, volume: 1000,
volume: 1000, }],
}], )
.expect("dataset"),
PriceField::Close,
) )
.expect("dataset"), .expect("close");
PriceField::Close,
)
.expect("prev close");
portfolio.begin_trading_day();
portfolio.add_cash_receivable(CashReceivable {
symbol: "000001.SZ".to_string(),
ex_date: prev_date,
payable_date: date.succ_opt().unwrap(),
amount: 25.0,
reason: "cash_dividend".to_string(),
});
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.apply_cash_dividend(0.2);
portfolio
.position_mut_if_exists("000001.SZ")
.expect("position")
.record_trade_cost(5.0);
portfolio.update_prices(
date,
&DataSet::from_components(
vec![Instrument {
symbol: "000001.SZ".to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: None,
day_open: 10.5,
open: 10.5,
high: 10.5,
low: 10.5,
close: 10.5,
last_price: 10.5,
bid1: 10.49,
ask1: 10.51,
prev_close: 10.0,
volume: 1000,
tick_volume: 1000,
bid1_volume: 1000,
ask1_volume: 1000,
trading_phase: None,
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: 50.0,
free_float_cap_bn: 45.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: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 999.0,
volume: 1000,
}],
)
.expect("dataset"),
PriceField::Close,
)
.expect("close");
let position = portfolio.position("000001.SZ").expect("position"); let position = portfolio.position("000001.SZ").expect("position");
assert!((position.dividend_receivable - 25.0).abs() < 1e-6); assert!((position.dividend_receivable - 25.0).abs() < 1e-6);

View File

@@ -1,11 +1,57 @@
use chrono::{Datelike, NaiveDate}; use chrono::{Datelike, NaiveDate, NaiveTime, Timelike};
use crate::calendar::TradingCalendar; use crate::calendar::TradingCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScheduleStage { pub enum ScheduleStage {
BeforeTrading,
OpenAuction, OpenAuction,
OnDay, OnDay,
AfterTrading,
Settlement,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScheduleTimeRule {
BeforeTrading,
MinuteOfDay(u32),
}
impl ScheduleTimeRule {
pub fn before_trading() -> Self {
Self::BeforeTrading
}
pub fn market_open(hour: i32, minute: i32) -> Self {
let mut minutes_since_midnight = 9 * 60 + 31 + hour * 60 + minute;
if minutes_since_midnight > 11 * 60 + 30 {
minutes_since_midnight += 90;
}
Self::MinuteOfDay(minutes_since_midnight.max(0) as u32)
}
pub fn market_close(hour: i32, minute: i32) -> Self {
let mut minutes_since_midnight = 15 * 60 - hour * 60 - minute;
if minutes_since_midnight < 13 * 60 {
minutes_since_midnight -= 90;
}
Self::MinuteOfDay(minutes_since_midnight.max(0) as u32)
}
pub fn physical_time(hour: u32, minute: u32) -> Self {
Self::MinuteOfDay(hour.saturating_mul(60).saturating_add(minute))
}
pub fn from_time(time: NaiveTime) -> Self {
Self::physical_time(time.hour(), time.minute())
}
pub fn minute_of_day(&self) -> Option<u32> {
match self {
Self::BeforeTrading => None,
Self::MinuteOfDay(value) => Some(*value),
}
}
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,6 +71,7 @@ pub struct ScheduleRule {
pub name: String, pub name: String,
pub stage: ScheduleStage, pub stage: ScheduleStage,
pub frequency: ScheduleFrequency, pub frequency: ScheduleFrequency,
pub time_rule: Option<ScheduleTimeRule>,
} }
impl ScheduleRule { impl ScheduleRule {
@@ -33,6 +80,7 @@ impl ScheduleRule {
name: name.into(), name: name.into(),
stage, stage,
frequency: ScheduleFrequency::Daily, frequency: ScheduleFrequency::Daily,
time_rule: None,
} }
} }
@@ -44,6 +92,7 @@ impl ScheduleRule {
weekday: Some(weekday), weekday: Some(weekday),
tradingday: None, tradingday: None,
}, },
time_rule: None,
} }
} }
@@ -59,6 +108,7 @@ impl ScheduleRule {
weekday: None, weekday: None,
tradingday: Some(tradingday), tradingday: Some(tradingday),
}, },
time_rule: None,
} }
} }
@@ -67,8 +117,14 @@ impl ScheduleRule {
name: name.into(), name: name.into(),
stage, stage,
frequency: ScheduleFrequency::Monthly { tradingday }, frequency: ScheduleFrequency::Monthly { tradingday },
time_rule: None,
} }
} }
pub fn with_time_rule(mut self, time_rule: ScheduleTimeRule) -> Self {
self.time_rule = Some(time_rule);
self
}
} }
pub struct Scheduler<'a> { pub struct Scheduler<'a> {
@@ -85,10 +141,24 @@ impl<'a> Scheduler<'a> {
date: NaiveDate, date: NaiveDate,
stage: ScheduleStage, stage: ScheduleStage,
rules: &'r [ScheduleRule], rules: &'r [ScheduleRule],
) -> Vec<&'r ScheduleRule> {
self.triggered_rules_at(date, stage, default_stage_time(stage), rules)
}
pub fn triggered_rules_at<'r>(
&self,
date: NaiveDate,
stage: ScheduleStage,
current_time: Option<NaiveTime>,
rules: &'r [ScheduleRule],
) -> Vec<&'r ScheduleRule> { ) -> Vec<&'r ScheduleRule> {
rules rules
.iter() .iter()
.filter(|rule| rule.stage == stage && self.matches(date, rule)) .filter(|rule| {
rule.stage == stage
&& self.matches(date, rule)
&& self.matches_time(stage, current_time, rule.time_rule.as_ref())
})
.collect() .collect()
} }
@@ -129,6 +199,33 @@ impl<'a> Scheduler<'a> {
}) })
.collect() .collect()
} }
fn matches_time(
&self,
stage: ScheduleStage,
current_time: Option<NaiveTime>,
time_rule: Option<&ScheduleTimeRule>,
) -> bool {
let Some(time_rule) = time_rule else {
return true;
};
match time_rule {
ScheduleTimeRule::BeforeTrading => stage == ScheduleStage::BeforeTrading,
ScheduleTimeRule::MinuteOfDay(expected) => current_time
.map(|value| value.hour() * 60 + value.minute())
.is_some_and(|current| current == *expected),
}
}
}
pub fn default_stage_time(stage: ScheduleStage) -> Option<NaiveTime> {
match stage {
ScheduleStage::BeforeTrading => Some(NaiveTime::from_hms_opt(9, 0, 0).expect("valid time")),
ScheduleStage::OpenAuction => Some(NaiveTime::from_hms_opt(9, 31, 0).expect("valid time")),
ScheduleStage::OnDay => Some(NaiveTime::from_hms_opt(10, 18, 0).expect("valid time")),
ScheduleStage::AfterTrading => Some(NaiveTime::from_hms_opt(15, 0, 0).expect("valid time")),
ScheduleStage::Settlement => Some(NaiveTime::from_hms_opt(15, 1, 0).expect("valid time")),
}
} }
fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option<NaiveDate> { fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option<NaiveDate> {
@@ -145,9 +242,9 @@ fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option<NaiveDate> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::NaiveDate; use chrono::{NaiveDate, NaiveTime};
use super::{ScheduleRule, ScheduleStage, Scheduler}; use super::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
use crate::calendar::TradingCalendar; use crate::calendar::TradingCalendar;
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -198,4 +295,80 @@ mod tests {
assert!(feb_3.contains(&"first_trading_day_of_month")); assert!(feb_3.contains(&"first_trading_day_of_month"));
assert!(!feb_3.contains(&"friday")); assert!(!feb_3.contains(&"friday"));
} }
#[test]
fn scheduler_matches_before_trading_and_minute_level_rules() {
let calendar = sample_calendar();
let scheduler = Scheduler::new(&calendar);
let rules = vec![
ScheduleRule::daily("before_trading", ScheduleStage::BeforeTrading)
.with_time_rule(ScheduleTimeRule::before_trading()),
ScheduleRule::daily("market_open", ScheduleStage::OpenAuction)
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
ScheduleRule::daily("physical_time", ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
ScheduleRule::daily("market_close", ScheduleStage::AfterTrading)
.with_time_rule(ScheduleTimeRule::market_close(0, 0)),
];
let before = scheduler
.triggered_rules_at(
d(2025, 2, 3),
ScheduleStage::BeforeTrading,
Some(NaiveTime::from_hms_opt(9, 0, 0).unwrap()),
&rules,
)
.into_iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>();
assert_eq!(before, vec!["before_trading"]);
let market_open = scheduler
.triggered_rules_at(
d(2025, 2, 3),
ScheduleStage::OpenAuction,
Some(NaiveTime::from_hms_opt(9, 31, 0).unwrap()),
&rules,
)
.into_iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>();
assert_eq!(market_open, vec!["market_open"]);
let intraday = scheduler
.triggered_rules_at(
d(2025, 2, 3),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(10, 18, 0).unwrap()),
&rules,
)
.into_iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>();
assert_eq!(intraday, vec!["physical_time"]);
let close = scheduler
.triggered_rules_at(
d(2025, 2, 3),
ScheduleStage::AfterTrading,
Some(NaiveTime::from_hms_opt(15, 0, 0).unwrap()),
&rules,
)
.into_iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>();
assert_eq!(close, vec!["market_close"]);
let mismatch = scheduler
.triggered_rules_at(
d(2025, 2, 3),
ScheduleStage::OnDay,
Some(NaiveTime::from_hms_opt(10, 17, 0).unwrap()),
&rules,
)
.into_iter()
.map(|rule| rule.name.as_str())
.collect::<Vec<_>>();
assert!(mismatch.is_empty());
}
} }

View File

@@ -100,7 +100,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
}, },
ManualSection { ManualSection {
title: "rebalance.weekly / rebalance.monthly".to_string(), title: "rebalance.weekly / rebalance.monthly".to_string(),
detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。当前这些调度规则会在 on_day 阶段触发".to_string(), detail: "支持按交易周或交易月调仓,例如 rebalance.weekly(weekday=5).at([\"10:18\"])、rebalance.weekly(tradingday=-1).at([\"10:18\"])、rebalance.monthly(tradingday=1).at([\"10:18\"])。`.at([...])` 的最后一个时刻会编进分钟级 schedule/time_rule当前平台把 on_day 近似到 10:18把 open_auction 近似到 09:31".to_string(),
}, },
ManualSection { ManualSection {
title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(), title: "selection.market_cap_band / selection.limit / ordering.rank_by / ordering.rank_expr".to_string(),
@@ -120,7 +120,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual {
}, },
ManualSection { ManualSection {
title: "trading.rotation / order.* / cancel.*".to_string(), title: "trading.rotation / order.* / cancel.*".to_string(),
detail: "支持显式下单和撤单。可以用 trading.rotation(false) 关闭默认轮动链路,再用 trading.stage(\"open_auction\" | \"on_day\") 指定执行阶段,用 trading.schedule.daily() / trading.schedule.weekly(weekday=5) / trading.schedule.weekly(tradingday=-1) / trading.schedule.monthly(tradingday=1) 指定触发频率,然后写 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(), 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(),
}, },
ManualSection { ManualSection {
title: "when / unless / else".to_string(), title: "when / unless / else".to_string(),

View File

@@ -6,8 +6,8 @@ use chrono::NaiveDate;
use fidc_core::{ use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, Strategy, Instrument, OrderIntent, PriceField, ProcessEventKind, ScheduleRule, ScheduleStage,
StrategyContext, StrategyDecision, ScheduleTimeRule, Strategy, StrategyContext, StrategyDecision,
}; };
fn d(year: i32, month: u32, day: u32) -> NaiveDate { fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -147,9 +147,14 @@ impl Strategy for ScheduledProbeStrategy {
fn schedule_rules(&self) -> Vec<ScheduleRule> { fn schedule_rules(&self) -> Vec<ScheduleRule> {
vec![ vec![
ScheduleRule::daily("daily_auction", ScheduleStage::OpenAuction), ScheduleRule::daily("daily_before_trading", ScheduleStage::BeforeTrading)
ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay), .with_time_rule(ScheduleTimeRule::before_trading()),
ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay), ScheduleRule::daily("daily_market_open", ScheduleStage::OpenAuction)
.with_time_rule(ScheduleTimeRule::market_open(0, 0)),
ScheduleRule::weekly_by_weekday("friday_on_day", 5, ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
ScheduleRule::monthly("first_trading_day_on_day", 1, ScheduleStage::OnDay)
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
] ]
} }
@@ -898,24 +903,27 @@ fn engine_runs_scheduled_rules_for_daily_weekly_and_monthly_triggers() {
assert_eq!( assert_eq!(
log.borrow().as_slice(), log.borrow().as_slice(),
[ [
"scheduled:daily_auction:2025-01-30", "scheduled:daily_before_trading:2025-01-30",
"scheduled:daily_market_open:2025-01-30",
"scheduled:first_trading_day_on_day:2025-01-30", "scheduled:first_trading_day_on_day:2025-01-30",
"scheduled:daily_auction:2025-01-31", "scheduled:daily_before_trading:2025-01-31",
"scheduled:daily_market_open:2025-01-31",
"scheduled:friday_on_day:2025-01-31", "scheduled:friday_on_day:2025-01-31",
"scheduled:daily_auction:2025-02-03", "scheduled:daily_before_trading:2025-02-03",
"scheduled:daily_market_open:2025-02-03",
"scheduled:first_trading_day_on_day:2025-02-03", "scheduled:first_trading_day_on_day:2025-02-03",
] ]
); );
let process_log = process_log.borrow(); let process_log = process_log.borrow();
assert!( assert!(
process_log process_log.iter().any(|item| {
.iter() item == "PreScheduled:scheduled:daily_before_trading:before_trading:pre"
.any(|item| { item == "PreScheduled:scheduled:daily_auction:open_auction:pre" }) })
); );
assert!( assert!(
process_log process_log
.iter() .iter()
.any(|item| { item == "PostScheduled:scheduled:daily_auction:open_auction:post" }) .any(|item| { item == "PostScheduled:scheduled:daily_market_open:open_auction:post" })
); );
assert!( assert!(
process_log process_log
@@ -1154,9 +1162,9 @@ fn engine_dispatches_process_events_to_external_bus_listeners() {
let external_log = external_log.borrow(); let external_log = external_log.borrow();
assert!( assert!(
external_log external_log.iter().any(|item| {
.iter() item == "PreScheduled:scheduled:daily_before_trading:before_trading:pre"
.any(|item| { item == "PreScheduled:scheduled:daily_auction:open_auction:pre" }) })
); );
assert!( assert!(
external_log external_log

View File

@@ -317,7 +317,10 @@ fn broker_executes_target_shares_like_order_to() {
) )
.expect("broker execution"); .expect("broker execution");
assert_eq!(portfolio.position("000002.SZ").map(|pos| pos.quantity), Some(200)); assert_eq!(
portfolio.position("000002.SZ").map(|pos| pos.quantity),
Some(200)
);
assert_eq!(report.fill_events.len(), 1); assert_eq!(report.fill_events.len(), 1);
assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell); assert_eq!(report.fill_events[0].side, fidc_core::OrderSide::Sell);
assert_eq!(report.fill_events[0].quantity, 100); assert_eq!(report.fill_events[0].quantity, 100);
@@ -500,7 +503,13 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
assert_eq!(report.fill_events[1].symbol, "000002.SZ"); assert_eq!(report.fill_events[1].symbol, "000002.SZ");
assert_eq!(report.fill_events[1].side, fidc_core::OrderSide::Buy); assert_eq!(report.fill_events[1].side, fidc_core::OrderSide::Buy);
assert_eq!(report.fill_events[1].quantity, 100); assert_eq!(report.fill_events[1].quantity, 100);
assert_eq!(portfolio.position("000001.SZ").map(|pos| pos.quantity).unwrap_or(0), 0); assert_eq!(
portfolio
.position("000001.SZ")
.map(|pos| pos.quantity)
.unwrap_or(0),
0
);
assert_eq!( assert_eq!(
portfolio.position("000002.SZ").map(|pos| pos.quantity), portfolio.position("000002.SZ").map(|pos| pos.quantity),
Some(100) Some(100)

View File

@@ -20,11 +20,11 @@ current alignment pass.
### Phase 2: Scheduling and execution surface ### Phase 2: Scheduling and execution surface
- [ ] minute-level `time_rule` semantics like `market_open`, `market_close`, - [x] minute-level `time_rule` semantics like `market_open`, `market_close`,
`physical_time` `physical_time`
- [ ] finer `1m` / `tick` strategy execution entrypoints beyond `open_auction` - [ ] finer `1m` / `tick` strategy execution entrypoints beyond `open_auction`
and `on_day` and `on_day`
- [ ] scheduled actions evaluated against explicit intraday times - [x] scheduled actions evaluated against explicit intraday times
### Phase 3: Universe and subscription model ### Phase 3: Universe and subscription model
@@ -57,4 +57,4 @@ current alignment pass.
## Current Step ## Current Step
Active implementation target: Phase 2, minute-level time_rule semantics. Active implementation target: Phase 3, dynamic universe and subscription model.