From 236ec62e44ff765c65416f4b8fa5331c7373aa11 Mon Sep 17 00:00:00 2001 From: boris Date: Tue, 28 Apr 2026 19:40:09 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E7=AD=96=E7=95=A5=E8=A7=84?= =?UTF-8?q?=E6=A0=BC=E8=A7=A3=E6=9E=90=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/lib.rs | 10 + .../fidc-core/src/platform_strategy_spec.rs | 1036 +++++++++++++++++ crates/fidc-core/src/strategy.rs | 2 + 3 files changed, 1048 insertions(+) create mode 100644 crates/fidc-core/src/platform_strategy_spec.rs diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 2506df1..9f76a4e 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod futures; pub mod instrument; pub mod metrics; pub mod platform_expr_strategy; +pub mod platform_strategy_spec; pub mod portfolio; pub mod rules; pub mod scheduler; @@ -49,6 +50,15 @@ pub use platform_expr_strategy::{ PlatformRebalanceSchedule, PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, }; +pub use platform_strategy_spec::{ + DynamicRangeConfig, IndexThrottleConfig, MovingAverageFilterConfig, SkipWindowConfig, + StrategyBenchmarkSpec, StrategyEngineConfig, StrategyExecutionSpec, + StrategyExpressionActionConfig, StrategyExpressionAllocationConfig, + StrategyExpressionOrderingConfig, StrategyExpressionRiskConfig, + StrategyExpressionScheduleConfig, StrategyExpressionSelectionConfig, + StrategyExpressionTradingConfig, StrategyRuntimeEnvironment, StrategyRuntimeExpressions, + StrategyRuntimeSpec, platform_expr_config_from_spec, platform_expr_config_from_value, +}; pub use portfolio::{CashReceivable, HoldingSummary, PendingCashFlow, PortfolioState, Position}; pub use rules::{ChinaEquityRuleHooks, EquityRuleHooks, RuleCheck}; pub use scheduler::{ diff --git a/crates/fidc-core/src/platform_strategy_spec.rs b/crates/fidc-core/src/platform_strategy_spec.rs new file mode 100644 index 0000000..55b9607 --- /dev/null +++ b/crates/fidc-core/src/platform_strategy_spec.rs @@ -0,0 +1,1036 @@ +use std::collections::BTreeMap; + +use chrono::NaiveTime; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind, + PlatformExplicitOrderKind, PlatformExprStrategyConfig, PlatformRebalanceSchedule, + PlatformScheduleFrequency, PlatformTradeAction, PlatformUniverseActionKind, ScheduleTimeRule, +}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyRuntimeSpec { + #[serde(default)] + pub strategy_id: Option, + #[serde(default)] + pub market: Option, + #[serde(default)] + pub benchmark: Option, + #[serde(default)] + pub signal_symbol: Option, + #[serde(default)] + pub execution: Option, + #[serde(default)] + pub engine_config: Option, + #[serde(default)] + pub runtime_expressions: Option, + #[serde(default)] + pub environment: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyBenchmarkSpec { + #[serde(default)] + pub instrument_id: Option, + #[serde(default)] + pub fallback_instrument_id: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExecutionSpec { + #[serde(default)] + pub matching_type: Option, + #[serde(default)] + pub slippage_model: Option, + #[serde(default)] + pub slippage_value: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyEngineConfig { + #[serde(default)] + pub template_id: Option, + #[serde(default)] + pub benchmark_symbol: Option, + #[serde(default)] + pub signal_symbol: Option, + #[serde(default)] + pub rank_limit: Option, + #[serde(default)] + pub refresh_rate: Option, + #[serde(default)] + pub rsi_rate: Option, + #[serde(default)] + pub dynamic_range: Option, + #[serde(default)] + pub stock_ma_filter: Option, + #[serde(default)] + pub index_throttle: Option, + #[serde(default)] + pub stop_loss_multiplier: Option, + #[serde(default)] + pub take_profit_multiplier: Option, + #[serde(default)] + pub matching_type: Option, + #[serde(default)] + pub slippage_model: Option, + #[serde(default)] + pub slippage_value: Option, + #[serde(default)] + pub dividend_reinvestment: Option, + #[serde(default)] + pub rebalance_schedule: Option, + #[serde(default)] + pub skip_windows: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DynamicRangeConfig { + #[serde(default)] + pub base_index_level: Option, + #[serde(default)] + pub base_cap_floor: Option, + #[serde(default)] + pub cap_span: Option, + #[serde(default)] + pub xs: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MovingAverageFilterConfig { + #[serde(default)] + pub short_days: Option, + #[serde(default)] + pub mid_days: Option, + #[serde(default)] + pub long_days: Option, + #[serde(default)] + pub rsi_rate: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexThrottleConfig { + #[serde(default)] + pub short_days: Option, + #[serde(default)] + pub long_days: Option, + #[serde(default)] + pub rsi_rate: Option, + #[serde(default)] + pub defensive_exposure: Option, + #[serde(default)] + pub full_exposure: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SkipWindowConfig { + #[serde(default)] + pub month: Option, + #[serde(default)] + pub start_day: Option, + #[serde(default)] + pub end_day: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyRuntimeExpressions { + #[serde(default)] + pub prelude: Option, + #[serde(default)] + pub schedule: Option, + #[serde(default)] + pub selection: Option, + #[serde(default)] + pub allocation: Option, + #[serde(default)] + pub risk: Option, + #[serde(default)] + pub ordering: Option, + #[serde(default)] + pub trading: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyRuntimeEnvironment { + #[serde(default)] + pub variables: BTreeMap, + #[serde(default)] + pub functions: BTreeMap, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionSelectionConfig { + #[serde(default)] + pub limit_expr: Option, + #[serde(default)] + pub market_cap_field: Option, + #[serde(default)] + pub market_cap_lower_expr: Option, + #[serde(default)] + pub market_cap_upper_expr: Option, + #[serde(default)] + pub stock_filter_expr: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionAllocationConfig { + #[serde(default)] + pub buy_scale_expr: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionRiskConfig { + #[serde(default)] + pub exposure_expr: Option, + #[serde(default)] + pub stop_loss_expr: Option, + #[serde(default)] + pub take_profit_expr: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionOrderingConfig { + #[serde(default)] + pub rank_by: Option, + #[serde(default)] + pub rank_expr: Option, + #[serde(default)] + pub rank_order: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionTradingConfig { + #[serde(default)] + pub stage: Option, + #[serde(default)] + pub schedule: Option, + #[serde(default)] + pub rotation_enabled: Option, + #[serde(default)] + pub subscription_guard_required: Option, + #[serde(default)] + pub actions: Vec, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionActionConfig { + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub symbol: Option, + #[serde(default)] + pub symbols_expr: Option, + #[serde(default)] + pub amount_expr: Option, + #[serde(default)] + pub start_time_expr: Option, + #[serde(default)] + pub end_time_expr: Option, + #[serde(default)] + pub limit_price_expr: Option, + #[serde(default)] + pub target_weights_expr: Option, + #[serde(default)] + pub order_prices_expr: Option, + #[serde(default)] + pub valuation_prices_expr: Option, + #[serde(default)] + pub order_id_expr: Option, + #[serde(default)] + pub when_expr: Option, + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrategyExpressionScheduleConfig { + #[serde(default)] + pub frequency: Option, + #[serde(default)] + pub weekday: Option, + #[serde(default)] + pub tradingday: Option, + #[serde(default)] + pub time: Option, + #[serde(default)] + pub time_rule: Option, + #[serde(default)] + pub time_rule_hour: Option, + #[serde(default)] + pub time_rule_minute: Option, +} + +pub fn platform_expr_config_from_value( + strategy_id: &str, + signal_symbol: &str, + value: &Value, +) -> Result { + if value.is_null() || value.as_object().is_some_and(|object| object.is_empty()) { + return Ok(platform_expr_config_from_spec( + strategy_id, + signal_symbol, + None, + )); + } + let spec = serde_json::from_value::(value.clone())?; + Ok(platform_expr_config_from_spec( + strategy_id, + signal_symbol, + Some(&spec), + )) +} + +pub fn platform_expr_config_from_spec( + strategy_id: &str, + signal_symbol: &str, + strategy_spec: Option<&StrategyRuntimeSpec>, +) -> PlatformExprStrategyConfig { + let mut cfg = PlatformExprStrategyConfig::microcap_rotation(); + cfg.strategy_name = strategy_id.to_string(); + if !signal_symbol.trim().is_empty() { + cfg.signal_symbol = signal_symbol.trim().to_string(); + } + + let Some(spec) = strategy_spec else { + return cfg; + }; + + if let Some(spec_strategy_id) = spec + .strategy_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.strategy_name = spec_strategy_id.clone(); + } + if let Some(market) = spec + .market + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.market = market.clone(); + } + if let Some(engine) = spec.engine_config.as_ref() { + if let Some(rank_limit) = engine.rank_limit.filter(|value| *value > 0) { + cfg.max_positions = rank_limit; + } + if let Some(refresh_rate) = engine.refresh_rate.filter(|value| *value > 0) { + cfg.refresh_rate = refresh_rate; + } + if let Some(schedule) = engine + .rebalance_schedule + .as_ref() + .and_then(parse_platform_rebalance_schedule) + { + cfg.rebalance_schedule = Some(schedule); + } + if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() { + if let Some(days) = stock_ma_filter.short_days.filter(|value| *value > 0) { + cfg.stock_short_ma_days = days; + } + if let Some(days) = stock_ma_filter.mid_days.filter(|value| *value > 0) { + cfg.stock_mid_ma_days = days; + } + if let Some(days) = stock_ma_filter.long_days.filter(|value| *value > 0) { + cfg.stock_long_ma_days = days; + } + } + if let Some(index_throttle) = engine.index_throttle.as_ref() { + if let Some(days) = index_throttle.short_days.filter(|value| *value > 0) { + cfg.benchmark_short_ma_days = days; + } + if let Some(days) = index_throttle.long_days.filter(|value| *value > 0) { + cfg.benchmark_long_ma_days = days; + } + } + if !engine.skip_windows.is_empty() { + cfg.skip_month_day_ranges = engine + .skip_windows + .iter() + .filter_map(|window| Some((window.month?, window.start_day?, window.end_day?))) + .collect(); + } + if let Some(spec_signal_symbol) = engine + .signal_symbol + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.signal_symbol = spec_signal_symbol.clone(); + } + if let Some(spec_benchmark_symbol) = engine + .benchmark_symbol + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.benchmark_symbol = spec_benchmark_symbol.clone(); + } + } + + if let Some(spec_signal_symbol) = spec + .signal_symbol + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.signal_symbol = spec_signal_symbol.clone(); + } + if let Some(benchmark) = spec.benchmark.as_ref() { + if let Some(spec_benchmark_symbol) = benchmark + .instrument_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.benchmark_symbol = spec_benchmark_symbol.clone(); + } + if cfg.signal_symbol.trim().is_empty() + && let Some(spec_signal_symbol) = benchmark + .instrument_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.signal_symbol = spec_signal_symbol.clone(); + } + if cfg.signal_symbol.trim().is_empty() + && let Some(spec_signal_symbol) = benchmark + .fallback_instrument_id + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.signal_symbol = spec_signal_symbol.clone(); + } + } + + let mut prelude_parts = Vec::new(); + if let Some(runtime_expr) = spec.runtime_expressions.as_ref() + && let Some(prelude) = runtime_expr + .prelude + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + prelude_parts.push(prelude.trim().to_string()); + } + if let Some(environment) = spec.environment.as_ref() { + for (name, value) in &environment.variables { + if name.trim().is_empty() { + continue; + } + prelude_parts.push(format!("let {} = {};", name.trim(), rhai_literal(value))); + } + for (name, body) in &environment.functions { + if name.trim().is_empty() || body.trim().is_empty() { + continue; + } + prelude_parts.push(format!( + "fn {}(index_close) {{ {} }}", + name.trim(), + body.trim() + )); + } + } + if !prelude_parts.is_empty() { + cfg.prelude = prelude_parts.join("\n"); + } + + if let Some(runtime_expr) = spec.runtime_expressions.as_ref() { + if let Some(schedule) = runtime_expr + .schedule + .as_ref() + .and_then(parse_platform_rebalance_schedule) + { + cfg.rebalance_schedule = Some(schedule); + } + if let Some(selection) = runtime_expr.selection.as_ref() { + if let Some(expr) = selection + .limit_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.selection_limit_expr = expr.clone(); + } + if let Some(field) = selection + .market_cap_field + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.market_cap_field = field.clone(); + } + if let Some(expr) = selection + .market_cap_lower_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.market_cap_lower_expr = expr.clone(); + } + if let Some(expr) = selection + .market_cap_upper_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.market_cap_upper_expr = expr.clone(); + } + if let Some(expr) = selection + .stock_filter_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.stock_filter_expr = expr.clone(); + } + } + if let Some(allocation) = runtime_expr.allocation.as_ref() + && let Some(expr) = allocation + .buy_scale_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.buy_scale_expr = expr.clone(); + } + if let Some(risk) = runtime_expr.risk.as_ref() { + if let Some(expr) = risk + .exposure_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.exposure_expr = expr.clone(); + } + if let Some(expr) = risk + .stop_loss_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.stop_loss_expr = expr.clone(); + } + if let Some(expr) = risk + .take_profit_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.take_profit_expr = expr.clone(); + } + } + if let Some(ordering) = runtime_expr.ordering.as_ref() { + if let Some(rank_by) = ordering + .rank_by + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.rank_by = rank_by.clone(); + } + if let Some(rank_expr) = ordering + .rank_expr + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.rank_expr = rank_expr.clone(); + } + if let Some(rank_order) = ordering + .rank_order + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + cfg.rank_desc = rank_order.eq_ignore_ascii_case("desc"); + } + } + if let Some(trading) = runtime_expr.trading.as_ref() { + if let Some(enabled) = trading.rotation_enabled { + cfg.rotation_enabled = enabled; + } + if let Some(required) = trading.subscription_guard_required { + cfg.subscription_guard_required = required; + } + if let Some(stage) = trading.stage.as_deref().map(str::trim) { + cfg.explicit_action_stage = match stage.to_ascii_lowercase().as_str() { + "open_auction" | "open-auction" => PlatformExplicitActionStage::OpenAuction, + _ => PlatformExplicitActionStage::OnDay, + }; + } + if let Some(schedule) = trading + .schedule + .as_ref() + .and_then(parse_platform_rebalance_schedule) + { + cfg.explicit_action_schedule = Some(schedule); + } + cfg.explicit_actions = trading + .actions + .iter() + .filter_map(parse_platform_trade_action) + .collect(); + } + } else if let Some(engine) = spec.engine_config.as_ref() { + if let Some(dynamic_range) = engine.dynamic_range.as_ref() { + let base_index_level = dynamic_range.base_index_level.unwrap_or(2000.0); + let base_cap_floor = dynamic_range.base_cap_floor.unwrap_or(7.0); + let cap_span = dynamic_range.cap_span.unwrap_or(10.0); + let xs = dynamic_range.xs.unwrap_or(4.0 / 500.0); + cfg.market_cap_lower_expr = format!( + "round((signal_close - {}) * {} + {})", + base_index_level, xs, base_cap_floor + ); + cfg.market_cap_upper_expr = format!( + "round((signal_close - {}) * {} + {})", + base_index_level, + xs, + base_cap_floor + cap_span + ); + } + if let Some(stock_ma_filter) = engine.stock_ma_filter.as_ref() { + let ratio = stock_ma_filter.rsi_rate.unwrap_or(1.0001); + cfg.stock_filter_expr = format!( + "stock_ma_short > stock_ma_mid * {} && stock_ma_mid > stock_ma_long", + ratio + ); + } + if let Some(index_throttle) = engine.index_throttle.as_ref() { + let ratio = index_throttle.rsi_rate.unwrap_or(1.0001); + let defensive = index_throttle.defensive_exposure.unwrap_or(0.5); + let full = index_throttle.full_exposure.unwrap_or(1.0); + cfg.exposure_expr = format!( + "benchmark_ma_short < benchmark_ma_long * {} ? {} : {}", + ratio, defensive, full + ); + } + if let Some(stop_loss_multiplier) = engine + .stop_loss_multiplier + .filter(|value| value.is_finite()) + { + cfg.stop_loss_expr = stop_loss_multiplier.to_string(); + } + if let Some(take_profit_multiplier) = engine + .take_profit_multiplier + .filter(|value| value.is_finite()) + { + cfg.take_profit_expr = take_profit_multiplier.to_string(); + } + cfg.selection_limit_expr = cfg.max_positions.to_string(); + } + + if !cfg.signal_symbol.trim().is_empty() { + cfg.signal_symbol = normalize_symbol(&cfg.signal_symbol, None); + } + if !cfg.benchmark_symbol.trim().is_empty() { + cfg.benchmark_symbol = normalize_symbol(&cfg.benchmark_symbol, None); + } + + cfg +} + +fn parse_platform_rebalance_schedule( + schedule: &StrategyExpressionScheduleConfig, +) -> Option { + let frequency = schedule.frequency.as_deref()?.trim().to_ascii_lowercase(); + let time_rule = parse_schedule_time_rule(schedule); + match frequency.as_str() { + "weekly" => Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Weekly { + weekday: schedule.weekday, + tradingday: schedule.tradingday, + }, + time_rule, + }), + "monthly" => Some(PlatformRebalanceSchedule { + frequency: PlatformScheduleFrequency::Monthly { + tradingday: schedule.tradingday.unwrap_or(1), + }, + time_rule, + }), + _ => None, + } +} + +fn parse_schedule_time_rule( + schedule: &StrategyExpressionScheduleConfig, +) -> Option { + let rule_name = schedule + .time_rule + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_lowercase()); + match rule_name.as_deref() { + Some("before_trading") | Some("before-trading") => Some(ScheduleTimeRule::before_trading()), + Some("market_open") | Some("market-open") => Some(ScheduleTimeRule::market_open( + schedule.time_rule_hour.unwrap_or(0), + schedule.time_rule_minute.unwrap_or(0), + )), + Some("market_close") | Some("market-close") => Some(ScheduleTimeRule::market_close( + schedule.time_rule_hour.unwrap_or(0), + schedule.time_rule_minute.unwrap_or(0), + )), + Some("physical_time") | Some("physical-time") => { + parse_schedule_clock_time(schedule.time.as_deref()).map(ScheduleTimeRule::from_time) + } + Some(_) => None, + None => { + parse_schedule_clock_time(schedule.time.as_deref()).map(ScheduleTimeRule::from_time) + } + } +} + +fn parse_schedule_clock_time(raw: Option<&str>) -> Option { + let value = raw?.trim(); + if value.is_empty() { + return None; + } + NaiveTime::parse_from_str(value, "%H:%M:%S") + .ok() + .or_else(|| NaiveTime::parse_from_str(value, "%H:%M").ok()) +} + +fn parse_platform_trade_action( + action: &StrategyExpressionActionConfig, +) -> Option { + let kind = action.kind.as_deref()?.trim().to_ascii_lowercase(); + let reason = action + .reason + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("platform_explicit_action") + .to_string(); + let when_expr = action + .when_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string); + match kind.as_str() { + "target_portfolio_smart" => Some(PlatformTradeAction::TargetPortfolioSmart { + target_weights_expr: action + .target_weights_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + order_prices_expr: action + .order_prices_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + valuation_prices_expr: action + .valuation_prices_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + when_expr, + reason, + }), + "shares" + | "limit_shares" + | "lots" + | "limit_lots" + | "vwap_value" + | "twap_value" + | "target_shares" + | "limit_target_shares" + | "value" + | "vwap_percent" + | "twap_percent" + | "limit_value" + | "percent" + | "limit_percent" + | "target_value" + | "limit_target_value" + | "target_percent" + | "limit_target_percent" => { + let symbol = action + .symbol + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let amount_expr = action + .amount_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let order_kind = match kind.as_str() { + "shares" => PlatformExplicitOrderKind::Shares, + "limit_shares" => PlatformExplicitOrderKind::LimitShares, + "lots" => PlatformExplicitOrderKind::Lots, + "limit_lots" => PlatformExplicitOrderKind::LimitLots, + "vwap_value" => PlatformExplicitOrderKind::VwapValue, + "twap_value" => PlatformExplicitOrderKind::TwapValue, + "target_shares" => PlatformExplicitOrderKind::TargetShares, + "limit_target_shares" => PlatformExplicitOrderKind::LimitTargetShares, + "value" => PlatformExplicitOrderKind::Value, + "vwap_percent" => PlatformExplicitOrderKind::VwapPercent, + "twap_percent" => PlatformExplicitOrderKind::TwapPercent, + "limit_value" => PlatformExplicitOrderKind::LimitValue, + "percent" => PlatformExplicitOrderKind::Percent, + "limit_percent" => PlatformExplicitOrderKind::LimitPercent, + "target_value" => PlatformExplicitOrderKind::TargetValue, + "limit_target_value" => PlatformExplicitOrderKind::LimitTargetValue, + "target_percent" => PlatformExplicitOrderKind::TargetPercent, + "limit_target_percent" => PlatformExplicitOrderKind::LimitTargetPercent, + _ => unreachable!(), + }; + Some(PlatformTradeAction::Order { + kind: order_kind, + symbol, + amount_expr, + limit_price_expr: action + .limit_price_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + start_time_expr: action + .start_time_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + end_time_expr: action + .end_time_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + when_expr, + reason, + }) + } + "cancel_order" => Some(PlatformTradeAction::Cancel { + kind: PlatformExplicitCancelKind::Order, + symbol: None, + order_id_expr: action + .order_id_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + when_expr, + reason, + }), + "cancel_symbol" => Some(PlatformTradeAction::Cancel { + kind: PlatformExplicitCancelKind::Symbol, + symbol: action + .symbol + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), + order_id_expr: None, + when_expr, + reason, + }), + "cancel_all" => Some(PlatformTradeAction::Cancel { + kind: PlatformExplicitCancelKind::All, + symbol: None, + order_id_expr: None, + when_expr, + reason, + }), + "update_universe" => Some(PlatformTradeAction::Universe { + kind: PlatformUniverseActionKind::UpdateUniverse, + symbols_expr: action + .symbols_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string)?, + when_expr, + reason, + }), + "subscribe" => Some(PlatformTradeAction::Universe { + kind: PlatformUniverseActionKind::Subscribe, + symbols_expr: action + .symbols_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string)?, + when_expr, + reason, + }), + "unsubscribe" => Some(PlatformTradeAction::Universe { + kind: PlatformUniverseActionKind::Unsubscribe, + symbols_expr: action + .symbols_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string)?, + when_expr, + reason, + }), + "deposit_withdraw" => Some(PlatformTradeAction::Account { + kind: PlatformAccountActionKind::DepositWithdraw, + amount_expr: action + .amount_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + receiving_days_expr: None, + when_expr, + reason, + }), + "finance_repay" => Some(PlatformTradeAction::Account { + kind: PlatformAccountActionKind::FinanceRepay, + amount_expr: action + .amount_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + receiving_days_expr: None, + when_expr, + reason, + }), + "set_management_fee_rate" => Some(PlatformTradeAction::Account { + kind: PlatformAccountActionKind::SetManagementFeeRate, + amount_expr: action + .amount_expr + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(), + receiving_days_expr: None, + when_expr, + reason, + }), + _ => None, + } +} + +fn rhai_literal(value: &Value) -> String { + match value { + Value::Null => "()".to_string(), + Value::Bool(flag) => flag.to_string(), + Value::Number(number) => number.to_string(), + Value::String(text) => serde_json::to_string(text).unwrap_or_else(|_| "\"\"".to_string()), + other => serde_json::to_string(other).unwrap_or_else(|_| "()".to_string()), + } +} + +fn normalize_symbol(symbol: &str, raw_board: Option<&str>) -> String { + let trimmed = symbol.trim().trim_matches('"').trim_matches('\'').trim(); + if trimmed.is_empty() { + return String::new(); + } + if trimmed.contains('.') { + return trimmed.to_ascii_uppercase(); + } + instrument_query_id(trimmed, &normalize_board(trimmed, raw_board)) +} + +fn instrument_query_id(symbol: &str, board: &str) -> String { + if symbol.contains('.') { + return symbol.to_ascii_uppercase(); + } + let exchange = match board.trim().to_ascii_uppercase().as_str() { + "SH" | "KSH" => "SH", + "SZ" => "SZ", + "BJ" | "BJS" => "BJ", + _ => { + if symbol.starts_with('6') || symbol.starts_with('9') { + "SH" + } else if symbol.starts_with('8') || symbol.starts_with('4') { + "BJ" + } else { + "SZ" + } + } + }; + format!("{symbol}.{exchange}") +} + +fn normalize_board(symbol: &str, raw_board: Option<&str>) -> String { + let normalized = raw_board + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_uppercase()); + if let Some(board) = normalized + && matches!(board.as_str(), "SH" | "SZ" | "BJ" | "KSH" | "BJS") + { + return board; + } + if let Some((_, suffix)) = symbol.rsplit_once('.') { + return suffix.to_ascii_uppercase(); + } + if symbol.starts_with("688") { + return "KSH".to_string(); + } + if symbol.starts_with('8') || symbol.starts_with('4') { + return "BJ".to_string(); + } + if symbol.starts_with('6') || symbol.starts_with('9') { + return "SH".to_string(); + } + if symbol.starts_with('0') + || symbol.starts_with('1') + || symbol.starts_with('2') + || symbol.starts_with('3') + { + return "SZ".to_string(); + } + "UNK".to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_runtime_expression_spec_into_platform_config() { + let spec = serde_json::json!({ + "strategyId": "runtime_spec_test", + "signalSymbol": "000852.SH", + "benchmark": { "instrumentId": "000852.SH" }, + "runtimeExpressions": { + "prelude": "let stocknum = 8;", + "selection": { + "limitExpr": "stocknum", + "marketCapLowerExpr": "3", + "marketCapUpperExpr": "28", + "stockFilterExpr": "stock_ma5 > stock_ma10" + }, + "trading": { + "rotationEnabled": false, + "stage": "open_auction", + "actions": [ + { + "kind": "value", + "symbol": "000001.SZ", + "amountExpr": "10000", + "reason": "test_buy" + } + ] + } + } + }); + + let cfg = platform_expr_config_from_value("", "", &spec).expect("config"); + + assert_eq!(cfg.strategy_name, "runtime_spec_test"); + assert_eq!(cfg.signal_symbol, "000852.SH"); + assert_eq!(cfg.selection_limit_expr, "stocknum"); + assert!(!cfg.rotation_enabled); + assert_eq!(cfg.explicit_actions.len(), 1); + assert_eq!( + cfg.explicit_action_stage, + PlatformExplicitActionStage::OpenAuction + ); + } +} diff --git a/crates/fidc-core/src/strategy.rs b/crates/fidc-core/src/strategy.rs index 7b9a16b..c45ba5e 100644 --- a/crates/fidc-core/src/strategy.rs +++ b/crates/fidc-core/src/strategy.rs @@ -1675,6 +1675,7 @@ impl OmniMicroCapStrategy { .max(1) } + #[allow(dead_code)] fn projected_buy_quantity(&self, cash: f64, sizing_price: f64, execution_price: f64) -> u32 { if cash <= 0.0 || sizing_price <= 0.0 || execution_price <= 0.0 { return 0; @@ -1856,6 +1857,7 @@ impl OmniMicroCapStrategy { Some(fill.quantity) } + #[allow(dead_code)] fn projected_market_fillable_quantity( &self, ctx: &StrategyContext<'_>,