Add persistent limit orders and cancel semantics
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ pub enum OrderSide {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub enum OrderStatus {
|
pub enum OrderStatus {
|
||||||
|
Pending,
|
||||||
Filled,
|
Filled,
|
||||||
PartiallyFilled,
|
PartiallyFilled,
|
||||||
Canceled,
|
Canceled,
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ pub enum OrderIntent {
|
|||||||
quantity: i32,
|
quantity: i32,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
LimitShares {
|
||||||
|
symbol: String,
|
||||||
|
quantity: i32,
|
||||||
|
limit_price: f64,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
Lots {
|
Lots {
|
||||||
symbol: String,
|
symbol: String,
|
||||||
lots: i32,
|
lots: i32,
|
||||||
@@ -114,6 +120,17 @@ pub enum OrderIntent {
|
|||||||
target_percent: f64,
|
target_percent: f64,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
CancelOrder {
|
||||||
|
order_id: u64,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
CancelSymbol {
|
||||||
|
symbol: String,
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
|
CancelAll {
|
||||||
|
reason: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use chrono::NaiveDate;
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
|
||||||
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
|
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, Instrument,
|
||||||
IntradayExecutionQuote, MatchingType, OrderIntent, PortfolioState, PriceField,
|
IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState, PriceField,
|
||||||
ProcessEventKind, SlippageModel, StrategyDecision,
|
ProcessEventKind, SlippageModel, StrategyDecision,
|
||||||
};
|
};
|
||||||
use std::collections::{BTreeMap, BTreeSet};
|
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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user