fix(stock-pool): preserve exit roles and freeze relative reduction bases

This commit is contained in:
boris
2026-09-12 15:54:33 +08:00
parent 4ac9ee5058
commit ffd23b9920
11 changed files with 398 additions and 43 deletions
@@ -470,11 +470,31 @@ fn ordinary_sell_has_one_order_owner_before_broker_execution() {
assert_eq!(report.account_events[1].cash_before,40000.);
}
#[test]
fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
let data=data(false);let broker=broker(false);let mut account=PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2),1000,10.);
let mut intent=contract(day(2),1,true);
intent.constraints.independent_position_target_bps.insert(code(1),5000);
let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),500);
let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
intent.generation="a-new-reduction-signal".into();
let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap();
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
}
#[test]
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
let intent = contract(day(2), 1, false);
for quote_condition in ["", "price<5"] {
let program = StockPoolProgram {
exit_signals: vec![],
schema_version: 1,
pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(),
@@ -541,6 +561,34 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
}
}
#[test]
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
for (ordinary, risk, quote, sold) in [
(Some(0),None,"price<1",0),
(None,Some(0),"price<1",3000),
(Some(0),Some(5000),"price<1",1500),
(Some(0),Some(5000),"price>1",3000),
(None,Some(5000),"",1500),
] {
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
config.matching_type=MatchingType::CurrentBarClose;
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
}).run().unwrap();
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
}
}
#[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
@@ -764,6 +812,7 @@ fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
let intent=contract(day(2),1,true);
let program=StockPoolProgram {
exit_signals: vec![],
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
@@ -798,7 +847,7 @@ fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
}
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
@@ -158,6 +158,42 @@ fn legacy_state_without_quantity_keeps_its_serialized_identity() {
assert_eq!(serde_json::to_value(state).unwrap(), original);
}
#[test]
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
let original = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
assert!(next_day.position_action_bases_for("new-signal").is_empty());
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
}
assert!(original.record_position_action_bases(" ", &basis).is_err());
}
#[test]
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
let initial = StockPoolExecutionState::default()
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
}
#[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()];