实现FIDC配置化风控与交易成本

This commit is contained in:
boris
2026-07-02 07:16:47 +08:00
parent 754fc91376
commit 7db0e8da1d
17 changed files with 2689 additions and 249 deletions
+2 -2
View File
@@ -71,11 +71,11 @@ fn aiquant_cost_model_matches_alv_run_options() {
let model = ChinaAShareCostModel::aiquant_default();
let buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 49_978.84);
assert!((buy.commission - 12.49471).abs() < 1e-9);
assert!((buy.commission - 14.993652).abs() < 1e-9);
assert_eq!(buy.stamp_tax, 0.0);
let sell = model.calculate(d(2026, 5, 19), OrderSide::Sell, 100_724.72);
assert!((sell.commission - 25.18118).abs() < 1e-9);
assert!((sell.commission - 30.217416).abs() < 1e-9);
assert!((sell.stamp_tax - 50.36236).abs() < 1e-9);
let small_buy = model.calculate(d(2026, 5, 19), OrderSide::Buy, 1_000.0);
@@ -88,6 +88,7 @@ impl Strategy for BuyAndHoldStrategy {
},
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
+1
View File
@@ -34,6 +34,7 @@ impl Strategy for BuyThenHoldStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
+10
View File
@@ -294,6 +294,7 @@ impl Strategy for HookProbeStrategy {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -336,6 +337,7 @@ impl Strategy for AuctionOrderStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -384,6 +386,7 @@ impl Strategy for FuturesOrderStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -716,6 +719,7 @@ impl Strategy for LimitCarryStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -793,6 +797,7 @@ impl Strategy for UniverseDirectiveStrategy {
order_intents,
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -816,6 +821,7 @@ impl Strategy for MinuteProbeStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -857,6 +863,7 @@ impl Strategy for MinuteProbeStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
}
@@ -958,6 +965,7 @@ impl Strategy for OrderInspectionStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -1015,6 +1023,7 @@ impl Strategy for AccountFlowStrategy {
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
})
}
@@ -3765,6 +3774,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
});
}
Ok(StrategyDecision::default())
+350 -3
View File
@@ -2,8 +2,9 @@ use chrono::{NaiveDate, NaiveTime};
use fidc_core::{
AlgoOrderStyle, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility, ChinaAShareCostModel,
ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet, DynamicSlippageConfig,
Instrument, IntradayExecutionQuote, MatchingType, OrderIntent, OrderStatus, PortfolioState,
PriceField, ProcessEventKind, SlippageModel, StrategyDecision, TargetPortfolioOrderPricing,
FidcRiskControlConfig, Instrument, IntradayExecutionQuote, MatchingType, OrderIntent,
OrderStatus, PortfolioState, PriceField, ProcessEventKind, SlippageModel, StrategyDecision,
TargetPortfolioOrderPricing,
};
use std::collections::{BTreeMap, BTreeSet};
@@ -104,12 +105,87 @@ fn execute_single_value_order(
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
(portfolio, report)
}
fn single_symbol_limit_price_data(
date: NaiveDate,
symbol: &str,
price: f64,
upper_limit: f64,
lower_limit: f64,
) -> DataSet {
DataSet::from_components(
vec![Instrument {
symbol: symbol.to_string(),
name: "Test".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap()),
delisted_at: None,
status: "active".to_string(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: 10.0,
volume: 1_000_000,
minute_volume: 1_000_000,
bid1_volume: 500_000,
ask1_volume: 500_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit,
lower_limit,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.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,
symbol: symbol.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,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset")
}
#[test]
fn broker_executes_explicit_order_value_buy() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
@@ -215,6 +291,7 @@ fn broker_executes_explicit_order_value_buy() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -370,6 +447,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -504,6 +582,7 @@ fn broker_executes_order_shares_and_order_lots() {
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -613,6 +692,7 @@ fn broker_executes_target_shares_like_order_to() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -793,6 +873,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -943,6 +1024,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1050,6 +1132,7 @@ fn broker_executes_order_percent_and_target_percent() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("percent execution");
@@ -1073,6 +1156,7 @@ fn broker_executes_order_percent_and_target_percent() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("target percent execution");
@@ -1172,6 +1256,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1276,6 +1361,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1376,6 +1462,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1490,6 +1577,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1592,6 +1680,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1710,6 +1799,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1813,6 +1903,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -1934,6 +2025,7 @@ fn broker_executes_intraday_last_on_start_quote_without_trade_delta() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2050,6 +2142,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2163,6 +2256,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2296,6 +2390,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2458,6 +2553,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2631,6 +2727,7 @@ fn broker_executes_algo_vwap_value_with_time_window() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2776,6 +2873,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -2906,6 +3004,7 @@ fn broker_uses_best_own_price_for_intraday_matching() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3021,6 +3120,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3183,6 +3283,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3376,6 +3477,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3555,6 +3657,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3681,6 +3784,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3780,6 +3884,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3883,6 +3988,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -3894,7 +4000,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() {
}
#[test]
fn same_day_sell_then_rebuy_reinserts_position_at_end() {
fn same_day_sell_then_rebuy_is_rejected_by_default() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
@@ -4020,6 +4126,149 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
let symbols = portfolio.positions().keys().cloned().collect::<Vec<_>>();
assert_eq!(
symbols,
vec!["000001.SZ".to_string(), "000003.SZ".to_string()]
);
}
#[test]
fn same_day_sell_then_rebuy_can_be_allowed_by_policy() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
let instruments = symbols
.iter()
.map(|symbol| Instrument {
symbol: (*symbol).to_string(),
name: (*symbol).to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: None,
delisted_at: None,
status: "active".to_string(),
})
.collect::<Vec<_>>();
let market = symbols
.iter()
.map(|symbol| DailyMarketSnapshot {
date,
symbol: (*symbol).to_string(),
timestamp: Some("2024-01-10 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.1,
low: 9.9,
close: 10.0,
last_price: 10.0,
bid1: 9.99,
ask1: 10.01,
prev_close: 10.0,
volume: 100_000,
minute_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,
})
.collect::<Vec<_>>();
let factors = symbols
.iter()
.map(|symbol| DailyFactorSnapshot {
date,
symbol: (*symbol).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(),
})
.collect::<Vec<_>>();
let candidates = symbols
.iter()
.map(|symbol| CandidateEligibility {
date,
symbol: (*symbol).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,
risk_level_code: None,
})
.collect::<Vec<_>>();
let data = DataSet::from_components(
instruments,
market,
factors,
candidates,
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio
.position_mut("000001.SZ")
.buy(prev_date, 100, 10.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 100, 10.0);
portfolio
.position_mut("000003.SZ")
.buy(prev_date, 100, 10.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.forbid_same_day_rebuy_after_sell = false;
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_risk_config(risk_config);
broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![
OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 0.0,
reason: "sell_then_rebuy".to_string(),
},
OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 10_000.0,
reason: "sell_then_rebuy".to_string(),
},
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -4035,6 +4284,94 @@ fn same_day_sell_then_rebuy_reinserts_position_at_end() {
);
}
#[test]
fn broker_configured_policy_can_allow_upper_limit_buy() {
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = single_symbol_limit_price_data(date, "000002.SZ", 11.0, 11.0, 9.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_upper_limit_buy = false;
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Value {
symbol: "000002.SZ".to_string(),
value: 10_000.0,
reason: "configured_upper_limit_buy".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(
report.order_events.last().map(|event| event.status),
Some(OrderStatus::Filled)
);
}
#[test]
fn broker_configured_policy_can_allow_lower_limit_sell() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = single_symbol_limit_price_data(date, "000002.SZ", 9.0, 11.0, 9.0);
let mut risk_config = FidcRiskControlConfig::default();
risk_config.static_rules.reject_lower_limit_sell = false;
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
)
.with_risk_config(risk_config);
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio
.position_mut("000002.SZ")
.buy(prev_date, 1_000, 10.0);
let report = broker
.execute(
date,
&mut portfolio,
&data,
&StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::TargetValue {
symbol: "000002.SZ".to_string(),
target_value: 0.0,
reason: "configured_lower_limit_sell".to_string(),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
assert_eq!(report.fill_events.len(), 1);
assert_eq!(
report.order_events.last().map(|event| event.status),
Some(OrderStatus::Filled)
);
}
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();
@@ -4195,6 +4532,7 @@ fn broker_rejects_open_limit_buy_at_market_close() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("day1 execution");
@@ -4229,6 +4567,7 @@ fn broker_rejects_open_limit_buy_at_market_close() {
order_intents: Vec::new(),
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("day2 execution");
@@ -4266,6 +4605,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -4303,6 +4643,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -4346,6 +4687,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -4370,6 +4712,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");
@@ -4405,6 +4748,7 @@ fn broker_cancels_open_order_by_order_id() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("day1 execution");
@@ -4425,6 +4769,7 @@ fn broker_cancels_open_order_by_order_id() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("day2 execution");
@@ -4471,6 +4816,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() {
}],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("cancel reject execution");
@@ -4585,6 +4931,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() {
],
notes: Vec::new(),
diagnostics: Vec::new(),
risk_decisions: Vec::new(),
},
)
.expect("broker execution");