合并最新执行风控与策略保护内核

This commit is contained in:
boris
2026-09-11 09:52:05 +08:00
4 changed files with 167 additions and 25 deletions
+18 -18
View File
@@ -6198,6 +6198,24 @@ where
} else {
rule
};
if (rule.allowed || rule.reason.as_deref() == Some("invalid execution price"))
&& let Some(missing_reason) =
self.missing_daily_execution_price_reason(snapshot, algo_request)
{
Self::reject_missing_execution_price_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
missing_reason,
emit_creation_events,
);
self.clear_open_order(order_id);
return Ok(());
}
if !rule.allowed {
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
let status = match rule.reason.as_deref() {
@@ -6234,24 +6252,6 @@ where
return Ok(());
}
if let Some(missing_reason) =
self.missing_daily_execution_price_reason(snapshot, algo_request)
{
Self::reject_missing_execution_price_order(
report,
date,
order_id,
symbol,
OrderSide::Buy,
requested_qty,
reason,
missing_reason,
emit_creation_events,
);
self.clear_open_order(order_id);
return Ok(());
}
let current_position_quantity = portfolio
.position(symbol)
.map(|position| position.quantity)
+19 -3
View File
@@ -6699,17 +6699,33 @@ mod tests {
}
#[test]
fn next_bar_open_execution_risk_rejects_execution_day_one_yuan_state() {
fn next_bar_open_execution_risk_rejects_one_yuan_open_despite_higher_close() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 12.0, 99.0),
market(second, 0.9, 1.2),
candidate(first),
candidate(second),
));
assert_next_open_canceled_with_reason(&result, "one_yuan");
}
#[test]
fn next_bar_open_execution_risk_ignores_later_one_yuan_close() {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
let result = run_scheduled_next_open_with_dataset(dataset_with(
market(first, 10.0, 11.5),
market(second, 1.2, 0.9),
candidate(first),
one_yuan_candidate(second),
));
assert_next_open_canceled_with_reason(&result, "one_yuan");
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, second);
assert_eq!(result.fills[0].price, 1.2);
}
#[test]
@@ -14939,6 +14939,71 @@ mod tests {
.expect("single-symbol platform dataset")
}
#[test]
fn shared_signal_reduction_and_stops_use_each_accounts_cost_quantity_and_sold_state() {
use crate::{BrokerSimulator, ChinaAShareCostModel, ChinaEquityRuleHooks, PriceField};
use crate::signal_contract::{SignalBook, ValidatedSignalBook};
use serde_json::json;
let previous=d(2025,1,6);
let current=d(2025,1,7);
let symbol="000001.SZ";
let data=single_symbol_platform_data(&[previous,current],symbol);
let make_book=|action| -> Arc<ValidatedSignalBook> {
let mut book:SignalBook=serde_json::from_value(json!({
"schema":"fidc.signal-book/v2","versionSha256":"0".repeat(64),"generatorSha256":"a".repeat(64),
"modelSha256":null,"knowledgeCutoff":null,"provenance":"reconstructed","frequency":"daily",
"expectedDecisions":["2025-01-07T15:00:00+08:00"],"snapshots":[{
"signalAt":"2025-01-06T16:00:00+08:00","decisionAt":"2025-01-07T15:00:00+08:00",
"inputAsOf":"2025-01-06T16:00:00+08:00","inputAvailableAt":"2025-01-06T16:00:00+08:00",
"generatedAt":"2026-09-11T08:00:00+08:00","publishedAt":"2026-09-11T08:00:00+08:00",
"inputSha256":"b".repeat(64),"completeTargets":false,"actions":[action]}]
})).unwrap();
book.version_sha256=book.content_sha256().unwrap();
Arc::new(book.validate().unwrap())
};
let shared=make_book(json!({"kind":"reduce","symbol":symbol,"remaining_ratio":0.5}));
let shared_version=shared.version_sha256().to_owned();
let buy=make_book(json!({"kind":"target_weight","symbol":symbol,"weight":0.5}));
let subscriptions=BTreeSet::new();
let plan=|portfolio:&PortfolioState,book:Arc<ValidatedSignalBook>,stops:bool| {
let ctx=StrategyContext {
execution_date:current,decision_date:current,decision_index:1,data:&data,portfolio,
futures_account:None,open_orders:&[],dynamic_universe:None,subscriptions:&subscriptions,
process_events:&[],active_process_event:None,active_datetime:Some(current.and_hms_opt(15,0,0).unwrap()),
order_events:&[],fills:&[],
};
let mut cfg=PlatformExprStrategyConfig::generic();
cfg.signal_symbol=symbol.into();
cfg.rotation_enabled=false;
cfg.signal_book=Some(book);
cfg.explicit_actions=vec![PlatformTradeAction::ConsumeSignal];
if stops { cfg.stop_loss_expr="0.1".into();cfg.take_profit_expr="0.2".into(); }
PlatformExprStrategy::new(cfg).on_day(&ctx).unwrap()
};
let broker=|| BrokerSimulator::new_with_execution_price(ChinaAShareCostModel::default(),ChinaEquityRuleHooks::default(),PriceField::Close)
.with_matching_type(MatchingType::CurrentBarClose).with_volume_limit(false).with_liquidity_limit(false);
for (quantity,entry,fees,expected) in [(1000,8.0,0.0,0),(1000,10.0,0.0,500),(3000,10.0,0.0,1500),
(1000,12.0,0.0,0),(1000,11.11,0.0,500),(1000,11.11,2.0,0)] {
let mut account=PortfolioState::new(100_000.0);
account.position_mut(symbol).buy(previous,quantity,entry);
account.position_mut(symbol).record_buy_trade_cost(quantity,fees);
let decision=plan(&account,shared.clone(),true);
assert_eq!(account.position(symbol).unwrap().quantity,quantity);
let executor=broker();
let report=executor.execute(current,&mut account,&data,&decision).unwrap();
assert_eq!(account.position(symbol).map_or(0,|p|p.quantity),expected,"entry={entry} fees={fees} decision={decision:?} report={report:?}");
assert!(!report.fill_events.is_empty());
let attempted_rebuy=plan(&account,buy.clone(),false);
let rejected=executor.execute(current,&mut account,&data,&attempted_rebuy).unwrap();
assert!(rejected.fill_events.iter().all(|fill|fill.side!=OrderSide::Buy),"{rejected:?}");
}
let mut untouched=PortfolioState::new(100_000.0);
let allowed=plan(&untouched,buy,false);
let result=broker().execute(current,&mut untouched,&data,&allowed).unwrap();
assert!(result.fill_events.iter().any(|fill|fill.side==OrderSide::Buy));
assert_eq!(shared.version_sha256(),shared_version);
}
#[test]
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
use std::sync::Mutex;
+65 -4
View File
@@ -414,7 +414,7 @@ impl ChinaAShareRiskControl {
}
let reject_one_yuan = match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
};
if reject_one_yuan
@@ -487,6 +487,14 @@ impl ChinaAShareRiskControl {
) {
return Some(reason);
}
if !check_price.is_finite() || check_price <= 0.0 {
return Some("invalid execution price");
}
// Daily candidate flags can describe the later close. Execution
// price constraints must use this order's actual pricing clock.
if config.static_rules.reject_one_yuan_buy && check_price <= 1.0 {
return Some("one_yuan");
}
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
return Some("buy_disabled");
}
@@ -668,7 +676,6 @@ fn missing_buy_risk_state_rejected(code: &str, config: &FidcRiskControlConfig) -
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell;
}
@@ -745,7 +752,7 @@ fn missing_single_field_rejected(
},
"is_one_yuan" | "one_yuan" => match scope {
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
RiskCheckScope::Buy => false,
RiskCheckScope::Sell => false,
},
"allow_buy" => match scope {
@@ -789,7 +796,6 @@ fn missing_single_field_rejected(
|| config.static_rules.reject_new_listing_buy
|| config.static_rules.reject_kcb_buy
|| config.static_rules.reject_bjse_buy
|| config.static_rules.reject_one_yuan_buy
|| config.static_rules.reject_upper_limit_buy
|| config.static_rules.respect_allow_buy_sell
}
@@ -906,6 +912,61 @@ mod tests {
position
}
#[test]
fn one_yuan_buy_rule_uses_execution_price_not_later_close_or_earlier_open() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let mut snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.is_one_yuan = true;
snapshot.day_open = 0.9;
snapshot.close = 0.8;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.is_one_yuan = false;
snapshot.day_open = 1.2;
snapshot.close = 1.3;
for price in [0.9, 1.0] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("one_yuan"));
}
let mut relaxed = config;
relaxed.static_rules.reject_one_yuan_buy = false;
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 0.9, &relaxed), None);
}
#[test]
fn execution_quote_covers_missing_one_yuan_flag_but_not_other_risk_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
let snapshot = market(day, 1.2, 0.5);
let config = FidcRiskControlConfig::default();
candidate.risk_level_code = Some("missing_risk_state:is_one_yuan".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), None);
candidate.risk_level_code = Some("missing_risk_state:is_st".into());
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, 1.2, &config), Some("missing_risk_state"));
candidate.risk_level_code = None;
for price in [0.0, f64::NAN, f64::INFINITY] {
assert_eq!(ChinaAShareRiskControl::buy_rejection_reason_with_config(
day, &candidate, &snapshot, None, price, &config), Some("invalid execution price"));
}
}
#[test]
fn explicit_one_yuan_selection_policy_still_uses_selection_facts() {
let day = d(2025, 2, 6);
let mut candidate = candidate(day);
candidate.is_one_yuan = true;
let snapshot = market(day, 1.2, 0.5);
let mut config = FidcRiskControlConfig::default();
config.static_rules.reject_one_yuan_selection = true;
assert_eq!(ChinaAShareRiskControl::selection_rejection_reason_with_config(
day, &candidate, &snapshot, None, &config), Some("one_yuan"));
}
#[test]
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
let prev_date = d(2024, 4, 16);