From 60457389a35be386ad847999709d739b66ee7136 Mon Sep 17 00:00:00 2001 From: boris Date: Fri, 17 Jul 2026 12:07:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=98=BE=E5=BC=8F=E6=AD=A2?= =?UTF-8?q?=E7=9B=88=E6=AD=A2=E6=8D=9F=E5=8F=82=E8=80=83=E4=BB=B7=E5=8F=A3?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/data.rs | 17 ++ crates/fidc-core/src/lib.rs | 3 +- .../fidc-core/src/platform_expr_strategy.rs | 204 ++++++++++++++---- .../fidc-core/src/platform_strategy_spec.rs | 71 +++++- 4 files changed, 249 insertions(+), 46 deletions(-) diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 1ce6963..b13de4e 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -554,6 +554,18 @@ impl AdjustedCloseSeries { 12, )) } + + fn latest_back_adjusted_close(&self, date: NaiveDate) -> Option { + let index = match self.dates.binary_search(&date) { + Ok(index) => index, + Err(0) => return None, + Err(index) => index - 1, + }; + self.back_adjusted_closes + .get(index) + .copied() + .filter(|value| value.is_finite() && *value > 0.0) + } } impl SymbolPriceSeries { @@ -2429,6 +2441,11 @@ impl DataSet { } } + pub fn market_latest_back_adjusted_close(&self, date: NaiveDate, symbol: &str) -> Option { + self.adjusted_close_series(symbol) + .and_then(|series| series.latest_back_adjusted_close(date)) + } + pub fn market_decision_numeric_values( &self, date: NaiveDate, diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 5f45841..474805d 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -53,7 +53,8 @@ pub use platform_expr_strategy::{ PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, - PlatformSelectionQuotePlan, PlatformTradeAction, PlatformUniverseActionKind, + PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction, + PlatformUniverseActionKind, }; pub use platform_runtime_schema::{ PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names, diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3679f17..3e66741 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -324,6 +324,12 @@ pub enum PlatformExplicitActionStage { OnDay, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlatformStopTakeReferencePriceMode { + PositionCostBasis, + SignalDayPostAdjustedClose, +} + #[derive(Debug, Clone)] pub struct PlatformExprStrategyConfig { pub strategy_name: String, @@ -346,6 +352,7 @@ pub struct PlatformExprStrategyConfig { pub portfolio_drawdown_control: Option, pub stop_loss_expr: String, pub take_profit_expr: String, + pub stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode, pub rank_by: String, pub rank_expr: String, pub rank_desc: bool, @@ -424,6 +431,7 @@ fn band_low(index_close) { portfolio_drawdown_control: None, stop_loss_expr: String::new(), take_profit_expr: String::new(), + stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode::PositionCostBasis, rank_by: "market_cap".to_string(), rank_expr: String::new(), rank_desc: false, @@ -7824,6 +7832,21 @@ impl PlatformExprStrategy { } continue; } + if self.config.stop_take_reference_price_mode + == PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose + && ctx + .data + .market_latest_back_adjusted_close(date, &candidate.symbol) + .is_none() + { + if diagnostics.len() < 12 { + diagnostics.push(format!( + "{} rejected by missing signal-day post-adjusted close", + candidate.symbol + )); + } + continue; + } selected.push(candidate.symbol.clone()); if selected.len() >= limit { break; @@ -8642,13 +8665,33 @@ impl PlatformExprStrategy { .average_entry_price() .filter(|value| value.is_finite() && *value > 0.0) .unwrap_or(position.average_cost); - let stop_take_base_price = if self.config.aiquant_transaction_cost - && position.average_cost.is_finite() - && position.average_cost > 0.0 - { - position.average_cost - } else { - entry_avg_price + let stop_take_base_price = match self.config.stop_take_reference_price_mode { + PlatformStopTakeReferencePriceMode::PositionCostBasis => { + if self.config.aiquant_transaction_cost + && position.average_cost.is_finite() + && position.average_cost > 0.0 + { + position.average_cost + } else { + entry_avg_price + } + } + PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose => { + let entry_date = self.position_entry_dates.get(symbol).copied().ok_or_else(|| { + BacktestError::Execution(format!( + "missing model admission date for stop/take reference: symbol={symbol}, signal_date={signal_date}" + )) + })?; + ctx.data + .market_latest_back_adjusted_close(entry_date, symbol) + .ok_or_else(|| { + BacktestError::Data(crate::data::DataSetError::MissingSnapshot { + kind: "model admission post-adjusted close", + date: entry_date, + symbol: symbol.to_string(), + }) + })? + } }; if position.quantity == 0 || stop_take_base_price <= 0.0 { return Ok((false, false)); @@ -8776,6 +8819,20 @@ impl PlatformExprStrategy { symbol: &str, stock: &StockExpressionState, ) -> Result { + if self.config.stop_take_reference_price_mode + == PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose + { + return ctx + .data + .market_latest_back_adjusted_close(signal_date, symbol) + .ok_or_else(|| { + BacktestError::Data(crate::data::DataSetError::MissingSnapshot { + kind: "post-adjusted close", + date: signal_date, + symbol: symbol.to_string(), + }) + }); + } if self.config.aiquant_transaction_cost && self.config.matching_type == MatchingType::NextBarOpen { @@ -10367,9 +10424,9 @@ mod tests { PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformPortfolioDrawdownController, - PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, - PlatformUniverseActionKind, SelectionRiskDeferral, StockFilterQuoteUsage, - precomputed_stock_rolling_mean, + PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, + PlatformTradeAction, PlatformUniverseActionKind, SelectionRiskDeferral, + StockFilterQuoteUsage, precomputed_stock_rolling_mean, }; use crate::{ AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction, @@ -14214,39 +14271,70 @@ mod tests { delisted_at: None, status: "active".to_string(), }], - vec![DailyMarketSnapshot { - date, - symbol: symbol.to_string(), - timestamp: Some("2025-03-14 10:18:00".to_string()), - day_open: 9.30, - open: 9.30, - high: 9.35, - low: 9.18, - close: 9.20, - last_price: 9.20, - bid1: 9.20, - ask1: 9.21, - prev_close: 10.00, - volume: 1_000_000, - minute_volume: 1_000, - bid1_volume: 1_000, - ask1_volume: 1_000, - trading_phase: Some("continuous".to_string()), - paused: false, - upper_limit: 11.00, - lower_limit: 9.00, - price_tick: 0.01, - }], - vec![DailyFactorSnapshot { - date, - symbol: symbol.to_string(), - market_cap_bn: 3.2, - free_float_cap_bn: 2.1, - pe_ttm: 8.0, - turnover_ratio: Some(3.0), - effective_turnover_ratio: Some(3.0), - extra_factors: BTreeMap::new(), - }], + vec![ + DailyMarketSnapshot { + date: prev_date, + symbol: symbol.to_string(), + timestamp: Some("2025-03-13 15:00:00".to_string()), + day_open: 10.00, + open: 10.00, + high: 10.10, + low: 9.90, + close: 10.00, + last_price: 10.00, + bid1: 9.99, + ask1: 10.00, + prev_close: 10.00, + volume: 1_000_000, + minute_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("close".to_string()), + paused: false, + upper_limit: 11.00, + lower_limit: 9.00, + price_tick: 0.01, + }, + DailyMarketSnapshot { + date, + symbol: symbol.to_string(), + timestamp: Some("2025-03-14 10:18:00".to_string()), + day_open: 9.30, + open: 9.30, + high: 9.35, + low: 9.18, + close: 9.30, + last_price: 9.20, + bid1: 9.20, + ask1: 9.21, + prev_close: 10.00, + volume: 1_000_000, + minute_volume: 1_000, + bid1_volume: 1_000, + ask1_volume: 1_000, + trading_phase: Some("continuous".to_string()), + paused: false, + upper_limit: 11.00, + lower_limit: 9.00, + price_tick: 0.01, + }, + ], + [prev_date, date] + .into_iter() + .map(|factor_date| DailyFactorSnapshot { + date: factor_date, + symbol: symbol.to_string(), + market_cap_bn: 3.2, + free_float_cap_bn: 2.1, + pe_ttm: 8.0, + turnover_ratio: Some(3.0), + effective_turnover_ratio: Some(3.0), + extra_factors: BTreeMap::from([( + "adjustment_factor_backward1".to_string(), + 1.0, + )]), + }) + .collect(), vec![CandidateEligibility { date, symbol: symbol.to_string(), @@ -14335,6 +14423,36 @@ mod tests { "{:?}", decision.order_intents ); + + let mut signal_price_cfg = PlatformExprStrategyConfig::microcap_rotation(); + signal_price_cfg.rotation_enabled = false; + signal_price_cfg.aiquant_transaction_cost = true; + signal_price_cfg.intraday_execution_time = + Some(NaiveTime::from_hms_opt(10, 18, 0).expect("time")); + signal_price_cfg.signal_symbol = symbol.to_string(); + signal_price_cfg.stop_loss_expr = "0.92".to_string(); + signal_price_cfg.take_profit_expr.clear(); + signal_price_cfg.stop_take_reference_price_mode = + PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose; + let mut signal_price_strategy = PlatformExprStrategy::new(signal_price_cfg); + signal_price_strategy.remember_position_entry_date(symbol, prev_date); + + let signal_price_decision = signal_price_strategy + .on_day(&ctx) + .expect("signal price platform decision"); + + assert!( + !signal_price_decision.order_intents.iter().any(|intent| matches!( + intent, + OrderIntent::TargetValue { + symbol: intent_symbol, + target_value, + reason, + } if intent_symbol == symbol && *target_value == 0.0 && reason == "stop_loss_exit" + )), + "{:?}", + signal_price_decision.order_intents + ); } #[test] diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs index a826baf..9977c78 100644 --- a/crates/fidc-core/src/platform_strategy_spec.rs +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -8,8 +8,8 @@ use crate::{ DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency, - PlatformTradeAction, PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, - SlippageModel, + PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind, + RebalanceCashMode, ScheduleTimeRule, SlippageModel, }; #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -669,6 +669,12 @@ pub struct StrategyExpressionRiskConfig { pub stop_loss_expr: Option, #[serde(default)] pub take_profit_expr: Option, + #[serde( + default, + alias = "referencePriceMode", + alias = "stop_take_reference_price_mode" + )] + pub stop_take_reference_price_mode: Option, } #[derive(Debug, Clone, Default, Deserialize, Serialize)] @@ -849,6 +855,24 @@ fn is_aiquant_profile(value: Option<&str>) -> bool { .is_some_and(|item| item == "aiquant" || item == "aiquant_rqalpha" || item == "rqalpha") } +fn parse_stop_take_reference_price_mode( + value: &str, +) -> Result { + match value.trim().to_ascii_lowercase().replace('-', "_").as_str() { + "position_cost_basis" | "position_cost" | "execution_cost_basis" => { + Ok(PlatformStopTakeReferencePriceMode::PositionCostBasis) + } + "signal_day_post_adjusted_close" + | "signal_post_adjusted_close" + | "model_signal_post_adjusted_close" => { + Ok(PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose) + } + other => Err(format!( + "runtimeExpressions.risk.stopTakeReferencePriceMode unsupported: {other}" + )), + } +} + fn apply_cost_overrides( cfg: &mut PlatformExprStrategyConfig, commission_rate: Option, @@ -1667,6 +1691,14 @@ pub fn platform_expr_config_from_spec( { cfg.take_profit_expr = expr.clone(); } + if let Some(mode) = risk + .stop_take_reference_price_mode + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + cfg.stop_take_reference_price_mode = parse_stop_take_reference_price_mode(mode)?; + } } if let Some(ordering) = runtime_expr.ordering.as_ref() { if let Some(rank_by) = ordering @@ -3367,4 +3399,39 @@ mod tests { let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid trigger"); assert!(error.to_string().contains("drawdownTrigger")); } + + #[test] + fn parses_signal_day_post_adjusted_stop_take_reference_price_mode() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "risk": { + "stopLossExpr": "0.92", + "takeProfitExpr": "1.16", + "stopTakeReferencePriceMode": "signal_day_post_adjusted_close" + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!( + cfg.stop_take_reference_price_mode, + PlatformStopTakeReferencePriceMode::SignalDayPostAdjustedClose + ); + } + + #[test] + fn rejects_unknown_stop_take_reference_price_mode() { + let spec = serde_json::json!({ + "runtimeExpressions": { + "risk": { + "stopTakeReferencePriceMode": "future_price" + } + } + }); + + let error = platform_expr_config_from_value("", "", &spec).expect_err("invalid mode"); + + assert!(error.to_string().contains("stopTakeReferencePriceMode")); + } }