feat: execute factor position target rules
This commit is contained in:
@@ -62,9 +62,9 @@ pub use metrics::{
|
||||
pub use platform_expr_strategy::{
|
||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
|
||||
PlatformTradeAction, PlatformUniverseActionKind,
|
||||
};
|
||||
pub use platform_runtime_schema::{
|
||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||
|
||||
@@ -371,6 +371,13 @@ pub enum PlatformStopTakeReferencePriceMode {
|
||||
SignalDayPostAdjustedClose,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PlatformPositionTargetRule {
|
||||
pub when_expr: String,
|
||||
pub remaining_position_bps: u32,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PlatformExprStrategyConfig {
|
||||
pub strategy_name: String,
|
||||
@@ -394,6 +401,7 @@ pub struct PlatformExprStrategyConfig {
|
||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||
pub stop_loss_expr: String,
|
||||
pub take_profit_expr: String,
|
||||
pub position_target_rules: Vec<PlatformPositionTargetRule>,
|
||||
pub stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode,
|
||||
pub rank_by: String,
|
||||
pub rank_expr: String,
|
||||
@@ -467,6 +475,7 @@ impl PlatformExprStrategyConfig {
|
||||
portfolio_drawdown_control: None,
|
||||
stop_loss_expr: String::new(),
|
||||
take_profit_expr: String::new(),
|
||||
position_target_rules: Vec::new(),
|
||||
stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode::PositionCostBasis,
|
||||
rank_by: "market_cap".to_string(),
|
||||
rank_expr: String::new(),
|
||||
@@ -1562,6 +1571,12 @@ impl PlatformExprStrategy {
|
||||
),
|
||||
("rank_expr".to_string(), self.config.rank_expr.as_str()),
|
||||
];
|
||||
for (index, rule) in self.config.position_target_rules.iter().enumerate() {
|
||||
expressions.push((
|
||||
format!("position_target_rules[{index}].when_expr"),
|
||||
rule.when_expr.as_str(),
|
||||
));
|
||||
}
|
||||
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
||||
match action {
|
||||
PlatformTradeAction::Order {
|
||||
@@ -9395,6 +9410,41 @@ impl PlatformExprStrategy {
|
||||
Ok(symbols)
|
||||
}
|
||||
|
||||
fn current_position_target_rules(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
signal_date: NaiveDate,
|
||||
factor_date: NaiveDate,
|
||||
day: &DayExpressionState,
|
||||
) -> Result<BTreeMap<String, (u32, String)>, BacktestError> {
|
||||
let mut targets = BTreeMap::new();
|
||||
if self.config.position_target_rules.is_empty() {
|
||||
return Ok(targets);
|
||||
}
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
if position.quantity == 0 {
|
||||
continue;
|
||||
}
|
||||
let stock =
|
||||
self.stock_state_with_factor_date(ctx, signal_date, factor_date, &position.symbol)?;
|
||||
for rule in &self.config.position_target_rules {
|
||||
if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? {
|
||||
continue;
|
||||
}
|
||||
let replace = targets
|
||||
.get(&position.symbol)
|
||||
.map_or(true, |(bps, _)| rule.remaining_position_bps < *bps);
|
||||
if replace {
|
||||
targets.insert(
|
||||
position.symbol.clone(),
|
||||
(rule.remaining_position_bps, rule.reason.clone()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(targets)
|
||||
}
|
||||
|
||||
fn explicit_action_decision(
|
||||
&self,
|
||||
ctx: &StrategyContext<'_>,
|
||||
@@ -10414,6 +10464,15 @@ impl PlatformExprStrategy {
|
||||
Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized);
|
||||
Self::require_stock_rollings_for_helper_calls(&mut requirements, &normalized);
|
||||
}
|
||||
for rule in &config.position_target_rules {
|
||||
let normalized = Self::normalize_expr(&rule.when_expr);
|
||||
if Self::extract_identifier_candidates(&normalized).contains("factors") {
|
||||
requirements.require_all();
|
||||
return requirements;
|
||||
}
|
||||
Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized);
|
||||
Self::require_stock_rollings_for_helper_calls(&mut requirements, &normalized);
|
||||
}
|
||||
requirements
|
||||
}
|
||||
|
||||
@@ -10438,6 +10497,17 @@ impl PlatformExprStrategy {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for rule in &config.position_target_rules {
|
||||
let compact = Self::compact_expr(&Self::normalize_expr(&rule.when_expr));
|
||||
Self::require_stock_rollings_for_named_helper(
|
||||
&mut requirements,
|
||||
&compact,
|
||||
"rolling_mean_current",
|
||||
);
|
||||
if !requirements.fields.is_empty() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
@@ -10469,6 +10539,11 @@ impl PlatformExprStrategy {
|
||||
expr,
|
||||
)));
|
||||
}
|
||||
for rule in &config.position_target_rules {
|
||||
identifiers.extend(Self::extract_identifier_candidates(&Self::normalize_expr(
|
||||
&rule.when_expr,
|
||||
)));
|
||||
}
|
||||
StockSnapshotFieldRequirements {
|
||||
amount: identifiers.contains("amount"),
|
||||
touched_upper_limit: identifiers.contains("touched_upper_limit")
|
||||
@@ -10496,6 +10571,11 @@ impl PlatformExprStrategy {
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if config.position_target_rules.iter().any(|rule| {
|
||||
Self::expr_requires_stock_extra_factors(&rule.when_expr, prelude_declared_identifiers)
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
[
|
||||
config.buy_scale_expr.as_str(),
|
||||
config.stop_loss_expr.as_str(),
|
||||
@@ -10528,6 +10608,11 @@ impl PlatformExprStrategy {
|
||||
.map(Self::normalize_expr)
|
||||
.map(|expr| Self::extract_identifier_candidates(&expr))
|
||||
.any(|identifiers| identifiers.contains("factors") || identifiers.contains("factor"))
|
||||
|| config.position_target_rules.iter().any(|rule| {
|
||||
let identifiers =
|
||||
Self::extract_identifier_candidates(&Self::normalize_expr(&rule.when_expr));
|
||||
identifiers.contains("factors") || identifiers.contains("factor")
|
||||
})
|
||||
}
|
||||
|
||||
fn stock_extra_factor_identifiers_for_config(
|
||||
@@ -10558,6 +10643,13 @@ impl PlatformExprStrategy {
|
||||
prelude_declared_identifiers,
|
||||
);
|
||||
}
|
||||
for rule in &config.position_target_rules {
|
||||
Self::collect_stock_extra_factor_identifiers(
|
||||
&mut identifiers,
|
||||
&rule.when_expr,
|
||||
prelude_declared_identifiers,
|
||||
);
|
||||
}
|
||||
identifiers
|
||||
}
|
||||
|
||||
@@ -10600,6 +10692,9 @@ impl PlatformExprStrategy {
|
||||
]
|
||||
.into_iter()
|
||||
.any(|expr| Self::expr_may_use_stock_text_factors(expr, prelude_declared_identifiers))
|
||||
|| config.position_target_rules.iter().any(|rule| {
|
||||
Self::expr_may_use_stock_text_factors(&rule.when_expr, prelude_declared_identifiers)
|
||||
})
|
||||
}
|
||||
|
||||
fn has_stock_explicit_actions(config: &PlatformExprStrategyConfig) -> bool {
|
||||
@@ -11469,8 +11564,12 @@ impl Strategy for PlatformExprStrategy {
|
||||
let in_skip_window = self.config.in_skip_window(signal_date);
|
||||
|
||||
let day = self.day_state(ctx, decision_date)?;
|
||||
let (selection_market_date, selection_universe_factor_date, selection_factor_date) =
|
||||
self.selection_dates(ctx);
|
||||
let current_stop_take_exit_symbols =
|
||||
self.current_stop_take_exit_symbols(ctx, signal_date, &day)?;
|
||||
let factor_position_targets =
|
||||
self.current_position_target_rules(ctx, signal_date, selection_factor_date, &day)?;
|
||||
let mut model_only_lifecycle_exit_symbols = current_stop_take_exit_symbols
|
||||
.iter()
|
||||
.filter(|symbol| {
|
||||
@@ -11497,8 +11596,6 @@ impl Strategy for PlatformExprStrategy {
|
||||
self.forget_position_entry_date(symbol);
|
||||
}
|
||||
}
|
||||
let (selection_market_date, selection_universe_factor_date, selection_factor_date) =
|
||||
self.selection_dates(ctx);
|
||||
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
||||
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
||||
&& self.config.explicit_action_schedule.is_none()
|
||||
@@ -12045,6 +12142,77 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
}
|
||||
|
||||
let mut factor_position_action_symbols = BTreeSet::new();
|
||||
for (symbol, (remaining_bps, rule_reason)) in &factor_position_targets {
|
||||
if delayed_sold_symbols.contains(symbol)
|
||||
|| unresolved_delisted_symbols.contains(symbol)
|
||||
|| current_stop_take_exit_symbols.contains(symbol)
|
||||
|| exit_symbols.contains(symbol)
|
||||
|| carried_full_close_symbols.contains(symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(position) = ctx.portfolio.position(symbol) else {
|
||||
continue;
|
||||
};
|
||||
let order_step = self.projected_order_step_size(ctx, symbol).max(1);
|
||||
let raw_target = (u64::from(position.quantity) * u64::from(*remaining_bps) / 10_000)
|
||||
.min(u64::from(u32::MAX)) as u32;
|
||||
let target_quantity = (raw_target / order_step) * order_step;
|
||||
if target_quantity >= position.quantity {
|
||||
continue;
|
||||
}
|
||||
factor_position_action_symbols.insert(symbol.clone());
|
||||
let reason = format!("factor_position_target:{rule_reason}");
|
||||
let target_quantity_i32 = i32::try_from(target_quantity).map_err(|_| {
|
||||
BacktestError::Execution(format!(
|
||||
"factor position target quantity exceeds i32 symbol={symbol} quantity={target_quantity}"
|
||||
))
|
||||
})?;
|
||||
order_intents.push(OrderIntent::TargetShares {
|
||||
symbol: symbol.clone(),
|
||||
target_quantity: target_quantity_i32,
|
||||
reason: reason.clone(),
|
||||
});
|
||||
if target_quantity == 0 {
|
||||
exit_symbols.insert(symbol.clone());
|
||||
self.forget_position_entry_date(symbol);
|
||||
self.project_target_zero(
|
||||
ctx,
|
||||
&mut projected,
|
||||
projection_date,
|
||||
symbol,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
} else {
|
||||
let current_value = self.projected_position_value_at_execution_price(
|
||||
ctx,
|
||||
&projected,
|
||||
projection_date,
|
||||
symbol,
|
||||
);
|
||||
let target_value =
|
||||
current_value * f64::from(target_quantity) / f64::from(position.quantity);
|
||||
self.project_target_value(
|
||||
ctx,
|
||||
&mut projected,
|
||||
projection_date,
|
||||
symbol,
|
||||
target_value,
|
||||
&mut projected_execution_state,
|
||||
);
|
||||
}
|
||||
self.refresh_available_cash_after_projected_sell(&mut available_cash, &projected);
|
||||
if Self::projected_position_is_flat(&projected, symbol) {
|
||||
same_day_sold_symbols.insert(symbol.clone());
|
||||
slot_working_symbols.remove(symbol);
|
||||
}
|
||||
selection_notes.push(format!(
|
||||
"factor_position_target symbol={} remaining_bps={} target_quantity={} reason={}",
|
||||
symbol, remaining_bps, target_quantity, rule_reason
|
||||
));
|
||||
}
|
||||
|
||||
let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone();
|
||||
|
||||
if self.config.rotation_enabled
|
||||
@@ -12063,6 +12231,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
if position.quantity == 0
|
||||
|| delayed_sold_symbols.contains(&position.symbol)
|
||||
|| unresolved_delisted_symbols.contains(&position.symbol)
|
||||
|| factor_position_action_symbols.contains(&position.symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -12230,6 +12399,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
for position in ctx.portfolio.positions().values() {
|
||||
if delayed_sold_symbols.contains(&position.symbol)
|
||||
|| unresolved_delisted_symbols.contains(&position.symbol)
|
||||
|| factor_position_action_symbols.contains(&position.symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -12598,6 +12768,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
.position_entry_dates
|
||||
.keys()
|
||||
.filter(|symbol| !exit_symbols.contains(*symbol))
|
||||
.filter(|symbol| !factor_position_action_symbols.contains(*symbol))
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
for symbol in &stock_list {
|
||||
@@ -12718,7 +12889,9 @@ impl Strategy for PlatformExprStrategy {
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
for symbol in pre_rebalance_symbols.iter() {
|
||||
if unresolved_delisted_symbols.contains(symbol) {
|
||||
if unresolved_delisted_symbols.contains(symbol)
|
||||
|| factor_position_action_symbols.contains(symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||
@@ -12772,7 +12945,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
if unresolved_delisted_symbols.contains(symbol) {
|
||||
continue;
|
||||
}
|
||||
if exit_symbols.contains(symbol) {
|
||||
if exit_symbols.contains(symbol) || factor_position_action_symbols.contains(symbol)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let decision_stock = self.stock_state_with_factor_date(
|
||||
@@ -13064,11 +13238,11 @@ mod tests {
|
||||
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||
PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig,
|
||||
PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
||||
RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField,
|
||||
StockSnapshotFieldRequirements, framework_stock_rolling_factor_requirement,
|
||||
scheduled_position_exposure,
|
||||
PlatformPortfolioDrawdownController, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral,
|
||||
StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements,
|
||||
framework_stock_rolling_factor_requirement, scheduled_position_exposure,
|
||||
};
|
||||
use crate::{
|
||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
||||
@@ -34706,6 +34880,143 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0;
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factor_position_target_reduces_once_without_daily_rebalance_override() {
|
||||
let previous_date = d(2025, 5, 13);
|
||||
let date = d(2025, 5, 14);
|
||||
let symbol = "600778.SH";
|
||||
let data = DataSet::from_components(
|
||||
vec![Instrument {
|
||||
symbol: symbol.to_string(),
|
||||
name: symbol.to_string(),
|
||||
board: "SH".to_string(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(d(2020, 1, 1)),
|
||||
delisted_at: None,
|
||||
status: "active".to_string(),
|
||||
}],
|
||||
vec![DailyMarketSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
timestamp: None,
|
||||
day_open: 10.0,
|
||||
open: 10.0,
|
||||
high: 10.5,
|
||||
low: 9.8,
|
||||
close: 10.0,
|
||||
last_price: 10.0,
|
||||
bid1: 10.0,
|
||||
ask1: 10.0,
|
||||
prev_close: 9.9,
|
||||
volume: 1_000_000,
|
||||
minute_volume: 10_000,
|
||||
bid1_volume: 10_000,
|
||||
ask1_volume: 10_000,
|
||||
trading_phase: Some("continuous".to_string()),
|
||||
paused: false,
|
||||
upper_limit: 11.0,
|
||||
lower_limit: 9.0,
|
||||
price_tick: 0.01,
|
||||
}],
|
||||
vec![DailyFactorSnapshot {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
market_cap_bn: 10.0,
|
||||
free_float_cap_bn: 10.0,
|
||||
pe_ttm: 8.0,
|
||||
turnover_ratio: Some(1.0),
|
||||
effective_turnover_ratio: Some(1.0),
|
||||
extra_factors: BTreeMap::from([("reduce_signal".into(), 1.0)]),
|
||||
}],
|
||||
vec![CandidateEligibility {
|
||||
date,
|
||||
symbol: symbol.to_string(),
|
||||
is_st: false,
|
||||
is_star_st: false,
|
||||
is_new_listing: false,
|
||||
is_paused: false,
|
||||
allow_buy: true,
|
||||
allow_sell: true,
|
||||
is_kcb: false,
|
||||
is_one_yuan: false,
|
||||
risk_level_code: None,
|
||||
}],
|
||||
vec![BenchmarkSnapshot {
|
||||
date,
|
||||
benchmark: "000852.SH".to_string(),
|
||||
open: 1_000.0,
|
||||
close: 1_000.0,
|
||||
prev_close: 1_000.0,
|
||||
volume: 1_000_000,
|
||||
}],
|
||||
)
|
||||
.expect("factor reduction dataset");
|
||||
let mut portfolio = PortfolioState::new(100_000.0);
|
||||
portfolio
|
||||
.position_mut(symbol)
|
||||
.buy(previous_date, 1_000, 10.0);
|
||||
let subscriptions = BTreeSet::new();
|
||||
let context = StrategyContext {
|
||||
execution_date: date,
|
||||
decision_date: date,
|
||||
decision_index: 20,
|
||||
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 config = PlatformExprStrategyConfig::generic();
|
||||
config.signal_symbol = symbol.to_string();
|
||||
config.benchmark_symbol = "000852.SH".to_string();
|
||||
config.max_positions = 1;
|
||||
config.selection_limit_expr = "1".to_string();
|
||||
config.market_cap_lower_expr = "0".to_string();
|
||||
config.market_cap_upper_expr = "100".to_string();
|
||||
config.stock_filter_expr = "close > 0 && factors[\"reduce_signal\"] != 1".to_string();
|
||||
config.daily_position_target_adjust_enabled = true;
|
||||
config.target_portfolio_daily_enabled = true;
|
||||
config.rebalance_existing_positions = true;
|
||||
config.hold_until_exit_enabled = true;
|
||||
config.position_target_rules = vec![PlatformPositionTargetRule {
|
||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||
remaining_position_bps: 5_000,
|
||||
reason: "factor_reduce_position".to_string(),
|
||||
}];
|
||||
let mut strategy = PlatformExprStrategy::new(config);
|
||||
|
||||
let decision = strategy
|
||||
.on_day(&context)
|
||||
.expect("factor reduction decision");
|
||||
|
||||
assert_eq!(
|
||||
decision.order_intents.len(),
|
||||
1,
|
||||
"{:?}",
|
||||
decision.order_intents
|
||||
);
|
||||
assert!(matches!(
|
||||
&decision.order_intents[0],
|
||||
OrderIntent::TargetShares {
|
||||
symbol: intent_symbol,
|
||||
target_quantity: 500,
|
||||
reason,
|
||||
} if intent_symbol == symbol && reason == "factor_position_target:factor_reduce_position"
|
||||
));
|
||||
assert!(
|
||||
decision
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|line| line.contains("remaining_bps=5000 target_quantity=500"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn portfolio_drawdown_control_is_idempotent_and_rearms_after_cooldown() {
|
||||
let mut controller =
|
||||
|
||||
@@ -7,10 +7,10 @@ use serde_json::Value;
|
||||
use crate::{
|
||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
||||
RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
|
||||
futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||
futures::FuturesDirection, futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -42,6 +42,8 @@ pub struct StrategyRuntimeSpec {
|
||||
pub metadata: Option<Value>,
|
||||
#[serde(default, alias = "factor_value_bindings")]
|
||||
pub factor_value_bindings: Vec<Value>,
|
||||
#[serde(default, alias = "stock_pool_factor_contract")]
|
||||
pub stock_pool_factor_contract: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub parameters: Option<Value>,
|
||||
#[serde(default)]
|
||||
@@ -888,6 +890,8 @@ pub struct StrategyExpressionSelectionConfig {
|
||||
pub market_cap_upper_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stock_filter_expr: Option<String>,
|
||||
#[serde(default, alias = "current_day_precomputed_factors")]
|
||||
pub current_day_precomputed_factors: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
@@ -910,6 +914,8 @@ pub struct StrategyExpressionRiskConfig {
|
||||
pub stop_loss_expr: Option<String>,
|
||||
#[serde(default)]
|
||||
pub take_profit_expr: Option<String>,
|
||||
#[serde(default, alias = "position_target_rules")]
|
||||
pub position_target_rules: Vec<StrategyPositionTargetRule>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "referencePriceMode",
|
||||
@@ -918,6 +924,17 @@ pub struct StrategyExpressionRiskConfig {
|
||||
pub stop_take_reference_price_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPositionTargetRule {
|
||||
#[serde(alias = "when_expr")]
|
||||
pub when_expr: String,
|
||||
#[serde(alias = "remaining_position_bps", alias = "remainingBps")]
|
||||
pub remaining_position_bps: u32,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StrategyPositionExposureSchedulePoint {
|
||||
@@ -2020,6 +2037,9 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.stock_filter_expr = expr.clone();
|
||||
}
|
||||
if let Some(enabled) = selection.current_day_precomputed_factors {
|
||||
cfg.current_day_precomputed_factors = enabled;
|
||||
}
|
||||
}
|
||||
if let Some(allocation) = runtime_expr.allocation.as_ref()
|
||||
&& let Some(expr) = allocation
|
||||
@@ -2125,6 +2145,37 @@ pub fn platform_expr_config_from_spec(
|
||||
{
|
||||
cfg.take_profit_expr = expr.clone();
|
||||
}
|
||||
let mut position_target_identities = BTreeSet::new();
|
||||
for (index, rule) in risk.position_target_rules.iter().enumerate() {
|
||||
let when_expr = rule.when_expr.trim();
|
||||
if when_expr.is_empty() {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}].whenExpr cannot be empty"
|
||||
));
|
||||
}
|
||||
if rule.remaining_position_bps >= 10_000 {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}].remainingPositionBps must be between 0 and 9999"
|
||||
));
|
||||
}
|
||||
let identity = (when_expr.to_string(), rule.remaining_position_bps);
|
||||
if !position_target_identities.insert(identity) {
|
||||
return Err(format!(
|
||||
"runtimeExpressions.risk.positionTargetRules[{index}] is duplicated"
|
||||
));
|
||||
}
|
||||
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
||||
when_expr: when_expr.to_string(),
|
||||
remaining_position_bps: rule.remaining_position_bps,
|
||||
reason: rule
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("factor_position_target")
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
if let Some(mode) = risk
|
||||
.stop_take_reference_price_mode
|
||||
.as_deref()
|
||||
@@ -3005,7 +3056,8 @@ mod tests {
|
||||
"limitExpr": "stocknum",
|
||||
"marketCapLowerExpr": "3",
|
||||
"marketCapUpperExpr": "28",
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10"
|
||||
"stockFilterExpr": "stock_ma5 > stock_ma10",
|
||||
"currentDayPrecomputedFactors": true
|
||||
},
|
||||
"trading": {
|
||||
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
||||
@@ -3036,6 +3088,7 @@ mod tests {
|
||||
assert!(!cfg.rotation_enabled);
|
||||
assert!(cfg.daily_top_up_enabled);
|
||||
assert!(cfg.retry_empty_rebalance);
|
||||
assert!(cfg.current_day_precomputed_factors);
|
||||
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
||||
assert!(!cfg.calendar_rebalance_interval);
|
||||
assert_eq!(cfg.explicit_actions.len(), 1);
|
||||
@@ -3045,6 +3098,44 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_and_rejects_invalid_position_target_rules() {
|
||||
let spec = serde_json::json!({
|
||||
"strategyId": "factor_reduction",
|
||||
"runtimeExpressions": {
|
||||
"risk": {
|
||||
"positionTargetRules": [{
|
||||
"whenExpr": "factors[\"reduce_signal\"] == 1",
|
||||
"remainingPositionBps": 5000,
|
||||
"reason": "factor_reduce_position"
|
||||
}]
|
||||
}
|
||||
}
|
||||
});
|
||||
let cfg = platform_expr_config_from_value("", "", &spec).expect("position rule config");
|
||||
assert_eq!(
|
||||
cfg.position_target_rules,
|
||||
vec![PlatformPositionTargetRule {
|
||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||
remaining_position_bps: 5000,
|
||||
reason: "factor_reduce_position".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
let invalid_bps = serde_json::json!({
|
||||
"runtimeExpressions": {"risk": {"positionTargetRules": [{
|
||||
"whenExpr": "true",
|
||||
"remainingPositionBps": 10000
|
||||
}]}}
|
||||
});
|
||||
assert!(
|
||||
platform_expr_config_from_value("", "", &invalid_bps)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("must be between 0 and 9999")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
||||
let spec = serde_json::json!({
|
||||
|
||||
Reference in New Issue
Block a user