Add persistent limit orders and cancel semantics

This commit is contained in:
boris
2026-04-23 03:13:26 -07:00
parent 8aae4941a5
commit 14326c0847
4 changed files with 1020 additions and 116 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -31,6 +31,7 @@ pub enum OrderSide {
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum OrderStatus {
Pending,
Filled,
PartiallyFilled,
Canceled,

View File

@@ -89,6 +89,12 @@ pub enum OrderIntent {
quantity: i32,
reason: String,
},
LimitShares {
symbol: String,
quantity: i32,
limit_price: f64,
reason: String,
},
Lots {
symbol: String,
lots: i32,
@@ -114,6 +120,17 @@ pub enum OrderIntent {
target_percent: f64,
reason: String,
},
CancelOrder {
order_id: u64,
reason: String,
},
CancelSymbol {
symbol: String,
reason: String,
},
CancelAll {
reason: String,
},
}
#[derive(Debug, Clone)]

View File

@@ -2,7 +2,7 @@ use chrono::NaiveDate;
use fidc_core::{
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
IntradayExecutionQuote, MatchingType, OrderIntent, PortfolioState, PriceField,
IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField,
ProcessEventKind, SlippageModel, StrategyDecision,
};
use std::collections::{BTreeMap, BTreeSet};
@@ -2609,3 +2609,258 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
]
);
}
fn two_day_limit_order_data(day1_open: f64, day2_open: f64) -> DataSet {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
DataSet::from_components(
vec![Instrument {
symbol: "000002.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: day1,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-10 09:30:00".to_string()),
day_open: day1_open,
open: day1_open,
high: day1_open + 0.2,
low: day1_open - 0.2,
close: day1_open,
last_price: day1_open,
bid1: day1_open - 0.01,
ask1: day1_open + 0.01,
prev_close: 10.0,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
DailyMarketSnapshot {
date: day2,
symbol: "000002.SZ".to_string(),
timestamp: Some("2024-01-11 09:30:00".to_string()),
day_open: day2_open,
open: day2_open,
high: day2_open + 0.2,
low: day2_open - 0.2,
close: day2_open,
last_price: day2_open,
bid1: day2_open - 0.01,
ask1: day2_open + 0.01,
prev_close: day1_open,
volume: 100_000,
tick_volume: 100_000,
bid1_volume: 80_000,
ask1_volume: 80_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 11.0,
lower_limit: 9.0,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: day1,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
DailyFactorSnapshot {
date: day2,
symbol: "000002.SZ".to_string(),
market_cap_bn: 50.0,
free_float_cap_bn: 45.0,
pe_ttm: 15.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(1.8),
extra_factors: BTreeMap::new(),
},
],
vec![
CandidateEligibility {
date: day1,
symbol: "000002.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,
},
CandidateEligibility {
date: day2,
symbol: "000002.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: day1,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
},
BenchmarkSnapshot {
date: day2,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 100.0,
volume: 1_000_000,
},
],
)
.expect("dataset")
}
#[test]
fn broker_keeps_limit_buy_open_until_price_becomes_marketable() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 9.7);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 9.8,
reason: "limit_entry".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("day1 execution");
assert_eq!(day1_report.fill_events.len(), 0);
assert_eq!(day1_report.order_events.len(), 1);
assert_eq!(day1_report.order_events[0].status, OrderStatus::Pending);
let order_id = day1_report.order_events[0].order_id.expect("order id");
let day2_report = broker
.execute(
day2,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("day2 execution");
assert_eq!(day2_report.fill_events.len(), 1);
assert_eq!(day2_report.fill_events[0].order_id, Some(order_id));
assert_eq!(day2_report.order_events.len(), 1);
assert_eq!(day2_report.order_events[0].status, OrderStatus::Filled);
assert_eq!(day2_report.order_events[0].order_id, Some(order_id));
assert_eq!(
portfolio.position("000002.SZ").expect("position").quantity,
200
);
}
#[test]
fn broker_cancels_open_order_by_order_id() {
let day1 = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let day2 = NaiveDate::from_ymd_opt(2024, 1, 11).unwrap();
let data = two_day_limit_order_data(10.0, 10.1);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut portfolio = PortfolioState::new(1_000_000.0);
let day1_report = broker
.execute(
day1,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::LimitShares {
symbol: "000002.SZ".to_string(),
quantity: 200,
limit_price: 9.8,
reason: "limit_entry".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("day1 execution");
let order_id = day1_report.order_events[0].order_id.expect("order id");
let day2_report = broker
.execute(
day2,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::CancelOrder {
order_id,
reason: "user_cancel".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
},
)
.expect("day2 execution");
assert!(day2_report.fill_events.is_empty());
assert!(
day2_report
.order_events
.iter()
.any(|event| event.order_id == Some(order_id) && event.status == OrderStatus::Canceled)
);
assert!(portfolio.position("000002.SZ").is_none());
}