feat: execute factor position target rules

This commit is contained in:
boris
2026-09-06 02:45:39 +08:00
parent c7f5188354
commit 0f1d49bf63
3 changed files with 419 additions and 17 deletions
+3 -3
View File
@@ -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,
+320 -9
View File
@@ -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 =
+96 -5
View File
@@ -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!({