Add rqalpha-style scheduler time rules
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_to,order.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_to,order.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(),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user