Add rqalpha-style scheduler time rules
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,44 +502,134 @@ 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(
|
||||
prev_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: prev_date,
|
||||
portfolio
|
||||
.position_mut("000001.SZ")
|
||||
.buy(prev_date, 100, 10.0);
|
||||
portfolio
|
||||
.update_prices(
|
||||
prev_date,
|
||||
&DataSet::from_components(
|
||||
vec![Instrument {
|
||||
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,
|
||||
name: "Test".to_string(),
|
||||
board: "SZ".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: None,
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![
|
||||
DailyMarketSnapshot {
|
||||
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,
|
||||
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 {
|
||||
}],
|
||||
)
|
||||
.expect("dataset"),
|
||||
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,
|
||||
@@ -553,127 +651,41 @@ mod tests {
|
||||
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,
|
||||
}],
|
||||
}],
|
||||
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("dataset"),
|
||||
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");
|
||||
.expect("close");
|
||||
|
||||
let position = portfolio.position("000001.SZ").expect("position");
|
||||
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;
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_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 {
|
||||
title: "when / unless / else".to_string(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user