Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5482c8a52d | |||
| 2a6bbb82a6 | |||
| 24e4ac9284 | |||
| 81d70f18b3 | |||
| 85c9d03b99 | |||
| a147c495af | |||
| 4cf0224d2d | |||
| 7503dc8517 | |||
| 1c04318ecf | |||
| 4b577517a9 | |||
| c52478708f | |||
| 1d7ac19886 | |||
| 0686532be0 | |||
| 911074ae95 | |||
| 555f2ab9bd | |||
| a79077af17 | |||
| 61a4172bd4 | |||
| 589f94e5b2 | |||
| 8254ebbb47 | |||
| ea79fdae46 | |||
| 2013314e4f | |||
| 869c14e2b0 | |||
| cea079a770 | |||
| 9a7e5c7903 | |||
| 279d6a100f | |||
| 7afb72dca8 |
Generated
+1
@@ -146,6 +146,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|||||||
name = "fidc-core"
|
name = "fidc-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
"chrono",
|
"chrono",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"rayon",
|
"rayon",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ version = "0.1.0"
|
|||||||
authors = ["OpenAI Codex"]
|
authors = ["OpenAI Codex"]
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
ahash = "=0.8.12"
|
||||||
chrono = { version = "=0.4.44", features = ["serde"] }
|
chrono = { version = "=0.4.44", features = ["serde"] }
|
||||||
indexmap = { version = "=2.11.4", features = ["serde"] }
|
indexmap = { version = "=2.11.4", features = ["serde"] }
|
||||||
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "=0.12.24", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ license.workspace = true
|
|||||||
authors.workspace = true
|
authors.workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ahash.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
indexmap.workspace = true
|
indexmap.workspace = true
|
||||||
rayon.workspace = true
|
rayon.workspace = true
|
||||||
|
|||||||
@@ -384,6 +384,10 @@ impl<C, R> BrokerSimulator<C, R> {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_open_orders(&self) -> bool {
|
||||||
|
!self.open_orders.borrow().is_empty()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<C, R> BrokerSimulator<C, R>
|
impl<C, R> BrokerSimulator<C, R>
|
||||||
|
|||||||
+814
-324
File diff suppressed because it is too large
Load Diff
+211
-28
@@ -1,7 +1,7 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::{Datelike, Duration, NaiveDate, NaiveTime};
|
use chrono::{Datelike, Duration, NaiveDate, NaiveTime, Timelike};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
|
use crate::broker::{BrokerExecutionReport, BrokerSimulator, MatchingType};
|
||||||
@@ -71,7 +71,7 @@ impl Default for FuturesValidationConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct DailyEquityPoint {
|
pub struct DailyEquityPoint {
|
||||||
#[serde(with = "date_format")]
|
#[serde(with = "date_format")]
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
@@ -578,6 +578,9 @@ where
|
|||||||
if self.execution_quote_request_cache.contains(&request_key) {
|
if self.execution_quote_request_cache.contains(&request_key) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if start_time.is_none() && end_time.is_none() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if start_time.is_some() && end_time.is_none() {
|
if start_time.is_some() && end_time.is_none() {
|
||||||
return !has_execution_quote_near_start_time(
|
return !has_execution_quote_near_start_time(
|
||||||
&self.data,
|
&self.data,
|
||||||
@@ -604,7 +607,19 @@ where
|
|||||||
.as_mut()
|
.as_mut()
|
||||||
.expect("checked execution quote loader")
|
.expect("checked execution quote loader")
|
||||||
.as_mut()(request)?;
|
.as_mut()(request)?;
|
||||||
|
let requested_symbol_set = requested_symbols.iter().cloned().collect::<BTreeSet<_>>();
|
||||||
|
if let Some(quote) = quotes.iter().find(|quote| {
|
||||||
|
quote.date != execution_date || !requested_symbol_set.contains("e.symbol)
|
||||||
|
}) {
|
||||||
|
return Err(BacktestError::Execution(format!(
|
||||||
|
"execution quote loader returned a row outside the request: requested_date={} actual_date={} symbol={}",
|
||||||
|
execution_date, quote.date, quote.symbol
|
||||||
|
)));
|
||||||
|
}
|
||||||
self.data.add_execution_quotes(quotes);
|
self.data.add_execution_quotes(quotes);
|
||||||
|
if start_time.is_none() && end_time.is_none() {
|
||||||
|
self.validate_full_day_execution_quote_coverage(execution_date, &requested_symbols)?;
|
||||||
|
}
|
||||||
for symbol in requested_symbols {
|
for symbol in requested_symbols {
|
||||||
self.execution_quote_request_cache.insert((
|
self.execution_quote_request_cache.insert((
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -616,6 +631,48 @@ where
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_full_day_execution_quote_coverage(
|
||||||
|
&self,
|
||||||
|
execution_date: NaiveDate,
|
||||||
|
requested_symbols: &[String],
|
||||||
|
) -> Result<(), BacktestError> {
|
||||||
|
let mut missing_active = Vec::new();
|
||||||
|
let mut paused_with_quotes = Vec::new();
|
||||||
|
let mut missing_daily_market = Vec::new();
|
||||||
|
for symbol in requested_symbols {
|
||||||
|
let Some(_candidate) = self.data.candidate(execution_date, symbol) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(market) = self.data.market(execution_date, symbol) else {
|
||||||
|
missing_daily_market.push(symbol.clone());
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let has_quotes = !self
|
||||||
|
.data
|
||||||
|
.execution_quotes_on(execution_date, symbol)
|
||||||
|
.is_empty();
|
||||||
|
if market.paused {
|
||||||
|
if has_quotes {
|
||||||
|
paused_with_quotes.push(symbol.clone());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if market.volume > 0 && !has_quotes {
|
||||||
|
missing_active.push(symbol.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if missing_daily_market.is_empty()
|
||||||
|
&& missing_active.is_empty()
|
||||||
|
&& paused_with_quotes.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(BacktestError::Execution(format!(
|
||||||
|
"full-minute subscription coverage mismatch on {}: missing_daily_market={:?}, missing_active_minute_bars={:?}, paused_with_minute_bars={:?}",
|
||||||
|
execution_date, missing_daily_market, missing_active, paused_with_quotes
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
fn ensure_execution_quotes_for_portfolio_times(
|
fn ensure_execution_quotes_for_portfolio_times(
|
||||||
&mut self,
|
&mut self,
|
||||||
execution_date: NaiveDate,
|
execution_date: NaiveDate,
|
||||||
@@ -1029,6 +1086,10 @@ where
|
|||||||
views
|
views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn has_open_orders(&self) -> bool {
|
||||||
|
self.broker.has_open_orders() || !self.futures_open_orders.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
fn aggregate_initial_cash(&self) -> f64 {
|
fn aggregate_initial_cash(&self) -> f64 {
|
||||||
self.config.initial_cash
|
self.config.initial_cash
|
||||||
+ self
|
+ self
|
||||||
@@ -1666,6 +1727,7 @@ where
|
|||||||
F: FnMut(&BacktestDayProgress),
|
F: FnMut(&BacktestDayProgress),
|
||||||
{
|
{
|
||||||
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
let mut portfolio = PortfolioState::new(self.config.initial_cash);
|
||||||
|
self.subscriptions = self.strategy.initial_subscriptions();
|
||||||
let scheduler_calendar = self.data.calendar().clone();
|
let scheduler_calendar = self.data.calendar().clone();
|
||||||
let scheduler = Scheduler::new(&scheduler_calendar);
|
let scheduler = Scheduler::new(&scheduler_calendar);
|
||||||
let calendar_dates = self
|
let calendar_dates = self
|
||||||
@@ -2410,6 +2472,15 @@ where
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
if should_run_minute_events(&schedule_rules, &self.subscriptions) {
|
if should_run_minute_events(&schedule_rules, &self.subscriptions) {
|
||||||
|
if self.execution_quote_loader.is_some() && !self.subscriptions.is_empty() {
|
||||||
|
let mut minute_symbols = self.subscriptions.clone();
|
||||||
|
self.load_missing_execution_quotes(
|
||||||
|
execution_date,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
&mut minute_symbols,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
let filter_by_subscription = !self.subscriptions.is_empty();
|
let filter_by_subscription = !self.subscriptions.is_empty();
|
||||||
let minute_quotes = self
|
let minute_quotes = self
|
||||||
.data
|
.data
|
||||||
@@ -2419,8 +2490,42 @@ where
|
|||||||
!filter_by_subscription || self.subscriptions.contains("e.symbol)
|
!filter_by_subscription || self.subscriptions.contains("e.symbol)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
for quote in minute_quotes {
|
let requires_minute_callbacks = self.strategy.requires_minute_callbacks();
|
||||||
let minute_time = quote.timestamp.time();
|
let has_minute_process_listeners = self.process_event_bus.has_listeners_for(&[
|
||||||
|
ProcessEventKind::PreMinute,
|
||||||
|
ProcessEventKind::Minute,
|
||||||
|
ProcessEventKind::PostMinute,
|
||||||
|
]);
|
||||||
|
let minute_schedule_all_times = schedule_rules
|
||||||
|
.iter()
|
||||||
|
.any(|rule| rule.stage == ScheduleStage::Minute && rule.time_rule.is_none());
|
||||||
|
let minute_schedule_minutes = schedule_rules
|
||||||
|
.iter()
|
||||||
|
.filter(|rule| rule.stage == ScheduleStage::Minute)
|
||||||
|
.filter_map(|rule| rule.time_rule.as_ref()?.minute_of_day())
|
||||||
|
.collect::<BTreeSet<_>>();
|
||||||
|
let mut minute_cursor = 0usize;
|
||||||
|
while minute_cursor < minute_quotes.len() {
|
||||||
|
let minute_timestamp = minute_quotes[minute_cursor].timestamp;
|
||||||
|
let minute_time = minute_timestamp.time();
|
||||||
|
let mut minute_end = minute_cursor + 1;
|
||||||
|
while minute_end < minute_quotes.len()
|
||||||
|
&& minute_quotes[minute_end].timestamp == minute_timestamp
|
||||||
|
{
|
||||||
|
minute_end += 1;
|
||||||
|
}
|
||||||
|
let minute_group = &minute_quotes[minute_cursor..minute_end];
|
||||||
|
let schedule_candidate = minute_schedule_all_times
|
||||||
|
|| minute_schedule_minutes
|
||||||
|
.contains(&(minute_time.hour() * 60 + minute_time.minute()));
|
||||||
|
if !requires_minute_callbacks
|
||||||
|
&& !has_minute_process_listeners
|
||||||
|
&& !schedule_candidate
|
||||||
|
&& !self.has_open_orders()
|
||||||
|
{
|
||||||
|
minute_cursor = minute_end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let minute_open_orders = self.open_order_views();
|
let minute_open_orders = self.open_order_views();
|
||||||
publish_phase_event(
|
publish_phase_event(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
@@ -2437,7 +2542,7 @@ where
|
|||||||
&mut process_events,
|
&mut process_events,
|
||||||
execution_date,
|
execution_date,
|
||||||
ProcessEventKind::PreMinute,
|
ProcessEventKind::PreMinute,
|
||||||
format!("minute:{}:{}:pre", quote.symbol, quote.timestamp),
|
format!("minute:{minute_timestamp}:pre"),
|
||||||
)?;
|
)?;
|
||||||
let mut minute_decision = collect_scheduled_decisions(
|
let mut minute_decision = collect_scheduled_decisions(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
@@ -2459,25 +2564,29 @@ where
|
|||||||
result.order_events.as_slice(),
|
result.order_events.as_slice(),
|
||||||
result.fills.as_slice(),
|
result.fills.as_slice(),
|
||||||
)?;
|
)?;
|
||||||
minute_decision.merge_from(self.strategy.on_minute(
|
if requires_minute_callbacks {
|
||||||
&StrategyContext {
|
for quote in minute_group {
|
||||||
execution_date,
|
minute_decision.merge_from(self.strategy.on_minute(
|
||||||
decision_date,
|
&StrategyContext {
|
||||||
decision_index,
|
execution_date,
|
||||||
data: &self.data,
|
decision_date,
|
||||||
portfolio: &portfolio,
|
decision_index,
|
||||||
futures_account: self.futures_account.as_ref(),
|
data: &self.data,
|
||||||
open_orders: &minute_open_orders,
|
portfolio: &portfolio,
|
||||||
dynamic_universe: self.dynamic_universe.as_ref(),
|
futures_account: self.futures_account.as_ref(),
|
||||||
subscriptions: &self.subscriptions,
|
open_orders: &minute_open_orders,
|
||||||
process_events: &process_events,
|
dynamic_universe: self.dynamic_universe.as_ref(),
|
||||||
active_process_event: None,
|
subscriptions: &self.subscriptions,
|
||||||
active_datetime: Some(quote.timestamp),
|
process_events: &process_events,
|
||||||
order_events: result.order_events.as_slice(),
|
active_process_event: None,
|
||||||
fills: result.fills.as_slice(),
|
active_datetime: Some(minute_timestamp),
|
||||||
},
|
order_events: result.order_events.as_slice(),
|
||||||
"e,
|
fills: result.fills.as_slice(),
|
||||||
)?);
|
},
|
||||||
|
quote,
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
publish_phase_event(
|
publish_phase_event(
|
||||||
&mut self.strategy,
|
&mut self.strategy,
|
||||||
&mut self.process_event_bus,
|
&mut self.process_event_bus,
|
||||||
@@ -2493,7 +2602,7 @@ where
|
|||||||
&mut process_events,
|
&mut process_events,
|
||||||
execution_date,
|
execution_date,
|
||||||
ProcessEventKind::Minute,
|
ProcessEventKind::Minute,
|
||||||
format!("minute:{}:{}", quote.symbol, quote.timestamp),
|
format!("minute:{minute_timestamp}"),
|
||||||
)?;
|
)?;
|
||||||
self.apply_strategy_directives(
|
self.apply_strategy_directives(
|
||||||
execution_date,
|
execution_date,
|
||||||
@@ -2559,9 +2668,11 @@ where
|
|||||||
&mut process_events,
|
&mut process_events,
|
||||||
execution_date,
|
execution_date,
|
||||||
ProcessEventKind::PostMinute,
|
ProcessEventKind::PostMinute,
|
||||||
format!("minute:{}:{}:post", quote.symbol, quote.timestamp),
|
format!("minute:{minute_timestamp}:post"),
|
||||||
)?;
|
)?;
|
||||||
|
minute_cursor = minute_end;
|
||||||
}
|
}
|
||||||
|
self.data.remove_execution_quotes_on_date(execution_date);
|
||||||
}
|
}
|
||||||
|
|
||||||
portfolio.update_prices_with_options(
|
portfolio.update_prices_with_options(
|
||||||
@@ -4106,7 +4217,7 @@ fn futures_cancel_report(
|
|||||||
|
|
||||||
mod date_format {
|
mod date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::Serializer;
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
const FORMAT: &str = "%Y-%m-%d";
|
const FORMAT: &str = "%Y-%m-%d";
|
||||||
|
|
||||||
@@ -4116,6 +4227,14 @@ mod date_format {
|
|||||||
{
|
{
|
||||||
serializer.serialize_str(&date.format(FORMAT).to_string())
|
serializer.serialize_str(&date.format(FORMAT).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -4789,6 +4908,70 @@ mod tests {
|
|||||||
.expect("backtest run")
|
.expect("backtest run")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn full_day_coverage_engine(
|
||||||
|
data: DataSet,
|
||||||
|
date: NaiveDate,
|
||||||
|
) -> BacktestEngine<BuyWhenDecisionDateStrategy, ChinaAShareCostModel, ChinaEquityRuleHooks>
|
||||||
|
{
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks,
|
||||||
|
PriceField::Last,
|
||||||
|
)
|
||||||
|
.with_matching_type(MatchingType::MinuteLast)
|
||||||
|
.with_volume_limit(false)
|
||||||
|
.with_liquidity_limit(false);
|
||||||
|
BacktestEngine::new(
|
||||||
|
data,
|
||||||
|
BuyWhenDecisionDateStrategy {
|
||||||
|
decision_date: date,
|
||||||
|
},
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 100_000.0,
|
||||||
|
benchmark_code: "000852.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Last,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_minute_coverage_rejects_missing_active_bars_but_allows_paused_or_zero_volume() {
|
||||||
|
let first = d(2025, 1, 2);
|
||||||
|
let second = d(2025, 1, 3);
|
||||||
|
let active = full_day_coverage_engine(dataset(), first);
|
||||||
|
let error = active
|
||||||
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
||||||
|
.expect_err("active stock with daily volume requires minute bars");
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("missing_active_minute_bars"),
|
||||||
|
"{error}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let paused_data = dataset_with(
|
||||||
|
market_with_state(first, 10.0, 10.0, true, 11.0, 9.0),
|
||||||
|
market(second, 10.0, 10.0),
|
||||||
|
candidate_with_state(first, true, false),
|
||||||
|
candidate(second),
|
||||||
|
);
|
||||||
|
full_day_coverage_engine(paused_data, first)
|
||||||
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
||||||
|
.expect("paused stock may have no minute bars");
|
||||||
|
|
||||||
|
let zero_volume_data = dataset_with(
|
||||||
|
market_with_volume(first, 10.0, 10.0, 0),
|
||||||
|
market(second, 10.0, 10.0),
|
||||||
|
candidate(first),
|
||||||
|
candidate(second),
|
||||||
|
);
|
||||||
|
full_day_coverage_engine(zero_volume_data, first)
|
||||||
|
.validate_full_day_execution_quote_coverage(first, &[SYMBOL.to_string()])
|
||||||
|
.expect("zero-volume stock may have no minute bars");
|
||||||
|
}
|
||||||
|
|
||||||
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
fn run_scheduled_next_open_with_dataset(dataset: DataSet) -> super::BacktestResult {
|
||||||
run_scheduled_next_open_with_dataset_and_broker(
|
run_scheduled_next_open_with_dataset_and_broker(
|
||||||
dataset,
|
dataset,
|
||||||
|
|||||||
@@ -125,6 +125,15 @@ impl ProcessEventBus {
|
|||||||
loader.install_enabled(self, enabled_names)
|
loader.install_enabled(self, enabled_names)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_listeners_for(&self, kinds: &[ProcessEventKind]) -> bool {
|
||||||
|
!self.any_listeners.is_empty()
|
||||||
|
|| kinds.iter().any(|kind| {
|
||||||
|
self.listeners
|
||||||
|
.get(kind)
|
||||||
|
.is_some_and(|listeners| !listeners.is_empty())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn publish(&mut self, event: &ProcessEvent) {
|
pub fn publish(&mut self, event: &ProcessEvent) {
|
||||||
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
|
if let Some(listeners) = self.listeners.get_mut(&event.kind) {
|
||||||
for listener in listeners {
|
for listener in listeners {
|
||||||
|
|||||||
@@ -0,0 +1,531 @@
|
|||||||
|
//! Independent fixed-point acceptance model for money and fee arithmetic.
|
||||||
|
//!
|
||||||
|
//! The execution kernel still exposes f64 because prices and source rows are
|
||||||
|
//! represented that way today. This module is deliberately separate: it is a
|
||||||
|
//! deterministic shadow model used to prove that cash, fees, budget checks,
|
||||||
|
//! FIFO PnL, and external cash flows do not depend on binary floating-point
|
||||||
|
//! accumulation.
|
||||||
|
|
||||||
|
use std::collections::{BTreeMap, VecDeque};
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
use crate::events::OrderSide;
|
||||||
|
|
||||||
|
pub const MONEY_SCALE: i128 = 1_000_000;
|
||||||
|
const MONEY_SCALE_F64: f64 = MONEY_SCALE as f64;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
|
||||||
|
pub struct FixedMoney(i128);
|
||||||
|
|
||||||
|
impl FixedMoney {
|
||||||
|
pub const ZERO: Self = Self(0);
|
||||||
|
|
||||||
|
pub const fn from_raw(raw: i128) -> Self {
|
||||||
|
Self(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const fn raw(self) -> i128 {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_decimal_str(value: &str) -> Result<Self, String> {
|
||||||
|
let value = value.trim();
|
||||||
|
if value.is_empty() {
|
||||||
|
return Err("fixed money value is empty".to_string());
|
||||||
|
}
|
||||||
|
let (negative, unsigned) = match value.as_bytes()[0] {
|
||||||
|
b'-' => (true, &value[1..]),
|
||||||
|
b'+' => (false, &value[1..]),
|
||||||
|
_ => (false, value),
|
||||||
|
};
|
||||||
|
let mut parts = unsigned.split('.');
|
||||||
|
let whole = parts.next().unwrap_or_default();
|
||||||
|
let fractional = parts.next().unwrap_or_default();
|
||||||
|
if parts.next().is_some()
|
||||||
|
|| whole.is_empty()
|
||||||
|
|| !whole.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
|| !fractional.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
{
|
||||||
|
return Err(format!("invalid fixed money decimal: {value}"));
|
||||||
|
}
|
||||||
|
let whole = whole
|
||||||
|
.parse::<i128>()
|
||||||
|
.map_err(|_| format!("fixed money whole part is out of range: {value}"))?;
|
||||||
|
let mut fractional_digits = fractional.as_bytes().to_vec();
|
||||||
|
let round_up = fractional_digits.len() > 6 && fractional_digits[6] >= b'5';
|
||||||
|
fractional_digits.truncate(6);
|
||||||
|
while fractional_digits.len() < 6 {
|
||||||
|
fractional_digits.push(b'0');
|
||||||
|
}
|
||||||
|
let fractional = if fractional_digits.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
std::str::from_utf8(&fractional_digits)
|
||||||
|
.expect("fractional digits are ASCII")
|
||||||
|
.parse::<i128>()
|
||||||
|
.map_err(|_| format!("fixed money fractional part is invalid: {value}"))?
|
||||||
|
};
|
||||||
|
let mut raw = whole
|
||||||
|
.checked_mul(MONEY_SCALE)
|
||||||
|
.and_then(|raw| raw.checked_add(fractional))
|
||||||
|
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
|
||||||
|
if round_up {
|
||||||
|
raw = raw
|
||||||
|
.checked_add(1)
|
||||||
|
.ok_or_else(|| format!("fixed money value is out of range: {value}"))?;
|
||||||
|
}
|
||||||
|
Ok(Self(if negative { -raw } else { raw }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_f64(value: f64) -> Option<Self> {
|
||||||
|
if !value.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let raw = (value * MONEY_SCALE_F64).round();
|
||||||
|
if !raw.is_finite() || raw < i128::MIN as f64 || raw > i128::MAX as f64 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self(raw as i128))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_f64(self) -> f64 {
|
||||||
|
self.0 as f64 / MONEY_SCALE_F64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked_add(self, other: Self) -> Option<Self> {
|
||||||
|
self.0.checked_add(other.0).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked_sub(self, other: Self) -> Option<Self> {
|
||||||
|
self.0.checked_sub(other.0).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked_mul_quantity(self, quantity: u64) -> Option<Self> {
|
||||||
|
self.0.checked_mul(i128::from(quantity)).map(Self)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn checked_mul_rate(self, rate: Self) -> Option<Self> {
|
||||||
|
let product = self.0.checked_mul(rate.0)?;
|
||||||
|
let half = MONEY_SCALE / 2;
|
||||||
|
let rounded = if product >= 0 {
|
||||||
|
product.checked_add(half)? / MONEY_SCALE
|
||||||
|
} else {
|
||||||
|
product.checked_sub(half)? / MONEY_SCALE
|
||||||
|
};
|
||||||
|
Some(Self(rounded))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn abs(self) -> Self {
|
||||||
|
Self(self.0.abs())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct FixedTradingCost {
|
||||||
|
pub commission: FixedMoney,
|
||||||
|
pub stamp_tax: FixedMoney,
|
||||||
|
pub transfer_fee: FixedMoney,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixedTradingCost {
|
||||||
|
pub fn total(self) -> FixedMoney {
|
||||||
|
FixedMoney::from_raw(self.commission.raw() + self.stamp_tax.raw() + self.transfer_fee.raw())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FixedChinaAShareCostModel {
|
||||||
|
pub commission_rate: FixedMoney,
|
||||||
|
pub stamp_tax_rate_before_change: FixedMoney,
|
||||||
|
pub stamp_tax_rate_after_change: FixedMoney,
|
||||||
|
pub stamp_tax_change_date: NaiveDate,
|
||||||
|
pub minimum_commission: FixedMoney,
|
||||||
|
pub transfer_fee_rate: FixedMoney,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixedChinaAShareCostModel {
|
||||||
|
pub fn commission_for(self, gross_amount: FixedMoney) -> FixedMoney {
|
||||||
|
if gross_amount.raw() <= 0 {
|
||||||
|
return FixedMoney::ZERO;
|
||||||
|
}
|
||||||
|
let raw = gross_amount
|
||||||
|
.checked_mul_rate(self.commission_rate)
|
||||||
|
.expect("fixed commission multiplication overflow");
|
||||||
|
raw.max(self.minimum_commission)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stamp_tax_rate_for(self, date: NaiveDate) -> FixedMoney {
|
||||||
|
if date < self.stamp_tax_change_date {
|
||||||
|
self.stamp_tax_rate_before_change
|
||||||
|
} else {
|
||||||
|
self.stamp_tax_rate_after_change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stamp_tax_for(
|
||||||
|
self,
|
||||||
|
date: NaiveDate,
|
||||||
|
side: OrderSide,
|
||||||
|
gross_amount: FixedMoney,
|
||||||
|
) -> FixedMoney {
|
||||||
|
if gross_amount.raw() <= 0 || side == OrderSide::Buy {
|
||||||
|
return FixedMoney::ZERO;
|
||||||
|
}
|
||||||
|
gross_amount
|
||||||
|
.checked_mul_rate(self.stamp_tax_rate_for(date))
|
||||||
|
.expect("fixed stamp tax multiplication overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn transfer_fee_for(self, gross_amount: FixedMoney) -> FixedMoney {
|
||||||
|
if gross_amount.raw() <= 0 {
|
||||||
|
return FixedMoney::ZERO;
|
||||||
|
}
|
||||||
|
gross_amount
|
||||||
|
.checked_mul_rate(self.transfer_fee_rate)
|
||||||
|
.expect("fixed transfer fee multiplication overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn calculate(
|
||||||
|
self,
|
||||||
|
date: NaiveDate,
|
||||||
|
side: OrderSide,
|
||||||
|
gross_amount: FixedMoney,
|
||||||
|
) -> FixedTradingCost {
|
||||||
|
FixedTradingCost {
|
||||||
|
commission: self.commission_for(gross_amount),
|
||||||
|
stamp_tax: self.stamp_tax_for(date, side, gross_amount),
|
||||||
|
transfer_fee: self.transfer_fee_for(gross_amount),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commission_for_order_fill(
|
||||||
|
self,
|
||||||
|
gross_amount: FixedMoney,
|
||||||
|
order_id: Option<u64>,
|
||||||
|
commission_state: &mut BTreeMap<u64, FixedMoney>,
|
||||||
|
) -> FixedMoney {
|
||||||
|
if gross_amount.raw() <= 0 {
|
||||||
|
return FixedMoney::ZERO;
|
||||||
|
}
|
||||||
|
let raw = gross_amount
|
||||||
|
.checked_mul_rate(self.commission_rate)
|
||||||
|
.expect("fixed commission multiplication overflow");
|
||||||
|
let Some(order_id) = order_id else {
|
||||||
|
return raw.max(self.minimum_commission);
|
||||||
|
};
|
||||||
|
let remaining = commission_state
|
||||||
|
.entry(order_id)
|
||||||
|
.or_insert(self.minimum_commission);
|
||||||
|
if raw > *remaining {
|
||||||
|
let charged = if *remaining == self.minimum_commission {
|
||||||
|
raw
|
||||||
|
} else {
|
||||||
|
raw.checked_sub(*remaining)
|
||||||
|
.expect("fixed remaining commission underflow")
|
||||||
|
};
|
||||||
|
*remaining = FixedMoney::ZERO;
|
||||||
|
charged
|
||||||
|
} else {
|
||||||
|
let charged = if *remaining == self.minimum_commission {
|
||||||
|
self.minimum_commission
|
||||||
|
} else {
|
||||||
|
FixedMoney::ZERO
|
||||||
|
};
|
||||||
|
*remaining = remaining
|
||||||
|
.checked_sub(raw)
|
||||||
|
.expect("fixed remaining commission underflow");
|
||||||
|
charged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct FixedLot {
|
||||||
|
pub acquired_date: NaiveDate,
|
||||||
|
pub quantity: u64,
|
||||||
|
pub entry_price: FixedMoney,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FixedLotBook {
|
||||||
|
lots: VecDeque<FixedLot>,
|
||||||
|
pub realized_pnl: FixedMoney,
|
||||||
|
pub quantity: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixedLotBook {
|
||||||
|
pub fn buy(&mut self, date: NaiveDate, quantity: u64, price: FixedMoney) {
|
||||||
|
if quantity == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.lots.push_back(FixedLot {
|
||||||
|
acquired_date: date,
|
||||||
|
quantity,
|
||||||
|
entry_price: price,
|
||||||
|
});
|
||||||
|
self.quantity = self.quantity.saturating_add(quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sell(&mut self, quantity: u64, price: FixedMoney) -> Result<FixedMoney, String> {
|
||||||
|
if quantity > self.quantity {
|
||||||
|
return Err(format!(
|
||||||
|
"fixed sell quantity {} exceeds current quantity {}",
|
||||||
|
quantity, self.quantity
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut remaining = quantity;
|
||||||
|
let mut realized = FixedMoney::ZERO;
|
||||||
|
while remaining > 0 {
|
||||||
|
let Some(mut lot) = self.lots.pop_front() else {
|
||||||
|
return Err("fixed lot book is empty while selling".to_string());
|
||||||
|
};
|
||||||
|
let sold = remaining.min(lot.quantity);
|
||||||
|
let price_delta = price
|
||||||
|
.checked_sub(lot.entry_price)
|
||||||
|
.and_then(|delta| delta.checked_mul_quantity(sold))
|
||||||
|
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||||
|
realized = realized
|
||||||
|
.checked_add(price_delta)
|
||||||
|
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||||
|
lot.quantity -= sold;
|
||||||
|
remaining -= sold;
|
||||||
|
if lot.quantity > 0 {
|
||||||
|
self.lots.push_front(lot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.quantity -= quantity;
|
||||||
|
self.realized_pnl = self
|
||||||
|
.realized_pnl
|
||||||
|
.checked_add(realized)
|
||||||
|
.ok_or_else(|| "fixed realized PnL overflow".to_string())?;
|
||||||
|
Ok(realized)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn market_value(&self, mark_price: FixedMoney) -> FixedMoney {
|
||||||
|
mark_price
|
||||||
|
.checked_mul_quantity(self.quantity)
|
||||||
|
.expect("fixed market value overflow")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unrealized_pnl(&self, mark_price: FixedMoney) -> FixedMoney {
|
||||||
|
self.lots.iter().fold(FixedMoney::ZERO, |total, lot| {
|
||||||
|
let delta = mark_price
|
||||||
|
.checked_sub(lot.entry_price)
|
||||||
|
.and_then(|value| value.checked_mul_quantity(lot.quantity))
|
||||||
|
.expect("fixed unrealized PnL overflow");
|
||||||
|
total
|
||||||
|
.checked_add(delta)
|
||||||
|
.expect("fixed unrealized PnL overflow")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FixedAccount {
|
||||||
|
pub cash: FixedMoney,
|
||||||
|
pub units: FixedMoney,
|
||||||
|
pub external_cash_flow_total: FixedMoney,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FixedAccount {
|
||||||
|
pub fn new(initial_cash: FixedMoney) -> Self {
|
||||||
|
Self {
|
||||||
|
cash: initial_cash,
|
||||||
|
units: initial_cash,
|
||||||
|
external_cash_flow_total: FixedMoney::ZERO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_external_cash_flow(
|
||||||
|
&mut self,
|
||||||
|
amount: FixedMoney,
|
||||||
|
unit_nav: FixedMoney,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if unit_nav.raw() <= 0 {
|
||||||
|
return Err("fixed unit NAV must be positive".to_string());
|
||||||
|
}
|
||||||
|
let exact_units_raw = amount
|
||||||
|
.raw()
|
||||||
|
.checked_mul(MONEY_SCALE)
|
||||||
|
.and_then(|value| value.checked_div(unit_nav.raw()))
|
||||||
|
.ok_or_else(|| "fixed external flow unit conversion overflow".to_string())?;
|
||||||
|
self.cash = self
|
||||||
|
.cash
|
||||||
|
.checked_add(amount)
|
||||||
|
.ok_or_else(|| "fixed cash overflow".to_string())?;
|
||||||
|
self.units = self
|
||||||
|
.units
|
||||||
|
.checked_add(FixedMoney::from_raw(exact_units_raw))
|
||||||
|
.ok_or_else(|| "fixed units overflow".to_string())?;
|
||||||
|
self.external_cash_flow_total = self
|
||||||
|
.external_cash_flow_total
|
||||||
|
.checked_add(amount)
|
||||||
|
.ok_or_else(|| "fixed external flow overflow".to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unit_nav(&self, total_equity: FixedMoney) -> Result<FixedMoney, String> {
|
||||||
|
if self.units.raw() <= 0 {
|
||||||
|
return Err("fixed account has no units".to_string());
|
||||||
|
}
|
||||||
|
let raw = total_equity
|
||||||
|
.raw()
|
||||||
|
.checked_mul(MONEY_SCALE)
|
||||||
|
.and_then(|value| value.checked_div(self.units.raw()))
|
||||||
|
.ok_or_else(|| "fixed unit NAV overflow".to_string())?;
|
||||||
|
Ok(FixedMoney::from_raw(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cost::{ChinaAShareCostModel, CostModel};
|
||||||
|
use crate::risk_control::TradingConstraintConfig;
|
||||||
|
|
||||||
|
fn fixed_model() -> FixedChinaAShareCostModel {
|
||||||
|
let config = TradingConstraintConfig::default();
|
||||||
|
FixedChinaAShareCostModel {
|
||||||
|
commission_rate: FixedMoney::from_f64(config.commission_rate).unwrap(),
|
||||||
|
stamp_tax_rate_before_change: FixedMoney::from_f64(config.stamp_tax_rate_before_change)
|
||||||
|
.unwrap(),
|
||||||
|
stamp_tax_rate_after_change: FixedMoney::from_f64(config.stamp_tax_rate_after_change)
|
||||||
|
.unwrap(),
|
||||||
|
stamp_tax_change_date: config.stamp_tax_change_date,
|
||||||
|
minimum_commission: FixedMoney::from_f64(config.minimum_commission).unwrap(),
|
||||||
|
transfer_fee_rate: FixedMoney::from_f64(config.transfer_fee_rate).unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decimal_parser_rounds_only_beyond_money_scale() {
|
||||||
|
assert_eq!(
|
||||||
|
FixedMoney::from_decimal_str("1.234567").unwrap().raw(),
|
||||||
|
1_234_567
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FixedMoney::from_decimal_str("1.2345675").unwrap().raw(),
|
||||||
|
1_234_568
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
FixedMoney::from_decimal_str("-0.0000014").unwrap().raw(),
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_cost_matches_float_cost_model_within_one_micro_yuan() {
|
||||||
|
let fixed = fixed_model();
|
||||||
|
let float = ChinaAShareCostModel::default();
|
||||||
|
let dates = [
|
||||||
|
NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
|
||||||
|
NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(),
|
||||||
|
];
|
||||||
|
for gross in [0.01, 10.0, 16_666.67, 248_059.812, 1_000_000.01] {
|
||||||
|
let fixed_gross = FixedMoney::from_f64(gross).unwrap();
|
||||||
|
for date in dates {
|
||||||
|
for side in [OrderSide::Buy, OrderSide::Sell] {
|
||||||
|
let expected = float.calculate(date, side, gross);
|
||||||
|
let actual = fixed.calculate(date, side, fixed_gross);
|
||||||
|
for (actual, expected) in [
|
||||||
|
(actual.commission, expected.commission),
|
||||||
|
(actual.stamp_tax, expected.stamp_tax),
|
||||||
|
(actual.transfer_fee, expected.transfer_fee),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
(actual.to_f64() - expected).abs() <= 1.0 / MONEY_SCALE_F64,
|
||||||
|
"fixed={} float={} gross={} date={date} side={side:?}",
|
||||||
|
actual.to_f64(),
|
||||||
|
expected,
|
||||||
|
gross
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_order_commission_state_matches_float_order_split() {
|
||||||
|
let fixed = fixed_model();
|
||||||
|
let float = ChinaAShareCostModel::default();
|
||||||
|
let mut fixed_state = BTreeMap::new();
|
||||||
|
let mut float_state = BTreeMap::new();
|
||||||
|
let mut fixed_total = FixedMoney::ZERO;
|
||||||
|
let mut float_total = 0.0;
|
||||||
|
for gross in [1000.0, 2000.0, 4000.0, 40_000.0] {
|
||||||
|
let fixed_fee = fixed.commission_for_order_fill(
|
||||||
|
FixedMoney::from_f64(gross).unwrap(),
|
||||||
|
Some(42),
|
||||||
|
&mut fixed_state,
|
||||||
|
);
|
||||||
|
let float_fee = float.commission_for_order_fill(gross, Some(42), &mut float_state);
|
||||||
|
fixed_total = fixed_total.checked_add(fixed_fee).unwrap();
|
||||||
|
float_total += float_fee;
|
||||||
|
}
|
||||||
|
assert!((fixed_total.to_f64() - float_total).abs() <= 4.0 / MONEY_SCALE_F64);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_budget_never_exceeds_cash_after_cost() {
|
||||||
|
let model = fixed_model();
|
||||||
|
let date = NaiveDate::from_ymd_opt(2025, 2, 3).unwrap();
|
||||||
|
let cash = FixedMoney::from_decimal_str("99880.00").unwrap();
|
||||||
|
let price = FixedMoney::from_decimal_str("19.9731").unwrap();
|
||||||
|
let mut quantity = 5_000u64;
|
||||||
|
while quantity > 0 {
|
||||||
|
let gross = price.checked_mul_quantity(quantity).unwrap();
|
||||||
|
if gross
|
||||||
|
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
|
||||||
|
.unwrap()
|
||||||
|
<= cash
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
quantity -= 100;
|
||||||
|
}
|
||||||
|
let gross = price.checked_mul_quantity(quantity).unwrap();
|
||||||
|
let total = gross
|
||||||
|
.checked_add(model.calculate(date, OrderSide::Buy, gross).total())
|
||||||
|
.unwrap();
|
||||||
|
assert!(total <= cash);
|
||||||
|
assert!(quantity < 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fixed_fifo_pnl_and_external_flow_are_deterministic() {
|
||||||
|
let day_one = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
|
||||||
|
let day_two = NaiveDate::from_ymd_opt(2025, 1, 3).unwrap();
|
||||||
|
let mut book = FixedLotBook::default();
|
||||||
|
book.buy(day_one, 100, FixedMoney::from_decimal_str("10.01").unwrap());
|
||||||
|
book.buy(day_two, 100, FixedMoney::from_decimal_str("10.03").unwrap());
|
||||||
|
let realized = book
|
||||||
|
.sell(150, FixedMoney::from_decimal_str("10.11").unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(realized.raw(), 14_000_000);
|
||||||
|
assert_eq!(book.quantity, 50);
|
||||||
|
assert_eq!(
|
||||||
|
book.unrealized_pnl(FixedMoney::from_decimal_str("10.20").unwrap())
|
||||||
|
.raw(),
|
||||||
|
8_500_000
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut account = FixedAccount::new(FixedMoney::from_decimal_str("100.00").unwrap());
|
||||||
|
account
|
||||||
|
.apply_external_cash_flow(
|
||||||
|
FixedMoney::from_decimal_str("50.00").unwrap(),
|
||||||
|
FixedMoney::from_decimal_str("1.00").unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(account.units.raw(), 150 * MONEY_SCALE);
|
||||||
|
assert_eq!(
|
||||||
|
account
|
||||||
|
.unit_nav(FixedMoney::from_decimal_str("150.00").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.raw(),
|
||||||
|
MONEY_SCALE
|
||||||
|
);
|
||||||
|
assert_eq!(account.external_cash_flow_total.raw(), 50 * MONEY_SCALE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ pub mod data;
|
|||||||
pub mod engine;
|
pub mod engine;
|
||||||
pub mod event_bus;
|
pub mod event_bus;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
|
pub mod fixed_point;
|
||||||
pub mod futures;
|
pub mod futures;
|
||||||
pub mod instrument;
|
pub mod instrument;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
mod numeric_expr_vm;
|
||||||
pub mod platform_expr_strategy;
|
pub mod platform_expr_strategy;
|
||||||
pub mod platform_runtime_schema;
|
pub mod platform_runtime_schema;
|
||||||
pub mod platform_strategy_spec;
|
pub mod platform_strategy_spec;
|
||||||
@@ -42,6 +44,10 @@ pub use events::{
|
|||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
};
|
};
|
||||||
|
pub use fixed_point::{
|
||||||
|
FixedAccount, FixedChinaAShareCostModel, FixedLotBook, FixedMoney, FixedTradingCost,
|
||||||
|
MONEY_SCALE,
|
||||||
|
};
|
||||||
pub use futures::{
|
pub use futures::{
|
||||||
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
||||||
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
|
FuturesExecutionReport, FuturesOrderIntent, FuturesPosition, FuturesPositionEffect,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -756,6 +756,8 @@ pub struct StrategyExpressionTradingConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub subscription_guard_required: Option<bool>,
|
pub subscription_guard_required: Option<bool>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub subscriptions: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub actions: Vec<StrategyExpressionActionConfig>,
|
pub actions: Vec<StrategyExpressionActionConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1389,7 +1391,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
signal_symbol: &str,
|
signal_symbol: &str,
|
||||||
strategy_spec: Option<&StrategyRuntimeSpec>,
|
strategy_spec: Option<&StrategyRuntimeSpec>,
|
||||||
) -> Result<PlatformExprStrategyConfig, String> {
|
) -> Result<PlatformExprStrategyConfig, String> {
|
||||||
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
|
let mut cfg = PlatformExprStrategyConfig::generic();
|
||||||
cfg.strategy_name = strategy_id.to_string();
|
cfg.strategy_name = strategy_id.to_string();
|
||||||
if !signal_symbol.trim().is_empty() {
|
if !signal_symbol.trim().is_empty() {
|
||||||
cfg.signal_symbol = signal_symbol.trim().to_string();
|
cfg.signal_symbol = signal_symbol.trim().to_string();
|
||||||
@@ -1620,6 +1622,17 @@ pub fn platform_expr_config_from_spec(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
{
|
{
|
||||||
cfg.selection_limit_expr = expr.clone();
|
cfg.selection_limit_expr = expr.clone();
|
||||||
|
if let Ok(limit) = expr.trim().parse::<usize>()
|
||||||
|
&& limit > 0
|
||||||
|
&& spec
|
||||||
|
.engine_config
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|engine| engine.rank_limit)
|
||||||
|
.filter(|value| *value > 0)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
cfg.max_positions = limit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some(expr) = selection
|
if let Some(expr) = selection
|
||||||
.candidate_limit_expr
|
.candidate_limit_expr
|
||||||
@@ -1844,9 +1857,16 @@ pub fn platform_expr_config_from_spec(
|
|||||||
if let Some(required) = trading.subscription_guard_required {
|
if let Some(required) = trading.subscription_guard_required {
|
||||||
cfg.subscription_guard_required = required;
|
cfg.subscription_guard_required = required;
|
||||||
}
|
}
|
||||||
|
cfg.initial_subscriptions = trading
|
||||||
|
.subscriptions
|
||||||
|
.iter()
|
||||||
|
.map(|symbol| symbol.trim().to_ascii_uppercase())
|
||||||
|
.filter(|symbol| !symbol.is_empty())
|
||||||
|
.collect();
|
||||||
if let Some(stage) = trading.stage.as_deref().map(str::trim) {
|
if let Some(stage) = trading.stage.as_deref().map(str::trim) {
|
||||||
cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() {
|
cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() {
|
||||||
"open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction,
|
"open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction,
|
||||||
|
"minute" | "on_minute" | "on-minute" => PlatformExplicitActionStage::Minute,
|
||||||
_ => PlatformExplicitActionStage::OnDay,
|
_ => PlatformExplicitActionStage::OnDay,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2006,6 +2026,10 @@ fn parse_platform_rebalance_schedule(
|
|||||||
let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase();
|
let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase();
|
||||||
let time_rule = parse_schedule_time_rule(schedule);
|
let time_rule = parse_schedule_time_rule(schedule);
|
||||||
match frequency.as_str() {
|
match frequency.as_str() {
|
||||||
|
"daily" => Some(PlatformRebalanceSchedule {
|
||||||
|
frequency: PlatformScheduleFrequency::Daily,
|
||||||
|
time_rule,
|
||||||
|
}),
|
||||||
"weekly" => Some(PlatformRebalanceSchedule {
|
"weekly" => Some(PlatformRebalanceSchedule {
|
||||||
frequency: PlatformScheduleFrequency::Weekly {
|
frequency: PlatformScheduleFrequency::Weekly {
|
||||||
weekday: schedule.weekday,
|
weekday: schedule.weekday,
|
||||||
@@ -2491,6 +2515,73 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"strategyId": "minute_runtime_strategy",
|
||||||
|
"signalSymbol": "000300.SH",
|
||||||
|
"benchmark": {"instrumentId": "000300.SH"},
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"selection": {"limitExpr": "1"},
|
||||||
|
"trading": {
|
||||||
|
"stage": "minute",
|
||||||
|
"subscriptions": ["000001.sz", "000002.SZ"],
|
||||||
|
"schedule": {"frequency": "daily", "time": "10:18"},
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"kind": "target_percent",
|
||||||
|
"symbol": "000001.SZ",
|
||||||
|
"amountExpr": "0.5",
|
||||||
|
"reason": "minute_target"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("minute config");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.explicit_action_stage,
|
||||||
|
PlatformExplicitActionStage::Minute
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.initial_subscriptions,
|
||||||
|
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
|
||||||
|
);
|
||||||
|
let schedule = cfg.explicit_action_schedule.expect("minute schedule");
|
||||||
|
assert_eq!(schedule.frequency, PlatformScheduleFrequency::Daily);
|
||||||
|
assert_eq!(
|
||||||
|
schedule.time_rule,
|
||||||
|
Some(ScheduleTimeRule::physical_time(10, 18))
|
||||||
|
);
|
||||||
|
assert_eq!(cfg.explicit_actions.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_expression_parser_does_not_inherit_microcap_template_defaults() {
|
||||||
|
let spec = serde_json::json!({
|
||||||
|
"strategyId": "generic_runtime_strategy",
|
||||||
|
"signalSymbol": "000300.SH",
|
||||||
|
"benchmark": { "instrumentId": "000300.SH" },
|
||||||
|
"runtimeExpressions": {
|
||||||
|
"selection": { "limitExpr": "7" },
|
||||||
|
"trading": { "rotationEnabled": true }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("generic config");
|
||||||
|
|
||||||
|
assert_eq!(cfg.strategy_name, "generic_runtime_strategy");
|
||||||
|
assert_eq!(cfg.max_positions, 7);
|
||||||
|
assert_eq!(cfg.selection_limit_expr, "7");
|
||||||
|
assert_eq!(cfg.market_cap_lower_expr, "0.0");
|
||||||
|
assert_eq!(cfg.market_cap_upper_expr, "1.0e30");
|
||||||
|
assert!(cfg.stock_filter_expr.is_empty());
|
||||||
|
assert!(cfg.prelude.is_empty());
|
||||||
|
assert_eq!(cfg.refresh_rate, 1);
|
||||||
|
assert!(!cfg.daily_position_target_adjust_enabled);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn engine_config_parses_weak_market_shrink_overweight_threshold() {
|
fn engine_config_parses_weak_market_shrink_overweight_threshold() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
@@ -3164,7 +3255,13 @@ mod tests {
|
|||||||
|
|
||||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
let cfg = platform_expr_config_from_value("", "", &spec).expect("config");
|
||||||
|
|
||||||
assert_eq!(cfg.rebalance_schedule, None);
|
assert_eq!(
|
||||||
|
cfg.rebalance_schedule,
|
||||||
|
Some(PlatformRebalanceSchedule {
|
||||||
|
frequency: PlatformScheduleFrequency::Daily,
|
||||||
|
time_rule: Some(ScheduleTimeRule::MinuteOfDay(9 * 60 + 33)),
|
||||||
|
})
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.intraday_execution_time,
|
cfg.intraday_execution_time,
|
||||||
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
|
Some(NaiveTime::from_hms_opt(9, 33, 0).unwrap())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use indexmap::IndexMap;
|
use indexmap::IndexMap;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use crate::data::{DataSet, DataSetError, PriceField};
|
use crate::data::{DataSet, DataSetError, PriceField};
|
||||||
@@ -1692,7 +1692,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HoldingSummary {
|
pub struct HoldingSummary {
|
||||||
#[serde(with = "date_format")]
|
#[serde(with = "date_format")]
|
||||||
pub date: NaiveDate,
|
pub date: NaiveDate,
|
||||||
@@ -1730,7 +1730,7 @@ pub struct CashReceivable {
|
|||||||
|
|
||||||
mod date_format {
|
mod date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::Serializer;
|
use serde::{Deserialize, Deserializer, Serializer};
|
||||||
|
|
||||||
const FORMAT: &str = "%Y-%m-%d";
|
const FORMAT: &str = "%Y-%m-%d";
|
||||||
|
|
||||||
@@ -1740,6 +1740,14 @@ mod date_format {
|
|||||||
{
|
{
|
||||||
serializer.serialize_str(&date.format(FORMAT).to_string())
|
serializer.serialize_str(&date.format(FORMAT).to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn deserialize<'de, D>(deserializer: D) -> Result<NaiveDate, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
NaiveDate::parse_from_str(&value, FORMAT).map_err(serde::de::Error::custom)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn round_half_up_u32(value: f64) -> u32 {
|
fn round_half_up_u32(value: f64) -> u32 {
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ use crate::universe::{DynamicMarketCapBandSelector, SelectionContext, UniverseSe
|
|||||||
|
|
||||||
pub trait Strategy {
|
pub trait Strategy {
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
fn initial_subscriptions(&self) -> BTreeSet<String> {
|
||||||
|
BTreeSet::new()
|
||||||
|
}
|
||||||
|
fn requires_minute_callbacks(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
fn management_fee(
|
fn management_fee(
|
||||||
&mut self,
|
&mut self,
|
||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use chrono::{NaiveDate, NaiveDateTime};
|
use chrono::{NaiveDate, NaiveDateTime};
|
||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
|
BacktestConfig, BacktestEngine, BacktestProcessMod, BacktestProcessModLoader,
|
||||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, FuturesAccountState,
|
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, ExecutionQuoteRequest,
|
||||||
FuturesCommissionType, FuturesContractSpec, FuturesDirection, FuturesOrderIntent,
|
FuturesAccountState, FuturesCommissionType, FuturesContractSpec, FuturesDirection,
|
||||||
FuturesTradingParameter, FuturesValidationConfig, Instrument, IntradayExecutionQuote,
|
FuturesOrderIntent, FuturesTradingParameter, FuturesValidationConfig, Instrument,
|
||||||
IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent, OrderSide, OrderStatus,
|
IntradayExecutionQuote, IntradayOrderBookDepthLevel, MatchingType, OpenOrderView, OrderIntent,
|
||||||
PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState, PriceField, ProcessEvent,
|
OrderSide, OrderStatus, PlatformExprStrategy, PlatformExprStrategyConfig, PortfolioState,
|
||||||
ProcessEventBus, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
|
PriceField, ProcessEvent, ProcessEventBus, 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 {
|
||||||
@@ -634,6 +635,9 @@ struct UniverseDirectiveStrategy {
|
|||||||
|
|
||||||
struct MinuteProbeStrategy {
|
struct MinuteProbeStrategy {
|
||||||
seen_ticks: Rc<RefCell<Vec<String>>>,
|
seen_ticks: Rc<RefCell<Vec<String>>>,
|
||||||
|
scheduled_count: Rc<RefCell<usize>>,
|
||||||
|
subscribe_symbols: BTreeSet<String>,
|
||||||
|
minute_callbacks: bool,
|
||||||
ordered: bool,
|
ordered: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,6 +813,26 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
"minute-probe"
|
"minute-probe"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn requires_minute_callbacks(&self) -> bool {
|
||||||
|
self.minute_callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_rules(&self) -> Vec<ScheduleRule> {
|
||||||
|
vec![
|
||||||
|
ScheduleRule::daily("minute_barrier", ScheduleStage::Minute)
|
||||||
|
.with_time_rule(ScheduleTimeRule::physical_time(10, 18)),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_scheduled(
|
||||||
|
&mut self,
|
||||||
|
_ctx: &StrategyContext<'_>,
|
||||||
|
_rule: &ScheduleRule,
|
||||||
|
) -> Result<StrategyDecision, fidc_core::BacktestError> {
|
||||||
|
*self.scheduled_count.borrow_mut() += 1;
|
||||||
|
Ok(StrategyDecision::default())
|
||||||
|
}
|
||||||
|
|
||||||
fn on_day(
|
fn on_day(
|
||||||
&mut self,
|
&mut self,
|
||||||
_ctx: &StrategyContext<'_>,
|
_ctx: &StrategyContext<'_>,
|
||||||
@@ -818,7 +842,7 @@ impl Strategy for MinuteProbeStrategy {
|
|||||||
target_weights: BTreeMap::new(),
|
target_weights: BTreeMap::new(),
|
||||||
exit_symbols: BTreeSet::new(),
|
exit_symbols: BTreeSet::new(),
|
||||||
order_intents: vec![OrderIntent::Subscribe {
|
order_intents: vec![OrderIntent::Subscribe {
|
||||||
symbols: BTreeSet::from(["000001.SZ".to_string()]),
|
symbols: self.subscribe_symbols.clone(),
|
||||||
reason: "subscribe_minute_probe".to_string(),
|
reason: "subscribe_minute_probe".to_string(),
|
||||||
}],
|
}],
|
||||||
notes: Vec::new(),
|
notes: Vec::new(),
|
||||||
@@ -2011,6 +2035,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
amount_delta: 10_200.0,
|
amount_delta: 10_200.0,
|
||||||
trading_phase: Some("continuous".to_string()),
|
trading_phase: Some("continuous".to_string()),
|
||||||
},
|
},
|
||||||
|
IntradayExecutionQuote {
|
||||||
|
date,
|
||||||
|
symbol: "000002.SZ".to_string(),
|
||||||
|
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||||
|
last_price: 20.4,
|
||||||
|
bid1: 20.3,
|
||||||
|
ask1: 20.4,
|
||||||
|
bid1_volume: 1_000,
|
||||||
|
ask1_volume: 1_000,
|
||||||
|
volume_delta: 1_000,
|
||||||
|
amount_delta: 20_400.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
},
|
||||||
IntradayExecutionQuote {
|
IntradayExecutionQuote {
|
||||||
date,
|
date,
|
||||||
symbol: "000001.SZ".to_string(),
|
symbol: "000001.SZ".to_string(),
|
||||||
@@ -2029,8 +2066,12 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
.expect("dataset");
|
.expect("dataset");
|
||||||
|
|
||||||
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
|
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let scheduled_count = Rc::new(RefCell::new(0usize));
|
||||||
let strategy = MinuteProbeStrategy {
|
let strategy = MinuteProbeStrategy {
|
||||||
seen_ticks: seen_ticks.clone(),
|
seen_ticks: seen_ticks.clone(),
|
||||||
|
scheduled_count: scheduled_count.clone(),
|
||||||
|
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()]),
|
||||||
|
minute_callbacks: true,
|
||||||
ordered: false,
|
ordered: false,
|
||||||
};
|
};
|
||||||
let broker = BrokerSimulator::new_with_execution_price(
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
@@ -2038,6 +2079,8 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
ChinaEquityRuleHooks::default(),
|
ChinaEquityRuleHooks::default(),
|
||||||
PriceField::Last,
|
PriceField::Last,
|
||||||
);
|
);
|
||||||
|
let loader_requests = Arc::new(Mutex::new(Vec::<ExecutionQuoteRequest>::new()));
|
||||||
|
let loader_requests_for_callback = Arc::clone(&loader_requests);
|
||||||
let mut engine = BacktestEngine::new(
|
let mut engine = BacktestEngine::new(
|
||||||
data,
|
data,
|
||||||
strategy,
|
strategy,
|
||||||
@@ -2050,7 +2093,11 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
decision_lag_trading_days: 0,
|
decision_lag_trading_days: 0,
|
||||||
execution_price_field: PriceField::Last,
|
execution_price_field: PriceField::Last,
|
||||||
},
|
},
|
||||||
);
|
)
|
||||||
|
.with_execution_quote_loader(move |request| {
|
||||||
|
loader_requests_for_callback.lock().unwrap().push(request);
|
||||||
|
Ok(Vec::new())
|
||||||
|
});
|
||||||
|
|
||||||
let result = engine.run().expect("backtest run");
|
let result = engine.run().expect("backtest run");
|
||||||
|
|
||||||
@@ -2058,9 +2105,19 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
seen_ticks.borrow().as_slice(),
|
seen_ticks.borrow().as_slice(),
|
||||||
[
|
[
|
||||||
"000001.SZ:10:18:00:true:visible=10.20:previous=",
|
"000001.SZ:10:18:00:true:visible=10.20:previous=",
|
||||||
|
"000002.SZ:10:18:00:true:visible=20.40:previous=",
|
||||||
"000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20"
|
"000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
assert_eq!(*scheduled_count.borrow(), 1);
|
||||||
|
let loader_requests = loader_requests.lock().unwrap();
|
||||||
|
assert_eq!(loader_requests.len(), 1);
|
||||||
|
assert_eq!(loader_requests[0].start_time, None);
|
||||||
|
assert_eq!(loader_requests[0].end_time, None);
|
||||||
|
assert_eq!(
|
||||||
|
loader_requests[0].symbols,
|
||||||
|
BTreeSet::from(["000001.SZ".to_string(), "000002.SZ".to_string()])
|
||||||
|
);
|
||||||
assert_eq!(result.fills.len(), 1);
|
assert_eq!(result.fills.len(), 1);
|
||||||
assert_eq!(result.fills[0].reason, "minute_buy");
|
assert_eq!(result.fills[0].reason, "minute_buy");
|
||||||
assert_eq!(result.fills[0].quantity, 100);
|
assert_eq!(result.fills[0].quantity, 100);
|
||||||
@@ -2082,6 +2139,90 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
|
|||||||
.iter()
|
.iter()
|
||||||
.any(|event| event.kind == ProcessEventKind::PostMinute)
|
.any(|event| event.kind == ProcessEventKind::PostMinute)
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.process_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.kind == ProcessEventKind::PreMinute)
|
||||||
|
.count(),
|
||||||
|
2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
|
||||||
|
let date = d(2025, 1, 2);
|
||||||
|
let mut data = single_day_anchor_data(date);
|
||||||
|
data.add_execution_quotes(vec![
|
||||||
|
IntradayExecutionQuote {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: dt(2025, 1, 2, 10, 18, 0),
|
||||||
|
last_price: 10.2,
|
||||||
|
bid1: 10.1,
|
||||||
|
ask1: 10.2,
|
||||||
|
bid1_volume: 1_000,
|
||||||
|
ask1_volume: 1_000,
|
||||||
|
volume_delta: 1_000,
|
||||||
|
amount_delta: 10_200.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
},
|
||||||
|
IntradayExecutionQuote {
|
||||||
|
date,
|
||||||
|
symbol: "000001.SZ".to_string(),
|
||||||
|
timestamp: dt(2025, 1, 2, 10, 19, 0),
|
||||||
|
last_price: 10.3,
|
||||||
|
bid1: 10.2,
|
||||||
|
ask1: 10.3,
|
||||||
|
bid1_volume: 1_000,
|
||||||
|
ask1_volume: 1_000,
|
||||||
|
volume_delta: 1_000,
|
||||||
|
amount_delta: 10_300.0,
|
||||||
|
trading_phase: Some("continuous".to_string()),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
let seen_ticks = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let scheduled_count = Rc::new(RefCell::new(0usize));
|
||||||
|
let strategy = MinuteProbeStrategy {
|
||||||
|
seen_ticks: seen_ticks.clone(),
|
||||||
|
scheduled_count: scheduled_count.clone(),
|
||||||
|
subscribe_symbols: BTreeSet::from(["000001.SZ".to_string()]),
|
||||||
|
minute_callbacks: false,
|
||||||
|
ordered: false,
|
||||||
|
};
|
||||||
|
let broker = BrokerSimulator::new_with_execution_price(
|
||||||
|
ChinaAShareCostModel::default(),
|
||||||
|
ChinaEquityRuleHooks::default(),
|
||||||
|
PriceField::Last,
|
||||||
|
);
|
||||||
|
let mut engine = BacktestEngine::new(
|
||||||
|
data,
|
||||||
|
strategy,
|
||||||
|
broker,
|
||||||
|
BacktestConfig {
|
||||||
|
initial_cash: 10_000.0,
|
||||||
|
benchmark_code: "000300.SH".to_string(),
|
||||||
|
start_date: Some(date),
|
||||||
|
end_date: Some(date),
|
||||||
|
decision_lag_trading_days: 0,
|
||||||
|
execution_price_field: PriceField::Last,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.with_execution_quote_loader(|_| Ok(Vec::new()));
|
||||||
|
|
||||||
|
let result = engine.run().expect("scheduled-only minute run");
|
||||||
|
|
||||||
|
assert!(seen_ticks.borrow().is_empty());
|
||||||
|
assert_eq!(*scheduled_count.borrow(), 1);
|
||||||
|
assert!(result.fills.is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.process_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.kind == ProcessEventKind::PreMinute)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user