feat(stock-pool): unify target execution, durable intent state and ETF rules
This commit is contained in:
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user