diff --git a/crates/fidc-core/src/data.rs b/crates/fidc-core/src/data.rs index 3f89d96..93d893f 100644 --- a/crates/fidc-core/src/data.rs +++ b/crates/fidc-core/src/data.rs @@ -485,7 +485,9 @@ impl SymbolPriceSeries { #[derive(Debug, Clone)] struct BenchmarkPriceSeries { dates: Vec, + opens: Vec, closes: Vec, + open_prefix: Vec, close_prefix: Vec, } @@ -494,16 +496,24 @@ impl BenchmarkPriceSeries { let mut sorted = rows.to_vec(); sorted.sort_by_key(|row| row.date); let dates = sorted.iter().map(|row| row.date).collect::>(); + let opens = sorted.iter().map(|row| row.open).collect::>(); let closes = sorted.iter().map(|row| row.close).collect::>(); + let open_prefix = prefix_sums(&opens); let close_prefix = prefix_sums(&closes); Self { dates, + opens, closes, + open_prefix, close_prefix, } } fn moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + self.moving_average_for(date, lookback, PriceField::Close) + } + + fn moving_average_for(&self, date: NaiveDate, lookback: usize, field: PriceField) -> Option { if lookback == 0 { return None; } @@ -516,7 +526,11 @@ impl BenchmarkPriceSeries { return None; } let start = end - lookback; - let sum = self.close_prefix[end] - self.close_prefix[start]; + let prefix = match field { + PriceField::Open => &self.open_prefix, + PriceField::Close | PriceField::Last => &self.close_prefix, + }; + let sum = prefix[end] - prefix[start]; Some(sum / lookback as f64) } @@ -942,6 +956,20 @@ impl DataSet { self.benchmark_series_cache.moving_average(date, lookback) } + pub fn benchmark_open_moving_average(&self, date: NaiveDate, lookback: usize) -> Option { + self.benchmark_series_cache + .moving_average_for(date, lookback, PriceField::Open) + } + + pub fn market_open_moving_average( + &self, + date: NaiveDate, + symbol: &str, + lookback: usize, + ) -> Option { + self.market_moving_average(date, symbol, lookback, PriceField::Open) + } + pub fn eligible_universe_on(&self, date: NaiveDate) -> &[EligibleUniverseSnapshot] { self.eligible_universe_by_date .get(&date) diff --git a/crates/fidc-core/src/platform_expr_strategy.rs b/crates/fidc-core/src/platform_expr_strategy.rs index 3d3fa76..e491faa 100644 --- a/crates/fidc-core/src/platform_expr_strategy.rs +++ b/crates/fidc-core/src/platform_expr_strategy.rs @@ -69,10 +69,9 @@ fn band_low(index_close) { "stock_ma_short > stock_ma_mid * ma_ratio && stock_ma_mid > stock_ma_long" .to_string(), buy_scale_expr: "1.0".to_string(), - exposure_expr: - "benchmark_ma_short < benchmark_ma_long * ma_ratio ? 0.5 : 1.0".to_string(), - stop_loss_expr: "0.93".to_string(), - take_profit_expr: "1.07".to_string(), + exposure_expr: "1.0".to_string(), + stop_loss_expr: String::new(), + take_profit_expr: String::new(), rank_by: "market_cap".to_string(), rank_expr: String::new(), rank_desc: false, @@ -110,7 +109,9 @@ struct ProjectedExecutionFill { #[derive(Debug, Clone)] struct DayExpressionState { date: NaiveDate, + signal_open: f64, signal_close: f64, + benchmark_open: f64, benchmark_close: f64, benchmark_ma_short: f64, benchmark_ma_long: f64, @@ -647,6 +648,15 @@ impl PlatformExprStrategy { ctx: &StrategyContext<'_>, date: NaiveDate, ) -> Result { + let signal_open = ctx + .data + .market(date, &self.config.signal_symbol) + .ok_or_else(|| BacktestError::MissingPrice { + date, + symbol: self.config.signal_symbol.clone(), + field: "open", + })? + .open; let signal_close = ctx .data .market_decision_close(date, &self.config.signal_symbol) @@ -655,11 +665,12 @@ impl PlatformExprStrategy { symbol: self.config.signal_symbol.clone(), field: "decision_close", })?; - let benchmark_close = ctx + let benchmark = ctx .data .benchmark(date) - .ok_or(BacktestError::MissingBenchmark { date })? - .close; + .ok_or(BacktestError::MissingBenchmark { date })?; + let benchmark_open = benchmark.open; + let benchmark_close = benchmark.close; let benchmark_ma_short = ctx .data .market_decision_close_moving_average( @@ -711,7 +722,9 @@ impl PlatformExprStrategy { Ok(DayExpressionState { date, + signal_open, signal_close, + benchmark_open, benchmark_close, benchmark_ma_short, benchmark_ma_long, @@ -868,7 +881,9 @@ impl PlatformExprStrategy { position: Option<&PositionExpressionState>, ) -> Scope<'static> { let mut scope = Scope::new(); + scope.push("signal_open", day.signal_open); scope.push("signal_close", day.signal_close); + scope.push("benchmark_open", day.benchmark_open); scope.push("benchmark_close", day.benchmark_close); scope.push("signal_ma5", day.signal_ma5); scope.push("signal_ma10", day.signal_ma10); @@ -898,7 +913,9 @@ impl PlatformExprStrategy { scope.push("is_month_end", day.is_month_end); scope.push("signal_ma30", day.signal_ma30); let mut day_factors = Map::new(); + day_factors.insert("signal_open".into(), Dynamic::from(day.signal_open)); day_factors.insert("signal_close".into(), Dynamic::from(day.signal_close)); + day_factors.insert("benchmark_open".into(), Dynamic::from(day.benchmark_open)); day_factors.insert("benchmark_close".into(), Dynamic::from(day.benchmark_close)); day_factors.insert("signal_ma5".into(), Dynamic::from(day.signal_ma5)); day_factors.insert("signal_ma10".into(), Dynamic::from(day.signal_ma10)); @@ -1244,7 +1261,11 @@ impl PlatformExprStrategy { )); } let value = match field { + "benchmark_open" => ctx.data.benchmark_open_moving_average(day.date, lookback), "benchmark_close" => ctx.data.benchmark_moving_average(day.date, lookback), + "signal_open" => ctx + .data + .market_open_moving_average(day.date, &self.config.signal_symbol, lookback), "signal_close" => ctx .data .market_decision_close_moving_average(day.date, &self.config.signal_symbol, lookback), @@ -1838,6 +1859,9 @@ impl PlatformExprStrategy { let Some(position) = ctx.portfolio.position(symbol) else { return Ok((false, false)); }; + if self.config.stop_loss_expr.trim().is_empty() && self.config.take_profit_expr.trim().is_empty() { + return Ok((false, false)); + } if position.quantity == 0 || position.average_cost <= 0.0 { return Ok((false, false)); } @@ -1855,27 +1879,35 @@ impl PlatformExprStrategy { quantity: position.quantity as i64, sellable_qty: position.sellable_qty(date) as i64, }; - let stop_result = self.eval_dynamic(ctx, &self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; - let stop_hit = if let Some(boolean) = stop_result.clone().try_cast::() { - boolean - } else if let Some(multiplier) = stop_result.clone().try_cast::() { - current_price <= position.average_cost * multiplier - } else if let Some(multiplier) = stop_result.try_cast::() { - current_price <= position.average_cost * multiplier as f64 - } else { + let stop_hit = if self.config.stop_loss_expr.trim().is_empty() { false + } else { + let stop_result = self.eval_dynamic(ctx, &self.config.stop_loss_expr, day, Some(&stock), Some(&position_state))?; + if let Some(boolean) = stop_result.clone().try_cast::() { + boolean + } else if let Some(multiplier) = stop_result.clone().try_cast::() { + current_price <= position.average_cost * multiplier + } else if let Some(multiplier) = stop_result.try_cast::() { + current_price <= position.average_cost * multiplier as f64 + } else { + false + } }; - let take_result = self.eval_dynamic(ctx, &self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; - let profit_hit = if let Some(boolean) = take_result.clone().try_cast::() { - boolean - } else if let Some(multiplier) = take_result.clone().try_cast::() { - !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) - && current_price / position.average_cost > multiplier - } else if let Some(multiplier) = take_result.try_cast::() { - !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) - && current_price / position.average_cost > multiplier as f64 - } else { + let profit_hit = if self.config.take_profit_expr.trim().is_empty() { false + } else { + let take_result = self.eval_dynamic(ctx, &self.config.take_profit_expr, day, Some(&stock), Some(&position_state))?; + if let Some(boolean) = take_result.clone().try_cast::() { + boolean + } else if let Some(multiplier) = take_result.clone().try_cast::() { + !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + && current_price / position.average_cost > multiplier + } else if let Some(multiplier) = take_result.try_cast::() { + !ctx.data.require_market(date, symbol)?.is_at_upper_limit_price(current_price) + && current_price / position.average_cost > multiplier as f64 + } else { + false + } }; Ok((stop_hit, profit_hit)) } diff --git a/crates/fidc-core/src/strategy_ai.rs b/crates/fidc-core/src/strategy_ai.rs index 2e21f64..515f78e 100644 --- a/crates/fidc-core/src/strategy_ai.rs +++ b/crates/fidc-core/src/strategy_ai.rs @@ -116,8 +116,8 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { title: "日级字段".to_string(), detail: "可直接在表达式中使用的日级与账户字段。".to_string(), fields: vec![ - ManualField { name: "signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数前一日收盘价。".to_string() }, - ManualField { name: "benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准前一日收盘价。".to_string() }, + ManualField { name: "signal_open/signal_close".to_string(), field_type: "float".to_string(), detail: "信号指数当日开盘价与前一日收盘价。".to_string() }, + ManualField { name: "benchmark_open/benchmark_close".to_string(), field_type: "float".to_string(), detail: "基准当日开盘价与前一日收盘价。".to_string() }, ManualField { name: "signal_ma5/signal_ma10/signal_ma20/signal_ma30".to_string(), field_type: "float".to_string(), detail: "信号指数滚动均线。".to_string() }, ManualField { name: "benchmark_ma5/benchmark_ma10/benchmark_ma20/benchmark_ma30".to_string(), field_type: "float".to_string(), detail: "基准指数滚动均线。".to_string() }, ManualField { name: "cash/available_cash/market_value/total_equity".to_string(), field_type: "float".to_string(), detail: "账户资金与总资产。".to_string() }, @@ -158,7 +158,7 @@ pub fn built_in_strategy_manual() -> StrategyAiManual { functions: vec![ ManualFunction { name: "factor".to_string(), signature: "factor(\"column_name\")".to_string(), detail: "读取当前股票的数据库因子列。".to_string() }, ManualFunction { name: "day_factor".to_string(), signature: "day_factor(\"field_name\")".to_string(), detail: "读取日级/指数级字段映射。".to_string() }, - ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio 等。".to_string() }, + ManualFunction { name: "rolling_mean".to_string(), signature: "rolling_mean(\"field\", lookback)".to_string(), detail: "任意字段滚动均值,支持 volume/amount/turnover_ratio、signal_open/signal_close、benchmark_open/benchmark_close 等。".to_string() }, ManualFunction { name: "sma".to_string(), signature: "sma(\"field\", lookback)".to_string(), detail: "rolling_mean 的别名。".to_string() }, ManualFunction { name: "round/floor/ceil/abs/min/max/clamp".to_string(), signature: "round(x)".to_string(), detail: "常用数值函数。".to_string() }, ManualFunction { name: "safe_div".to_string(), signature: "safe_div(lhs, rhs, fallback)".to_string(), detail: "安全除法。".to_string() },