fix: price one-yuan buy risk at execution and audit account-local signal exits
This commit is contained in:
@@ -14749,6 +14749,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.fills.is_empty());
|
||||
let attempted_rebuy=plan(&account,buy.clone(),false);
|
||||
let rejected=executor.execute(current,&mut account,&data,&attempted_rebuy).unwrap();
|
||||
assert!(rejected.fills.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.fills.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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user