合并最新执行风控与策略保护内核
This commit is contained in:
@@ -6198,6 +6198,24 @@ where
|
|||||||
} else {
|
} else {
|
||||||
rule
|
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 {
|
if !rule.allowed {
|
||||||
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string();
|
||||||
let status = match rule.reason.as_deref() {
|
let status = match rule.reason.as_deref() {
|
||||||
@@ -6234,24 +6252,6 @@ where
|
|||||||
return Ok(());
|
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
|
let current_position_quantity = portfolio
|
||||||
.position(symbol)
|
.position(symbol)
|
||||||
.map(|position| position.quantity)
|
.map(|position| position.quantity)
|
||||||
|
|||||||
@@ -6699,17 +6699,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 first = d(2025, 1, 2);
|
||||||
let second = d(2025, 1, 3);
|
let second = d(2025, 1, 3);
|
||||||
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
let result = run_scheduled_next_open_with_dataset(dataset_with(
|
||||||
market(first, 10.0, 11.5),
|
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),
|
candidate(first),
|
||||||
one_yuan_candidate(second),
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -14939,6 +14939,71 @@ mod tests {
|
|||||||
.expect("single-symbol platform dataset")
|
.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]
|
#[test]
|
||||||
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
fn portfolio_loss_observes_finalized_nav_after_fees_and_cash_flows() {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ impl ChinaAShareRiskControl {
|
|||||||
}
|
}
|
||||||
let reject_one_yuan = match scope {
|
let reject_one_yuan = match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
};
|
};
|
||||||
if reject_one_yuan
|
if reject_one_yuan
|
||||||
@@ -487,6 +487,14 @@ impl ChinaAShareRiskControl {
|
|||||||
) {
|
) {
|
||||||
return Some(reason);
|
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 {
|
if config.static_rules.respect_allow_buy_sell && !candidate.allow_buy {
|
||||||
return Some("buy_disabled");
|
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_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell;
|
|| config.static_rules.respect_allow_buy_sell;
|
||||||
}
|
}
|
||||||
@@ -745,7 +752,7 @@ fn missing_single_field_rejected(
|
|||||||
},
|
},
|
||||||
"is_one_yuan" | "one_yuan" => match scope {
|
"is_one_yuan" | "one_yuan" => match scope {
|
||||||
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
RiskCheckScope::Selection => config.static_rules.reject_one_yuan_selection,
|
||||||
RiskCheckScope::Buy => config.static_rules.reject_one_yuan_buy,
|
RiskCheckScope::Buy => false,
|
||||||
RiskCheckScope::Sell => false,
|
RiskCheckScope::Sell => false,
|
||||||
},
|
},
|
||||||
"allow_buy" => match scope {
|
"allow_buy" => match scope {
|
||||||
@@ -789,7 +796,6 @@ fn missing_single_field_rejected(
|
|||||||
|| config.static_rules.reject_new_listing_buy
|
|| config.static_rules.reject_new_listing_buy
|
||||||
|| config.static_rules.reject_kcb_buy
|
|| config.static_rules.reject_kcb_buy
|
||||||
|| config.static_rules.reject_bjse_buy
|
|| config.static_rules.reject_bjse_buy
|
||||||
|| config.static_rules.reject_one_yuan_buy
|
|
||||||
|| config.static_rules.reject_upper_limit_buy
|
|| config.static_rules.reject_upper_limit_buy
|
||||||
|| config.static_rules.respect_allow_buy_sell
|
|| config.static_rules.respect_allow_buy_sell
|
||||||
}
|
}
|
||||||
@@ -906,6 +912,61 @@ mod tests {
|
|||||||
position
|
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]
|
#[test]
|
||||||
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
fn sell_rejection_respects_allow_sell_policy_on_execution_day() {
|
||||||
let prev_date = d(2024, 4, 16);
|
let prev_date = d(2024, 4, 16);
|
||||||
|
|||||||
Reference in New Issue
Block a user