From a02ac6e853b5e8dc2939441c4d8362f49c3b42de Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 21:51:51 +0800 Subject: [PATCH 01/21] fix: gate bound daily indicator fields by completed session --- .../fidc-core/src/platform_expr_strategy.rs | 68 +++++++++++++++++++ .../fidc-core/src/platform_strategy_spec.rs | 31 +++++++++ 2 files changed, 99 insertions(+) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 2b389c2..c03da60 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -649,6 +649,7 @@ pub struct PlatformExprStrategyConfig { pub matching_type: MatchingType, pub quote_quantity_limit: bool, pub current_day_precomputed_factors: bool, + pub completed_session_factor_fields: BTreeSet, pub candidate_symbols_by_date: BTreeMap>, pub intraday_execution_time: Option, pub explicit_action_times: Vec, @@ -727,6 +728,7 @@ impl PlatformExprStrategyConfig { matching_type: MatchingType::CurrentBarClose, quote_quantity_limit: true, current_day_precomputed_factors: false, + completed_session_factor_fields: BTreeSet::new(), candidate_symbols_by_date: BTreeMap::new(), intraday_execution_time: None, explicit_action_times: Vec::new(), @@ -1396,6 +1398,26 @@ fn platform_safe_div_default(lhs: f64, rhs: f64) -> f64 { platform_safe_div(lhs, rhs, 0.0) } +fn completed_session_factor_date( + ctx: &StrategyContext<'_>, + date: NaiveDate, + factor_date: NaiveDate, + execution_time: Option, +) -> Option { + let factor_date = factor_date.min(ctx.decision_date); + if factor_date < date || factor_date < ctx.decision_date { + return Some(factor_date); + } + let time = execution_time.or_else(|| ctx.active_datetime.map(|value| value.time())); + // Native CN stock daily indicator rows become usable only after the + // session closes. Absence of an intraday clock denotes a daily close bar. + if time.is_none_or(|time| time >= NaiveTime::from_hms_opt(15, 0, 0).unwrap()) { + Some(factor_date) + } else { + ctx.data.previous_trading_date(factor_date, 1) + } +} + impl PlatformExprStrategy { fn market_cap_storage_to_strategy_unit(value: f64) -> f64 { value @@ -4705,6 +4727,23 @@ impl PlatformExprStrategy { } else { BTreeMap::new() }; + if !self.config.completed_session_factor_fields.is_empty() { + let visible_date = completed_session_factor_date( + ctx, date, factor_date, + execution_time.or(self.config.intraday_execution_time), + ); + let visible_factor = visible_date + .and_then(|visible_date| ctx.data.factor_by_symbol_id(visible_date, symbol_id)); + for field in &self.config.completed_session_factor_fields { + if self.stock_extra_factor_map_required || self.stock_extra_factor_identifiers.contains(field) { + let value = visible_factor + .and_then(|row| row.extra_factors.get(field.as_str())) + .copied() + .unwrap_or(f64::NAN); + extra_factors.insert(field.clone(), value); + } + } + } if self.stock_extra_factors_required && (self.stock_extra_factor_map_required || self @@ -13913,6 +13952,35 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + #[test] + fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let data = single_symbol_platform_data(&[prev, curr], "000001.SZ"); + let portfolio = PortfolioState::new(10_000.0); + let subscriptions = BTreeSet::new(); + let mut ctx = StrategyContext { + execution_date: curr, decision_date: curr, decision_index: 1, + data: &data, portfolio: &portfolio, futures_account: None, + open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions, + process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], fills: &[], + }; + for hour in [9, 10, 14] { + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, + NaiveTime::from_hms_opt(hour, 30, 0)), Some(prev)); + } + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, + NaiveTime::from_hms_opt(15, 0, 0)), Some(curr)); + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(curr)); + ctx.active_datetime = Some(curr.and_hms_opt(10, 0, 0).unwrap()); + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev)); + ctx.decision_date = prev; + assert_eq!(super::completed_session_factor_date(&ctx, curr, curr, None), Some(prev)); + ctx.execution_date = prev; + assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None); + } + #[test] fn target_scale_replenishment_preserves_strategy_cash_allocation() { let scale = 30.0 / 31.0; diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index c39dfc0..9e2781d 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -1785,6 +1785,23 @@ pub fn platform_expr_config_from_spec( let Some(spec) = strategy_spec else { return Ok(cfg); }; + if let Some(conditions) = spec.stock_pool_factor_contract.as_ref() + .and_then(|contract| contract.get("conditions")) + .and_then(Value::as_array) + { + for condition in conditions { + let Some(binding) = condition.pointer("/semantic/backtestBinding") else { continue }; + let field = binding.get("field").and_then(Value::as_str).unwrap_or(""); + let dataset = binding.get("sourceDataset").and_then(Value::as_str).unwrap_or(""); + if !dataset.starts_with("indicators_") || field.is_empty() + || !field.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + || field.as_bytes()[0].is_ascii_digit() + { + return Err("invalid native factor backtest binding".to_string()); + } + cfg.completed_session_factor_fields.insert(field.to_string()); + } + } let mut benchmark_short_explicit = false; let mut benchmark_long_explicit = false; let mut stock_short_explicit = false; @@ -3110,6 +3127,20 @@ fn symbol_is_kcb(symbol: &str) -> bool { mod tests { use super::*; + #[test] + fn native_factor_bindings_declare_completed_session_fields() { + let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [ + {"factorRef": "up_days_stock", "semantic": {"backtestBinding": { + "field": "ths_up_days_stock", "sourceDataset": "indicators_up_days_stock" + }}} + ]}}); + let cfg = platform_expr_config_from_value("test", "000852.SH", &spec).unwrap(); + assert_eq!(cfg.completed_session_factor_fields, + BTreeSet::from(["ths_up_days_stock".to_string()])); + let empty = platform_expr_config_from_value("test", "000852.SH", &serde_json::json!({})).unwrap(); + assert!(empty.completed_session_factor_fields.is_empty()); + } + #[test] fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { assert_eq!(normalize_board("688001.SH", None), "KSH"); From cb97aa193dfb6bf083ae7ba79153f85221b21a1d Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 21:54:26 +0800 Subject: [PATCH 02/21] test: verify native daily values respect intraday availability --- .../fidc-core/src/platform_expr_strategy.rs | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index c03da60..648b3cc 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -13981,6 +13981,40 @@ mod tests { assert_eq!(super::completed_session_factor_date(&ctx, prev, prev, None), None); } + #[test] + fn completed_session_bound_value_does_not_read_same_day_future_value() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let symbol = "000001.SZ"; + let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components(); + for row in &mut parts.factors { + row.extra_factors.insert("native_daily".into(), if row.date == prev { 2.0 } else { 999.0 }); + row.extra_factors.insert("published_today".into(), 7.0); + } + let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, + parts.candidates, parts.benchmarks).unwrap(); + let portfolio = PortfolioState::new(10_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: curr, decision_date: curr, decision_index: 1, + data: &data, portfolio: &portfolio, futures_account: None, + open_orders: &[], dynamic_universe: None, subscriptions: &subscriptions, + process_events: &[], active_process_event: None, active_datetime: None, + order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.stock_filter_expr = "native_daily > 0 && published_today > 0".to_string(); + cfg.completed_session_factor_fields.insert("native_daily".to_string()); + let strategy = PlatformExprStrategy::new(cfg); + let morning = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol, + NaiveTime::from_hms_opt(10, 0, 0), false).unwrap(); + assert_eq!(morning.extra_factors["native_daily"], 2.0); + assert_eq!(morning.extra_factors["published_today"], 7.0); + let close = strategy.stock_state_with_factor_date_and_time(&ctx, curr, curr, symbol, + NaiveTime::from_hms_opt(15, 0, 0), false).unwrap(); + assert_eq!(close.extra_factors["native_daily"], 999.0); + } + #[test] fn target_scale_replenishment_preserves_strategy_cash_allocation() { let scale = 30.0 / 31.0; From 1b78186c4e37273c64c1f9e208c2e47021ee4fff Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 21:56:12 +0800 Subject: [PATCH 03/21] docs: record scoped native daily factor visibility tests --- ...tive-daily-factor-visibility-20260907.json | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 docs/evidence/native-daily-factor-visibility-20260907.json diff --git a/docs/evidence/native-daily-factor-visibility-20260907.json b/docs/evidence/native-daily-factor-visibility-20260907.json new file mode 100644 index 0000000..d5e6b44 --- /dev/null +++ b/docs/evidence/native-daily-factor-visibility-20260907.json @@ -0,0 +1,26 @@ +{ + "date": "2026-09-07", + "host": "192.168.31.177", + "identity": "boris", + "implementationCommit": "a02ac6e", + "valueRegressionCommit": "cb97aa1", + "scope": "Native daily indicator fields explicitly bound in stockPoolFactorContract; other factor fields and pricing are unchanged.", + "targetedTests": {"passed": 3, "failed": 0}, + "fullLibraryTestsBeforeAdditionalValueCase": {"passed": 447, "ignored": 6, "failed": 0}, + "provenCases": [ + "09:30, 10:30 and 14:30 resolve to the preceding trading date", + "15:00 resolves to the completed decision day", + "active intraday datetime applies when no explicit execution time exists", + "next-open retains the completed decision day", + "no previous trading date does not fall back to the current day", + "stock state with prior value 2 and current value 999 reads 2 intraday and 999 at close", + "unbound factor value remains unchanged" + ], + "productionDeployed": false, + "realBacktestAcceptanceComplete": false, + "limitations": [ + "This is not a generic per-field publication-timestamp model for all factor datasets.", + "Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.", + "Saved-version browser handoff and real same-sample intraday/next-open replay are still required." + ] +} From 4a7199275210272cbfcb27999ab5c0d8aa950145 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 22:14:59 +0800 Subject: [PATCH 04/21] docs: record intraday native factor and five-year next-open replays --- .../native-pit-intraday-20260907.json | 467 +++++++++++++++ .../native-pit-intraday-request-20260907.json | 531 ++++++++++++++++++ .../native-pit-nextopen-20260907.json | 276 +++++++++ .../native-pit-nextopen-request-20260907.json | 424 ++++++++++++++ ...tive-daily-factor-visibility-20260907.json | 35 +- 5 files changed, 1731 insertions(+), 2 deletions(-) create mode 100644 docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json create mode 100644 docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json create mode 100644 docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json create mode 100644 docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json new file mode 100644 index 0000000..c868aeb --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-20260907.json @@ -0,0 +1,467 @@ +{ + "schemaVersion": "fidc-backtest-benchmark/v5", + "label": "native-pit-intraday", + "generatedAtUnixSeconds": 1788790043, + "requestSha256": "9dd420ae606878b90ff660fcd4efe520e04a237ec758ed974b87b6d9a72cab73", + "requestOrigin": { + "kind": "request_file" + }, + "baseline": null, + "baselineComparable": true, + "baselineComparison": null, + "bundleRefresh": null, + "baseUrl": "http://127.0.0.1:8081", + "runs": [ + { + "slot": 0, + "cacheClass": "restart_or_cold", + "runId": "btr_1788790021780_1150210_0", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 8.994, + "totalSeconds": 8.994, + "runnerSeconds": 7.904, + "bundleValidationSeconds": 0.166, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.001, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.031, + "riskFreeRateCacheHit": false, + "engineSeconds": 0.012, + "dataSeconds": 7.662, + "resultSeconds": 0.03, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "resultFinalization": { + "canonicalSeconds": 0.001, + "processEventStoreSeconds": 0.001, + "storePublishSeconds": 0.027, + "summarySeconds": 0.0 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.344, + "candidatePlanBasePanelSeconds": 0.006, + "candidatePlanSelectionSeconds": 0.004, + "corporateActionSeconds": 0.605, + "datasetConstructSeconds": 0.0, + "externalFetchSeconds": 0.344, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.0, + "instrumentMetadataSeconds": 0.016, + "loopSeconds": 0.0, + "marketIndexBuildSeconds": 0.001, + "quotePlanSeconds": 0.935, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 5.752, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 7.659 + }, + "finalizationSeconds": 1.089, + "finalizationTimings": { + "artifactVerificationSeconds": 0.0, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.216, + "postgresPersistSeconds": 0.872, + "preTerminalUpdateSeconds": 1.089, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "riskDecisionCount": 11, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 104, + "sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e" + }, + "equityFacts": { + "rowCount": 5, + "sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060" + }, + "fillEvents": { + "rowCount": 104, + "sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48" + }, + "holdingSnapshots": { + "rowCount": 112, + "sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67" + }, + "orderEvents": { + "rowCount": 108, + "sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5" + }, + "riskAudits": { + "rowCount": 11, + "sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e" + } + }, + "sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalRows": 444 + }, + "processEventStore": { + "bytes": 44302, + "identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7", + "manifestBytes": 461, + "manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 1, + "bytes": 43841, + "rowCount": 370, + "sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6" + } + }, + "sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094", + "totalEvents": 370 + }, + "resultStore": { + "bytes": 148006, + "identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a", + "manifestBytes": 2999, + "manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 1, + "bytes": 13313, + "rowCount": 104, + "sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424" + }, + "equityFacts": { + "blockCount": 1, + "bytes": 11009, + "rowCount": 5, + "sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae" + }, + "fillEvents": { + "blockCount": 1, + "bytes": 40541, + "rowCount": 104, + "sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708" + }, + "holdingSnapshots": { + "blockCount": 1, + "bytes": 51338, + "rowCount": 112, + "sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9" + }, + "orderEvents": { + "blockCount": 1, + "bytes": 25768, + "rowCount": 108, + "sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c" + }, + "riskAudits": { + "blockCount": 1, + "bytes": 3038, + "rowCount": 11, + "sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff" + } + }, + "sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "totalEvents": 444 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + }, + { + "slot": 1, + "cacheClass": "process_hot", + "runId": "btr_1788790036494_1150210_1", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 0.596, + "totalSeconds": 0.596, + "runnerSeconds": 0.026, + "bundleValidationSeconds": 0.0, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.0, + "riskFreeRateCacheHit": true, + "engineSeconds": 0.012, + "dataSeconds": 0.007, + "resultSeconds": 0.002, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "resultFinalization": { + "canonicalSeconds": 0.001, + "processEventStoreSeconds": 0.001, + "storePublishSeconds": 0.0, + "summarySeconds": 0.0 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.0, + "candidatePlanBasePanelSeconds": 0.0, + "candidatePlanSelectionSeconds": 0.0, + "corporateActionSeconds": 0.0, + "datasetConstructSeconds": 0.0, + "externalFetchSeconds": 0.0, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.0, + "instrumentMetadataSeconds": 0.0, + "loopSeconds": 0.0, + "marketIndexBuildSeconds": 0.0, + "quotePlanSeconds": 0.0, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 0.0, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 0.006 + }, + "finalizationSeconds": 0.569, + "finalizationTimings": { + "artifactVerificationSeconds": 0.001, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.27, + "postgresPersistSeconds": 0.298, + "preTerminalUpdateSeconds": 0.569, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "riskDecisionCount": 11, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 104, + "sha256": "ea6bbbd55df3da0692dfd712f88ef6f8af510ada318641ede71884f5c5d14e7e" + }, + "equityFacts": { + "rowCount": 5, + "sha256": "96004e235e4603c0a7d1a5629a2f0d2b364258d562e1d397cdd6925718a7d060" + }, + "fillEvents": { + "rowCount": 104, + "sha256": "2c37f626b7de014f5638dc8edf33f3ca8d23126ab2e62f26f01fbcb33e8c4e48" + }, + "holdingSnapshots": { + "rowCount": 112, + "sha256": "50abab8a304d2e2a7de344538aff604c8bf96ef00283330d107916cc2e234f67" + }, + "orderEvents": { + "rowCount": 108, + "sha256": "f327f21151a5d7dbe53d2dcde9202be699f9672f758373eeb9c67985ca3826f5" + }, + "riskAudits": { + "rowCount": 11, + "sha256": "428a8d7395b54ec5a9523eb745d5007c64b9d9867615e870d95ea9ea176f536e" + } + }, + "sha256": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalRows": 444 + }, + "processEventStore": { + "bytes": 44302, + "identitySha256": "1444075abea626e8d7d1b8c9c9cda984a2a687ff2b9465db5648e0f4525130e7", + "manifestBytes": 461, + "manifestSha256": "b43a818096d09648b809a46d12ad9eae39638d57ea52f7696906359036a80c52", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 1, + "bytes": 43841, + "rowCount": 370, + "sha256": "8edb6dad95777e3eef5946acbbdfc4b9125df34a79ac716d8c3ff7835b552bf6" + } + }, + "sha256": "661d84b7b430ac25fb5c959df8e7fb309133784d85a7524375cd803e29e52094", + "totalEvents": 370 + }, + "resultStore": { + "bytes": 148006, + "identitySha256": "484b7a4a8d5cc77c6222ce01f2fe452c890995a476579f209d1d4ac43885d60a", + "manifestBytes": 2999, + "manifestSha256": "a0a7072f176db79d781846cc124d2e4794c5084c48c525876863751f709c4e7f", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 1, + "bytes": 13313, + "rowCount": 104, + "sha256": "ab2f936c9072f80d58487b62a6f64553d279582ddfe6af0299afe20447e83424" + }, + "equityFacts": { + "blockCount": 1, + "bytes": 11009, + "rowCount": 5, + "sha256": "c40650f5b27cfff873194572ffe28a5412fe77730fe75492c7b5a575f8f5caae" + }, + "fillEvents": { + "blockCount": 1, + "bytes": 40541, + "rowCount": 104, + "sha256": "80a3f1d3a9c23db7dec2dc70f029280c0844370be6d87703cd396a234068f708" + }, + "holdingSnapshots": { + "blockCount": 1, + "bytes": 51338, + "rowCount": 112, + "sha256": "7684244dc8e7788f777309169be61b14fcb532abdd89fe93f0a7f2980a615ba9" + }, + "orderEvents": { + "blockCount": 1, + "bytes": 25768, + "rowCount": 108, + "sha256": "31ff15d2cf79b1eb9c333d19d8fb400c9c5a52c36b0f662330ec39aa9d0f805c" + }, + "riskAudits": { + "blockCount": 1, + "bytes": 3038, + "rowCount": 11, + "sha256": "5a7269219a21cf14c23d788a6a96ddf1d1ddf0ef1e3b57a8db4ae4b1da789aff" + } + }, + "sha256": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "totalEvents": 444 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + } + ], + "summary": { + "resultConsistent": true, + "resultStoreDigest": "5ed069a1f5999542e6da78b10797aa21aabe71e13dd79649cd11157cbc57d3b4", + "canonicalResultDigest": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "totalReturn": 0.011211658838299776, + "tradeCount": 104, + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2025-09-12", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditSha256": "5836794e260aa2cfd1a06d4568ae6577a37c9cbe09ec0f00995121ea5b2522d9", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "restartOrCold": { + "queueWaitSeconds": 0.0, + "executionSeconds": 8.994, + "totalSeconds": 8.994, + "runnerSeconds": 7.904, + "bundleValidationSeconds": 0.166, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.001, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.031, + "riskFreeRateCacheHit": false, + "engineSeconds": 0.012, + "dataSeconds": 7.662, + "resultSeconds": 0.03, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "finalizationSeconds": 1.089 + }, + "processHotMedian": { + "queueWaitSeconds": 0.0, + "executionSeconds": 0.596, + "totalSeconds": 0.596, + "runnerSeconds": 0.026, + "bundleValidationSeconds": 0.0, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.0, + "riskFreeRateCacheHit": true, + "engineSeconds": 0.012, + "dataSeconds": 0.007, + "resultSeconds": 0.002, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.001, + "finalizationSeconds": 0.569 + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json new file mode 100644 index 0000000..6bcf4de --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-intraday-request-20260907.json @@ -0,0 +1,531 @@ +{ + "strategy_id": "benchmark-native-factor-overlay", + "strategy_version_id": "1.0.0", + "user_id": "2d84120b-567a-417e-b6a4-6ffedceb132f", + "runtime": { + "start_date": "2025-09-08", + "end_date": "2025-09-12", + "frequency": "1d", + "source_table": "strategy_factory_source_lake.daily_source_rows_v1", + "signal_symbol": "000300.SH", + "benchmark_symbol": "000300.SH", + "initial_cash": 10000000.0, + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991" + }, + "execution": { + "matchingType": "current_bar_close", + "rebalanceCashMode": "sell_then_buy", + "sellThenBuyDelaySlippageRate": 0, + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "commissionRate": 0.0001, + "minimumCommission": 0.0, + "stampTaxRateBeforeChange": 0.001, + "stampTaxRateAfterChange": 0.0005, + "stampTaxChangeDate": "2023-08-28", + "volumeLimit": true, + "liquidityLimit": false, + "volumePercent": 0.25, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + } + }, + "strategy_source": { + "source_type": "platform-strategy", + "language": "engine-script", + "parser": "omniquant-engine-script-v2", + "source_code": "strategy(\"许总_组合_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1).at([\"09:30\"])\n universe.include([\"688116.SH\", \"603200.SH\", \"300395.SZ\", \"600732.SH\", \"000063.SZ\", \"002625.SZ\", \"300811.SZ\", \"002851.SZ\", \"000988.SZ\", \"002281.SZ\", \"300205.SZ\", \"002239.SZ\", \"600079.SH\", \"600276.SH\", \"600909.SH\", \"002169.SZ\", \"603156.SH\", \"002654.SZ\", \"000999.SZ\", \"601066.SH\", \"603345.SH\", \"000333.SZ\", \"603726.SH\", \"688012.SH\"])\n selection.limit(24)\n selection.candidate_limit(24)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr((close > 0)) && (ths_up_days_stock >= 1)\n ordering.rank_expr((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24, \"asc\")\n risk.index_exposure(max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0)\n risk.stop_loss(holding_return <= -0.0800000000)\n risk.take_profit(holding_return >= 0.1600000000)\n risk.reference_price_mode(\"position_average_entry_price\")\n trading.daily_top_up(true)\n trading.daily_position_target_adjust(true)\n trading.target_portfolio_daily(true)\n trading.rebalance_existing_positions(true)\n trading.hold_until_exit(true)\n trading.retry_empty_rebalance(true)\n trading.release_slot_on_exit_signal(true)\n trading.redistribute_target_weights_after_exit(true)\n trading.reenter_exited_targets(true)\n execution.matching_type(\"current_bar_close\")\n}" + }, + "strategy_spec": { + "benchmark": { + "fallbackInstrumentId": "000300.SH", + "instrumentId": "000300.SH", + "note": "必须使用真实指数链路;若 000852.SH 不可用,应直接报错而不是退化到其他标的。" + }, + "engineConfig": { + "benchmarkSymbol": "000300.SH", + "commissionRate": 0.0001, + "dividendReinvestment": false, + "dynamicRange": { + "baseCapFloor": 7, + "baseIndexLevel": 2000, + "capSpan": 1000000000000, + "xs": 0.008 + }, + "frequency": "1d", + "indexThrottle": { + "defensiveExposure": 0.5, + "fullExposure": 1, + "longDays": 10, + "rsiRate": 1.0001, + "shortDays": 5 + }, + "liquidityLimit": false, + "matchingType": "current_bar_close", + "minimumCommission": 0.0, + "rankLimit": 24, + "rebalanceCashMode": "sell_then_buy", + "rebalanceSchedule": { + "frequency": "daily", + "time": "09:30" + }, + "refreshRate": 1, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "rsiRate": 1.0001, + "sellThenBuyDelaySlippageRate": 0, + "signalSymbol": "000300.SH", + "skipWindows": [], + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "stockMaFilter": { + "longDays": 20, + "midDays": 10, + "rsiRate": 1.0001, + "shortDays": 5, + "volumeLongDays": 60, + "volumeShortDays": 5 + }, + "stopLossMultiplier": 0.93, + "strictValueBudget": true, + "takeProfitMultiplier": 1.07, + "templateId": "许总_组合_backtest", + "volumeLimit": true, + "volumePercent": 0.25 + }, + "execution": { + "commissionRate": 0.0001, + "executionGranularity": "daily_or_minute_bar", + "extractor": "omniquant-engine-script-v2", + "frequency": "1d", + "liquidityLimit": false, + "matchingType": "current_bar_close", + "minimumCommission": 0.0, + "priceSource": "current_bar_close_or_next_bar_open_or_minute_bar", + "rebalanceCashMode": "sell_then_buy", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1", + "sellThenBuyDelaySlippageRate": 0, + "slippageModel": "price_ratio", + "slippageValue": 0.002, + "sourceKind": "platform-strategy", + "sourceLanguage": "engine-script", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "strictValueBudget": true, + "volumeLimit": true, + "volumePercent": 0.25 + }, + "factorRefs": [ + "close", + "ths_up_days_stock" + ], + "factorValueBindings": [], + "market": "CN_A", + "metadata": { + "backtestDataBundle": { + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991" + }, + "backtestDataBundleHash": "c83203674aa4ba23eed0bbc043c2a53f13bcbc37f45c395b3fb8d560a4d14991", + "backtestDataBundleId": "bt_bundle_85825809f940ce489825", + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "strategyExecutionContract": { + "commissionRate": "0.0001", + "dynamicSlippageImpact": "0", + "dynamicSlippageMax": "0", + "dynamicSlippageVolatility": "0", + "frequency": "1d", + "hash": "fnv1a-66a4a68e", + "liquidityLimitEnabled": false, + "matchingType": "current_bar_close", + "minimumCommission": "0", + "rebalanceCashMode": "sell_then_buy", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": true, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": false, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "maxOrderNotional": 100000000, + "maxOrderQuantity": 1000000, + "maxSymbolPosition": 10000000, + "minimumCommission": 0, + "rejectBjseBuy": true, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": false, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "sellThenBuyDelaySlippageRate": "0", + "slippageMode": "fixed", + "slippageRate": "0.002", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": "0.0005", + "stampTaxRateBeforeChange": "0.001", + "version": "fidc-strategy-execution-v1", + "volumeLimitEnabled": true, + "volumePercent": "0.25" + }, + "strategyExecutionContractHash": "fnv1a-66a4a68e", + "strategyExecutionContractVersion": "fidc-strategy-execution-v1", + "strategyRiskPolicyVersion": "fidc-risk-policy-v2" + }, + "mode": "rotation", + "rebalance": { + "dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合", + "frequencyDays": 1, + "schedule": { + "frequency": "daily", + "time": "09:30" + }, + "tradeTimes": [ + "09:30" + ] + }, + "risk": { + "indexThrottleExpr": "max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0))", + "stopLossExpr": "holding_return <= -0.0800000000", + "stopTakeReferencePriceMode": "position_average_entry_price", + "takeProfitExpr": "holding_return >= 0.1600000000" + }, + "runtimeExpressions": { + "allocation": { + "buyScaleExpr": "(((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0" + }, + "ordering": { + "rankBy": "market_cap", + "rankExpr": "(((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24", + "rankOrder": "asc" + }, + "prelude": "", + "risk": { + "exposureExpr": "max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0))", + "stopLossExpr": "holding_return <= -0.0800000000", + "stopTakeReferencePriceMode": "position_average_entry_price", + "takeProfitExpr": "holding_return >= 0.1600000000" + }, + "schedule": { + "frequency": "daily", + "time": "09:30" + }, + "selection": { + "candidateLimitExpr": "24", + "limitExpr": "24", + "marketCapField": "close", + "marketCapLowerExpr": "0", + "marketCapUpperExpr": "1000000000000", + "stockFilterExpr": "((close > 0)) && (ths_up_days_stock >= 1)" + }, + "trading": { + "actions": [], + "dailyPositionTargetAdjust": true, + "dailyTopUp": true, + "holdUntilExit": true, + "rebalanceExistingPositions": true, + "redistributeTargetWeightsAfterExit": true, + "reenterExitedTargets": true, + "releaseSlotOnExitSignal": true, + "retryEmptyRebalance": true, + "rotationEnabled": true, + "stage": "on_day", + "subscriptionGuardRequired": false, + "targetPortfolioDaily": true + } + }, + "seasonality": { + "skipWindows": [] + }, + "selectors": [ + { + "field": "market_cap", + "lowerExpr": "0", + "mapping": "close -> strategy_factory_source_lake.runtime_fields.close", + "type": "dynamicRange", + "upperExpr": "1000000000000" + }, + { + "expr": "((close > 0)) && (ths_up_days_stock >= 1)", + "type": "filter" + }, + { + "limitExpr": "24", + "orderBy": [ + "market_cap asc" + ], + "type": "rank" + } + ], + "signalSymbol": "000300.SH", + "sourceCode": "strategy(\"许总_组合_backtest\") {\n mode(\"rotation\")\n market(\"CN_A\")\n benchmark(\"000300.SH\")\n signal(\"000300.SH\")\n rebalance.every_days(1).at([\"09:30\"])\n universe.include([\"688116.SH\", \"603200.SH\", \"300395.SZ\", \"600732.SH\", \"000063.SZ\", \"002625.SZ\", \"300811.SZ\", \"002851.SZ\", \"000988.SZ\", \"002281.SZ\", \"300205.SZ\", \"002239.SZ\", \"600079.SH\", \"600276.SH\", \"600909.SH\", \"002169.SZ\", \"603156.SH\", \"002654.SZ\", \"000999.SZ\", \"601066.SH\", \"603345.SH\", \"000333.SZ\", \"603726.SH\", \"688012.SH\"])\n selection.limit(24)\n selection.candidate_limit(24)\n selection.market_cap_band(field=\"close\", lower=0, upper=1000000000000)\n filter.stock_expr((close > 0)) && (ths_up_days_stock >= 1)\n ordering.rank_expr((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) ? ((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) ? (((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") ? ((symbol == \"688116.SH\" || symbol == \"603200.SH\") ? (symbol == \"688116.SH\" ? (0) : (1)) : (2)) : ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") ? (symbol == \"600732.SH\" ? (3) : (4)) : (5))) : (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") ? ((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") ? (symbol == \"300811.SZ\" ? (6) : (7)) : (8)) : ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") ? (symbol == \"002281.SZ\" ? (9) : (10)) : (11)))) : ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) ? (((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") ? ((symbol == \"600079.SH\" || symbol == \"600276.SH\") ? (symbol == \"600079.SH\" ? (12) : (13)) : (14)) : ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") ? (symbol == \"002169.SZ\" ? (15) : (16)) : (17))) : (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") ? ((symbol == \"000999.SZ\" || symbol == \"601066.SH\") ? (symbol == \"000999.SZ\" ? (18) : (19)) : (20)) : ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") ? (symbol == \"000333.SZ\" ? (21) : (22)) : (23))))) : 24, \"asc\")\n risk.index_exposure(max(0.0, 0.9000000000 - 0.0000 / max(total_equity, 1.0)))\n allocation.buy_scale((((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || symbol == \"300395.SZ\") || ((symbol == \"600732.SH\" || symbol == \"000063.SZ\") || symbol == \"002625.SZ\")) || (((symbol == \"300811.SZ\" || symbol == \"002851.SZ\") || symbol == \"000988.SZ\") || ((symbol == \"002281.SZ\" || symbol == \"300205.SZ\") || symbol == \"002239.SZ\"))) || ((((symbol == \"600079.SH\" || symbol == \"600276.SH\") || symbol == \"600909.SH\") || ((symbol == \"002169.SZ\" || symbol == \"603156.SH\") || symbol == \"002654.SZ\")) || (((symbol == \"000999.SZ\" || symbol == \"601066.SH\") || symbol == \"603345.SH\") || ((symbol == \"000333.SZ\" || symbol == \"603726.SH\") || symbol == \"688012.SH\")))) ? (((((symbol == \"688116.SH\" || symbol == \"603200.SH\") || (symbol == \"300395.SZ\" || symbol == \"600732.SH\")) || ((symbol == \"000063.SZ\" || symbol == \"002625.SZ\") || (symbol == \"300811.SZ\" || symbol == \"002851.SZ\"))) || (((symbol == \"000988.SZ\" || symbol == \"002281.SZ\") || (symbol == \"300205.SZ\" || symbol == \"002239.SZ\")) || ((symbol == \"600079.SH\" || symbol == \"600276.SH\") || (symbol == \"600909.SH\" || symbol == \"002169.SZ\")))) ? (1.0008000000) : (0.9984000000)) : 0.0)\n risk.stop_loss(holding_return <= -0.0800000000)\n risk.take_profit(holding_return >= 0.1600000000)\n risk.reference_price_mode(\"position_average_entry_price\")\n trading.daily_top_up(true)\n trading.daily_position_target_adjust(true)\n trading.target_portfolio_daily(true)\n trading.rebalance_existing_positions(true)\n trading.hold_until_exit(true)\n trading.retry_empty_rebalance(true)\n trading.release_slot_on_exit_signal(true)\n trading.redistribute_target_weights_after_exit(true)\n trading.reenter_exited_targets(true)\n execution.matching_type(\"current_bar_close\")\n}", + "strategyId": "许总_组合_backtest", + "universe": { + "exclude": [], + "implementationNotes": [ + "ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行", + "上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断", + "盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动" + ], + "include": [ + "688116.SH", + "603200.SH", + "300395.SZ", + "600732.SH", + "000063.SZ", + "002625.SZ", + "300811.SZ", + "002851.SZ", + "000988.SZ", + "002281.SZ", + "300205.SZ", + "002239.SZ", + "600079.SH", + "600276.SH", + "600909.SH", + "002169.SZ", + "603156.SH", + "002654.SZ", + "000999.SZ", + "601066.SH", + "603345.SH", + "000333.SZ", + "603726.SH", + "688012.SH" + ] + }, + "version": "1.0.0", + "stockPoolFactorContract": { + "schemaVersion": 1, + "entryLogic": "all", + "exitLogic": "any", + "conditions": [ + { + "factorRef": "up_days_stock", + "label": "连涨天数", + "role": "selection", + "registryRole": "selection_feature", + "roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57", + "roleEvidence": { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + }, + "operator": ">=", + "threshold": 1, + "semantic": { + "ref": "up_days_stock", + "label": "连涨天数", + "status": "available", + "queryable": true, + "source": "strategy-factory-source-lake:indicator", + "schema": "strategy-factory.value-semantics/v1", + "valueType": "integer", + "semanticType": "count", + "comparisonGroup": "count", + "storageUnit": "days", + "inputUnit": "days", + "inputScale": 1.0, + "allowedOperators": [ + ">", + ">=", + "<", + "<=", + "==", + "!=", + "between", + "in" + ], + "nullable": true, + "declared": true, + "metadataStatus": "declared", + "semanticProvenance": "explicit_manifest", + "businessSemanticDeclared": true, + "minimum": 0, + "backtestBinding": { + "field": "ths_up_days_stock", + "sourceDataset": "indicators_up_days_stock" + }, + "tradingRoles": [ + { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + } + ], + "tradingRoleTradable": true, + "tradingRoleEvidenceStatus": "source_lake_registered_indicator", + "tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57" + } + } + ] + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json new file mode 100644 index 0000000..5fbd2d8 --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-20260907.json @@ -0,0 +1,276 @@ +{ + "schemaVersion": "fidc-backtest-benchmark/v5", + "label": "native-pit-nextopen", + "generatedAtUnixSeconds": 1788790370, + "requestSha256": "7948497bfa966eb118e56438e7c261869a26ab70a62186b92f761719f85e91c7", + "requestOrigin": { + "kind": "request_file" + }, + "baseline": null, + "baselineComparable": true, + "baselineComparison": null, + "bundleRefresh": null, + "baseUrl": "http://127.0.0.1:8081", + "runs": [ + { + "slot": 0, + "cacheClass": "restart_or_cold", + "runId": "btr_1788790344805_1150210_2", + "status": "succeeded", + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "resultFinalization": { + "canonicalSeconds": 0.206, + "processEventStoreSeconds": 0.168, + "storePublishSeconds": 0.005, + "summarySeconds": 0.003 + }, + "dataPreparationTimings": { + "adjustmentValidationSeconds": 0.0, + "availableStockStDates": 0, + "benchmarkFetchSeconds": 0.023, + "candidatePlanBasePanelSeconds": 0.0, + "candidatePlanSelectionSeconds": 0.0, + "corporateActionSeconds": 0.017, + "datasetConstructSeconds": 2.943, + "externalFetchSeconds": 0.025, + "historyDateSeconds": 0.0, + "instrumentIndexBuildSeconds": 0.269, + "instrumentMetadataSeconds": 0.006, + "loopSeconds": 0.925, + "marketIndexBuildSeconds": 1.846, + "quotePlanSeconds": 0.0, + "riskSnapshotSeconds": 0.0, + "sourceQuerySeconds": 9.0, + "stockStLookupSeconds": 0.0, + "supplementalExecutionFetchSeconds": 0.0, + "totalSeconds": 15.3 + }, + "finalizationSeconds": 0.307, + "finalizationTimings": { + "artifactVerificationSeconds": 0.008, + "cacheRegistrationSeconds": 0.0, + "postgresClientAcquireSeconds": 0.0, + "postgresPersistSeconds": 0.299, + "preTerminalUpdateSeconds": 0.307, + "schemaCheckSeconds": 0.0, + "timingSource": "fidc-backtest-service-monotonic" + }, + "totalReturn": 0.9857987122635, + "tradeCount": 25408, + "riskDecisionCount": 113, + "resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2026-08-28", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditVerified": true, + "terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationVerified": true, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "artifactManifest": { + "canonicalResult": { + "algorithm": "sha256", + "ordering": "engine_fact_order_v2", + "schemaVersion": "fidc-canonical-backtest-result/v2", + "sections": { + "accountEvents": { + "rowCount": 25572, + "sha256": "da7576c1cbade9b48f1a154d07e870f1472924f4e0054cba0ef06739585ce380" + }, + "equityFacts": { + "rowCount": 1216, + "sha256": "13db0a6d484cdf871e4f1dc1a4bc8db1e809d2808b21cf4fcab3961e6ad9e6fe" + }, + "fillEvents": { + "rowCount": 25408, + "sha256": "92e0d103bbb487af45d27b720cdc92ef398d16e11655a6d0bcff8046013335db" + }, + "holdingSnapshots": { + "rowCount": 33164, + "sha256": "97e3d60230a167a960bd2f615adfa33a9171527b62f1a181e2d5af694090e492" + }, + "orderEvents": { + "rowCount": 25541, + "sha256": "635fe79eab9b1db0c116da2860ded66c350c051f44751d36306d158777039744" + }, + "riskAudits": { + "rowCount": 113, + "sha256": "17bc940bcac18b050235ae3f17195f12586d1e29cd7bc64b6189edf2e07d1406" + } + }, + "sha256": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "totalRows": 111014 + }, + "processEventStore": { + "bytes": 10115947, + "identitySha256": "e89d00ea1c2525c2ab1b57a29f2447047f2523083a953869ea2e25a488986565", + "manifestBytes": 9757, + "manifestSha256": "9bff5b97488c8804275ee65a717f01dca9ac68443b9766d4aab8d36189b1d8a1", + "schemaVersion": "fidc-backtest-process-event-manifest/v1", + "sections": { + "processEvents": { + "blockCount": 61, + "bytes": 10106190, + "rowCount": 83859, + "sha256": "f436a32403aaa74b3068a0d8c74bfb7c429523d3f78617234eec82a009ab3da2" + } + }, + "sha256": "aa7488614a85f1527713332039b0fa212d1807f11b03e0e701480bf5d8ede1a4", + "totalEvents": 83859 + }, + "resultStore": { + "bytes": 35831197, + "identitySha256": "a238986d82354e802d2c73526d035021b38e1eed0a2b0b0f1eb6ce41ed4f321b", + "manifestBytes": 54237, + "manifestSha256": "0991f096db161fd16da00a7529ece54a8cf68643d88aeec8f4d3fedc6c740d03", + "schemaVersion": "fidc-backtest-fact-manifest/v1", + "sections": { + "accountEvents": { + "blockCount": 61, + "bytes": 3303444, + "rowCount": 25572, + "sha256": "276fd3edd7e60afbb05fa32c111db3e1df2025637887509b6353b24c7f127a48" + }, + "equityFacts": { + "blockCount": 61, + "bytes": 3761920, + "rowCount": 1216, + "sha256": "42c22c70bad16b01d8f70fa80977028b2789381772e4eff7bb231701e18293a1" + }, + "fillEvents": { + "blockCount": 61, + "bytes": 7731746, + "rowCount": 25408, + "sha256": "c59a4a1e70195bd8a2421941e11b7fbb138d658796ef4d42466c44505b8353d8" + }, + "holdingSnapshots": { + "blockCount": 61, + "bytes": 15176388, + "rowCount": 33164, + "sha256": "6e40d3f1d43005dc4f0c492662c4fdf600ca96e9abe874db4b56b9bd51e89cd4" + }, + "orderEvents": { + "blockCount": 61, + "bytes": 5775840, + "rowCount": 25541, + "sha256": "14fa27db9328c4c6337157af4fc1efc1c4bc9f239a4605bf92439b0f3f426200" + }, + "riskAudits": { + "blockCount": 36, + "bytes": 27622, + "rowCount": 113, + "sha256": "2f6d9a01ec568ed13943b9590e21d73bf614ce2c777302764533d2da0168b2d5" + } + }, + "sha256": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "totalEvents": 111014 + }, + "schemaVersion": "fidc-backtest-artifacts/v4" + } + } + ], + "summary": { + "resultConsistent": true, + "resultStoreDigest": "1eab5a5ffe59efeaba6b2048a33760aab31d42efa0fc10e35a4a7048db853235", + "canonicalResultDigest": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "totalReturn": 0.9857987122635, + "tradeCount": 25408, + "terminalAudit": { + "cashReceivableCount": 0, + "cashReceivableTotalAmount": 0.0, + "earliestDeferredCashDate": null, + "futuresOpenOrderCount": 0, + "lastExecutionDate": "2026-08-28", + "omittedOpenOrderCount": 0, + "openOrderSamples": [], + "pendingCashFlowCount": 0, + "pendingCashFlowNetAmount": 0.0, + "status": "clean", + "stockOpenOrderCount": 0 + }, + "terminalAuditSha256": "0e8503c2bfe9ecfd93bab3a95f79461830886eb693cfe6f477279908c0d95bd5", + "implementation": { + "buildManifestSha256": "fb4f7ac191c8ab8d8d39fc48014f84ce01d4c0731ffe986e7d5cb0107a3e5539", + "builtAt": "2026-09-07T22:06:18+08:00", + "engineCommit": "1b78186c4e37273c64c1f9e208c2e47021ee4fff", + "identitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "runnerBinarySha256": "7cddebf9ab5cbbd5d6f9bd5c7bf006c3895ee26967d23d80dc86b47c50fb761d", + "schemaVersion": "fidc-backtest-implementation/v1", + "serviceBinarySha256": "f09d121d73c34ad3cd5e2e0c24b0bdaf1974309c9ec3b93229cfb824b1e9f11a", + "serviceCommit": "c80a38b91a398fd713f1f1faaa19a581ea85efad", + "status": "verified" + }, + "implementationIdentitySha256": "14e7ff10848d64ffcec232dea8cff23a032702301b81ae036bb0b26b4b0b5e33", + "restartOrCold": { + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "finalizationSeconds": 0.307 + }, + "processHotMedian": { + "queueWaitSeconds": 0.0, + "executionSeconds": 21.61, + "totalSeconds": 21.61, + "runnerSeconds": 21.294, + "bundleValidationSeconds": 0.004, + "sourceEpochSeconds": 0.0, + "strategyContractSeconds": 0.003, + "terminalCalendarSeconds": 0.0, + "riskFreeRateSeconds": 0.017, + "riskFreeRateCacheHit": false, + "engineSeconds": 5.566, + "dataSeconds": 15.305, + "resultSeconds": 0.395, + "unattributedSeconds": 0.0, + "canonicalSeconds": 0.206, + "finalizationSeconds": 0.307 + } + } +} diff --git a/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json new file mode 100644 index 0000000..5034e40 --- /dev/null +++ b/docs/evidence/native-daily-factor-replays-20260907/native-pit-nextopen-request-20260907.json @@ -0,0 +1,424 @@ +{ + "strategy_id": "benchmark-native-factor-overlay", + "strategy_version_id": "goal-five-year-semantics-v1", + "user_id": "boris", + "runtime": { + "start_date": "2021-08-23", + "end_date": "2026-08-28", + "frequency": "1d", + "source_table": "strategy_factory_source_lake.daily_source_rows_v1", + "signal_symbol": "000852.SH", + "benchmark_symbol": "000852.SH", + "initial_cash": 10000000.0, + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f" + }, + "execution": { + "matchingType": "next_bar_open", + "rebalanceCashMode": "same_point_net", + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "commissionRate": 0.0001, + "minimumCommission": 5.0, + "stampTaxRateBeforeChange": 0.001, + "stampTaxRateAfterChange": 0.0005, + "stampTaxChangeDate": "2023-08-28", + "volumeLimit": true, + "liquidityLimit": false, + "volumePercent": 0.25, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + } + }, + "strategy_source": { + "source_type": "platform-strategy", + "language": "engine-script", + "parser": "omniquant-engine-script-v2", + "source_code": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}" + }, + "strategy_spec": { + "benchmark": { + "fallbackInstrumentId": "000852.SH", + "instrumentId": "000852.SH" + }, + "engineConfig": { + "benchmarkSymbol": "000852.SH", + "commissionRate": 0.0001, + "dividendReinvestment": false, + "dynamicRange": { + "baseCapFloor": 7, + "baseIndexLevel": 2000, + "capSpan": 10, + "xs": 0.008 + }, + "frequency": "1d", + "indexThrottle": { + "defensiveExposure": 0.5, + "fullExposure": 1, + "longDays": 130, + "rsiRate": 1.0001, + "shortDays": 1 + }, + "liquidityLimit": false, + "matchingType": "next_bar_open", + "minimumCommission": 5.0, + "rankLimit": 30, + "rebalanceCashMode": "same_point_net", + "rebalanceSchedule": { + "frequency": "daily", + "time": "15:00" + }, + "refreshRate": 1, + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "rsiRate": 1.0001, + "signalSymbol": "000852.SH", + "skipWindows": [], + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "stockMaFilter": { + "longDays": 30, + "midDays": 10, + "rsiRate": 1.0001, + "shortDays": 5, + "volumeLongDays": 100, + "volumeShortDays": 5 + }, + "stopLossMultiplier": 0.08, + "strictValueBudget": true, + "takeProfitMultiplier": 0.16, + "templateId": "xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure", + "volumeLimit": true, + "volumePercent": 0.25 + }, + "execution": { + "commissionRate": 0.0001, + "executionGranularity": "daily_or_minute_bar", + "extractor": "omniquant-engine-script-v2", + "frequency": "1d", + "liquidityLimit": false, + "matchingType": "next_bar_open", + "minimumCommission": 5.0, + "priceSource": "current_bar_close_or_next_bar_open_or_minute_bar", + "rebalanceCashMode": "same_point_net", + "riskPolicy": { + "allowMarketOrders": true, + "blacklistEnabled": false, + "blacklistedSymbols": [], + "commissionRate": 0.0001, + "forbidSameDayRebuyAfterSell": true, + "liquidityLimitEnabled": false, + "liveTradingEnabled": false, + "minimumCommission": 5.0, + "rejectBjseBuy": false, + "rejectBjseSelection": false, + "rejectInactiveBuy": true, + "rejectInactiveSelection": false, + "rejectInactiveSell": true, + "rejectKcbBuy": true, + "rejectKcbSelection": false, + "rejectLowerLimitSelection": false, + "rejectLowerLimitSell": true, + "rejectNewListingBuy": true, + "rejectNewListingSelection": false, + "rejectOneYuanBuy": true, + "rejectOneYuanSelection": false, + "rejectPausedBuy": true, + "rejectPausedSelection": false, + "rejectPausedSell": true, + "rejectStBuy": true, + "rejectStSelection": false, + "rejectStarStBuy": true, + "rejectStarStSelection": false, + "rejectUpperLimitBuy": true, + "rejectUpperLimitSelection": false, + "respectAllowBuySell": true, + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "volumeLimitEnabled": true, + "volumePercent": 0.25 + }, + "selectionGranularity": "strategy_factory_source_lake.daily_source_rows_v1", + "slippageModel": "price_ratio", + "slippageValue": 0.0001, + "sourceKind": "platform-strategy", + "sourceLanguage": "engine-script", + "stampTaxChangeDate": "2023-08-28", + "stampTaxRateAfterChange": 0.0005, + "stampTaxRateBeforeChange": 0.001, + "strictValueBudget": true, + "volumeLimit": true, + "volumePercent": 0.25 + }, + "factorRefs": [ + "market_cap", + "ths_up_days_stock" + ], + "market": "CN_A", + "metadata": { + "backtestDataBundle": { + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1", + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f" + }, + "backtestDataBundleHash": "d7c1461131edaecb5981e207852782d92e636dbfee9fd7c44063605d96eb2b4f", + "backtestDataBundleId": "bt_bundle_b44e03990c76064f54a9", + "sourceTable": "strategy_factory_source_lake.daily_source_rows_v1" + }, + "rebalance": { + "dailyApproximation": "日线回测按 matching_type 撮合;分钟线回测按交易时刻分钟价格撮合", + "frequencyDays": 1, + "schedule": { + "frequency": "daily", + "time": "15:00" + }, + "tradeTimes": [ + "15:00" + ] + }, + "risk": { + "indexThrottleExpr": "final_exposure_t", + "stopLossExpr": "0.08", + "stopTakeReferencePriceMode": "signal_day_post_adjusted_close", + "takeProfitExpr": "0.16" + }, + "runtimeExpressions": { + "allocation": { + "buyScaleExpr": "30.0 / 31.0" + }, + "ordering": { + "rankBy": "market_cap", + "rankExpr": "", + "rankOrder": "asc" + }, + "prelude": "let stocknum = 30;\nlet candidate_pool_size = 50;\nlet position_denominator_extra = 1;\nlet signal_close_t = rolling_mean_current(\"signal_close\", 1);\nlet signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\nlet signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\nlet signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\nlet signal_high60_t = rolling_max_current(\"signal_close\", 60);\nlet signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\nlet signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\nlet market_cap_lower_t = 12.0 + signal_range_t * 5.0;\nlet market_cap_upper_t = 40.0 + signal_range_t * 5.0;\nlet base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\nlet volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\nlet drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\nlet final_exposure_t = signal_close_t > 0.0 && signal_ma10_t > 0.0 && signal_ma30_t > 0.0 && signal_high60_t > 0.0 ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t) : 0.0;\nlet warmup_probe = rolling_sum(\"amount\", 125);", + "risk": { + "exposureExpr": "final_exposure_t", + "stopLossExpr": "0.08", + "stopTakeReferencePriceMode": "signal_day_post_adjusted_close", + "takeProfitExpr": "0.16" + }, + "schedule": { + "frequency": "daily", + "time": "15:00" + }, + "selection": { + "candidateLimitExpr": "50", + "limitExpr": "stocknum", + "marketCapField": "market_cap", + "marketCapLowerExpr": "market_cap_lower_t", + "marketCapUpperExpr": "market_cap_upper_t", + "stockFilterExpr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)" + }, + "trading": { + "actions": [], + "dailyPositionTargetAdjust": true, + "dailyTopUp": true, + "holdUntilExit": true, + "maxHoldingDays": 90, + "rebalanceExistingPositions": true, + "releaseSlotOnExitSignal": true, + "retryEmptyRebalance": true, + "rotationEnabled": true, + "stage": "on_day", + "subscriptionGuardRequired": false, + "targetPortfolioDaily": true + } + }, + "seasonality": { + "skipWindows": [] + }, + "selectors": [ + { + "field": "market_cap", + "lowerExpr": "market_cap_lower_t", + "mapping": "market_cap -> strategy_factory_source_lake.runtime_fields.market_cap", + "type": "dynamicRange", + "upperExpr": "market_cap_upper_t" + }, + { + "expr": "(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1)", + "type": "filter" + }, + { + "limitExpr": "stocknum", + "orderBy": [ + "market_cap asc" + ], + "type": "rank" + } + ], + "signalSymbol": "000852.SH", + "sourceCode": "strategy(\"xiaoshizhi_1_06_dynamic_small_cap_csi2000_signal_day_exposure\") {\n market(\"CN_A\");\n benchmark(\"000300.SH\");\n signal(\"932000.CSI\");\n\n let stocknum = 30;\n let candidate_pool_size = 50;\n let position_denominator_extra = 1;\n let signal_close_t = rolling_mean_current(\"signal_close\", 1);\n let signal_ma10_t = rolling_mean_current(\"signal_close\", 10);\n let signal_ma30_t = rolling_mean_current(\"signal_close\", 30);\n let signal_vol20_t = rolling_return_stddev_current(\"signal_close\", 20);\n let signal_high60_t = rolling_max_current(\"signal_close\", 60);\n let signal_drawdown60_t = 1.0 - safe_div(signal_close_t, signal_high60_t);\n let signal_range_t = safe_div(clamp(signal_close_t, 2000.0, 3000.0) - 2000.0, 1000.0);\n let market_cap_lower_t = 12.0 + signal_range_t * 5.0;\n let market_cap_upper_t = 40.0 + signal_range_t * 5.0;\n let base_exposure_t = signal_ma10_t > signal_ma30_t ? 1.0 : 0.3;\n let volatility_exposure_t = signal_vol20_t >= 0.025 ? 0.3 : 1.0;\n let drawdown_exposure_t = signal_drawdown60_t >= 0.08 ? 0.2 : 1.0;\n let final_exposure_t =\n signal_close_t > 0.0 &&\n signal_ma10_t > 0.0 &&\n signal_ma30_t > 0.0 &&\n signal_high60_t > 0.0\n ? min(min(base_exposure_t, volatility_exposure_t), drawdown_exposure_t)\n : 0.0;\n\n rebalance.every_days(1).at([\"15:00\"]);\n\n selection.market_cap_band(\n field=\"market_cap\",\n lower=market_cap_lower_t,\n upper=market_cap_upper_t\n );\n\n filter.stock_expr(((!is_star_st && !is_kcb && !is_bjse && rolling_mean_current(\"close\", 5) > rolling_mean_current(\"close\", 10) && rolling_mean_current(\"close\", 10) > rolling_mean_current(\"close\", 30) && rolling_mean_current(\"volume\", 5) < rolling_mean_current(\"volume\", 100)) && (!is_st))) && (ths_up_days_stock >= 1);\n\n ordering.rank_by(\"market_cap\", \"asc\");\n selection.candidate_limit(50);\n selection.limit(stocknum);\n\n allocation.buy_scale(30.0 / 31.0);\n execution.strict_value_budget(true)\n\n trading.hold_until_exit(true);\n trading.max_holding_days(90);\n trading.daily_top_up(true);\n trading.daily_position_target_adjust(true);\n trading.target_portfolio_daily(true);\n trading.rebalance_existing_positions(true);\n trading.retry_empty_rebalance(true);\n trading.release_slot_on_exit_signal(true);\n\n risk.stop_loss(0.08);\n risk.take_profit(0.16);\n risk.reference_price_mode(\"signal_day_post_adjusted_close\");\n risk.index_exposure(final_exposure_t);\n\n risk.policy(reject_st_selection=false, reject_st_buy=true, reject_star_st_selection=false, reject_star_st_buy=true, reject_paused_selection=false, reject_paused_buy=true, reject_paused_sell=true, reject_inactive_selection=false, reject_inactive_buy=true, reject_inactive_sell=true, reject_new_listing_selection=false, reject_new_listing_buy=true, reject_kcb_selection=false, reject_kcb_buy=true, reject_bjse_selection=false, reject_bjse_buy=false, reject_one_yuan_selection=false, reject_one_yuan_buy=true, respect_allow_buy_sell=true, reject_upper_limit_selection=false, reject_lower_limit_selection=false, reject_upper_limit_buy=true, reject_lower_limit_sell=true, forbid_same_day_rebuy_after_sell=true, blacklist_enabled=false, blacklisted_symbols=[], allow_market_orders=true, live_trading_enabled=false, volume_limit_enabled=true, liquidity_limit_enabled=false, volume_percent=0.25, commission_rate=0.0001, minimum_commission=5.0, stamp_tax_rate_before_change=0.001, stamp_tax_rate_after_change=0.0005, stamp_tax_change_date=\"2023-08-28\");\n\n execution.matching_type(\"next_bar_open\");\n execution.slippage(\"price_ratio\", 0.0001);\n execution.rebalance_cash_mode(\"same_point_net\");\n}", + "strategyId": "warmup-expression-contract-acceptance", + "universe": { + "exclude": [], + "implementationNotes": [ + "ST、停牌、退市、新股、科创、一元、涨跌停、同日卖出禁买、成交量和费用由 riskPolicy / RiskLimits 统一执行", + "上市日期与退市日期取自 instrument 结构化字段,不再使用股票名称做 ST/退市判断", + "盘中 current_price / last_price 由策略交易时刻批量 tick 查询驱动" + ] + }, + "version": "1.0.0", + "stockPoolFactorContract": { + "schemaVersion": 1, + "entryLogic": "all", + "exitLogic": "any", + "conditions": [ + { + "factorRef": "up_days_stock", + "label": "连涨天数", + "role": "selection", + "registryRole": "selection_feature", + "roleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57", + "roleEvidence": { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + }, + "operator": ">=", + "threshold": 1, + "semantic": { + "ref": "up_days_stock", + "label": "连涨天数", + "status": "available", + "queryable": true, + "source": "strategy-factory-source-lake:indicator", + "schema": "strategy-factory.value-semantics/v1", + "valueType": "integer", + "semanticType": "count", + "comparisonGroup": "count", + "storageUnit": "days", + "inputUnit": "days", + "inputScale": 1.0, + "allowedOperators": [ + ">", + ">=", + "<", + "<=", + "==", + "!=", + "between", + "in" + ], + "nullable": true, + "declared": true, + "metadataStatus": "declared", + "semanticProvenance": "explicit_manifest", + "businessSemanticDeclared": true, + "minimum": 0, + "backtestBinding": { + "field": "ths_up_days_stock", + "sourceDataset": "indicators_up_days_stock" + }, + "tradingRoles": [ + { + "role": "selection_feature", + "polarity": "trend_persistence_positive", + "signalShape": "state", + "holdingStates": [ + "flat" + ], + "requiredConfirmations": [], + "cooldownTradingDays": 0, + "windowTradingDays": 1, + "recommendedParameters": { + "inputUnit": "days", + "minimum": 0 + } + } + ], + "tradingRoleTradable": true, + "tradingRoleEvidenceStatus": "source_lake_registered_indicator", + "tradingRoleRegistrySha256": "1d0b307c168feda08d5fbe20f0e88964230553f8ceb2017b66aec48dbd5a5b57" + } + } + ] + } + } +} diff --git a/docs/evidence/native-daily-factor-visibility-20260907.json b/docs/evidence/native-daily-factor-visibility-20260907.json index d5e6b44..904b803 100644 --- a/docs/evidence/native-daily-factor-visibility-20260907.json +++ b/docs/evidence/native-daily-factor-visibility-20260907.json @@ -16,11 +16,42 @@ "stock state with prior value 2 and current value 999 reads 2 intraday and 999 at close", "unbound factor value remains unchanged" ], - "productionDeployed": false, + "backtestServiceDeployed": true, + "paperLiveRuntimeDeployed": false, "realBacktestAcceptanceComplete": false, + "scopedBacktestEvidence": { + "intraday": { + "range": "2025-09-08..2025-09-12", + "time": "09:30", + "runIds": ["btr_1788790021780_1150210_0", "btr_1788790036494_1150210_1"], + "seconds": [8.994, 0.596], + "tradeCount": 104, + "riskDecisionCount": 11, + "canonical": "5c8a110cc6f285b9d569e818a0472a8b5c76f14df853a1c2c42d1b5222c39b3a", + "identical": true, + "persistedFactorBindingVerified": true, + "rawParquetAudit": { + "buyFills": 60, + "priorPassCurrentFailExamples": 21, + "existingPositionTopUpsBelowCurrentSelectionThreshold": 23, + "retainedTargetReentryBelowCurrentSelectionThreshold": {"symbol": "600276.SH", "date": "2025-09-12", "priorExit": "2025-09-11 stop_loss_exit", "configuration": "reenterExitedTargets=true", "reason": "model_target_portfolio_daily"}, + "note": "Selection-only conditions are not an execution-time buy veto. Position adjustment and explicit retained-target reentry must be audited separately from fresh candidate selection." + } + }, + "nextOpen": { + "range": "2021-08-23..2026-08-28", + "runId": "btr_1788790344805_1150210_2", + "seconds": 21.610, + "tradeCount": 25408, + "canonical": "b29b085d43bcc0f8f1712767421781c70570a24112933623d4bbbef46508d710", + "matchesPreFixBaseline": true + }, + "terminalAudits": "clean", + "rawEvidenceDirectory": "native-daily-factor-replays-20260907" + }, "limitations": [ "This is not a generic per-field publication-timestamp model for all factor datasets.", "Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.", - "Saved-version browser handoff and real same-sample intraday/next-open replay are still required." + "Saved-version browser handoff, all runtime deployment and broader factor/PIT acceptance remain required; these replays use isolated API research fixtures." ] } From bc666c6433004cfc443d7755a3488f14b75e17b9 Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 7 Sep 2026 22:30:14 +0800 Subject: [PATCH 05/21] docs: record paper and live deployment of native factor timing fix --- docs/evidence/native-daily-factor-visibility-20260907.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/evidence/native-daily-factor-visibility-20260907.json b/docs/evidence/native-daily-factor-visibility-20260907.json index 904b803..5b068ad 100644 --- a/docs/evidence/native-daily-factor-visibility-20260907.json +++ b/docs/evidence/native-daily-factor-visibility-20260907.json @@ -17,7 +17,8 @@ "unbound factor value remains unchanged" ], "backtestServiceDeployed": true, - "paperLiveRuntimeDeployed": false, + "paperLiveRuntimeDeployed": true, + "paperLiveDeploymentEvidence": "/Users/boris/WorkSpace/fidc-trading-platform/docs/evidence/trading-engine-revision-deployment-20260907.json", "realBacktestAcceptanceComplete": false, "scopedBacktestEvidence": { "intraday": { @@ -52,6 +53,6 @@ "limitations": [ "This is not a generic per-field publication-timestamp model for all factor datasets.", "Raw dynamic fields used without a stock-pool native binding need separate availability-contract review.", - "Saved-version browser handoff, all runtime deployment and broader factor/PIT acceptance remain required; these replays use isolated API research fixtures." + "Broader factor/PIT and actual trading acceptance remain required; these replays use isolated API research fixtures. Browser draft handoff is separately recorded in OmniQuant documentation." ] } From bac721e5934f09ff014cc65fcb4973ecc2dd9f4c Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 00:40:10 +0800 Subject: [PATCH 06/21] feat: add decision-scoped buy denials to broker submission --- crates/fidc-core/src/broker.rs | 72 +++++++++++++++++++ crates/fidc-core/src/engine.rs | 12 ++++ .../fidc-core/src/platform_expr_strategy.rs | 3 + crates/fidc-core/src/strategy.rs | 11 ++- crates/fidc-core/tests/corporate_actions.rs | 1 + .../fidc-core/tests/decision_quote_preload.rs | 2 + crates/fidc-core/tests/delisting.rs | 1 + crates/fidc-core/tests/engine_hooks.rs | 15 ++++ crates/fidc-core/tests/explicit_order_flow.rs | 66 +++++++++++++++++ 9 files changed, 182 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 11c9633..856e260 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -380,6 +380,7 @@ pub struct BrokerSimulator { runtime_intraday_start_time: Cell>, runtime_intraday_end_time: Cell>, runtime_decision_date: Cell>, + runtime_buy_denials: RefCell>, runtime_order_created_date: Cell>, runtime_decision_total_equity: Cell>, runtime_target_position_limit: Cell>, @@ -412,6 +413,7 @@ impl BrokerSimulator { runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), + runtime_buy_denials: RefCell::new(BTreeMap::new()), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), @@ -448,6 +450,7 @@ impl BrokerSimulator { runtime_intraday_start_time: Cell::new(None), runtime_intraday_end_time: Cell::new(None), runtime_decision_date: Cell::new(None), + runtime_buy_denials: RefCell::new(BTreeMap::new()), runtime_order_created_date: Cell::new(None), runtime_decision_total_equity: Cell::new(None), runtime_target_position_limit: Cell::new(None), @@ -1385,6 +1388,7 @@ where decision: &StrategyDecision, ) -> Result { let previous_decision_date = self.runtime_decision_date.get(); + let previous_buy_denials = self.runtime_buy_denials.replace(decision.buy_denials.clone()); let previous_order_created_date = self.runtime_order_created_date.get(); let previous_decision_total_equity = self.runtime_decision_total_equity.get(); self.runtime_decision_date.set(Some(decision_date)); @@ -1393,6 +1397,7 @@ where self.runtime_decision_total_equity .set(decision_total_equity.filter(|equity| equity.is_finite() && *equity >= 0.0)); let result = self.execute_with_runtime_dates(date, portfolio, data, decision); + self.runtime_buy_denials.replace(previous_buy_denials); self.runtime_decision_date.set(previous_decision_date); self.runtime_order_created_date .set(previous_order_created_date); @@ -4189,6 +4194,9 @@ where if !rule.allowed { return rule.reason; } + if let Some(reason) = self.runtime_buy_denials.borrow().get(symbol) { + return Some(reason.clone()); + } match self.market_fillable_quantity( snapshot, OrderSide::Buy, @@ -6140,6 +6148,12 @@ where data.instrument(symbol), algo_request, ); + let rule = if rule.allowed && emit_creation_events { + self.runtime_buy_denials.borrow().get(symbol) + .map_or(rule, |reason| RuleCheck::reject(reason.clone())) + } else { + rule + }; if !rule.allowed { let rule_reason = rule.reason.as_deref().unwrap_or_default().to_string(); let status = match rule.reason.as_deref() { @@ -8204,6 +8218,7 @@ mod tests { fn next_open_buy_decision() -> StrategyDecision { StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: 100, @@ -8213,8 +8228,58 @@ mod tests { } } + #[test] + fn decision_buy_denial_blocks_topup_but_allows_sell_and_does_not_leak() { + let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); + let data = DataSet::from_components( + vec![limit_test_instrument()], + vec![dated_limit_test_snapshot(first), dated_limit_test_snapshot(second)], + Vec::new(), + vec![dated_limit_test_candidate(first, false, false, true, true), + dated_limit_test_candidate(second, false, false, true, true)], + vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)], + ).unwrap(); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose); + let mut portfolio = PortfolioState::new(100_000.0); + broker.execute(first, &mut portfolio, &data, &next_open_buy_decision()).unwrap(); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100); + let mut blocked = StrategyDecision::default(); + blocked.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); + blocked.order_intents.push(OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(), + }); + let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap(); + assert!(report.fills.is_empty()); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100); + assert!(broker.runtime_buy_denials.borrow().is_empty()); + blocked.order_intents = next_open_sell_decision().order_intents; + let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap(); + assert_eq!(report.fills.len(), 1); + assert_eq!(report.fills[0].side, OrderSide::Sell); + assert!(broker.runtime_buy_denials.borrow().is_empty()); + } + + #[test] + fn decision_buy_denial_does_not_rewrite_existing_pending_order() { + let date = limit_test_snapshot().date; + let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], + Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::CurrentBarClose); + broker.upsert_open_order(test_open_order(99)); + let mut decision = StrategyDecision::default(); + decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); + let mut portfolio = PortfolioState::new(100_000.0); + let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap(); + assert!(!report.fills.is_empty()); + assert!(broker.runtime_buy_denials.borrow().is_empty()); + } + fn next_open_sell_decision() -> StrategyDecision { StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: -100, @@ -10192,6 +10257,7 @@ mod tests { ) .expect("valid dataset"); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000002.SZ".to_string(), @@ -10304,6 +10370,7 @@ mod tests { .position_mut("000002.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), @@ -10371,6 +10438,7 @@ mod tests { .position_mut("000002.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), @@ -10431,6 +10499,7 @@ mod tests { .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), target_value: 9_000.0, @@ -10595,6 +10664,7 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 10_000.0, @@ -10636,6 +10706,7 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 10_000.0, @@ -10673,6 +10744,7 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetPortfolioSmart { target_weights: BTreeMap::from([("000001.SZ".to_string(), 0.5)]), order_prices: None, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index 5cd3cd1..e0b2053 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4856,6 +4856,7 @@ mod tests { ) -> Result { if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4896,6 +4897,7 @@ mod tests { ); if ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4955,6 +4957,7 @@ mod tests { rule: &ScheduleRule, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4992,6 +4995,7 @@ mod tests { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5028,6 +5032,7 @@ mod tests { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetPortfolioSmart { target_weights: self.target_weights.clone(), order_prices: None, @@ -5070,6 +5075,7 @@ mod tests { Vec::new() }; Ok(StrategyDecision { + buy_denials: Default::default(), order_intents, ..StrategyDecision::default() }) @@ -5106,6 +5112,7 @@ mod tests { return Ok(StrategyDecision::default()); }; Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol, quantity: 100, @@ -5166,6 +5173,7 @@ mod tests { .unwrap_or_default() )); Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5206,6 +5214,7 @@ mod tests { && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5217,6 +5226,7 @@ mod tests { if ctx.decision_date == self.sell_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: -(position.quantity as i32), @@ -5256,6 +5266,7 @@ mod tests { && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5267,6 +5278,7 @@ mod tests { if ctx.decision_date == self.rebuy_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: SYMBOL.to_string(), diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 648b3cc..3a04c39 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -9913,6 +9913,7 @@ impl PlatformExprStrategy { )]; diagnostics.extend(action_diagnostics); Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -12578,6 +12579,7 @@ impl Strategy for PlatformExprStrategy { )); } return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols, @@ -13854,6 +13856,7 @@ impl Strategy for PlatformExprStrategy { ]; Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols, diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 6744815..68cc98f 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -977,6 +977,7 @@ fn safe_ratio(numerator: f64, denominator: f64) -> f64 { #[derive(Debug, Clone, Default)] pub struct StrategyDecision { + pub buy_denials: BTreeMap, pub rebalance: bool, pub target_weights: BTreeMap, pub exit_symbols: BTreeSet, @@ -988,6 +989,7 @@ pub struct StrategyDecision { impl StrategyDecision { pub fn merge_from(&mut self, mut other: StrategyDecision) { + self.buy_denials.append(&mut other.buy_denials); self.rebalance |= other.rebalance; self.target_weights.append(&mut other.target_weights); self.exit_symbols.append(&mut other.exit_symbols); @@ -998,7 +1000,8 @@ impl StrategyDecision { } pub fn is_empty(&self) -> bool { - !self.rebalance + self.buy_denials.is_empty() + && !self.rebalance && self.target_weights.is_empty() && self.exit_symbols.is_empty() && self.order_intents.is_empty() @@ -1569,6 +1572,7 @@ impl Strategy for CnSmallCapRotationStrategy { if self.config.in_skip_window(ctx.decision_date) { self.last_gross_exposure = Some(0.0); return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: true, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), @@ -1590,6 +1594,7 @@ impl Strategy for CnSmallCapRotationStrategy { if message.contains("signal series insufficient") => { return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1765,6 +1770,7 @@ impl Strategy for CnSmallCapRotationStrategy { self.last_gross_exposure = Some(gross_exposure); Ok(StrategyDecision { + buy_denials: Default::default(), rebalance, target_weights, exit_symbols, @@ -2773,6 +2779,7 @@ impl Strategy for OmniMicroCapStrategy { let lagged_execution = ctx.is_lagged_execution(); if self.config.in_skip_window(signal_date) { return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: ctx.portfolio.positions().keys().cloned().collect(), @@ -2803,6 +2810,7 @@ impl Strategy for OmniMicroCapStrategy { if message.contains("insufficient benchmark") => { return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -3013,6 +3021,7 @@ impl Strategy for OmniMicroCapStrategy { ]; Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols, diff --git a/crates/fidc-core/tests/corporate_actions.rs b/crates/fidc-core/tests/corporate_actions.rs index f72351d..27ed34a 100644 --- a/crates/fidc-core/tests/corporate_actions.rs +++ b/crates/fidc-core/tests/corporate_actions.rs @@ -90,6 +90,7 @@ impl Strategy for BuyAndHoldStrategy { ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index fc06c99..81f899c 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -37,6 +37,7 @@ impl Strategy for DecisionQuoteReader { self.day_count += 1; if self.day_count == 1 { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 5_000.0, @@ -626,6 +627,7 @@ impl Strategy for MultiTimeDecisionQuoteReader { self.day_count += 1; if self.day_count == 1 { return Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 5_000.0, diff --git a/crates/fidc-core/tests/delisting.rs b/crates/fidc-core/tests/delisting.rs index 48e3f4c..96d9bc1 100644 --- a/crates/fidc-core/tests/delisting.rs +++ b/crates/fidc-core/tests/delisting.rs @@ -24,6 +24,7 @@ impl Strategy for BuyThenHoldStrategy { ) -> Result { if ctx.decision_date == d(2025, 1, 2) && ctx.portfolio.position("000001.SZ").is_none() { return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 49cb815..05cbebc 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -295,6 +295,7 @@ impl Strategy for HookProbeStrategy { .borrow_mut() .push(format!("on_day:{}", ctx.execution_date)); Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -334,6 +335,7 @@ impl Strategy for AuctionOrderStrategy { _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -377,6 +379,7 @@ impl Strategy for FuturesOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -413,6 +416,7 @@ impl Strategy for FuturesLimitOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -444,6 +448,7 @@ impl Strategy for FuturesInvalidTickLimitStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -475,6 +480,7 @@ impl Strategy for FuturesClosedPhaseOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::open( "IF2501", @@ -506,6 +512,7 @@ impl Strategy for FuturesAboveUpperLimitStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -537,6 +544,7 @@ impl Strategy for FuturesDepthLimitOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -720,6 +728,7 @@ impl Strategy for LimitCarryStrategy { } self.issued = true; Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -803,6 +812,7 @@ impl Strategy for UniverseDirectiveStrategy { _ => Vec::new(), }; Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -844,6 +854,7 @@ impl Strategy for MinuteProbeStrategy { _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -885,6 +896,7 @@ impl Strategy for MinuteProbeStrategy { } self.ordered = true; Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -987,6 +999,7 @@ impl Strategy for OrderInspectionStrategy { _ctx: &StrategyContext<'_>, ) -> Result { Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1030,6 +1043,7 @@ impl Strategy for AccountFlowStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4134,6 +4148,7 @@ impl Strategy for BuyMissingRowThenHoldStrategy { ) -> Result { if ctx.execution_date == d(2025, 5, 26) { return Ok(StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index e97244e..26d3b8a 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -209,6 +209,7 @@ fn execute_single_value_order( &mut portfolio, data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -399,6 +400,7 @@ fn broker_executes_explicit_order_value_buy() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -557,6 +559,7 @@ fn broker_delayed_limit_open_sell_uses_minute_price() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -687,6 +690,7 @@ fn broker_executes_order_shares_and_order_lots() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -806,6 +810,7 @@ fn broker_executes_target_shares_like_order_to() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -981,6 +986,7 @@ fn broker_executes_target_portfolio_smart_with_custom_prices() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1139,6 +1145,7 @@ fn broker_executes_target_portfolio_smart_with_algo_order_style() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1254,6 +1261,7 @@ fn broker_executes_order_percent_and_target_percent() { &mut percent_portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1278,6 +1286,7 @@ fn broker_executes_order_percent_and_target_percent() { &mut target_percent_portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1380,6 +1389,7 @@ fn broker_uses_day_open_price_for_open_auction_matching() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1487,6 +1497,7 @@ fn broker_open_auction_uses_auction_volume_without_quote_liquidity() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1590,6 +1601,7 @@ fn broker_cancels_buy_when_open_hits_upper_limit() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1707,6 +1719,7 @@ fn broker_applies_price_ratio_slippage_on_snapshot_fills() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1812,6 +1825,7 @@ fn broker_applies_dynamic_slippage_on_snapshot_fills() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -1933,6 +1947,7 @@ fn broker_applies_tick_size_slippage_on_intraday_last_fills() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2039,6 +2054,7 @@ fn broker_rejects_intraday_last_order_without_execution_quotes() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2163,6 +2179,7 @@ fn broker_executes_intraday_last_on_start_quote_with_trade_delta() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2282,6 +2299,7 @@ fn broker_cancels_market_order_remainder_when_intraday_quote_liquidity_exhausted &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2398,6 +2416,7 @@ fn broker_cancels_market_buy_when_minute_has_no_volume() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2534,6 +2553,7 @@ fn broker_splits_intraday_quote_fills_and_tracks_commission_by_order() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2707,6 +2727,7 @@ fn broker_aggregates_intraday_quote_fills_into_vwap_leg() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -2888,6 +2909,7 @@ fn broker_executes_algo_vwap_value_with_time_window() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -3036,6 +3058,7 @@ fn broker_executes_algo_twap_percent_across_window_quotes() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -3172,6 +3195,7 @@ fn broker_uses_best_own_price_for_intraday_matching() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -3290,6 +3314,7 @@ fn broker_uses_best_counterparty_price_for_intraday_matching() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -3461,6 +3486,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: true, target_weights: BTreeMap::from([("000002.SZ".to_string(), 1.0)]), exit_symbols: BTreeSet::new(), @@ -3657,6 +3683,7 @@ fn rebalance_uses_day_open_for_open_auction_valuation() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: true, target_weights: BTreeMap::from([ ("000001.SZ".to_string(), 0.5), @@ -3841,6 +3868,7 @@ fn rebalance_optimizer_prioritizes_higher_target_weight_when_cash_is_tight() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: true, target_weights: BTreeMap::from([ ("000001.SZ".to_string(), 0.2), @@ -4025,6 +4053,7 @@ fn rebalance_optimizer_does_not_scale_targets_above_requested_weight() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: true, target_weights: BTreeMap::from([ ("000001.SZ".to_string(), 0.48), @@ -4139,6 +4168,7 @@ fn broker_uses_board_specific_min_quantity_and_step_size_for_buy_sizing() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4244,6 +4274,7 @@ fn broker_allows_bjse_quantities_above_minimum_without_round_lot_step() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4350,6 +4381,7 @@ fn broker_allows_full_odd_lot_sell_when_liquidating_position() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4483,6 +4515,7 @@ fn same_day_sell_then_rebuy_is_rejected_by_default() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4627,6 +4660,7 @@ fn same_day_sell_then_rebuy_can_be_allowed_by_policy() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4680,6 +4714,7 @@ fn broker_configured_policy_can_allow_upper_limit_buy() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4726,6 +4761,7 @@ fn broker_configured_policy_can_allow_lower_limit_sell() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4901,6 +4937,7 @@ fn broker_expires_day_limit_buy_at_market_close() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4941,6 +4978,7 @@ fn broker_expires_day_limit_buy_at_market_close() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -4976,6 +5014,7 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5014,6 +5053,7 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() { .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = || StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000002.SZ".to_string(), quantity: 100, @@ -5061,6 +5101,7 @@ fn broker_persists_quote_depth_until_fresh_level_data_arrives() { .with_liquidity_limit(true); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = |quantity| StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: symbol.to_string(), quantity, @@ -5078,6 +5119,7 @@ fn broker_persists_quote_depth_until_fresh_level_data_arrives() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: symbol.to_string(), @@ -5182,6 +5224,7 @@ fn broker_persists_quote_volume_participation_until_next_quote() { .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = |quantity| StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: symbol.to_string(), quantity, @@ -5264,6 +5307,7 @@ fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5305,6 +5349,7 @@ fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5351,6 +5396,7 @@ fn broker_fok_order_fills_when_full_quantity_is_available() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5388,6 +5434,7 @@ fn broker_gtc_limit_order_survives_close_and_fills_next_day() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5439,6 +5486,7 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5503,6 +5551,7 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5524,6 +5573,7 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(400), @@ -5591,6 +5641,7 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5614,6 +5665,7 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(200), @@ -5668,6 +5720,7 @@ fn broker_rejected_modify_has_zero_side_effects() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5691,6 +5744,7 @@ fn broker_rejected_modify_has_zero_side_effects() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(100), @@ -5728,6 +5782,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { ); let mut portfolio = PortfolioState::new(1_000_000.0); let create = |reason: &str| StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5758,6 +5813,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id: initial_ids[0], new_total_quantity: Some(200), @@ -5787,6 +5843,7 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id: initial_ids[0], new_total_quantity: None, @@ -5828,6 +5885,7 @@ fn broker_rejects_gtc_for_market_order() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5867,6 +5925,7 @@ fn broker_uses_limit_price_slippage_for_limit_orders() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -5905,6 +5964,7 @@ fn broker_rejects_limit_buy_when_final_execution_price_reaches_upper_limit() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -5949,6 +6009,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() { &mut value_portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -5974,6 +6035,7 @@ fn broker_executes_limit_value_and_limit_percent_intents() { &mut percent_portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -6010,6 +6072,7 @@ fn broker_cancels_open_order_by_order_id() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -6033,6 +6096,7 @@ fn broker_cancels_open_order_by_order_id() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -6080,6 +6144,7 @@ fn broker_emits_cancellation_reject_for_unknown_order() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), @@ -6188,6 +6253,7 @@ fn broker_reserves_sellable_quantity_for_open_limit_sells() { &mut portfolio, &data, &StrategyDecision { + buy_denials: Default::default(), rebalance: false, target_weights: BTreeMap::new(), exit_symbols: BTreeSet::new(), From da27204a71cdf2d182370e7db0dfd7d63a0db29d Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 00:41:11 +0800 Subject: [PATCH 07/21] test: assert broker fill events for scoped buy denials --- crates/fidc-core/src/broker.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 856e260..0350a8e 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -8251,13 +8251,13 @@ mod tests { symbol: "000001.SZ".to_string(), target_value: 3_000.0, reason: "topup".to_string(), }); let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap(); - assert!(report.fills.is_empty()); + assert!(report.fill_events.is_empty()); assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 100); assert!(broker.runtime_buy_denials.borrow().is_empty()); blocked.order_intents = next_open_sell_decision().order_intents; let report = broker.execute(second, &mut portfolio, &data, &blocked).unwrap(); - assert_eq!(report.fills.len(), 1); - assert_eq!(report.fills[0].side, OrderSide::Sell); + assert_eq!(report.fill_events.len(), 1); + assert_eq!(report.fill_events[0].side, OrderSide::Sell); assert!(broker.runtime_buy_denials.borrow().is_empty()); } @@ -8273,7 +8273,7 @@ mod tests { decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); let mut portfolio = PortfolioState::new(100_000.0); let report = broker.execute(date, &mut portfolio, &data, &decision).unwrap(); - assert!(!report.fills.is_empty()); + assert!(!report.fill_events.is_empty()); assert!(broker.runtime_buy_denials.borrow().is_empty()); } From 7d293f092e17425c2180d80c9f7bbbf9af170fcf Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 00:43:54 +0800 Subject: [PATCH 08/21] test: cover next-open side flips and trim redundant default fields --- crates/fidc-core/src/broker.rs | 47 +++++++++++++++---- crates/fidc-core/src/engine.rs | 12 ----- .../fidc-core/tests/decision_quote_preload.rs | 2 - crates/fidc-core/tests/engine_hooks.rs | 5 -- crates/fidc-core/tests/explicit_order_flow.rs | 20 -------- 5 files changed, 38 insertions(+), 48 deletions(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 0350a8e..652ee38 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -8218,7 +8218,6 @@ mod tests { fn next_open_buy_decision() -> StrategyDecision { StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: 100, @@ -8277,9 +8276,46 @@ mod tests { assert!(broker.runtime_buy_denials.borrow().is_empty()); } + #[test] + fn decision_buy_denial_uses_actual_next_open_target_delta() { + let first = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); + let second = chrono::NaiveDate::from_ymd_opt(2025, 1, 3).unwrap(); + let mut next = dated_limit_test_snapshot(second); + next.day_open = 9.5; + next.open = 9.5; + next.close = 9.5; + next.last_price = 9.5; + next.bid1 = 9.5; + next.ask1 = 9.5; + let data = DataSet::from_components(vec![limit_test_instrument()], + vec![dated_limit_test_snapshot(first), next], Vec::new(), + vec![dated_limit_test_candidate(first, false, false, true, true), + dated_limit_test_candidate(second, false, false, true, true)], + vec![dated_limit_test_benchmark(first), dated_limit_test_benchmark(second)]).unwrap(); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks) + .with_matching_type(MatchingType::NextBarOpen); + let mut portfolio = PortfolioState::new(1_000_000.0); + let mut initial = StrategyDecision::default(); + initial.order_intents.push(OrderIntent::Shares { + symbol: "000001.SZ".to_string(), quantity: 10_000, reason: "initial".to_string(), + }); + broker.execute(first, &mut portfolio, &data, &initial).unwrap(); + let mut decision = StrategyDecision::default(); + decision.buy_denials.insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); + // Below the signal-day holding value, but above next-open value. + decision.order_intents.push(OrderIntent::TargetValue { + symbol: "000001.SZ".to_string(), target_value: 97_500.0, reason: "target".to_string(), + }); + let report = broker.execute_with_event_dates_and_decision_equity( + second, first, first, None, &mut portfolio, &data, &decision).unwrap(); + assert!(report.fill_events.is_empty()); + assert_eq!(portfolio.position("000001.SZ").unwrap().quantity, 10_000); + assert!(report.order_events.iter().any(|event| event.side == OrderSide::Buy)); + assert!(broker.runtime_buy_denials.borrow().is_empty()); + } + fn next_open_sell_decision() -> StrategyDecision { StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000001.SZ".to_string(), quantity: -100, @@ -10257,7 +10293,6 @@ mod tests { ) .expect("valid dataset"); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000002.SZ".to_string(), @@ -10370,7 +10405,6 @@ mod tests { .position_mut("000002.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), @@ -10438,7 +10472,6 @@ mod tests { .position_mut("000002.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), @@ -10499,7 +10532,6 @@ mod tests { .position_mut("000001.SZ") .buy(prev_date, 1_000, 10.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000003.SZ".to_string(), target_value: 9_000.0, @@ -10664,7 +10696,6 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 10_000.0, @@ -10706,7 +10737,6 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetValue { symbol: "000001.SZ".to_string(), target_value: 10_000.0, @@ -10744,7 +10774,6 @@ mod tests { .with_inactive_limit(false); let mut portfolio = PortfolioState::new(20_000.0); let decision = StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetPortfolioSmart { target_weights: BTreeMap::from([("000001.SZ".to_string(), 0.5)]), order_prices: None, diff --git a/crates/fidc-core/src/engine.rs b/crates/fidc-core/src/engine.rs index e0b2053..5cd3cd1 100644 --- a/crates/fidc-core/src/engine.rs +++ b/crates/fidc-core/src/engine.rs @@ -4856,7 +4856,6 @@ mod tests { ) -> Result { if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4897,7 +4896,6 @@ mod tests { ); if ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4957,7 +4955,6 @@ mod tests { rule: &ScheduleRule, ) -> Result { Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -4995,7 +4992,6 @@ mod tests { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5032,7 +5028,6 @@ mod tests { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::TargetPortfolioSmart { target_weights: self.target_weights.clone(), order_prices: None, @@ -5075,7 +5070,6 @@ mod tests { Vec::new() }; Ok(StrategyDecision { - buy_denials: Default::default(), order_intents, ..StrategyDecision::default() }) @@ -5112,7 +5106,6 @@ mod tests { return Ok(StrategyDecision::default()); }; Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol, quantity: 100, @@ -5173,7 +5166,6 @@ mod tests { .unwrap_or_default() )); Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5214,7 +5206,6 @@ mod tests { && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5226,7 +5217,6 @@ mod tests { if ctx.decision_date == self.sell_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: -(position.quantity as i32), @@ -5266,7 +5256,6 @@ mod tests { && ctx.portfolio.position(SYMBOL).is_none() { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: SYMBOL.to_string(), quantity: 100, @@ -5278,7 +5267,6 @@ mod tests { if ctx.decision_date == self.rebuy_decision_date { if let Some(position) = ctx.portfolio.position(SYMBOL) { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: SYMBOL.to_string(), diff --git a/crates/fidc-core/tests/decision_quote_preload.rs b/crates/fidc-core/tests/decision_quote_preload.rs index 81f899c..fc06c99 100644 --- a/crates/fidc-core/tests/decision_quote_preload.rs +++ b/crates/fidc-core/tests/decision_quote_preload.rs @@ -37,7 +37,6 @@ impl Strategy for DecisionQuoteReader { self.day_count += 1; if self.day_count == 1 { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 5_000.0, @@ -627,7 +626,6 @@ impl Strategy for MultiTimeDecisionQuoteReader { self.day_count += 1; if self.day_count == 1 { return Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Value { symbol: "000001.SZ".to_string(), value: 5_000.0, diff --git a/crates/fidc-core/tests/engine_hooks.rs b/crates/fidc-core/tests/engine_hooks.rs index 05cbebc..2faf10a 100644 --- a/crates/fidc-core/tests/engine_hooks.rs +++ b/crates/fidc-core/tests/engine_hooks.rs @@ -416,7 +416,6 @@ impl Strategy for FuturesLimitOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -448,7 +447,6 @@ impl Strategy for FuturesInvalidTickLimitStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -480,7 +478,6 @@ impl Strategy for FuturesClosedPhaseOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::open( "IF2501", @@ -512,7 +509,6 @@ impl Strategy for FuturesAboveUpperLimitStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", @@ -544,7 +540,6 @@ impl Strategy for FuturesDepthLimitOrderStrategy { return Ok(StrategyDecision::default()); } Ok(StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Futures { intent: FuturesOrderIntent::limit_open( "IF2501", diff --git a/crates/fidc-core/tests/explicit_order_flow.rs b/crates/fidc-core/tests/explicit_order_flow.rs index 26d3b8a..f2fb863 100644 --- a/crates/fidc-core/tests/explicit_order_flow.rs +++ b/crates/fidc-core/tests/explicit_order_flow.rs @@ -5014,7 +5014,6 @@ fn broker_ioc_limit_order_fills_available_quantity_and_cancels_remainder() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5053,7 +5052,6 @@ fn broker_persists_daily_volume_consumption_across_execute_calls() { .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = || StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: "000002.SZ".to_string(), quantity: 100, @@ -5101,7 +5099,6 @@ fn broker_persists_quote_depth_until_fresh_level_data_arrives() { .with_liquidity_limit(true); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = |quantity| StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: symbol.to_string(), quantity, @@ -5119,7 +5116,6 @@ fn broker_persists_quote_depth_until_fresh_level_data_arrives() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: symbol.to_string(), @@ -5224,7 +5220,6 @@ fn broker_persists_quote_volume_participation_until_next_quote() { .with_liquidity_limit(false); let mut portfolio = PortfolioState::new(1_000_000.0); let decision = |quantity| StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::Shares { symbol: symbol.to_string(), quantity, @@ -5307,7 +5302,6 @@ fn broker_day_market_order_cancels_remainder_without_creating_invalid_open_order &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5349,7 +5343,6 @@ fn broker_fok_order_is_atomic_when_liquidity_is_insufficient() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5396,7 +5389,6 @@ fn broker_fok_order_fills_when_full_quantity_is_available() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), @@ -5434,7 +5426,6 @@ fn broker_gtc_limit_order_survives_close_and_fills_next_day() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5486,7 +5477,6 @@ fn broker_gtc_partial_fills_preserve_cumulative_order_and_commission_state() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5551,7 +5541,6 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5573,7 +5562,6 @@ fn broker_modifies_gtc_limit_order_without_changing_order_identity() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(400), @@ -5641,7 +5629,6 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5665,7 +5652,6 @@ fn broker_modifies_partially_filled_gtc_total_and_preserves_commission_state() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(200), @@ -5720,7 +5706,6 @@ fn broker_rejected_modify_has_zero_side_effects() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5744,7 +5729,6 @@ fn broker_rejected_modify_has_zero_side_effects() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id, new_total_quantity: Some(100), @@ -5782,7 +5766,6 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { ); let mut portfolio = PortfolioState::new(1_000_000.0); let create = |reason: &str| StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::LimitShares { symbol: "000002.SZ".to_string(), @@ -5813,7 +5796,6 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id: initial_ids[0], new_total_quantity: Some(200), @@ -5843,7 +5825,6 @@ fn broker_accepted_modify_resets_queue_priority_but_reduction_preserves_it() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![OrderIntent::ModifyOrder { order_id: initial_ids[0], new_total_quantity: None, @@ -5885,7 +5866,6 @@ fn broker_rejects_gtc_for_market_order() { &mut portfolio, &data, &StrategyDecision { - buy_denials: Default::default(), order_intents: vec![ OrderIntent::Shares { symbol: "000002.SZ".to_string(), From c85afb59ab19c113437b055c43a9a642977ea7c0 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 00:53:48 +0800 Subject: [PATCH 09/21] docs: define factor decision phase integration and acceptance gates --- ...cision-buy-denial-foundation-20260908.json | 16 +++++++++ docs/factor-decision-phase-contract.md | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 docs/evidence/decision-buy-denial-foundation-20260908.json create mode 100644 docs/factor-decision-phase-contract.md diff --git a/docs/evidence/decision-buy-denial-foundation-20260908.json b/docs/evidence/decision-buy-denial-foundation-20260908.json new file mode 100644 index 0000000..4a056a8 --- /dev/null +++ b/docs/evidence/decision-buy-denial-foundation-20260908.json @@ -0,0 +1,16 @@ +{ + "date": "2026-09-08", + "host": "192.168.31.177", + "candidateCommit": "7d293f0", + "unitTestsPassed": 451, + "integrationTestsPassed": 122, + "ignoredManualBenchmarks": 8, + "failed": 0, + "backtestRunnerCargoCheck": "passed", + "covered": ["top-up rejection", "sell permission preserved", "decision context restored", "existing pending order not rewritten", "next-open target direction determined by actual price"], + "deployed": false, + "factorCompilerConnected": false, + "paperLivePlanConnected": false, + "orderAmendmentAcceptanceComplete": false, + "realStrategyAcceptanceComplete": false +} diff --git a/docs/factor-decision-phase-contract.md b/docs/factor-decision-phase-contract.md new file mode 100644 index 0000000..1efb8be --- /dev/null +++ b/docs/factor-decision-phase-contract.md @@ -0,0 +1,36 @@ +# Factor Decision Phases + +Status: broker foundation implemented; factor compiler, evaluator and runtime-plan integration are not complete. Do not advertise this as a fully working stock-pool buy-condition feature. + +## Separate Contracts + +| Phase | Meaning | Must Not Do | +|---|---|---| +| Selection | Build and rank the candidate universe at the strategy decision clock | Pretend this also guards every later top-up | +| Buy permission | Decide whether this decision may create new buy exposure for a symbol | Convert a denied buy into a sell or silently drop a holding from a full target snapshot | +| Exit/reduction | Produce the explicitly configured exit or partial target | Normalize remaining targets upward without an explicit strategy rule | +| Execution risk | Apply actual execution-date price, ST, suspension, lifecycle, liquidity and cost constraints | Substitute decision-date risk facts for next-open execution facts | +| Existing orders | Continue the already submitted order under its execution risk and lifetime contract | Implicitly cancel or rewrite it merely because a later decision has a new buy denial | + +## Broker Primitive + +`StrategyDecision.buy_denials` is a symbol-to-reason map sampled by the strategy layer, not a factor evaluator. Merged decisions retain denials. The broker installs it only while processing that decision and restores the prior context afterward; it is never shared through DataSet caches. + +New positive buy quantities and target-buy planning respect the map after standard market/risk checks. Sells remain permitted. The actual execution price determines whether a value/portfolio target requires buying: a target below the signal-day holding value can become a buy after a lower next open, so signal-day direction alone is insufficient. + +Existing resting orders are not automatically canceled by this primitive. Cancellation and exposure-increasing order amendment need an explicit policy and further integration tests. + +## Required Integration + +1. Split selection and buy-role output in the stock-pool compiler instead of folding both into `stock_filter`. +2. Evaluate buy expressions at the declared decision clock using typed field availability, units and frozen data identity. Missing data must retain its own diagnostic, not silently become a false trading signal. +3. Populate denials for every symbol a decision can buy, including portfolio targets, retained-target reentry and top-ups. Do not infer execution direction from signal-day value. +4. Preserve/consume constraints in Paper/Live strategy-plan conversion. No consumer may silently discard a nonempty denial map. +5. Define and test pending-order amendment behavior separately; validate source-date and execution-date risk independently. +6. Verify same-bundle baseline parity when no buy constraint is configured, then test explicit buy failures across share, value, target and algorithmic orders. + +## Current Evidence + +On 177, broker tests verify blocked target top-ups, permitted sells, context restoration, existing pending-order preservation, and a next-open target direction flip. Full `fidc-core` tests passed: 451 unit tests and 122 integration tests, with 8 manual benchmarks ignored. The backtest runner compiles against the changed API. + +The candidate is not deployed. The current OmniQuant compiler still needs the above integration, and no production readiness claim follows from these low-level tests. From daa9d8d34138c1f1e3b8e6b5b020613b6410cf82 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 01:00:52 +0800 Subject: [PATCH 10/21] fix: apply decision buy denials to exposure-increasing amendments --- crates/fidc-core/src/broker.rs | 52 ++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index 652ee38..dd7cf0b 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -2838,6 +2838,18 @@ where } } + if existing.side == OrderSide::Buy + && (target_total_quantity > existing.requested_quantity + || target_limit_price > existing.limit_price) + && let Some(denial) = self.runtime_buy_denials.borrow().get(&existing.symbol) + { + Self::emit_open_order_update_rejected( + report, date, order_id, Some(&existing.symbol), Some(existing.side), + reason, denial, + ); + return; + } + let resets_queue_priority = target_limit_price.to_bits() != existing.limit_price.to_bits() || target_total_quantity > existing.requested_quantity; { @@ -8314,6 +8326,46 @@ mod tests { assert!(broker.runtime_buy_denials.borrow().is_empty()); } + #[test] + fn decision_buy_denial_rejects_increasing_amendments_without_mutation() { + let date = limit_test_snapshot().date; + let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], + Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); + for (quantity, price) in [(Some(300), None), (None, Some(10.5)), + (Some(100), Some(10.5)), (Some(300), Some(9.5))] { + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks); + broker.upsert_open_order(test_open_order(1)); + broker.upsert_open_order(test_open_order(2)); + broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); + let portfolio = PortfolioState::new(100_000.0); + let mut report = BrokerExecutionReport::default(); + broker.modify_open_order(date, &portfolio, &data, 1, quantity, price, "amend", &mut report); + let orders = broker.open_orders.borrow(); + assert_eq!(orders.iter().map(|order| order.order_id).collect::>(), vec![1, 2]); + assert_eq!(orders[0].requested_quantity, 200); + assert_eq!(orders[0].remaining_quantity, 200); + assert_eq!(orders[0].limit_price, 10.0); + assert!(report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false")); + } + } + + #[test] + fn decision_buy_denial_allows_reducing_an_existing_buy() { + let date = limit_test_snapshot().date; + let data = DataSet::from_components(vec![limit_test_instrument()], vec![limit_test_snapshot()], + Vec::new(), vec![limit_test_candidate(true, true)], vec![limit_test_benchmark()]).unwrap(); + let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks); + broker.upsert_open_order(test_open_order(1)); + broker.runtime_buy_denials.borrow_mut().insert("000001.SZ".to_string(), "strategy_buy_condition_false".to_string()); + let portfolio = PortfolioState::new(100_000.0); + let mut report = BrokerExecutionReport::default(); + broker.modify_open_order(date, &portfolio, &data, 1, Some(100), Some(9.5), "reduce", &mut report); + let orders = broker.open_orders.borrow(); + assert_eq!(orders[0].requested_quantity, 100); + assert_eq!(orders[0].limit_price, 9.5); + assert!(!report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false")); + } + fn next_open_sell_decision() -> StrategyDecision { StrategyDecision { order_intents: vec![OrderIntent::Shares { From fa6f189cdd659f33c2412ae0073c0195b288e00a Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 01:04:51 +0800 Subject: [PATCH 11/21] test: verify amendment rejection without order-state events --- crates/fidc-core/src/broker.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/broker.rs b/crates/fidc-core/src/broker.rs index dd7cf0b..52d4d96 100644 --- a/crates/fidc-core/src/broker.rs +++ b/crates/fidc-core/src/broker.rs @@ -8345,7 +8345,10 @@ mod tests { assert_eq!(orders[0].requested_quantity, 200); assert_eq!(orders[0].remaining_quantity, 200); assert_eq!(orders[0].limit_price, 10.0); - assert!(report.order_events.last().unwrap().reason.contains("strategy_buy_condition_false")); + assert!(report.order_events.is_empty()); + let event = report.process_events.last().unwrap(); + assert_eq!(event.kind, crate::events::ProcessEventKind::OrderUpdateReject); + assert!(event.detail.contains("strategy_buy_condition_false")); } } From 3784246e6be7cd6b1db74ec69fac1735f234deac Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 01:09:14 +0800 Subject: [PATCH 12/21] docs: specify buy-constrained amendment behavior and validation scope --- docs/evidence/decision-buy-denial-foundation-20260908.json | 6 ++++-- docs/factor-decision-phase-contract.md | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/evidence/decision-buy-denial-foundation-20260908.json b/docs/evidence/decision-buy-denial-foundation-20260908.json index 4a056a8..2d885a0 100644 --- a/docs/evidence/decision-buy-denial-foundation-20260908.json +++ b/docs/evidence/decision-buy-denial-foundation-20260908.json @@ -1,8 +1,8 @@ { "date": "2026-09-08", "host": "192.168.31.177", - "candidateCommit": "7d293f0", - "unitTestsPassed": 451, + "candidateCommit": "fa6f189", + "unitTestsPassed": 453, "integrationTestsPassed": 122, "ignoredManualBenchmarks": 8, "failed": 0, @@ -12,5 +12,7 @@ "factorCompilerConnected": false, "paperLivePlanConnected": false, "orderAmendmentAcceptanceComplete": false, + "brokerAmendmentTestsPassed": true, + "amendmentPolicy": "Deny buy quantity increases or limit-price increases; allow validated reductions; preserve original state and queue order on rejection.", "realStrategyAcceptanceComplete": false } diff --git a/docs/factor-decision-phase-contract.md b/docs/factor-decision-phase-contract.md index 1efb8be..2160bff 100644 --- a/docs/factor-decision-phase-contract.md +++ b/docs/factor-decision-phase-contract.md @@ -18,7 +18,7 @@ Status: broker foundation implemented; factor compiler, evaluator and runtime-pl New positive buy quantities and target-buy planning respect the map after standard market/risk checks. Sells remain permitted. The actual execution price determines whether a value/portfolio target requires buying: a target below the signal-day holding value can become a buy after a lower next open, so signal-day direction alone is insufficient. -Existing resting orders are not automatically canceled by this primitive. Cancellation and exposure-increasing order amendment need an explicit policy and further integration tests. +Existing resting orders are not automatically canceled by this primitive. A buy amendment is denied if it increases total quantity or raises the limit price, even if the other dimension decreases. Reductions in both dimensions remain allowed after normal validation. A rejected amendment emits an update-rejection process event without replacing the original order state or queue priority. Full runtime-plan integration still requires testing. ## Required Integration @@ -26,11 +26,11 @@ Existing resting orders are not automatically canceled by this primitive. Cancel 2. Evaluate buy expressions at the declared decision clock using typed field availability, units and frozen data identity. Missing data must retain its own diagnostic, not silently become a false trading signal. 3. Populate denials for every symbol a decision can buy, including portfolio targets, retained-target reentry and top-ups. Do not infer execution direction from signal-day value. 4. Preserve/consume constraints in Paper/Live strategy-plan conversion. No consumer may silently discard a nonempty denial map. -5. Define and test pending-order amendment behavior separately; validate source-date and execution-date risk independently. +5. Carry the tested amendment policy through runtime-plan conversion; validate source-date and execution-date risk independently. 6. Verify same-bundle baseline parity when no buy constraint is configured, then test explicit buy failures across share, value, target and algorithmic orders. ## Current Evidence -On 177, broker tests verify blocked target top-ups, permitted sells, context restoration, existing pending-order preservation, and a next-open target direction flip. Full `fidc-core` tests passed: 451 unit tests and 122 integration tests, with 8 manual benchmarks ignored. The backtest runner compiles against the changed API. +On 177, broker tests verify blocked target top-ups, permitted sells, context restoration, existing pending-order preservation, a next-open target direction flip, and risk-increasing/reducing amendments with unchanged state on rejection. Full `fidc-core` tests passed: 453 unit tests and 122 integration tests, with 8 manual benchmarks ignored. The backtest runner previously compiled against the changed API. The candidate is not deployed. The current OmniQuant compiler still needs the above integration, and no production readiness claim follows from these low-level tests. From 30da6eaead9b718f2300030e4e169f3a1299093c Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 01:27:42 +0800 Subject: [PATCH 13/21] feat: evaluate trading buy filters into decision-scoped constraints --- .../fidc-core/src/platform_expr_strategy.rs | 122 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 17 +++ crates/fidc-core/src/strategy.rs | 48 +++++++ 3 files changed, 185 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3a04c39..071c5e6 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -605,6 +605,7 @@ pub struct PlatformExprStrategyConfig { pub selection_limit_expr: String, pub selection_candidate_limit_expr: String, pub stock_filter_expr: String, + pub buy_filter_expr: String, pub buy_scale_expr: String, pub exposure_expr: String, pub position_exposure_schedule: BTreeMap, @@ -684,6 +685,7 @@ impl PlatformExprStrategyConfig { selection_limit_expr: "1".to_string(), selection_candidate_limit_expr: String::new(), stock_filter_expr: String::new(), + buy_filter_expr: String::new(), buy_scale_expr: "1.0".to_string(), exposure_expr: "1.0".to_string(), position_exposure_schedule: BTreeMap::new(), @@ -1800,6 +1802,7 @@ impl PlatformExprStrategy { "stock_filter_expr".to_string(), self.config.stock_filter_expr.as_str(), ), + ("buy_filter_expr".to_string(), self.config.buy_filter_expr.as_str()), ( "buy_scale_expr".to_string(), self.config.buy_scale_expr.as_str(), @@ -10960,6 +10963,7 @@ impl PlatformExprStrategy { let expressions = [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -10992,6 +10996,7 @@ impl PlatformExprStrategy { for expr in [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11039,6 +11044,7 @@ impl PlatformExprStrategy { for expr in [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11079,6 +11085,9 @@ impl PlatformExprStrategy { if Self::expr_requires_stock_extra_factors( &config.stock_filter_expr, prelude_declared_identifiers, + ) || Self::expr_requires_stock_extra_factors( + &config.buy_filter_expr, + prelude_declared_identifiers, ) { return true; } @@ -11108,6 +11117,7 @@ impl PlatformExprStrategy { [ config.prelude.as_str(), config.stock_filter_expr.as_str(), + config.buy_filter_expr.as_str(), config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), config.take_profit_expr.as_str(), @@ -11142,6 +11152,11 @@ impl PlatformExprStrategy { &config.stock_filter_expr, prelude_declared_identifiers, ); + Self::collect_stock_extra_factor_identifiers( + &mut identifiers, + &config.buy_filter_expr, + prelude_declared_identifiers, + ); for expr in [ config.buy_scale_expr.as_str(), config.stop_loss_expr.as_str(), @@ -12119,10 +12134,11 @@ impl Strategy for PlatformExprStrategy { .is_some(); if scheduled_rotation { self.executing_scheduled_rotation = true; - let rotation = self.on_day(ctx); + let rotation = self.compute_day_decision(ctx); self.executing_scheduled_rotation = false; decision.merge_from(rotation?); } + self.attach_buy_denials(ctx, &mut decision)?; Ok(decision) } @@ -12171,12 +12187,53 @@ impl Strategy for PlatformExprStrategy { && self.config.explicit_action_schedule.is_none() && self.unscheduled_explicit_actions_are_due(ctx.decision_date) { - return self.explicit_action_decision(ctx); + let mut decision = self.explicit_action_decision(ctx)?; + self.attach_buy_denials(ctx, &mut decision)?; + return Ok(decision); } Ok(StrategyDecision::default()) } fn on_day(&mut self, ctx: &StrategyContext<'_>) -> Result { + let mut decision = self.compute_day_decision(ctx)?; + self.attach_buy_denials(ctx, &mut decision)?; + Ok(decision) + } +} + +impl PlatformExprStrategy { + fn attach_buy_denials(&self, ctx: &StrategyContext<'_>, decision: &mut StrategyDecision) -> Result<(), BacktestError> { + if self.config.buy_filter_expr.trim().is_empty() { + return Ok(()); + } + let symbols = decision.potential_buy_symbols(ctx.open_orders); + if symbols.is_empty() { + return Ok(()); + } + let day = self.day_state(ctx, ctx.decision_date)?; + let (market_date, _, factor_date) = self.selection_dates(ctx); + let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr)) + != StockFilterQuoteUsage::DailyOnly; + for symbol in symbols { + if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution() + && self.scheduled_quote(ctx, market_date, &symbol).is_none() + { + return Err(BacktestError::Execution(format!( + "buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date, + ))); + } + let stock = self.stock_state_with_factor_date(ctx, market_date, factor_date, &symbol)?; + if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? { + decision.buy_denials.insert(symbol, format!( + "strategy_buy_condition_false decision_date={} expression={}", + ctx.decision_date, self.config.buy_filter_expr, + )); + } + } + Ok(()) + } + + fn compute_day_decision(&mut self, ctx: &StrategyContext<'_>) -> Result { if self.config.rotation_enabled && self .config @@ -13955,6 +14012,67 @@ mod tests { NaiveDate::from_ymd_opt(year, month, day).expect("valid date") } + #[test] + fn buy_filter_attaches_denials_without_rewriting_selection() { + let prev = d(2025, 1, 2); + let curr = d(2025, 1, 3); + let symbol = "000001.SZ"; + let mut parts = single_symbol_platform_data(&[prev, curr], symbol).snapshot_components(); + for row in &mut parts.factors { row.extra_factors.insert("entry_gate".into(), 0.0); } + let data = DataSet::from_components(parts.instruments, parts.market, parts.factors, parts.candidates, parts.benchmarks).unwrap(); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: curr, decision_date: curr, decision_index: 1, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.signal_symbol = symbol.to_string(); + cfg.max_positions = 1; + cfg.refresh_rate = 1; + cfg.benchmark_short_ma_days = 1; + cfg.benchmark_long_ma_days = 1; + cfg.market_cap_lower_expr = "0".to_string(); + cfg.market_cap_upper_expr = "100".to_string(); + cfg.selection_limit_expr = "1".to_string(); + cfg.stock_filter_expr = "close > 0".to_string(); + cfg.buy_filter_expr = "entry_gate > 0".to_string(); + cfg.current_day_precomputed_factors = true; + let mut strategy = PlatformExprStrategy::new(cfg); + let decision = strategy.on_day(&ctx).unwrap(); + assert!(!decision.order_intents.is_empty()); + assert!(decision.buy_denials.contains_key(symbol)); + assert!(strategy.stock_extra_factor_identifiers.contains("entry_gate")); + assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); + } + + #[test] + fn buy_quote_filter_rejects_missing_intraday_quote_not_daily_close() { + let date = d(2025, 1, 2); + let symbol = "000001.SZ"; + let data = single_symbol_platform_data(&[date], symbol); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let ctx = StrategyContext { + execution_date: date, decision_date: date, decision_index: 0, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.signal_symbol = symbol.to_string(); + cfg.buy_filter_expr = "last > 0".to_string(); + cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0); + let strategy = PlatformExprStrategy::new(cfg); + let mut decision = crate::StrategyDecision::default(); + decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "buy".to_string() }); + let error = strategy.attach_buy_denials(&ctx, &mut decision).unwrap_err(); + assert!(error.to_string().contains("buy condition quote unavailable"), "{error}"); + assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); + } + #[test] fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() { let prev = d(2025, 1, 2); diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 9e2781d..00d76e2 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -977,6 +977,8 @@ pub struct StrategyExpressionOrderingConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct StrategyExpressionTradingConfig { + #[serde(default, alias = "buy_filter_expr")] + pub buy_filter_expr: Option, #[serde(default)] pub stage: Option, #[serde(default)] @@ -2289,6 +2291,9 @@ pub fn platform_expr_config_from_spec( } } if let Some(trading) = runtime_expr.trading.as_ref() { + if let Some(expr) = trading.buy_filter_expr.as_ref() { + cfg.buy_filter_expr = expr.clone(); + } if let Some(expr) = trading .refresh_rate_expr .as_ref() @@ -3127,6 +3132,18 @@ fn symbol_is_kcb(symbol: &str) -> bool { mod tests { use super::*; + #[test] + fn parses_buy_filter_as_a_separate_trading_condition() { + let cfg = platform_expr_config_from_value("buy-guard", "000001.SZ", &serde_json::json!({ + "runtimeExpressions": { + "selection": {"stockFilterExpr": "close > 0"}, + "trading": {"buyFilterExpr": "gate > 0"} + } + })).unwrap(); + assert_eq!(cfg.stock_filter_expr, "close > 0"); + assert_eq!(cfg.buy_filter_expr, "gate > 0"); + } + #[test] fn native_factor_bindings_declare_completed_session_fields() { let spec = serde_json::json!({"stockPoolFactorContract": {"conditions": [ diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 68cc98f..fb76b14 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -988,6 +988,18 @@ pub struct StrategyDecision { } impl StrategyDecision { + pub fn potential_buy_symbols(&self, open_orders: &[OpenOrderView]) -> BTreeSet { + let mut symbols = BTreeSet::new(); + if self.rebalance { + symbols.extend(self.target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone())); + } + for intent in &self.order_intents { + intent.collect_potential_buy_symbols(open_orders, &mut symbols); + } + symbols.retain(|symbol| !symbol.trim().is_empty()); + symbols + } + pub fn merge_from(&mut self, mut other: StrategyDecision) { self.buy_denials.append(&mut other.buy_denials); self.rebalance |= other.rebalance; @@ -1217,6 +1229,42 @@ pub enum OrderIntent { } impl OrderIntent { + fn collect_potential_buy_symbols(&self, open_orders: &[OpenOrderView], symbols: &mut BTreeSet) { + match self.unwrapped() { + Self::Shares { symbol, quantity, .. } | Self::LimitShares { symbol, quantity, .. } if *quantity > 0 => { symbols.insert(symbol.clone()); } + Self::Lots { symbol, lots, .. } | Self::LimitLots { symbol, lots, .. } if *lots > 0 => { symbols.insert(symbol.clone()); } + Self::TargetShares { symbol, target_quantity, .. } | Self::LimitTargetShares { symbol, target_quantity, .. } if *target_quantity > 0 => { symbols.insert(symbol.clone()); } + Self::Value { symbol, value, .. } | Self::LimitValue { symbol, value, .. } | Self::AlgoValue { symbol, value, .. } if *value > 0.0 => { symbols.insert(symbol.clone()); } + Self::Percent { symbol, percent, .. } | Self::LimitPercent { symbol, percent, .. } | Self::AlgoPercent { symbol, percent, .. } if *percent > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetValue { symbol, target_value, .. } | Self::LimitTargetValue { symbol, target_value, .. } | Self::TimedTargetValue { symbol, target_value, .. } if *target_value > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetPercent { symbol, target_percent, .. } | Self::LimitTargetPercent { symbol, target_percent, .. } if *target_percent > 0.0 => { symbols.insert(symbol.clone()); } + Self::TargetPortfolioSmart { target_weights, .. } => { + symbols.extend(target_weights.iter().filter(|(_, weight)| **weight > 0.0).map(|(symbol, _)| symbol.clone())); + } + Self::ModifyOrder { order_id, new_total_quantity, new_limit_price, .. } => { + if let Some(order) = open_orders.iter().find(|order| order.order_id == *order_id) + && order.side == OrderSide::Buy + && (new_total_quantity.is_some_and(|value| value > order.requested_quantity) + || new_limit_price.is_some_and(|value| value > order.limit_price)) + { + symbols.insert(order.symbol.clone()); + } + } + Self::Shares { .. } | Self::LimitShares { .. } + | Self::Lots { .. } | Self::LimitLots { .. } + | Self::TargetShares { .. } | Self::LimitTargetShares { .. } + | Self::Value { .. } | Self::LimitValue { .. } | Self::AlgoValue { .. } + | Self::Percent { .. } | Self::LimitPercent { .. } | Self::AlgoPercent { .. } + | Self::TargetValue { .. } | Self::LimitTargetValue { .. } | Self::TimedTargetValue { .. } + | Self::TargetPercent { .. } | Self::LimitTargetPercent { .. } + | Self::CancelOrder { .. } | Self::CancelSymbol { .. } | Self::CancelAll { .. } + | Self::UpdateUniverse { .. } | Self::Subscribe { .. } | Self::Unsubscribe { .. } + | Self::DepositWithdraw { .. } | Self::FinanceRepay { .. } | Self::SetManagementFeeRate { .. } + | Self::Futures { .. } => {} + Self::WithTimeInForce { .. } => unreachable!("intent is unwrapped"), + } + } + pub fn with_time_in_force(self, time_in_force: OrderTimeInForce) -> Self { match self { Self::WithTimeInForce { intent, .. } => Self::WithTimeInForce { From 326438aac99ef1e77be51213c0477e885d173d69 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 01:45:39 +0800 Subject: [PATCH 14/21] fix: evaluate buy quote conditions at the active schedule clock --- .../fidc-core/src/platform_expr_strategy.rs | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 071c5e6..21000e2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -12212,17 +12212,19 @@ impl PlatformExprStrategy { } let day = self.day_state(ctx, ctx.decision_date)?; let (market_date, _, factor_date) = self.selection_dates(ctx); + let execution_time = ctx.active_datetime.filter(|value| value.date() == market_date) + .map(|value| value.time()).or(self.config.intraday_execution_time); let needs_quote = Self::stock_filter_quote_usage_for_expr(&Self::normalize_expr(&self.config.buy_filter_expr)) != StockFilterQuoteUsage::DailyOnly; for symbol in symbols { if needs_quote && self.uses_intraday_execution_quotes() && !ctx.is_lagged_execution() - && self.scheduled_quote(ctx, market_date, &symbol).is_none() + && self.scheduled_quote_at_time(ctx, market_date, &symbol, execution_time).is_none() { return Err(BacktestError::Execution(format!( "buy condition quote unavailable: symbol={symbol} decision_date={}", ctx.decision_date, ))); } - let stock = self.stock_state_with_factor_date(ctx, market_date, factor_date, &symbol)?; + let stock = self.stock_state_with_factor_date_and_time(ctx, market_date, factor_date, &symbol, execution_time, true)?; if !self.eval_bool(ctx, &self.config.buy_filter_expr, &day, Some(&stock), None)? { decision.buy_denials.insert(symbol, format!( "strategy_buy_condition_false decision_date={} expression={}", @@ -14073,6 +14075,40 @@ mod tests { assert_eq!(strategy.selection_quote_usage, StockFilterQuoteUsage::DailyOnly); } + #[test] + fn buy_filter_uses_active_schedule_time_instead_of_first_configured_time() { + let date = d(2025, 1, 2); + let symbol = "000001.SZ"; + let parts = single_symbol_platform_data(&[date], symbol).snapshot_components(); + let quotes = [(10, 18, 9.5), (14, 59, 10.5)].into_iter().map(|(hour, minute, price)| IntradayExecutionQuote { + date, symbol: symbol.to_string(), timestamp: date.and_hms_opt(hour, minute, 0).unwrap(), + last_price: price, bid1: price, ask1: price, bid1_volume: 1000, ask1_volume: 1000, + volume_delta: 1000, amount_delta: price * 1000.0, trading_phase: Some("continuous".to_string()), + }).collect(); + let data = DataSet::from_components_with_actions_and_quotes(parts.instruments, parts.market, + parts.factors, parts.candidates, parts.benchmarks, Vec::new(), quotes).unwrap(); + let portfolio = PortfolioState::new(30_000.0); + let subscriptions = BTreeSet::new(); + let mut ctx = StrategyContext { + execution_date: date, decision_date: date, decision_index: 0, data: &data, + portfolio: &portfolio, futures_account: None, open_orders: &[], dynamic_universe: None, + subscriptions: &subscriptions, process_events: &[], active_process_event: None, + active_datetime: None, order_events: &[], fills: &[], + }; + let mut cfg = PlatformExprStrategyConfig::generic(); + cfg.signal_symbol = symbol.to_string(); + cfg.buy_filter_expr = "last > 10".to_string(); + cfg.intraday_execution_time = NaiveTime::from_hms_opt(10, 18, 0); + let strategy = PlatformExprStrategy::new(cfg); + for (hour, minute, denied) in [(10, 18, true), (14, 59, false)] { + ctx.active_datetime = Some(date.and_hms_opt(hour, minute, 0).unwrap()); + let mut decision = crate::StrategyDecision::default(); + decision.order_intents.push(OrderIntent::TargetValue { symbol: symbol.to_string(), target_value: 10_000.0, reason: "target".to_string() }); + strategy.attach_buy_denials(&ctx, &mut decision).unwrap(); + assert_eq!(decision.buy_denials.contains_key(symbol), denied); + } + } + #[test] fn completed_session_factor_dates_exclude_intraday_and_preserve_next_open() { let prev = d(2025, 1, 2); From 078839b0f30d1a53ff454fc3b5981fcbcb1ea434 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 8 Sep 2026 22:42:33 +0800 Subject: [PATCH 15/21] fix: preserve authoritative STAR market classification in risk checks --- crates/fidc-core/src/instrument.rs | 25 +++++++++++++++++- .../fidc-core/src/platform_strategy_spec.rs | 25 +++++------------- crates/fidc-core/src/risk_control.rs | 26 ++++++++++++++----- 3 files changed, 51 insertions(+), 25 deletions(-) diff --git a/crates/fidc-core/src/instrument.rs b/crates/fidc-core/src/instrument.rs index 454c597..381639b 100644 --- a/crates/fidc-core/src/instrument.rs +++ b/crates/fidc-core/src/instrument.rs @@ -1,6 +1,17 @@ use chrono::NaiveDate; use serde::{Deserialize, Serialize}; +pub fn listed_sector_is_kcb(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "科创板" | "KSH" | "STAR" | "STAR_MARKET" => Some(true), + "主板" | "沪市主板" | "深市主板" | "中小板" | "中小企业板" | "创业板" + | "北交所" | "北证" | "新三板" | "基础层" | "创新层" | "精选层" + | "MAIN" | "MAIN_BOARD" | "CHINEXT" | "GEM" | "BJ" | "BJS" | "BJSE" + | "BSE" => Some(false), + _ => None, + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Instrument { pub symbol: String, @@ -70,7 +81,19 @@ fn default_status() -> String { #[cfg(test)] mod tests { - use super::Instrument; + use super::{Instrument, listed_sector_is_kcb}; + + #[test] + fn listing_sector_is_explicit_and_unknown_stays_unknown() { + assert_eq!(listed_sector_is_kcb("科创板"), Some(true)); + assert_eq!(listed_sector_is_kcb(" star "), Some(true)); + assert_eq!(listed_sector_is_kcb("主板"), Some(false)); + assert_eq!(listed_sector_is_kcb("创业板"), Some(false)); + assert_eq!(listed_sector_is_kcb("北证"), Some(false)); + for value in ["", "-", "SH", "688001.SH", "半导体"] { + assert_eq!(listed_sector_is_kcb(value), None); + } + } fn instrument(board: &str, round_lot: u32) -> Instrument { Instrument { diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index 00d76e2..26970e7 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -3085,8 +3085,7 @@ fn instrument_query_id(symbol: &str, board: &str) -> String { } fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { - let has_suffix = symbol.trim().rsplit_once('.').is_some(); - if has_suffix && symbol_is_kcb(symbol) { + if raw_board.and_then(crate::instrument::listed_sector_is_kcb) == Some(true) { return "KSH".to_string(); } let normalized = raw_board @@ -3101,9 +3100,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { if let Some((_, suffix)) = symbol.rsplit_once('.') { return suffix.to_ascii_uppercase(); } - if symbol_is_kcb(symbol) { - return "KSH".to_string(); - } if symbol.starts_with('8') || symbol.starts_with('4') { return "BJ".to_string(); } @@ -3120,14 +3116,6 @@ fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { "UNK".to_string() } -fn symbol_is_kcb(symbol: &str) -> bool { - let normalized = symbol.trim().to_ascii_uppercase(); - let Some((code, suffix)) = normalized.rsplit_once('.') else { - return normalized.starts_with("688") || normalized.starts_with("689"); - }; - suffix == "SH" && (code.starts_with("688") || code.starts_with("689")) -} - #[cfg(test)] mod tests { use super::*; @@ -3159,14 +3147,15 @@ mod tests { } #[test] - fn normalize_board_classifies_kcb_by_688_689_sh_suffix_only() { - assert_eq!(normalize_board("688001.SH", None), "KSH"); - assert_eq!(normalize_board("689001.SH", None), "KSH"); + fn normalize_board_does_not_infer_kcb_from_security_code() { + assert_eq!(normalize_board("688001.SH", None), "SH"); + assert_eq!(normalize_board("689001.SH", None), "SH"); assert_eq!(normalize_board("688001.BJ", None), "BJ"); assert_eq!(normalize_board("689001.SZ", None), "SZ"); - assert_eq!(normalize_board("688001", None), "KSH"); + assert_eq!(normalize_board("688001", None), "SH"); assert_eq!(normalize_board("688001", Some("SZ")), "SZ"); - assert_eq!(normalize_board("688001.SH", Some("SH")), "KSH"); + assert_eq!(normalize_board("688001.SH", Some("SH")), "SH"); + assert_eq!(normalize_board("000001.SZ", Some("KSH")), "KSH"); } #[test] diff --git a/crates/fidc-core/src/risk_control.rs b/crates/fidc-core/src/risk_control.rs index e701f96..cec4d24 100644 --- a/crates/fidc-core/src/risk_control.rs +++ b/crates/fidc-core/src/risk_control.rs @@ -397,7 +397,7 @@ impl ChinaAShareRiskControl { RiskCheckScope::Buy => config.static_rules.reject_kcb_buy, RiskCheckScope::Sell => false, }; - if reject_kcb && (candidate.is_kcb || symbol_is_kcb(&candidate.symbol)) { + if reject_kcb && candidate.is_kcb { return Some("kcb"); } let reject_bjse = match scope { @@ -600,11 +600,6 @@ impl ChinaAShareRiskControl { } } -fn symbol_is_kcb(symbol: &str) -> bool { - let normalized = symbol.trim().to_ascii_uppercase(); - (normalized.starts_with("688") || normalized.starts_with("689")) && normalized.ends_with(".SH") -} - fn symbol_is_bjse(symbol: &str) -> bool { let normalized = symbol.trim().to_ascii_uppercase(); normalized.ends_with(".BJ") || normalized.ends_with(".BSE") || normalized.ends_with(".BE") @@ -1009,6 +1004,24 @@ mod tests { assert_eq!(configured_reason, None); } + #[test] + fn kcb_filter_uses_classification_instead_of_security_code() { + let date = d(2025, 1, 2); + let market = market(date, 6.27, 5.63); + let mut candidate = candidate(date); + let config = FidcRiskControlConfig::default(); + for symbol in ["688001.SH", "689001.SH", "000001.SZ"] { + candidate.symbol = symbol.to_string(); + for is_kcb in [false, true] { + candidate.is_kcb = is_kcb; + let reason = ChinaAShareRiskControl::buy_rejection_reason_with_config( + date, &candidate, &market, None, 6.27, &config, + ); + assert_eq!(reason, is_kcb.then_some("kcb"), "{symbol}"); + } + } + } + #[test] fn st_and_star_st_filters_are_independent() { let date = d(2025, 1, 2); @@ -1139,6 +1152,7 @@ mod tests { let date = d(2025, 1, 2); let mut candidate = candidate(date); candidate.symbol = "688506.SH".to_string(); + candidate.is_kcb = true; candidate.risk_level_code = Some("missing_risk_state".to_string()); let market = market(date, 6.27, 5.63); let mut config = FidcRiskControlConfig::default(); From dd6b37be1670f9f10fd13af00e2406167653a1ba Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 02:51:26 +0800 Subject: [PATCH 16/21] fix: preserve missing numeric factors and reject nonfinite truthiness --- .../fidc-core/src/platform_expr_strategy.rs | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 21000e2..754a84e 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -4687,7 +4687,7 @@ impl PlatformExprStrategy { } else if intraday_same_day_factor { f64::NAN } else { - factor.extra_factors.get("amount").copied().unwrap_or(0.0) + factor.extra_factors.get("amount").copied().unwrap_or(f64::NAN) }; let market_cap_bn = decision_market_cap_bn(factor); let free_float_cap_bn = decision_free_float_cap_bn(factor); @@ -4769,8 +4769,8 @@ impl PlatformExprStrategy { minute_volume: market.minute_volume as i64, bid1_volume: market.bid1_volume as i64, ask1_volume: market.ask1_volume as i64, - turnover_ratio: factor.turnover_ratio.unwrap_or(0.0), - effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0), + turnover_ratio: factor.turnover_ratio.unwrap_or(f64::NAN), + effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(f64::NAN), open: feature_market.day_open, high: expression_high, low: expression_low, @@ -8359,7 +8359,7 @@ impl PlatformExprStrategy { if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? { return match value { NumericVmValue::Boolean(boolean) => Ok(boolean), - NumericVmValue::Number(number) => Ok(number != 0.0), + NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0), }; } let value = self.eval_dynamic(ctx, expr, day, stock, position)?; @@ -8367,7 +8367,7 @@ impl PlatformExprStrategy { return Ok(boolean); } if let Some(number) = value.clone().try_cast::() { - return Ok(number != 0.0); + return Ok(number.is_finite() && number != 0.0); } if let Some(number) = value.try_cast::() { return Ok(number != 0); @@ -16524,8 +16524,8 @@ mod tests { market_cap_bn: 12.0, free_float_cap_bn: 10.0, pe_ttm: 8.0, - turnover_ratio: Some(1.0), - effective_turnover_ratio: Some(1.0), + turnover_ratio: None, + effective_turnover_ratio: None, adjustment_factor_backward1: None, extra_factors: BTreeMap::new(), }, @@ -16535,8 +16535,8 @@ mod tests { market_cap_bn: 12.0, free_float_cap_bn: 10.0, pe_ttm: 8.0, - turnover_ratio: Some(1.0), - effective_turnover_ratio: Some(1.0), + turnover_ratio: Some(0.0), + effective_turnover_ratio: Some(0.0), adjustment_factor_backward1: None, extra_factors: BTreeMap::from([ ("model_score".into(), 2.0), @@ -16601,6 +16601,16 @@ mod tests { .expect("present stock state"); assert!(day.available_factor_names.contains("model_score")); + assert!(missing_stock.turnover_ratio.is_nan()); + assert!(missing_stock.effective_turnover_ratio.is_nan()); + assert_eq!(present_stock.turnover_ratio, 0.0); + assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap()); + assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap()); + for field in ["turnover_ratio", "effective_turnover_ratio"] { + let predicate = format!("{field} < 1.0"); + assert!(!strategy.eval_bool(&ctx, &predicate, &day, Some(&missing_stock), None).unwrap()); + assert!(strategy.eval_bool(&ctx, &predicate, &day, Some(&present_stock), None).unwrap()); + } assert!( !strategy .stock_passes_expr(&ctx, &day, &missing_stock) From 3cea91467d7baab9e784c548f556159615eaaf80 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 02:58:33 +0800 Subject: [PATCH 17/21] test: provide dated amount facts in next-open selection fixture --- crates/fidc-core/src/platform_expr_strategy.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 754a84e..73ad31a 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -23976,7 +23976,7 @@ mod tests { turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, - extra_factors: BTreeMap::new(), + extra_factors: BTreeMap::from([("amount".into(), 20_000_000.0)]), }, DailyFactorSnapshot { date: factor_date, @@ -23987,7 +23987,7 @@ mod tests { turnover_ratio: Some(1.0), effective_turnover_ratio: Some(1.0), adjustment_factor_backward1: None, - extra_factors: BTreeMap::new(), + extra_factors: BTreeMap::from([("amount".into(), 10_000_000.0)]), }, DailyFactorSnapshot { date: decision_date, @@ -24138,6 +24138,10 @@ mod tests { .stock_state_with_factor_date(&ctx, decision_date, decision_date, limit_symbol) .expect("next-open decision state"); assert_eq!(decision_day_state.amount, 30_000_000.0); + let prior_factor_state = strategy + .stock_state_with_factor_date(&ctx, decision_date, factor_date, limit_symbol) + .expect("previous factor-day state"); + assert_eq!(prior_factor_state.amount, 20_000_000.0); let decision = strategy.on_day(&ctx).expect("platform decision"); From fda2e70456e67cbdd90470b93a4a028610e854ef Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 03:56:44 +0800 Subject: [PATCH 18/21] fix: preserve unknown numeric conditions through boolean expressions --- crates/fidc-core/src/numeric_expr_vm.rs | 243 +++++++++++++++--- .../fidc-core/src/platform_expr_strategy.rs | 77 +++++- 2 files changed, 275 insertions(+), 45 deletions(-) diff --git a/crates/fidc-core/src/numeric_expr_vm.rs b/crates/fidc-core/src/numeric_expr_vm.rs index e8d4a38..171576d 100644 --- a/crates/fidc-core/src/numeric_expr_vm.rs +++ b/crates/fidc-core/src/numeric_expr_vm.rs @@ -11,6 +11,7 @@ pub(crate) enum ValueType { pub(crate) enum Value { Number(f64), Boolean(bool), + Missing(ValueType), } impl Value { @@ -18,20 +19,28 @@ impl Value { match self { Self::Number(_) => ValueType::Number, Self::Boolean(_) => ValueType::Boolean, + Self::Missing(value_type) => value_type, } } pub(crate) fn as_number(self) -> Option { match self { Self::Number(value) => Some(value), - Self::Boolean(_) => None, + Self::Boolean(_) | Self::Missing(_) => None, } } pub(crate) fn as_bool(self) -> Option { match self { Self::Boolean(value) => Some(value), - Self::Number(_) => None, + Self::Number(_) | Self::Missing(_) => None, + } + } + + fn normalized(self) -> Self { + match self { + Self::Number(value) if !value.is_finite() => Self::Missing(ValueType::Number), + value => value, } } } @@ -103,6 +112,8 @@ enum BinaryOp { LessEqual, Greater, GreaterEqual, + And, + Or, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -134,7 +145,7 @@ enum Instruction { Binary(BinaryOp), Call { builtin: Builtin, argc: u8 }, JumpIfFalse(usize), - JumpIfTrue(usize), + ShortCircuit { on: bool, target: usize }, Jump(usize), Return, } @@ -165,7 +176,7 @@ impl Program { let mut pc = 0usize; while let Some(instruction) = self.instructions.get(pc) { match *instruction { - Instruction::Push(value) => scratch.stack.push(value), + Instruction::Push(value) => scratch.stack.push(value.normalized()), Instruction::LoadVariable(index) => { let index = usize::from(index); let cached = scratch.variables[index]; @@ -173,7 +184,8 @@ impl Program { Some(value) => value, None => { let expected_type = self.variable_types[index]; - let value = resolve(index, &self.variables[index], expected_type)?; + let value = + resolve(index, &self.variables[index], expected_type)?.normalized(); if value.value_type() != expected_type { return Err(EvalError::new(format!( "variable {} expected {:?}, got {:?}", @@ -219,15 +231,23 @@ impl Program { scratch.stack.push(value); } Instruction::JumpIfFalse(target) => { - let condition = pop_bool(&mut scratch.stack)?; + // Like CASE WHEN, only a known true condition takes this branch. + let condition = match pop(&mut scratch.stack)? { + Value::Boolean(value) => value, + Value::Missing(ValueType::Boolean) => false, + _ => return Err(EvalError::new("boolean operand required")), + }; if !condition { pc = target; continue; } } - Instruction::JumpIfTrue(target) => { - let condition = pop_bool(&mut scratch.stack)?; - if condition { + Instruction::ShortCircuit { on, target } => { + let condition = + scratch.stack.last().copied().ok_or_else(|| { + EvalError::new("stack underflow during short circuit") + })?; + if condition.as_bool() == Some(on) { pc = target; continue; } @@ -284,31 +304,56 @@ fn pop(stack: &mut Vec) -> Result { stack.pop().ok_or_else(|| EvalError::new("stack underflow")) } -fn pop_bool(stack: &mut Vec) -> Result { - pop(stack)? - .as_bool() - .ok_or_else(|| EvalError::new("boolean operand required")) -} - fn number(value: Value) -> Result { + if value == Value::Missing(ValueType::Number) { + return Ok(f64::NAN); + } value .as_number() .ok_or_else(|| EvalError::new("numeric operand required")) } fn eval_unary(operator: UnaryOp, value: Value) -> Result { - match operator { + if matches!(value, Value::Missing(_)) { + return Ok(value); + } + let result: Result = match operator { UnaryOp::Negate => Ok(Value::Number(-number(value)?)), UnaryOp::Not => { Ok(Value::Boolean(!value.as_bool().ok_or_else(|| { EvalError::new("boolean operand required for !") })?)) } - } + }; + Ok(result?.normalized()) } fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result { - match operator { + if matches!(operator, BinaryOp::And | BinaryOp::Or) { + let (lhs, rhs) = (lhs.as_bool(), rhs.as_bool()); + let result = match operator { + BinaryOp::And if lhs == Some(false) || rhs == Some(false) => Some(false), + BinaryOp::And if lhs == Some(true) && rhs == Some(true) => Some(true), + BinaryOp::Or if lhs == Some(true) || rhs == Some(true) => Some(true), + BinaryOp::Or if lhs == Some(false) && rhs == Some(false) => Some(false), + _ => None, + }; + return Ok(result + .map(Value::Boolean) + .unwrap_or(Value::Missing(ValueType::Boolean))); + } + if matches!(lhs, Value::Missing(_)) || matches!(rhs, Value::Missing(_)) { + let value_type = match operator { + BinaryOp::Add + | BinaryOp::Subtract + | BinaryOp::Multiply + | BinaryOp::Divide + | BinaryOp::Remainder => ValueType::Number, + _ => ValueType::Boolean, + }; + return Ok(Value::Missing(value_type)); + } + let result: Result = match operator { BinaryOp::Add => Ok(Value::Number(number(lhs)? + number(rhs)?)), BinaryOp::Subtract => Ok(Value::Number(number(lhs)? - number(rhs)?)), BinaryOp::Multiply => Ok(Value::Number(number(lhs)? * number(rhs)?)), @@ -356,7 +401,27 @@ fn eval_binary(operator: BinaryOp, lhs: Value, rhs: Value) -> Result -f64::EPSILON, )) } + BinaryOp::And | BinaryOp::Or => unreachable!(), + }; + Ok(result?.normalized()) +} + +pub(crate) fn finite_comparison(operator: &str, lhs: f64, rhs: f64) -> Option { + if !lhs.is_finite() || !rhs.is_finite() { + return None; } + let operator = match operator { + "==" => BinaryOp::Equal, + "!=" => BinaryOp::NotEqual, + "<" => BinaryOp::Less, + "<=" => BinaryOp::LessEqual, + ">" => BinaryOp::Greater, + ">=" => BinaryOp::GreaterEqual, + _ => return None, + }; + eval_binary(operator, Value::Number(lhs), Value::Number(rhs)) + .ok()? + .as_bool() } fn float_comparison_scale(lhs: f64, rhs: f64) -> f64 { @@ -382,7 +447,16 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { .ok_or_else(|| EvalError::new("missing builtin argument")) .and_then(number) }; - Ok(match builtin { + if !matches!(builtin, Builtin::Nz | Builtin::SafeDiv | Builtin::Iff) + && args.iter().any(|value| matches!(value, Value::Missing(_))) + { + return Ok(Value::Missing(if builtin == Builtin::Between { + ValueType::Boolean + } else { + ValueType::Number + })); + } + let result = match builtin { Builtin::Round => Value::Number(numeric(0)?.round()), Builtin::Floor => Value::Number(numeric(0)?.floor()), Builtin::Ceil => Value::Number(numeric(0)?.ceil()), @@ -393,7 +467,13 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { Builtin::Pow => Value::Number(numeric(0)?.powf(numeric(1)?)), Builtin::Log => Value::Number(numeric(0)?.ln()), Builtin::Exp => Value::Number(numeric(0)?.exp()), - Builtin::Clamp => Value::Number(numeric(0)?.clamp(numeric(1)?, numeric(2)?)), + Builtin::Clamp => { + let (value, low, high) = (numeric(0)?, numeric(1)?, numeric(2)?); + if low > high { + return Err(EvalError::new("clamp lower bound exceeds upper bound")); + } + Value::Number(value.clamp(low, high)) + } Builtin::Between => { let value = numeric(0)?; Value::Boolean(value >= numeric(1)? && value <= numeric(2)?) @@ -417,13 +497,15 @@ fn eval_builtin(builtin: Builtin, args: &[Value]) -> Result { }) } Builtin::Iff => { - let condition = args - .first() - .and_then(|value| value.as_bool()) - .ok_or_else(|| EvalError::new("iff condition must be boolean"))?; + let condition = match args.first().copied() { + Some(Value::Boolean(value)) => value, + Some(Value::Missing(ValueType::Boolean)) => false, + _ => return Err(EvalError::new("iff condition must be boolean")), + }; if condition { args[1] } else { args[2] } } - }) + }; + Ok(result.normalized()) } #[derive(Debug, Clone, PartialEq)] @@ -1119,24 +1201,20 @@ where let lhs_type = self.expression(lhs)?; require_type(lhs_type, ValueType::Boolean, position)?; let branch = self.instructions.len(); - self.instructions.push(match operator { - ParsedBinaryOp::And => Instruction::JumpIfFalse(usize::MAX), - ParsedBinaryOp::Or => Instruction::JumpIfTrue(usize::MAX), - _ => unreachable!(), + self.instructions.push(Instruction::ShortCircuit { + on: operator == ParsedBinaryOp::Or, + target: usize::MAX, }); let rhs_type = self.expression(rhs)?; require_type(rhs_type, ValueType::Boolean, rhs.position())?; - let end_jump = self.instructions.len(); - self.instructions.push(Instruction::Jump(usize::MAX)); - let short_target = self.instructions.len(); self.instructions - .push(Instruction::Push(Value::Boolean(matches!( - operator, - ParsedBinaryOp::Or - )))); + .push(Instruction::Binary(if operator == ParsedBinaryOp::And { + BinaryOp::And + } else { + BinaryOp::Or + })); let end_target = self.instructions.len(); - patch_jump(&mut self.instructions, branch, short_target)?; - patch_jump(&mut self.instructions, end_jump, end_target)?; + patch_jump(&mut self.instructions, branch, end_target)?; return Ok(ValueType::Boolean); } @@ -1285,7 +1363,7 @@ fn patch_jump( }; match instruction { Instruction::JumpIfFalse(value) - | Instruction::JumpIfTrue(value) + | Instruction::ShortCircuit { target: value, .. } | Instruction::Jump(value) => { *value = target; Ok(()) @@ -1394,6 +1472,93 @@ mod tests { ); } + #[test] + fn nullable_boolean_truth_table_preserves_unknown_under_negation() { + let unknown = Value::Missing(ValueType::Boolean); + let states = [Value::Boolean(false), Value::Boolean(true), unknown]; + let and = [ + [states[0], states[0], states[0]], + [states[0], states[1], unknown], + [states[0], unknown, unknown], + ]; + let or = [ + [states[0], states[1], unknown], + [states[1], states[1], states[1]], + [unknown, states[1], unknown], + ]; + for (i, lhs) in states.iter().enumerate() { + for (j, rhs) in states.iter().enumerate() { + let values = [("lhs", *lhs), ("rhs", *rhs)]; + assert_eq!(evaluate("lhs && rhs", &values), and[i][j]); + assert_eq!(evaluate("lhs || rhs", &values), or[i][j]); + assert_eq!(evaluate("!!(lhs && rhs)", &values), and[i][j]); + assert_eq!(evaluate("!!(lhs || rhs)", &values), or[i][j]); + } + } + assert_eq!(evaluate("!value", &[("value", unknown)]), unknown); + } + + #[test] + fn missing_numeric_operands_do_not_become_boolean_false_or_zero() { + let unknown = Value::Missing(ValueType::Boolean); + for missing in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + for operator in ["==", "!=", "<", "<=", ">", ">="] { + let values = [("value", Value::Number(missing))]; + assert_eq!(evaluate(&format!("value {operator} 0.0"), &values), unknown); + assert_eq!( + evaluate(&format!("!(0.0 {operator} value)"), &values), + unknown + ); + assert_eq!( + evaluate(&format!("!((value + 1.0) {operator} 0.0)"), &values), + unknown + ); + } + } + let missing = [("value", Value::Number(f64::NAN))]; + assert_eq!(evaluate("!(min(value, 1.0) > 0.0)", &missing), unknown); + assert_eq!(evaluate("!between(value, 0.0, 1.0)", &missing), unknown); + assert_eq!(evaluate("!(1.0 / 0.0 > 0.0)", &[]), unknown); + assert_eq!(evaluate("!(sqrt(-1.0) > 0.0)", &[]), unknown); + assert_eq!(evaluate("nz(value, 7.0)", &missing), Value::Number(7.0)); + assert_eq!( + evaluate("nz(value, 0.0) == 0.0", &missing), + Value::Boolean(true) + ); + } + + #[test] + fn nullable_short_circuit_and_explicit_conditional_branches_are_lazy() { + for source in [ + "false && missing", + "true || missing", + "if true { true } else { missing }", + ] { + let program = compile(source, |_| Some(ValueType::Boolean)).unwrap(); + program + .evaluate(&mut Scratch::default(), |_, _, _| { + Err(EvalError::new("unused input must not be resolved")) + }) + .unwrap(); + } + let unknown = Value::Missing(ValueType::Boolean); + assert_eq!( + evaluate("if value { 1.0 } else { 2.0 }", &[("value", unknown)]), + Value::Number(2.0) + ); + assert_eq!( + evaluate("iff(value, 1.0, 2.0)", &[("value", unknown)]), + Value::Number(2.0) + ); + } + + #[test] + fn invalid_clamp_returns_error_without_panicking() { + let program = compile("clamp(1.0, 2.0, 0.0)", |_| None).unwrap(); + let result = program.evaluate(&mut Scratch::default(), |_, _, _| unreachable!()); + assert!(result.unwrap_err().to_string().contains("lower bound")); + } + #[test] fn short_circuit_does_not_resolve_unused_variable() { let program = compile("false && missing", |name| { diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 73ad31a..cb7858f 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1388,6 +1388,15 @@ pub struct PlatformSelectionQuotePlan { pub diagnostics: Vec, } +fn checked_rhai_comparison( + operator: &str, + lhs: f64, + rhs: f64, +) -> Result> { + numeric_expr_vm::finite_comparison(operator, lhs, rhs) + .ok_or_else(|| format!("missing_numeric_operand: operator={operator}").into()) +} + fn platform_safe_div(lhs: f64, rhs: f64, fallback: f64) -> f64 { if rhs.abs() <= f64::EPSILON { fallback @@ -1565,21 +1574,48 @@ impl PlatformExprStrategy { pub fn new(config: PlatformExprStrategyConfig) -> Self { let mut engine = Engine::new(); + // Dynamic scripts cannot carry a nullable boolean through Rhai's + // logical operators. Reject an unknown comparison instead of letting + // native NaN comparisons turn missing data into a buy/sell signal. + for operator in ["==", "!=", "<", "<=", ">", ">="] { + engine.register_fn(operator, move |lhs: f64, rhs: f64| { + checked_rhai_comparison(operator, lhs, rhs) + }); + engine.register_fn(operator, move |lhs: f64, rhs: i64| { + checked_rhai_comparison(operator, lhs, rhs as f64) + }); + engine.register_fn(operator, move |lhs: i64, rhs: f64| { + checked_rhai_comparison(operator, lhs as f64, rhs) + }); + } engine.register_fn("round", |value: f64| value.round()); engine.register_fn("floor", |value: f64| value.floor()); engine.register_fn("ceil", |value: f64| value.ceil()); engine.register_fn("abs", |value: f64| value.abs()); - engine.register_fn("min", |lhs: f64, rhs: f64| lhs.min(rhs)); - engine.register_fn("max", |lhs: f64, rhs: f64| lhs.max(rhs)); + engine.register_fn("min", |lhs: f64, rhs: f64| { + if lhs.is_finite() && rhs.is_finite() { lhs.min(rhs) } else { f64::NAN } + }); + engine.register_fn("max", |lhs: f64, rhs: f64| { + if lhs.is_finite() && rhs.is_finite() { lhs.max(rhs) } else { f64::NAN } + }); engine.register_fn("sqrt", |value: f64| value.sqrt()); engine.register_fn("pow", |lhs: f64, rhs: f64| lhs.powf(rhs)); engine.register_fn("log", |value: f64| value.ln()); engine.register_fn("exp", |value: f64| value.exp()); - engine.register_fn("clamp", |value: f64, low: f64, high: f64| { - value.clamp(low, high) + engine.register_fn("clamp", |value: f64, low: f64, high: f64| -> Result> { + if !value.is_finite() || !low.is_finite() || !high.is_finite() { + return Ok(f64::NAN); + } + if low > high { + return Err("clamp lower bound exceeds upper bound".into()); + } + Ok(value.clamp(low, high)) }); - engine.register_fn("between", |value: f64, low: f64, high: f64| { - value >= low && value <= high + engine.register_fn("between", |value: f64, low: f64, high: f64| -> Result> { + if !value.is_finite() || !low.is_finite() || !high.is_finite() { + return Err("missing_numeric_operand: function=between".into()); + } + Ok(value >= low && value <= high) }); engine.register_fn( "nz", @@ -8330,6 +8366,7 @@ impl PlatformExprStrategy { return match value { NumericVmValue::Number(number) => Ok(number), NumericVmValue::Boolean(boolean) => Ok(if boolean { 1.0 } else { 0.0 }), + NumericVmValue::Missing(_) => Ok(f64::NAN), }; } let value = self.eval_dynamic(ctx, expr, day, stock, position)?; @@ -8360,6 +8397,7 @@ impl PlatformExprStrategy { return match value { NumericVmValue::Boolean(boolean) => Ok(boolean), NumericVmValue::Number(number) => Ok(number.is_finite() && number != 0.0), + NumericVmValue::Missing(_) => Ok(false), }; } let value = self.eval_dynamic(ctx, expr, day, stock, position)?; @@ -16604,6 +16642,33 @@ mod tests { assert!(missing_stock.turnover_ratio.is_nan()); assert!(missing_stock.effective_turnover_ratio.is_nan()); assert_eq!(present_stock.turnover_ratio, 0.0); + for predicate in [ + "!(model_score > 0.0)", + "!(model_score != 0.0)", + "!between(model_score, 0.0, 1.0)", + "!(min(model_score, 1.0) > 0.0)", + "!(model_score > 0.0) || false", + ] { + assert!(!strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}"); + } + for predicate in [ + "!(model_score > 0.0) || true", + "!(model_score > 0.0 && false)", + ] { + assert!(strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap(), "{predicate}"); + } + for predicate in [ + "symbol == \"000001.SZ\" && !(model_score > 0.0)", + "symbol == \"000001.SZ\" && !(model_score > 0)", + "symbol == \"000001.SZ\" && !(0 < model_score)", + ] { + let error = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap_err(); + assert!(error.to_string().contains("missing_numeric_operand"), "{error}"); + } + assert!(!strategy.eval_bool( + &ctx, "symbol == \"OTHER\" && !(model_score > 0.0)", + &day, Some(&missing_stock), None, + ).unwrap()); assert!(!strategy.eval_bool(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap()); assert!(strategy.eval_bool(&ctx, "model_score", &day, Some(&present_stock), None).unwrap()); for field in ["turnover_ratio", "effective_turnover_ratio"] { From 3b1aa2ebcb85f9a7205bde7c9380b053f3997c42 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 03:58:49 +0800 Subject: [PATCH 19/21] test: identify the dynamic missing-value comparison path --- crates/fidc-core/src/platform_expr_strategy.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index cb7858f..4c978de 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -16662,7 +16662,9 @@ mod tests { "symbol == \"000001.SZ\" && !(model_score > 0)", "symbol == \"000001.SZ\" && !(0 < model_score)", ] { - let error = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None).unwrap_err(); + let result = strategy.eval_bool(&ctx, predicate, &day, Some(&missing_stock), None); + assert!(result.is_err(), "{predicate}: {result:?}"); + let error = result.unwrap_err(); assert!(error.to_string().contains("missing_numeric_operand"), "{error}"); } assert!(!strategy.eval_bool( From ea58ab2166dc8a1fa363d4343d8bd93d3e85662e Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 04:02:11 +0800 Subject: [PATCH 20/21] fix: enforce numeric guards in Rhai operator dispatch --- crates/fidc-core/src/platform_expr_strategy.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 4c978de..59bb088 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -1574,6 +1574,8 @@ impl PlatformExprStrategy { pub fn new(config: PlatformExprStrategyConfig) -> Self { let mut engine = Engine::new(); + engine.set_fast_operators(false); + engine.set_fail_on_invalid_map_property(true); // Dynamic scripts cannot carry a nullable boolean through Rhai's // logical operators. Reject an unknown comparison instead of letting // native NaN comparisons turn missing data into a buy/sell signal. From e3f10286677fe5cc1bb212a008eb5dfb152d17f9 Mon Sep 17 00:00:00 2001 From: boris Date: Wed, 9 Sep 2026 04:34:09 +0800 Subject: [PATCH 21/21] fix: reject missing numeric sizing and execution parameters --- .../fidc-core/src/platform_expr_strategy.rs | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 59bb088..f5b88f2 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -8363,6 +8363,25 @@ impl PlatformExprStrategy { day: &DayExpressionState, stock: Option<&StockExpressionState>, position: Option<&PositionExpressionState>, + ) -> Result { + let value = self.eval_float_or_missing(ctx, expr, day, stock, position)?; + if !value.is_finite() { + return Err(BacktestError::Execution(format!( + "missing_numeric_result: expression={expr:?}, symbol={}, decision_date={}, execution_date={}", + stock.map(|item| item.symbol.as_ref()).unwrap_or("portfolio"), + ctx.decision_date, ctx.execution_date, + ))); + } + Ok(value) + } + + fn eval_float_or_missing( + &self, + ctx: &StrategyContext<'_>, + expr: &str, + day: &DayExpressionState, + stock: Option<&StockExpressionState>, + position: Option<&PositionExpressionState>, ) -> Result { if let Some(value) = self.eval_numeric_vm(ctx, expr, day, stock, position)? { return match value { @@ -10441,7 +10460,7 @@ impl PlatformExprStrategy { stock: &StockExpressionState, ) -> Result { if self.rank_expr_present { - return match self.eval_float(ctx, &self.config.rank_expr, day, Some(stock), None) { + return match self.eval_float_or_missing(ctx, &self.config.rank_expr, day, Some(stock), None) { Ok(value) => Ok(value), Err(error) if Self::is_missing_rolling_mean_error(&error) => Ok(f64::NAN), Err(error) if Self::is_non_numeric_expr_error(&error) => Ok(f64::NAN), @@ -16644,6 +16663,12 @@ mod tests { assert!(missing_stock.turnover_ratio.is_nan()); assert!(missing_stock.effective_turnover_ratio.is_nan()); assert_eq!(present_stock.turnover_ratio, 0.0); + for expression in ["model_score", "min(model_score, 1.0)", "model_score / 100.0"] { + let error = strategy.eval_float(&ctx, expression, &day, Some(&missing_stock), None).unwrap_err(); + assert!(error.to_string().contains("missing_numeric_result"), "{error}"); + } + assert!(strategy.eval_float_or_missing(&ctx, "model_score", &day, Some(&missing_stock), None).unwrap().is_nan()); + assert_eq!(strategy.eval_float(&ctx, "nz(model_score, 0.0)", &day, Some(&missing_stock), None).unwrap(), 0.0); for predicate in [ "!(model_score > 0.0)", "!(model_score != 0.0)",