diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index c5efd01..69c082d 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -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) diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 957364d..e65d0c5 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -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] diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 5dc4cfc..9048139 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -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 { + 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,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; diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index ece85c9..0a058f4 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -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);