feat(stock-pool): unify target execution, durable intent state and ETF rules

This commit is contained in:
boris
2026-09-12 03:55:00 +08:00
parent 29eafc79e2
commit 6ffa0346aa
26 changed files with 7223 additions and 38 deletions
@@ -0,0 +1,275 @@
{
"strategyId": "fixture_hold_without_stops_backtest",
"version": "1.0.0",
"market": "CN_A",
"benchmark": {
"instrumentId": "000300.SH",
"fallbackInstrumentId": "000300.SH",
"note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。"
},
"universe": {
"exclude": [],
"implementationNotes": [
"ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行",
"上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断",
"盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动"
],
"include": [
"000001.SZ",
"000002.SZ"
]
},
"selectors": [
{
"type": "dynamicRange",
"field": "market_cap",
"lowerExpr": "0",
"upperExpr": "1000000000000",
"mapping": "close -> strategy_factory_source_lake.runtime_fields.close"
},
{
"type": "filter",
"expr": "(close > 0)"
},
{
"type": "rank",
"orderBy": [
"market_cap asc"
],
"limitExpr": "2"
}
],
"rebalance": {
"frequencyDays": 1,
"tradeTimes": [],
"dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合",
"schedule": {
"frequency": "daily"
}
},
"risk": {
"takeProfitExpr": "",
"stopLossExpr": "",
"indexThrottleExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"seasonality": {
"skipWindows": []
},
"execution": {
"selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1",
"executionGranularity": "daily_or_minute_bar",
"priceSource": "current_bar_close_or_next_bar_open_or_minute_bar",
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"sourceLanguage": "engine-script",
"sourceKind": "platform-strategy",
"extractor": "omniquant-engine-script-v2",
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"factorRefs": [
"close"
],
"runtimeExpressions": {
"prelude": "",
"schedule": {
"frequency": "daily"
},
"selection": {
"limitExpr": "2",
"candidateLimitExpr": "2",
"marketCapField": "close",
"marketCapLowerExpr": "0",
"marketCapUpperExpr": "1000000000000",
"stockFilterExpr": "(close > 0)"
},
"risk": {
"exposureExpr": "max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0))",
"stopLossExpr": "",
"takeProfitExpr": "",
"stopTakeReferencePriceMode": "position_average_entry_price"
},
"allocation": {
"buyScaleExpr": "1.0"
},
"ordering": {
"rankBy": "market_cap",
"rankExpr": "(symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2",
"rankOrder": "asc"
},
"trading": {
"rotationEnabled": true,
"subscriptionGuardRequired": false,
"stage": "on_day",
"actions": []
}
},
"engineConfig": {
"templateId": "fixture_hold_without_stops_backtest",
"benchmarkSymbol": "000300.SH",
"signalSymbol": "000300.SH",
"rankLimit": 2,
"refreshRate": 1,
"rsiRate": 1.0001,
"dynamicRange": {
"baseIndexLevel": 2000,
"baseCapFloor": 7,
"capSpan": 1000000000000,
"xs": 0.008
},
"stopLossMultiplier": null,
"takeProfitMultiplier": null,
"matchingType": "current_bar_close",
"rebalanceCashMode": "sell_then_buy",
"slippageModel": "none",
"slippageValue": 0,
"riskPolicy": {
"rejectStSelection": false,
"rejectStarStSelection": false,
"rejectPausedSelection": false,
"rejectInactiveSelection": false,
"rejectNewListingSelection": false,
"rejectKcbSelection": false,
"rejectBjseSelection": false,
"rejectOneYuanSelection": false,
"rejectUpperLimitSelection": false,
"rejectLowerLimitSelection": false,
"rejectStBuy": true,
"rejectStarStBuy": true,
"rejectPausedBuy": true,
"rejectInactiveBuy": true,
"rejectNewListingBuy": true,
"rejectKcbBuy": true,
"rejectBjseBuy": true,
"rejectOneYuanBuy": true,
"rejectUpperLimitBuy": true,
"rejectPausedSell": true,
"rejectInactiveSell": true,
"rejectLowerLimitSell": true,
"respectAllowBuySell": true,
"forbidSameDayRebuyAfterSell": true,
"blacklistEnabled": true,
"blacklistedSymbols": [],
"allowMarketOrders": true,
"liveTradingEnabled": false,
"volumeLimitEnabled": true,
"liquidityLimitEnabled": true,
"volumePercent": 0.25,
"maxOrderQuantity": 1000000,
"maxOrderNotional": 100000000,
"maxSymbolPosition": 10000000,
"commissionRate": 0.0003,
"minimumCommission": 5,
"stampTaxRateBeforeChange": 0.001,
"stampTaxRateAfterChange": 0.0005,
"stampTaxChangeDate": "2023-08-28"
},
"skipWindows": [],
"rebalanceSchedule": {
"frequency": "daily"
},
"dividendReinvestment": false,
"sellThenBuyDelaySlippageRate": 0,
"strictValueBudget": true
},
"stockPool": {
"schema_version": 1,
"pool_id": "fixture-pool",
"version_id": "fixture-version",
"members": [
{
"symbol": "000001.SZ",
"requested_order": 0,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
},
{
"symbol": "000002.SZ",
"requested_order": 1,
"recommendation_reason": "",
"target_weight_bps": null,
"stop_loss": null,
"take_profit": null
}
],
"allocation_policy": {
"target_holding_count": 2,
"portfolio_policy": {
"schema_version": 1,
"membership": "retain_holdings",
"rebalance_weights": false
},
"invest_ratio_bps": 10000,
"reserve_cash": 0
},
"timing_policy": {
"schema_version": 1,
"auto_execute": true,
"freeze_time": "00:00",
"window_start": "09:30",
"window_end": "15:00",
"trigger_mode": "scheduled_bar",
"pricing_mode": "first_tick",
"automatic_trade_protection": {
"buy_protection_days": 0,
"sell_cooldown_days": 0,
"max_holding_days": 0,
"locks": []
}
},
"stop_take_policy": {
"stop_loss": null,
"take_profit": null
},
"out_of_pool_policy": "hold"
},
"signalSymbol": "000300.SH",
"sourceCode": "strategy(\"fixture_hold_without_stops_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1)\n universe.include([\"000001.SZ\", \"000002.SZ\"])\n selection.limit(2)\n selection.candidate_limit(2)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr(close > 0)\n ordering.rank_expr((symbol == \"000001.SZ\" || symbol == \"000002.SZ\") ? (symbol == \"000001.SZ\" ? (0) : (1)) : 2, \"asc\")\n risk.index_exposure(max(0.0, 1.0000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale(1.0)\n stock_pool.config({\"schema_version\":1,\"pool_id\":\"fixture-pool\",\"version_id\":\"fixture-version\",\"members\":[{\"symbol\":\"000001.SZ\",\"requested_order\":0,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null},{\"symbol\":\"000002.SZ\",\"requested_order\":1,\"recommendation_reason\":\"\",\"target_weight_bps\":null,\"stop_loss\":null,\"take_profit\":null}],\"allocation_policy\":{\"target_holding_count\":2,\"portfolio_policy\":{\"schema_version\":1,\"membership\":\"retain_holdings\",\"rebalance_weights\":false},\"invest_ratio_bps\":10000,\"reserve_cash\":0},\"timing_policy\":{\"schema_version\":1,\"auto_execute\":true,\"freeze_time\":\"00:00\",\"window_start\":\"09:30\",\"window_end\":\"15:00\",\"trigger_mode\":\"scheduled_bar\",\"pricing_mode\":\"first_tick\",\"automatic_trade_protection\":{\"buy_protection_days\":0,\"sell_cooldown_days\":0,\"max_holding_days\":0,\"locks\":[]}},\"stop_take_policy\":{\"stop_loss\":null,\"take_profit\":null},\"out_of_pool_policy\":\"hold\"})\n risk.reference_price_mode(\"position_average_entry_price\")\n execution.matching_type(\"current_bar_close\")\n}\n",
"mode": "rotation"
}
@@ -0,0 +1,632 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
FidcRiskControlConfig, Instrument, MatchingType, OrderIntent, PlatformExprStrategy,
PortfolioState, PriceField, StrategyDecision, platform_expr_config_from_value,
};
use rust_decimal::Decimal;
use std::collections::{BTreeMap, BTreeSet};
fn day(n: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 1, n).unwrap()
}
fn code(n: usize) -> String {
format!("{n:06}.SZ")
}
fn data(low_volume: bool) -> DataSet {
data_with_first_volume(if low_volume { 100 } else { 1_000_000 })
}
fn data_with_first_volume(first_volume: u64) -> DataSet {
data_with_suspension(first_volume, None)
}
fn data_with_suspension(first_volume: u64, suspension: Option<NaiveDate>) -> DataSet {
data_with_fund_rules(first_volume, suspension, false)
}
fn data_with_fund_rules(
first_volume: u64,
suspension: Option<NaiveDate>,
fund_rules: bool,
) -> DataSet {
let mut instruments: Vec<Instrument> = (1..=2)
.map(|n| Instrument {
symbol: code(n),
name: code(n),
board: if fund_rules && n == 2 {
"ETF".into()
} else {
"SZ".into()
},
round_lot: 100,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
})
.collect();
instruments.push(Instrument {
symbol: "000300.SH".into(),
name: "fixture reference index".into(),
board: "INDEX".into(),
round_lot: 1,
listed_at: Some(day(1)),
delisted_at: None,
status: "active".into(),
});
let mut market = Vec::new();
let mut candidates = Vec::new();
for date in [day(2), day(5), day(6)] {
for n in 1..=2 {
let price = if fund_rules && n == 2 {
0.934
} else if n == 1 && date >= day(5) {
20.0
} else {
10.0
};
market.push(DailyMarketSnapshot {
date,
symbol: code(n),
timestamp: None,
day_open: price,
open: price,
high: price,
low: price,
close: price,
last_price: price,
bid1: price,
ask1: price,
prev_close: 10.,
volume: if n == 1 { first_volume } else { 1_000_000 },
minute_volume: 100_000,
bid1_volume: 100_000,
ask1_volume: 100_000,
trading_phase: None,
paused: n == 2 && suspension == Some(date),
upper_limit: 100.,
lower_limit: 0.1,
price_tick: if fund_rules && n == 2 { 0.001 } else { 0.01 },
});
candidates.push(CandidateEligibility {
date,
symbol: code(n),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: n == 2 && suspension == Some(date),
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
});
}
let mut reference = market.last().unwrap().clone();
reference.symbol = "000300.SH".into();
reference.day_open = 100.;
reference.open = 100.;
reference.high = 100.;
reference.low = 100.;
reference.close = 100.;
reference.last_price = 100.;
reference.bid1 = 100.;
reference.ask1 = 100.;
reference.prev_close = 100.;
reference.upper_limit = 1000.;
market.push(reference);
}
let benchmarks = [day(2), day(5), day(6)]
.into_iter()
.map(|date| BenchmarkSnapshot {
date,
benchmark: "000300.SH".into(),
open: 100.,
close: 100.,
prev_close: 100.,
volume: 1_000_000,
})
.collect();
let factors = [day(2), day(5), day(6)]
.into_iter()
.flat_map(|date| {
(1..=2).map(move |n| DailyFactorSnapshot {
date,
symbol: code(n),
market_cap_bn: 10.,
free_float_cap_bn: 10.,
pe_ttm: 10.,
turnover_ratio: None,
effective_turnover_ratio: None,
adjustment_factor_backward1: Some(1.),
extra_factors: Default::default(),
})
})
.collect();
DataSet::from_components(instruments, market, factors, candidates, benchmarks).unwrap()
}
fn broker(volume: bool) -> BrokerSimulator<ChinaAShareCostModel, ChinaEquityRuleHooks> {
let mut risk = FidcRiskControlConfig::default();
risk.trading_constraints.commission_rate = 0.;
risk.trading_constraints.minimum_commission = 0.;
risk.trading_constraints.transfer_fee_rate = 0.;
risk.trading_constraints.stamp_tax_rate_before_change = 0.;
risk.trading_constraints.stamp_tax_rate_after_change = 0.;
risk.trading_constraints.volume_limit_enabled = volume;
risk.trading_constraints.volume_percent = 0.25;
risk.trading_constraints.liquidity_limit_enabled = false;
BrokerSimulator::new(
ChinaAShareCostModel::from_trading_constraints(risk.trading_constraints),
ChinaEquityRuleHooks,
)
.with_matching_type(MatchingType::NextBarOpen)
.with_risk_config(risk)
}
fn contract(signal: NaiveDate, target: usize, preserve: bool) -> FrozenStockPoolIntent {
let symbols = vec![code(1), code(2)];
FrozenStockPoolIntent {
pool_id: "fixture-pool".into(),
signal_date: signal,
frozen_equity: Decimal::from(30000),
selection: StockPoolSelection {
trade_date: signal,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols,
final_symbols: vec![code(target)],
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some(format!("g-{signal}")),
},
members: (1..=2)
.map(|n| StockPoolMemberSpec {
symbol: code(n),
requested_order: n as i32,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
})
.collect(),
rule: StockPoolExecutionRule {
pricing_mode: POOL_PRICE_FIRST_TICK.into(),
..Default::default()
},
constraints: StockPoolDecisionConstraints {
target_holding_count: Some(1),
portfolio_policy: Some(StockPoolPortfolioPolicy {
schema_version: 1,
membership: MembershipPolicy::FollowCandidates,
rebalance_weights: !preserve,
}),
..Default::default()
},
invest_ratio_bps: 10000,
reserve_cash: Decimal::ZERO,
out_of_pool_policy: "hold".into(),
generation: format!("g-{signal}"),
}
}
fn decision(contract: FrozenStockPoolIntent) -> StrategyDecision {
StrategyDecision {
order_intents: vec![OrderIntent::StockPool {
contract: Box::new(contract),
}],
..Default::default()
}
}
#[test]
fn paused_execution_day_keeps_the_prior_slot_and_never_submits_an_exit() {
let data = data_with_suspension(1_000_000, Some(day(6)));
let broker = broker(false);
let mut account = PortfolioState::new(30_000.);
let first = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(first.fill_events.len(), 1);
let quantity = account.position(&code(2)).unwrap().quantity;
let mut replacement = contract(day(5), 1, false);
replacement
.members
.retain(|member| member.symbol != code(2));
replacement
.selection
.requested_symbols
.retain(|symbol| symbol != &code(2));
replacement.out_of_pool_policy = "reduce_to_zero_when_sellable".into();
let paused = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(replacement),
)
.unwrap();
assert!(paused.fill_events.is_empty(), "{paused:?}");
assert_eq!(account.position(&code(2)).unwrap().quantity, quantity);
assert!(
paused
.diagnostics
.iter()
.any(|line| line.contains("MARKET_SUSPENDED"))
);
}
#[test]
fn mixed_fund_and_stock_round_trip_uses_declared_ticks_and_asset_specific_fees() {
let data = data_with_fund_rules(1_000_000, None, true);
let mut costs = ChinaAShareCostModel::default();
costs.set_transfer_fee_rate(0.00001);
let broker = BrokerSimulator::new(costs, ChinaEquityRuleHooks)
.with_matching_type(MatchingType::NextBarOpen);
let mut account = PortfolioState::new(30_000.);
let mut entry = contract(day(2), 1, false);
entry.selection.final_symbols = vec![code(1), code(2)];
entry.constraints.target_holding_count = Some(2);
entry.rule.buy_offset_bps = 1;
entry.rule.sell_offset_bps = -1;
let buys = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(entry.clone()),
)
.unwrap();
assert_eq!(buys.fill_events.len(), 2, "{buys:?}");
let fund = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.quantity, 16000);
assert_eq!(fund.price, 0.934);
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = buys
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.quantity, 700);
assert_eq!(stock.transfer_fee, 0.14);
entry.signal_date = day(5);
entry.selection.trade_date = day(5);
entry.generation = "exit".into();
entry.invest_ratio_bps = 0;
let sells = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(entry),
)
.unwrap();
assert_eq!(sells.fill_events.len(), 2, "{sells:?}");
let fund = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(2))
.unwrap();
assert_eq!(fund.stamp_tax, 0.);
assert_eq!(fund.transfer_fee, 0.);
let stock = sells
.fill_events
.iter()
.find(|fill| fill.symbol == code(1))
.unwrap();
assert_eq!(stock.stamp_tax, 7.);
assert_eq!(stock.transfer_fee, 0.14);
assert!(
(account.cash() - 29972.72).abs() < 0.000001,
"cash={}",
account.cash()
);
assert!(
account
.positions()
.values()
.all(|position| position.quantity == 0)
);
}
#[test]
fn new_daily_target_sells_old_member_then_buys_using_frozen_equity() {
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 report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert_eq!(report.fill_events.len(), 2, "{report:?}");
assert!(account.position(&code(1)).is_none_or(|p| p.quantity == 0));
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
assert!(
(account.cash() - 10000.).abs() < 1e-8,
"next-open equity must not replace the frozen 30000 budget"
);
let unique = report
.fill_events
.iter()
.map(|fill| (fill.symbol.clone(), format!("{:?}", fill.side)))
.collect::<BTreeSet<_>>();
assert_eq!(unique.len(), 2);
let next = broker
.execute_with_event_dates(
day(6),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 2, true)),
)
.unwrap();
assert!(
next.fill_events.is_empty(),
"preserved shares must not be rebalanced: {next:?}"
);
}
#[test]
fn partial_sell_does_not_release_a_slot_or_authorize_replacement() {
let data = data(true);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(2), 1000, 10.);
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 2, false)),
)
.unwrap();
assert!(account.position(&code(2)).is_none());
assert_eq!(account.position(&code(1)).unwrap().quantity, 975);
assert!(
report
.diagnostics
.iter()
.any(|text| text.contains("DEFERRED_POSITION_SLOTS"))
);
}
#[test]
fn actual_fill_protection_is_evaluated_on_execution_date() {
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), 2, false);
intent.rule.automatic_trade_protection.buy_protection_days = 3;
let report = broker
.execute_with_event_dates(
day(5),
day(2),
day(2),
&mut account,
&data,
&decision(intent),
)
.unwrap();
assert!(report.fill_events.is_empty(), "{report:?}");
assert_eq!(account.position(&code(1)).unwrap().quantity, 1000);
}
#[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 {
schema_version: 1,
pool_id: "pool-fixture".into(),
version_id: "version-fixture".into(),
members: intent.members.clone(),
allocation_policy: serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"follow_candidates","rebalance_weights":false}}),
// Disabled natural trading must not disable an explicitly requested historical backtest.
timing_policy: serde_json::json!({"auto_execute":false,"pricing_mode":"first_tick","buy_condition":quote_condition}),
stop_take_policy: serde_json::json!({"stop_loss":null,"take_profit":0}),
out_of_pool_policy: "hold".into(),
};
let mut config=platform_expr_config_from_value("pool-fixture",&code(1),&serde_json::json!({
"stockPool":program,"signalSymbol":code(1),"benchmark":{"instrumentId":"000300.SH"},"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 = "1000000000000".into();
config.stock_filter_expr = "true".into();
config.selection_limit_expr = "1".into();
config.selection_candidate_limit_expr = "2".into();
config.rank_expr = format!(
"decision_date == \"2026-01-02\" ? (symbol == \"{}\" ? 0 : 1) : (symbol == \"{}\" ? 0 : 1)",
code(1),
code(2)
);
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(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
if quote_condition.is_empty() {
assert_eq!(
result.fills.len(),
3,
"fills={:#?}, decisions={:#?}, days={:#?}",
result.fills,
result.risk_decisions,
result
.equity_curve
.iter()
.map(|point| (&point.date, &point.diagnostics))
.collect::<Vec<_>>()
);
assert_eq!(result.fills[0].symbol, code(1));
assert_eq!(result.fills[1].symbol, code(1));
assert_eq!(result.fills[2].symbol, code(2));
assert_eq!(result.fills[2].quantity, 6000);
} else {
assert!(
result.fills.is_empty(),
"configured quote condition must reach the actual executor"
);
}
}
}
#[test]
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
// replacement runtimeExpressions contract. It used to inject 0.93/1.07.
let spec: serde_json::Value = serde_json::from_str(include_str!(
"fixtures/stock_pool_disabled_stops_compiled.json"
))
.unwrap();
let config =
platform_expr_config_from_value("fixture_hold_without_stops", "000300.SH", &spec).unwrap();
assert!(config.stop_loss_expr.is_empty());
assert!(config.take_profit_expr.is_empty());
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(6)),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Close,
},
)
.run()
.unwrap();
assert_eq!(
result.fills.len(),
2,
"stock one doubles in price, but disabled stops and weight rebalancing must not sell it: {:?}",
result.fills
);
assert!(
result
.fills
.iter()
.all(|fill| fill.side == fidc_core::OrderSide::Buy)
);
assert_eq!(result.equity_curve.len(), 3);
}
#[test]
fn partial_backtest_fills_do_not_turn_into_completed_preserved_holdings() {
let data = data_with_first_volume(2000);
let broker = broker(true).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(30000.);
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(contract(day(2), 1, true)),
)
.unwrap();
let partial = account
.position(&code(1))
.map(|position| position.quantity)
.unwrap_or(0);
assert!(partial > 0 && partial < 3000, "{first:?}");
let second = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(contract(day(5), 1, true)),
)
.unwrap();
assert!(
account.position(&code(1)).unwrap().quantity > partial,
"partial entry must continue on the next valid execution: {second:?}"
);
}
#[test]
fn next_day_outside_policy_executes_after_the_first_exclusion_signal() {
let data = data(false);
let broker = broker(false).with_matching_type(MatchingType::CurrentBarClose);
let mut account = PortfolioState::new(20000.);
account.position_mut(&code(1)).buy(day(1), 1000, 10.);
let outside = |signal| {
let mut value = contract(signal, 2, true);
value.members.retain(|member| member.symbol == code(2));
value.selection.requested_symbols = vec![code(2)];
value.selection.normal_trading_symbols = vec![code(2)];
value.selection.risk_eligible_symbols = vec![code(2)];
value.out_of_pool_policy = "reduce_next_trading_day".into();
value
};
let first = broker
.execute_with_event_dates(
day(2),
day(2),
day(2),
&mut account,
&data,
&decision(outside(day(2))),
)
.unwrap();
assert!(first.fill_events.is_empty(), "{first:?}");
let next = broker
.execute_with_event_dates(
day(5),
day(5),
day(5),
&mut account,
&data,
&decision(outside(day(5))),
)
.unwrap();
assert!(
account
.position(&code(1))
.is_none_or(|position| position.quantity == 0),
"{next:?}"
);
assert_eq!(account.position(&code(2)).unwrap().quantity, 3000);
}
@@ -0,0 +1,217 @@
use chrono::NaiveDate;
use fidc_core::stock_pool_execution::*;
use fidc_core::stock_pool_state::StockPoolExecutionState;
use rust_decimal::Decimal;
use serde_json::json;
use std::collections::BTreeMap;
fn day(value: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 9, value).unwrap()
}
fn member() -> StockPoolMemberSpec {
StockPoolMemberSpec {
symbol: "000001.SZ".into(),
requested_order: 0,
recommendation_reason: String::new(),
target_weight_bps: None,
stop_loss: None,
take_profit: None,
}
}
fn held(quantity: i64, closable: i64) -> Position {
Position {
symbol: "000001.SZ".into(),
quantity: quantity.into(),
closable_quantity: closable.into(),
average_cost: 10.into(),
}
}
fn quote() -> MarketSnapshot {
MarketSnapshot {
symbol: "000001.SZ".into(),
last_price: 10.into(),
prev_close: Some(10.into()),
volume: Some(1000000.into()),
turnover: Some(10000000.into()),
bid_price_1: Some(10.into()),
ask_price_1: Some(10.into()),
is_kcb: Some(false),
instrument_rules: None,
buy_sizing_price: None,
sell_sizing_price: None,
}
}
fn plan(
state: &StockPoolExecutionState,
at: NaiveDate,
members: &[StockPoolMemberSpec],
positions: &[Position],
cash: i64,
outside: &str,
) -> StockPoolPlan {
let symbols = members
.iter()
.map(|member| member.symbol.clone())
.collect::<Vec<_>>();
let selection = StockPoolSelection {
trade_date: at,
requested_symbols: symbols.clone(),
normal_trading_symbols: symbols.clone(),
risk_eligible_symbols: symbols.clone(),
final_symbols: symbols,
exclusion_reasons: BTreeMap::new(),
inherited_from_generation: None,
explicit_empty: false,
generation: Some("same-goal".into()),
};
let mut constraints = stock_pool_constraints_from_configuration(
&json!({"top_n_rebalance_policy":"preserve_existing"}),
&json!({}),
)
.unwrap();
constraints.pending_entry_symbols = state.pending_symbols();
constraints.next_day_outside_exit_symbols = state.next_day_exit_symbols(at);
build_stock_pool_target_plan_with_constraints(
&selection,
members,
&StockPoolExecutionRule::default(),
&AccountSnapshot {
total_equity: 10000.into(),
cash: cash.into(),
frozen_cash: Decimal::ZERO,
},
positions,
&[quote()],
10000,
Decimal::ZERO,
outside,
"preserve_existing",
&constraints,
"same-goal",
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
)
.unwrap()
}
#[test]
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
let members = vec![member()];
let calendar = vec![day(11), day(14)];
let first = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &members, &[])
.unwrap();
let initial = plan(&first, day(11), &members, &[], 10000, "hold");
assert_eq!(initial.rows[0].target_quantity, 1000.into());
let pending = first.record_plan(day(11), "same-goal", &initial).unwrap();
assert!(pending.entries["000001.SZ"].pending);
assert!(!pending.entries["000001.SZ"].observed_holding);
let persisted = serde_json::to_vec(&pending).unwrap();
let restored: StockPoolExecutionState = serde_json::from_slice(&persisted).unwrap();
let partial = restored
.observe(day(11), day(11), &calendar, &members, &[held(500, 0)])
.unwrap();
let retry = plan(&partial, day(11), &members, &[held(500, 0)], 5000, "hold");
assert_eq!(retry.rows[0].delta_quantity, 500.into(), "{retry:?}");
let pending = partial.record_plan(day(11), "same-goal", &retry).unwrap();
assert!(pending.entries["000001.SZ"].pending);
let filled = pending
.observe(day(14), day(14), &calendar, &members, &[held(1000, 1000)])
.unwrap();
let satisfied = plan(&filled, day(14), &members, &[held(1000, 1000)], 0, "hold");
assert_eq!(satisfied.rows[0].status, "ENTRY_TARGET_ALREADY_SATISFIED");
let completed = filled.record_plan(day(14), "new-day", &satisfied).unwrap();
assert!(!completed.entries["000001.SZ"].pending);
assert_eq!(
plan(
&completed,
day(14),
&members,
&[held(1000, 1000)],
0,
"hold"
)
.rows[0]
.status,
"PRESERVED_EXISTING_POSITION"
);
}
#[test]
fn removal_anchor_is_not_reset_by_rechecks_weekends_or_t_plus_one() {
let calendar = vec![day(11), day(14), day(15)];
let positions = vec![held(1000, 1000)];
let removed = StockPoolExecutionState::default()
.observe(day(11), day(11), &calendar, &[], &positions)
.unwrap();
assert_eq!(removed.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&removed,
day(11),
&[],
&positions,
0,
"reduce_next_trading_day"
)
.rows[0]
.status,
"DEFERRED_T_PLUS_ONE"
);
assert!(
removed
.observe(day(12), day(12), &calendar, &[], &positions)
.is_err()
);
let restored: StockPoolExecutionState =
serde_json::from_str(&serde_json::to_string(&removed).unwrap()).unwrap();
let monday = restored
.observe(day(14), day(14), &calendar, &[], &[held(1000, 0)])
.unwrap();
assert_eq!(monday.removed_since["000001.SZ"], day(11));
assert_eq!(
plan(
&monday,
day(14),
&[],
&[held(1000, 0)],
0,
"reduce_next_trading_day"
)
.rows[0]
.delta_quantity,
Decimal::ZERO
);
let next = monday
.observe(day(15), day(15), &calendar, &[], &positions)
.unwrap();
let exit = plan(
&next,
day(15),
&[],
&positions,
0,
"reduce_next_trading_day",
);
assert_eq!(exit.rows[0].target_quantity, Decimal::ZERO);
assert_eq!(exit.rows[0].side, Some(OrderSide::Sell));
let returned = next
.observe(day(15), day(15), &calendar, &[member()], &positions)
.unwrap();
assert!(returned.removed_since.is_empty());
}
#[test]
fn cloned_preview_does_not_start_a_timer_and_next_open_uses_signal_removal_date() {
let state = StockPoolExecutionState::default();
let calendar = vec![day(11), day(14)];
let preview = state
.observe(day(11), day(14), &calendar, &[], &[held(1000, 1000)])
.unwrap();
assert!(state.removed_since.is_empty());
assert!(preview.next_day_exit_symbols(day(14)).contains("000001.SZ"));
let mut invalid = preview;
invalid.schema_version = 0;
assert!(invalid.validate().is_err());
}