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 diagnostics = Vec::new();
for (symbol, weight) in target_weights {
let price =
self.rebalance_valuation_price_with_overrides(date, symbol, data, valuation_prices)?;
let price = self.rebalance_valuation_price_with_overrides(
date,
symbol,
data,
valuation_prices,
)?;
let raw_qty = ((equity * weight) / price).floor() as u32;
desired_targets.insert(
symbol.clone(),
@@ -1486,7 +1490,10 @@ where
symbols.extend(target_quantities.keys().cloned());
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);
if current_qty <= target_qty {
continue;
@@ -1529,7 +1536,10 @@ where
}
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);
if target_qty <= current_qty {
continue;

View File

@@ -13,7 +13,7 @@ use crate::events::{
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::rules::EquityRuleHooks;
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler};
use crate::scheduler::{ScheduleRule, ScheduleStage, Scheduler, default_stage_time};
use crate::strategy::{Strategy, StrategyContext};
#[derive(Debug, Error)]
@@ -274,6 +274,21 @@ where
ProcessEventKind::BeforeTrading,
"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(
&mut self.strategy,
&mut self.process_event_bus,
@@ -315,6 +330,7 @@ where
&pre_open_orders,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OpenAuction),
)?;
auction_decision.merge_from(self.strategy.open_auction(&StrategyContext {
execution_date,
@@ -417,6 +433,7 @@ where
&on_day_open_orders,
&mut process_events,
&mut self.process_event_bus,
default_stage_time(ScheduleStage::OnDay),
)?);
publish_phase_event(
&mut self.strategy,
@@ -512,6 +529,21 @@ where
ProcessEventKind::AfterTrading,
"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);
publish_process_events(
&mut self.strategy,
@@ -583,6 +615,21 @@ where
ProcessEventKind::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(
&mut self.strategy,
&mut self.process_event_bus,
@@ -1094,9 +1141,10 @@ fn collect_scheduled_decisions<S: Strategy>(
open_orders: &[crate::strategy::OpenOrderView],
process_events: &mut Vec<ProcessEvent>,
process_event_bus: &mut ProcessEventBus,
current_time: Option<chrono::NaiveTime>,
) -> Result<crate::strategy::StrategyDecision, BacktestError> {
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(
strategy,
process_event_bus,
@@ -1214,8 +1262,11 @@ fn publish_process_events<S: Strategy>(
fn stage_label(stage: ScheduleStage) -> &'static str {
match stage {
ScheduleStage::BeforeTrading => "before_trading",
ScheduleStage::OpenAuction => "open_auction",
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 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::{
CnSmallCapRotationConfig, CnSmallCapRotationStrategy, JqMicroCapConfig, JqMicroCapStrategy,
OpenOrderView, OrderIntent, Strategy, StrategyContext, StrategyDecision,

View File

@@ -8,7 +8,9 @@ use crate::data::{DailyMarketSnapshot, EligibleUniverseSnapshot, PriceField};
use crate::engine::BacktestError;
use crate::events::OrderSide;
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};
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -25,48 +27,48 @@ pub enum PlatformScheduleFrequency {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlatformRebalanceSchedule {
pub frequency: PlatformScheduleFrequency,
pub time_rule: Option<ScheduleTimeRule>,
}
impl PlatformRebalanceSchedule {
fn as_schedule_rule(&self) -> ScheduleRule {
match self.frequency {
fn as_schedule_rule(&self, stage: ScheduleStage) -> ScheduleRule {
let rule = match self.frequency {
PlatformScheduleFrequency::Weekly {
weekday: Some(weekday),
..
} => ScheduleRule::weekly_by_weekday(
"platform_periodic_rebalance",
weekday,
ScheduleStage::OnDay,
),
} => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", weekday, stage),
PlatformScheduleFrequency::Weekly {
tradingday: Some(tradingday),
..
} => ScheduleRule::weekly_by_tradingday(
"platform_periodic_rebalance",
tradingday,
ScheduleStage::OnDay,
),
PlatformScheduleFrequency::Monthly { tradingday } => ScheduleRule::monthly(
"platform_periodic_rebalance",
tradingday,
ScheduleStage::OnDay,
),
} => {
ScheduleRule::weekly_by_tradingday("platform_periodic_rebalance", tradingday, stage)
}
PlatformScheduleFrequency::Monthly { tradingday } => {
ScheduleRule::monthly("platform_periodic_rebalance", tradingday, stage)
}
PlatformScheduleFrequency::Weekly {
weekday: None,
tradingday: None,
} => ScheduleRule::weekly_by_weekday(
"platform_periodic_rebalance",
1,
ScheduleStage::OnDay,
),
} => ScheduleRule::weekly_by_weekday("platform_periodic_rebalance", 1, stage),
};
if let Some(time_rule) = self.time_rule.clone() {
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 rule = self.as_schedule_rule();
let rule = self.as_schedule_rule(stage);
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()
.next()
.is_some()
@@ -2617,10 +2619,16 @@ impl PlatformExprStrategy {
calendar: &crate::calendar::TradingCalendar,
date: NaiveDate,
) -> bool {
let stage = match self.config.explicit_action_stage {
PlatformExplicitActionStage::OpenAuction => ScheduleStage::OpenAuction,
PlatformExplicitActionStage::OnDay => ScheduleStage::OnDay,
};
self.config
.explicit_action_schedule
.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(
@@ -3013,7 +3021,12 @@ impl Strategy for PlatformExprStrategy {
};
let periodic_rebalance = if self.config.rotation_enabled {
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 {
ctx.decision_index % self.config.refresh_rate == 0
}
@@ -3224,7 +3237,7 @@ impl Strategy for PlatformExprStrategy {
mod tests {
use std::collections::BTreeMap;
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveTime};
use super::{
PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind,
@@ -3233,8 +3246,8 @@ mod tests {
};
use crate::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, Strategy,
StrategyContext, TradingCalendar,
Instrument, OpenOrderView, PortfolioState, ProcessEvent, ProcessEventKind, ScheduleStage,
ScheduleTimeRule, Strategy, StrategyContext, TradingCalendar, default_stage_time,
};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -3259,9 +3272,20 @@ mod tests {
weekday: Some(5),
tradingday: None,
},
time_rule: None,
};
assert!(schedule.matches(&calendar, d(2025, 1, 31)));
assert!(!schedule.matches(&calendar, d(2025, 2, 3)));
assert!(schedule.matches(
&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]
@@ -3269,9 +3293,44 @@ mod tests {
let calendar = sample_calendar();
let schedule = PlatformRebalanceSchedule {
frequency: PlatformScheduleFrequency::Monthly { tradingday: 1 },
time_rule: None,
};
assert!(schedule.matches(&calendar, d(2025, 2, 3)));
assert!(!schedule.matches(&calendar, d(2025, 2, 4)));
assert!(schedule.matches(
&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]
@@ -3572,15 +3631,13 @@ mod tests {
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
cfg.explicit_actions = vec![PlatformTradeAction::TargetPortfolioSmart {
target_weights_expr:
"{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(),
target_weights_expr: "{\"000001.SZ\": 0.30, \"000002.SZ\": 0.20}".to_string(),
order_prices_expr: Some(
"{\"000001.SZ\": signal_open * 1.01, \"000002.SZ\": benchmark_open / 100.0}"
.to_string(),
),
valuation_prices_expr: Some(
"{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}"
.to_string(),
"{\"000001.SZ\": signal_close, \"000002.SZ\": benchmark_close / 100.0}".to_string(),
),
when_expr: Some("benchmark_close > 0".to_string()),
reason: "platform_target_portfolio_smart".to_string(),
@@ -3704,6 +3761,7 @@ mod tests {
weekday: Some(1),
tradingday: None,
},
time_rule: None,
});
cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1;
@@ -3817,6 +3875,7 @@ mod tests {
weekday: Some(5),
tradingday: None,
},
time_rule: None,
});
cfg.benchmark_short_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) {
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> {
@@ -227,11 +231,12 @@ impl Position {
self.position_pnl = if self.day_start_quantity == 0 || self.day_start_price <= 0.0 {
0.0
} 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.trading_pnl = (self.day_trade_quantity_delta as f64 * self.last_price)
- self.day_trade_cost;
self.trading_pnl =
(self.day_trade_quantity_delta as f64 * self.last_price) - self.day_trade_cost;
}
}
@@ -466,8 +471,11 @@ impl PortfolioState {
#[cfg(test)]
mod tests {
use super::*;
use crate::data::{BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, PriceField};
use crate::Instrument;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use std::collections::BTreeMap;
#[test]
@@ -494,8 +502,11 @@ mod tests {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let date = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
let mut portfolio = PortfolioState::new(10_000.0);
portfolio.position_mut("000001.SZ").buy(prev_date, 100, 10.0);
portfolio.update_prices(
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 100, 10.0);
portfolio
.update_prices(
prev_date,
&DataSet::from_components(
vec![Instrument {
@@ -605,7 +616,8 @@ mod tests {
.position_mut_if_exists("000001.SZ")
.expect("position")
.record_trade_cost(5.0);
portfolio.update_prices(
portfolio
.update_prices(
date,
&DataSet::from_components(
vec![Instrument {

View File

@@ -1,11 +1,57 @@
use chrono::{Datelike, NaiveDate};
use chrono::{Datelike, NaiveDate, NaiveTime, Timelike};
use crate::calendar::TradingCalendar;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScheduleStage {
BeforeTrading,
OpenAuction,
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)]
@@ -25,6 +71,7 @@ pub struct ScheduleRule {
pub name: String,
pub stage: ScheduleStage,
pub frequency: ScheduleFrequency,
pub time_rule: Option<ScheduleTimeRule>,
}
impl ScheduleRule {
@@ -33,6 +80,7 @@ impl ScheduleRule {
name: name.into(),
stage,
frequency: ScheduleFrequency::Daily,
time_rule: None,
}
}
@@ -44,6 +92,7 @@ impl ScheduleRule {
weekday: Some(weekday),
tradingday: None,
},
time_rule: None,
}
}
@@ -59,6 +108,7 @@ impl ScheduleRule {
weekday: None,
tradingday: Some(tradingday),
},
time_rule: None,
}
}
@@ -67,8 +117,14 @@ impl ScheduleRule {
name: name.into(),
stage,
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> {
@@ -85,10 +141,24 @@ impl<'a> Scheduler<'a> {
date: NaiveDate,
stage: ScheduleStage,
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> {
rules
.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()
}
@@ -129,6 +199,33 @@ impl<'a> Scheduler<'a> {
})
.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> {
@@ -145,9 +242,9 @@ fn nth_date_in_period(period: &[NaiveDate], nth: i32) -> Option<NaiveDate> {
#[cfg(test)]
mod tests {
use chrono::NaiveDate;
use chrono::{NaiveDate, NaiveTime};
use super::{ScheduleRule, ScheduleStage, Scheduler};
use super::{ScheduleRule, ScheduleStage, ScheduleTimeRule, Scheduler};
use crate::calendar::TradingCalendar;
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(&"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 {
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 {
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 {
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 {
title: "when / unless / else".to_string(),

View File

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

View File

@@ -317,7 +317,10 @@ fn broker_executes_target_shares_like_order_to() {
)
.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[0].side, fidc_core::OrderSide::Sell);
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].side, fidc_core::OrderSide::Buy);
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!(
portfolio.position("000002.SZ").map(|pos| pos.quantity),
Some(100)

View File

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