fix(stock-pool): separate cumulative condition facts from quote capacity

This commit is contained in:
boris
2026-09-12 18:50:12 +08:00
parent 61bd14d001
commit 875e31f71f
12 changed files with 407 additions and 133 deletions
@@ -196,7 +196,7 @@ fn engine_uses_preplanned_decision_symbols_without_recomputing_strategy_plan() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 17, 59)),
@@ -411,7 +411,7 @@ fn engine_preloads_declared_decision_quotes_for_current_positions() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(t(10, 39, 59)),
@@ -556,7 +556,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: first,
symbol: "000001.SZ".to_string(),
timestamp: first.and_time(t(10, 39, 59)),
@@ -569,7 +569,7 @@ fn engine_reuses_preloaded_decision_quotes_without_loader_call() {
amount_delta: 100_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: second,
symbol: "000001.SZ".to_string(),
timestamp: second.and_time(t(10, 39, 59)),
@@ -826,7 +826,7 @@ fn engine_loads_distinct_decision_quote_times_on_same_day() {
Ok(request
.symbols
.into_iter()
.map(|symbol| IntradayExecutionQuote {
.map(|symbol| IntradayExecutionQuote { observation_kind: Default::default(),
date: request.date,
symbol,
timestamp: request.date.and_time(start_time) - Duration::seconds(1),
+8 -8
View File
@@ -2209,7 +2209,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2222,7 +2222,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2235,7 +2235,7 @@ fn engine_runs_minute_hooks_and_executes_minute_orders() {
amount_delta: 20_400.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0),
@@ -2341,7 +2341,7 @@ 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 {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 18, 0),
@@ -2354,7 +2354,7 @@ fn engine_skips_empty_platform_style_minute_callbacks_between_schedule_times() {
amount_delta: 10_200.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 2, 10, 19, 0),
@@ -2519,7 +2519,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
})
.collect::<Vec<_>>();
let quotes = vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: date2,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 3, 14, 30, 0),
@@ -2532,7 +2532,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
amount_delta: 10_150.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: date3,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 18, 0),
@@ -2545,7 +2545,7 @@ fn strategy_context_exposes_engine_native_data_helpers() {
amount_delta: 10_250.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date: date3,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 19, 0),
+25 -25
View File
@@ -146,7 +146,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -159,7 +159,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 19, 0).unwrap(),
@@ -172,7 +172,7 @@ fn intraday_liquidity_data(date: NaiveDate, symbol: &str) -> DataSet {
amount_delta: 10_000.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 20, 0).unwrap(),
@@ -373,7 +373,7 @@ fn broker_executes_explicit_order_value_buy() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -527,7 +527,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
@@ -663,7 +663,7 @@ fn broker_executes_order_shares_and_order_lots() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -1104,7 +1104,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 31, 0).unwrap(),
@@ -1117,7 +1117,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 35, 0).unwrap(),
@@ -1920,7 +1920,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -2153,7 +2153,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(9, 33, 0).unwrap(),
@@ -2273,7 +2273,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 0).unwrap(),
@@ -2509,7 +2509,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2522,7 +2522,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2682,7 +2682,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2695,7 +2695,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2839,7 +2839,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 17, 59).unwrap(),
@@ -2852,7 +2852,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -2865,7 +2865,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 6).unwrap(),
@@ -2878,7 +2878,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 40).unwrap(),
@@ -3001,7 +3001,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
}],
Vec::new(),
vec![
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 0, 0).unwrap(),
@@ -3014,7 +3014,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 15, 0).unwrap(),
@@ -3027,7 +3027,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
amount_delta: 0.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 30, 0).unwrap(),
@@ -3165,7 +3165,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -3284,7 +3284,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
volume: 1_000_000,
}],
Vec::new(),
vec![IntradayExecutionQuote {
vec![IntradayExecutionQuote { observation_kind: Default::default(),
date,
symbol: "000002.SZ".to_string(),
timestamp: date.and_hms_opt(10, 18, 3).unwrap(),
@@ -4915,7 +4915,7 @@ fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
},
],
Vec::new(),
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote {
[(day1, day1_open), (day2, day2_open)].into_iter().map(|(date, price)| IntradayExecutionQuote { observation_kind: Default::default(),
date, symbol: "000002.SZ".into(), timestamp: date.and_hms_opt(9, 30, 0).unwrap(),
last_price: price, bid1: price, ask1: price, bid1_volume: 0, ask1_volume: 0,
volume_delta: 100_000, amount_delta: 100_000.0 * price,
@@ -55,7 +55,7 @@ fn dataset(day_count: usize, bars_per_day: usize) -> (DataSet, Vec<NaiveDate>) {
let session_start = date.and_hms_opt(9, 30, 0).expect("valid session start");
for offset in 0..bars_per_day {
let timestamp = session_start + Duration::minutes(offset as i64);
quotes.push(IntradayExecutionQuote {
quotes.push(IntradayExecutionQuote { observation_kind: Default::default(),
date: *date,
symbol: SYMBOL.to_string(),
timestamp,
@@ -7,6 +7,7 @@ use fidc_core::{
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
};
use rust_decimal::Decimal;
use fidc_core::IntradayExecutionQuote;
use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate {
@@ -142,7 +143,7 @@ fn data_with_fund_rules(
})
})
.collect();
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote {
let quotes = market.iter().filter(|row| row.symbol != "000300.SH").map(|row| fidc_core::IntradayExecutionQuote { observation_kind: Default::default(),
date: row.date, symbol: row.symbol.to_string(), timestamp: row.date.and_hms_opt(9, 30, 0).unwrap(),
last_price: row.open, bid1: row.open, ask1: row.open, bid1_volume: 0, ask1_volume: 0,
volume_delta: row.volume, amount_delta: row.open * row.volume as f64,
@@ -489,6 +490,72 @@ fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
}
#[test]
fn daily_execution_price_does_not_satisfy_an_unobserved_order_book_condition() {
let data = data(false);
for field in ["bid1", "ask1"] {
let broker = broker(false);
let mut account = PortfolioState::new(30000.);
let mut intent = contract(day(2), 1, false);
intent.rule.trigger_mode = "condition".into();
intent.rule.buy_condition = format!("{field}>0");
let result = broker.execute_with_event_dates(day(5), day(2), day(2), &mut account, &data, &decision(intent));
assert!(result.unwrap_err().to_string().contains(field));
assert!(account.positions().is_empty());
assert_eq!(account.cash(), 30000.);
}
}
#[test]
fn cumulative_conditions_do_not_consume_future_bars_or_inflate_fill_capacity() {
let mut data = data(false);
let mut quotes = Vec::new();
for n in 1..=2 {
let price = if n == 1 {20.} else {10.};
for (minute, volume) in [(30,600), (31,0), (32,400)] {
quotes.push(IntradayExecutionQuote {
observation_kind: fidc_core::data::QuoteObservationKind::MinuteBar,
date: day(5), symbol: code(n), timestamp: day(5).and_hms_opt(9,minute,0).unwrap(),
last_price: price, bid1: 0., ask1: 0., bid1_volume: 0, ask1_volume: 0,
volume_delta: volume, amount_delta: volume as f64 * price, trading_phase: None,
});
}
}
data.replace_execution_quotes(quotes.clone());
let at = chrono::NaiveTime::from_hms_opt(9,32,0).unwrap();
for condition in ["volume>=1000", "amount>=20000"] {
let broker=broker(true).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
let mut account=PortfolioState::new(30000.);
let mut intent=contract(day(5),1,false);
intent.rule.buy_condition=condition.into();intent.rule.trigger_mode="condition".into();
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
assert_eq!(report.fill_events.iter().map(|fill|fill.quantity).sum::<u32>(),100,"{condition}: {report:?}");
assert_eq!(data.execution_quotes_on(day(5),&code(1))[2].volume_delta,400);
}
let mut future=quotes.last().unwrap().clone();future.symbol=code(1);future.timestamp=day(5).and_hms_opt(9,33,0).unwrap();future.volume_delta=9000;future.amount_delta=180000.;
data.add_execution_quotes(vec![future]);
let broker=broker(false).with_matching_type(MatchingType::MinuteLast).with_intraday_execution_start_time(at);
let mut account=PortfolioState::new(30000.);
let mut intent=contract(day(5),1,false);intent.rule.buy_condition="volume>1000".into();intent.rule.trigger_mode="condition".into();
let report=broker.execute_with_event_dates(day(5),day(5),day(5),&mut account,&data,&decision(intent)).unwrap();
assert!(report.fill_events.is_empty(),"future volume must not satisfy this signal: {report:?}");
}
#[test]
fn session_total_cache_is_invalidated_without_mutating_other_dataset_clones() {
let mut original=data(false);
let quote=IntradayExecutionQuote { observation_kind:fidc_core::data::QuoteObservationKind::MinuteBar,date:day(5),symbol:code(1),timestamp:day(5).and_hms_opt(9,30,0).unwrap(),last_price:20.,bid1:0.,ask1:0.,bid1_volume:0,ask1_volume:0,volume_delta:100,amount_delta:2000.,trading_phase:None };
original.replace_execution_quotes(vec![quote.clone()]);
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
let mut changed=original.clone();let mut next=quote.clone();next.timestamp=day(5).and_hms_opt(9,31,0).unwrap();
changed.add_execution_quotes(vec![next.clone()]);
assert_eq!(changed.execution_session_totals(&code(1),next.timestamp).unwrap().0,Decimal::from(200));
assert!(original.execution_session_totals(&code(1),next.timestamp).is_err());
changed.remove_execution_quotes_on_date(day(5));
assert!(changed.execution_session_totals(&code(1),quote.timestamp).is_err());
assert_eq!(original.execution_session_totals(&code(1),quote.timestamp).unwrap().0,Decimal::from(100));
}
#[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false);