feat: execute factor position target rules
This commit is contained in:
@@ -62,9 +62,9 @@ pub use metrics::{
|
|||||||
pub use platform_expr_strategy::{
|
pub use platform_expr_strategy::{
|
||||||
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
PlatformAccountActionKind, PlatformExplicitActionStage, PlatformExplicitCancelKind,
|
||||||
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
PlatformExplicitOrderKind, PlatformExprStrategy, PlatformExprStrategyConfig,
|
||||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||||
PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
PlatformScheduleFrequency, PlatformSelectionQuotePlan, PlatformStopTakeReferencePriceMode,
|
||||||
PlatformUniverseActionKind,
|
PlatformTradeAction, PlatformUniverseActionKind,
|
||||||
};
|
};
|
||||||
pub use platform_runtime_schema::{
|
pub use platform_runtime_schema::{
|
||||||
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
PLATFORM_RUNTIME_SCHEMA_VERSION, PlatformRuntimeSchema, reserved_scope_names,
|
||||||
|
|||||||
@@ -371,6 +371,13 @@ pub enum PlatformStopTakeReferencePriceMode {
|
|||||||
SignalDayPostAdjustedClose,
|
SignalDayPostAdjustedClose,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PlatformPositionTargetRule {
|
||||||
|
pub when_expr: String,
|
||||||
|
pub remaining_position_bps: u32,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PlatformExprStrategyConfig {
|
pub struct PlatformExprStrategyConfig {
|
||||||
pub strategy_name: String,
|
pub strategy_name: String,
|
||||||
@@ -394,6 +401,7 @@ pub struct PlatformExprStrategyConfig {
|
|||||||
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
pub portfolio_drawdown_control: Option<PlatformPortfolioDrawdownControlConfig>,
|
||||||
pub stop_loss_expr: String,
|
pub stop_loss_expr: String,
|
||||||
pub take_profit_expr: String,
|
pub take_profit_expr: String,
|
||||||
|
pub position_target_rules: Vec<PlatformPositionTargetRule>,
|
||||||
pub stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode,
|
pub stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode,
|
||||||
pub rank_by: String,
|
pub rank_by: String,
|
||||||
pub rank_expr: String,
|
pub rank_expr: String,
|
||||||
@@ -467,6 +475,7 @@ impl PlatformExprStrategyConfig {
|
|||||||
portfolio_drawdown_control: None,
|
portfolio_drawdown_control: None,
|
||||||
stop_loss_expr: String::new(),
|
stop_loss_expr: String::new(),
|
||||||
take_profit_expr: String::new(),
|
take_profit_expr: String::new(),
|
||||||
|
position_target_rules: Vec::new(),
|
||||||
stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode::PositionCostBasis,
|
stop_take_reference_price_mode: PlatformStopTakeReferencePriceMode::PositionCostBasis,
|
||||||
rank_by: "market_cap".to_string(),
|
rank_by: "market_cap".to_string(),
|
||||||
rank_expr: String::new(),
|
rank_expr: String::new(),
|
||||||
@@ -1562,6 +1571,12 @@ impl PlatformExprStrategy {
|
|||||||
),
|
),
|
||||||
("rank_expr".to_string(), self.config.rank_expr.as_str()),
|
("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() {
|
for (index, action) in self.config.explicit_actions.iter().enumerate() {
|
||||||
match action {
|
match action {
|
||||||
PlatformTradeAction::Order {
|
PlatformTradeAction::Order {
|
||||||
@@ -9395,6 +9410,41 @@ impl PlatformExprStrategy {
|
|||||||
Ok(symbols)
|
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(
|
fn explicit_action_decision(
|
||||||
&self,
|
&self,
|
||||||
ctx: &StrategyContext<'_>,
|
ctx: &StrategyContext<'_>,
|
||||||
@@ -10414,6 +10464,15 @@ impl PlatformExprStrategy {
|
|||||||
Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized);
|
Self::require_stock_rollings_for_identifiers(&mut requirements, config, &normalized);
|
||||||
Self::require_stock_rollings_for_helper_calls(&mut requirements, &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
|
requirements
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10438,6 +10497,17 @@ impl PlatformExprStrategy {
|
|||||||
return true;
|
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
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10469,6 +10539,11 @@ impl PlatformExprStrategy {
|
|||||||
expr,
|
expr,
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
for rule in &config.position_target_rules {
|
||||||
|
identifiers.extend(Self::extract_identifier_candidates(&Self::normalize_expr(
|
||||||
|
&rule.when_expr,
|
||||||
|
)));
|
||||||
|
}
|
||||||
StockSnapshotFieldRequirements {
|
StockSnapshotFieldRequirements {
|
||||||
amount: identifiers.contains("amount"),
|
amount: identifiers.contains("amount"),
|
||||||
touched_upper_limit: identifiers.contains("touched_upper_limit")
|
touched_upper_limit: identifiers.contains("touched_upper_limit")
|
||||||
@@ -10496,6 +10571,11 @@ impl PlatformExprStrategy {
|
|||||||
) {
|
) {
|
||||||
return true;
|
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.buy_scale_expr.as_str(),
|
||||||
config.stop_loss_expr.as_str(),
|
config.stop_loss_expr.as_str(),
|
||||||
@@ -10528,6 +10608,11 @@ impl PlatformExprStrategy {
|
|||||||
.map(Self::normalize_expr)
|
.map(Self::normalize_expr)
|
||||||
.map(|expr| Self::extract_identifier_candidates(&expr))
|
.map(|expr| Self::extract_identifier_candidates(&expr))
|
||||||
.any(|identifiers| identifiers.contains("factors") || identifiers.contains("factor"))
|
.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(
|
fn stock_extra_factor_identifiers_for_config(
|
||||||
@@ -10558,6 +10643,13 @@ impl PlatformExprStrategy {
|
|||||||
prelude_declared_identifiers,
|
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
|
identifiers
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10600,6 +10692,9 @@ impl PlatformExprStrategy {
|
|||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.any(|expr| Self::expr_may_use_stock_text_factors(expr, prelude_declared_identifiers))
|
.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 {
|
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 in_skip_window = self.config.in_skip_window(signal_date);
|
||||||
|
|
||||||
let day = self.day_state(ctx, decision_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 =
|
let current_stop_take_exit_symbols =
|
||||||
self.current_stop_take_exit_symbols(ctx, signal_date, &day)?;
|
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
|
let mut model_only_lifecycle_exit_symbols = current_stop_take_exit_symbols
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|symbol| {
|
.filter(|symbol| {
|
||||||
@@ -11497,8 +11596,6 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
self.forget_position_entry_date(symbol);
|
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
|
let (explicit_action_intents, mut explicit_action_diagnostics) = if !in_skip_window
|
||||||
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
&& self.config.explicit_action_stage == PlatformExplicitActionStage::OnDay
|
||||||
&& self.config.explicit_action_schedule.is_none()
|
&& 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();
|
let stop_take_exit_signal_symbols = current_stop_take_exit_symbols.clone();
|
||||||
|
|
||||||
if self.config.rotation_enabled
|
if self.config.rotation_enabled
|
||||||
@@ -12063,6 +12231,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
if position.quantity == 0
|
if position.quantity == 0
|
||||||
|| delayed_sold_symbols.contains(&position.symbol)
|
|| delayed_sold_symbols.contains(&position.symbol)
|
||||||
|| unresolved_delisted_symbols.contains(&position.symbol)
|
|| unresolved_delisted_symbols.contains(&position.symbol)
|
||||||
|
|| factor_position_action_symbols.contains(&position.symbol)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -12230,6 +12399,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
for position in ctx.portfolio.positions().values() {
|
for position in ctx.portfolio.positions().values() {
|
||||||
if delayed_sold_symbols.contains(&position.symbol)
|
if delayed_sold_symbols.contains(&position.symbol)
|
||||||
|| unresolved_delisted_symbols.contains(&position.symbol)
|
|| unresolved_delisted_symbols.contains(&position.symbol)
|
||||||
|
|| factor_position_action_symbols.contains(&position.symbol)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -12598,6 +12768,7 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.position_entry_dates
|
.position_entry_dates
|
||||||
.keys()
|
.keys()
|
||||||
.filter(|symbol| !exit_symbols.contains(*symbol))
|
.filter(|symbol| !exit_symbols.contains(*symbol))
|
||||||
|
.filter(|symbol| !factor_position_action_symbols.contains(*symbol))
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
for symbol in &stock_list {
|
for symbol in &stock_list {
|
||||||
@@ -12718,7 +12889,9 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
.cloned()
|
.cloned()
|
||||||
.collect::<BTreeSet<_>>();
|
.collect::<BTreeSet<_>>();
|
||||||
for symbol in pre_rebalance_symbols.iter() {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if stock_list.iter().any(|candidate| candidate == symbol) {
|
if stock_list.iter().any(|candidate| candidate == symbol) {
|
||||||
@@ -12772,7 +12945,8 @@ impl Strategy for PlatformExprStrategy {
|
|||||||
if unresolved_delisted_symbols.contains(symbol) {
|
if unresolved_delisted_symbols.contains(symbol) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if exit_symbols.contains(symbol) {
|
if exit_symbols.contains(symbol) || factor_position_action_symbols.contains(symbol)
|
||||||
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let decision_stock = self.stock_state_with_factor_date(
|
let decision_stock = self.stock_state_with_factor_date(
|
||||||
@@ -13064,11 +13238,11 @@ mod tests {
|
|||||||
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
CompiledRuntimeHelperArgs, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy,
|
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategy,
|
||||||
PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig,
|
PlatformExprStrategyConfig, PlatformPortfolioDrawdownControlConfig,
|
||||||
PlatformPortfolioDrawdownController, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
PlatformPortfolioDrawdownController, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||||
RuntimeHelperResolution, SelectionRiskDeferral, StockFilterQuoteUsage, StockRollingField,
|
PlatformUniverseActionKind, RuntimeHelperResolution, SelectionRiskDeferral,
|
||||||
StockSnapshotFieldRequirements, framework_stock_rolling_factor_requirement,
|
StockFilterQuoteUsage, StockRollingField, StockSnapshotFieldRequirements,
|
||||||
scheduled_position_exposure,
|
framework_stock_rolling_factor_requirement, scheduled_position_exposure,
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AlgoOrderStyle, BenchmarkSnapshot, CandidateEligibility, CorporateAction,
|
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]
|
#[test]
|
||||||
fn portfolio_drawdown_control_is_idempotent_and_rearms_after_cooldown() {
|
fn portfolio_drawdown_control_is_idempotent_and_rearms_after_cooldown() {
|
||||||
let mut controller =
|
let mut controller =
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use serde_json::Value;
|
|||||||
use crate::{
|
use crate::{
|
||||||
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
DynamicSlippageConfig, MatchingType, PlatformAccountActionKind, PlatformExplicitActionStage,
|
||||||
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
PlatformExplicitCancelKind, PlatformExplicitOrderKind, PlatformExprStrategyConfig,
|
||||||
PlatformPortfolioDrawdownControlConfig, PlatformRebalanceSchedule, PlatformScheduleFrequency,
|
PlatformPortfolioDrawdownControlConfig, PlatformPositionTargetRule, PlatformRebalanceSchedule,
|
||||||
PlatformStopTakeReferencePriceMode, PlatformTradeAction, PlatformUniverseActionKind,
|
PlatformScheduleFrequency, PlatformStopTakeReferencePriceMode, PlatformTradeAction,
|
||||||
RebalanceCashMode, ScheduleTimeRule, SlippageModel, futures::FuturesDirection,
|
PlatformUniverseActionKind, RebalanceCashMode, ScheduleTimeRule, SlippageModel,
|
||||||
futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
futures::FuturesDirection, futures::FuturesPositionEffect, strategy::OrderTimeInForce,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -42,6 +42,8 @@ pub struct StrategyRuntimeSpec {
|
|||||||
pub metadata: Option<Value>,
|
pub metadata: Option<Value>,
|
||||||
#[serde(default, alias = "factor_value_bindings")]
|
#[serde(default, alias = "factor_value_bindings")]
|
||||||
pub factor_value_bindings: Vec<Value>,
|
pub factor_value_bindings: Vec<Value>,
|
||||||
|
#[serde(default, alias = "stock_pool_factor_contract")]
|
||||||
|
pub stock_pool_factor_contract: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub parameters: Option<Value>,
|
pub parameters: Option<Value>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -888,6 +890,8 @@ pub struct StrategyExpressionSelectionConfig {
|
|||||||
pub market_cap_upper_expr: Option<String>,
|
pub market_cap_upper_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stock_filter_expr: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||||
@@ -910,6 +914,8 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
pub stop_loss_expr: Option<String>,
|
pub stop_loss_expr: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub take_profit_expr: Option<String>,
|
pub take_profit_expr: Option<String>,
|
||||||
|
#[serde(default, alias = "position_target_rules")]
|
||||||
|
pub position_target_rules: Vec<StrategyPositionTargetRule>,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
alias = "referencePriceMode",
|
alias = "referencePriceMode",
|
||||||
@@ -918,6 +924,17 @@ pub struct StrategyExpressionRiskConfig {
|
|||||||
pub stop_take_reference_price_mode: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct StrategyPositionExposureSchedulePoint {
|
pub struct StrategyPositionExposureSchedulePoint {
|
||||||
@@ -2020,6 +2037,9 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.stock_filter_expr = expr.clone();
|
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()
|
if let Some(allocation) = runtime_expr.allocation.as_ref()
|
||||||
&& let Some(expr) = allocation
|
&& let Some(expr) = allocation
|
||||||
@@ -2125,6 +2145,37 @@ pub fn platform_expr_config_from_spec(
|
|||||||
{
|
{
|
||||||
cfg.take_profit_expr = expr.clone();
|
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
|
if let Some(mode) = risk
|
||||||
.stop_take_reference_price_mode
|
.stop_take_reference_price_mode
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -3005,7 +3056,8 @@ mod tests {
|
|||||||
"limitExpr": "stocknum",
|
"limitExpr": "stocknum",
|
||||||
"marketCapLowerExpr": "3",
|
"marketCapLowerExpr": "3",
|
||||||
"marketCapUpperExpr": "28",
|
"marketCapUpperExpr": "28",
|
||||||
"stockFilterExpr": "stock_ma5 > stock_ma10"
|
"stockFilterExpr": "stock_ma5 > stock_ma10",
|
||||||
|
"currentDayPrecomputedFactors": true
|
||||||
},
|
},
|
||||||
"trading": {
|
"trading": {
|
||||||
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
"refreshRateExpr": "year >= 2024 ? 5 : 20",
|
||||||
@@ -3036,6 +3088,7 @@ mod tests {
|
|||||||
assert!(!cfg.rotation_enabled);
|
assert!(!cfg.rotation_enabled);
|
||||||
assert!(cfg.daily_top_up_enabled);
|
assert!(cfg.daily_top_up_enabled);
|
||||||
assert!(cfg.retry_empty_rebalance);
|
assert!(cfg.retry_empty_rebalance);
|
||||||
|
assert!(cfg.current_day_precomputed_factors);
|
||||||
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
assert_eq!(cfg.weak_market_shrink_overweight_threshold, Some(1.1));
|
||||||
assert!(!cfg.calendar_rebalance_interval);
|
assert!(!cfg.calendar_rebalance_interval);
|
||||||
assert_eq!(cfg.explicit_actions.len(), 1);
|
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]
|
#[test]
|
||||||
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
fn parses_minute_stage_schedule_and_initial_subscriptions() {
|
||||||
let spec = serde_json::json!({
|
let spec = serde_json::json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user