Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffd23b9920 |
@@ -343,6 +343,7 @@ impl<C: CostModel, R: EquityRuleHooks> BrokerSimulator<C, R> {
|
|||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
constraints.pending_entry_symbols = execution_state.pending_symbols();
|
||||||
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
constraints.prior_target_weights = execution_state.last_target_weights.clone();
|
||||||
|
constraints.position_action_bases = execution_state.position_action_bases_for(&contract.generation);
|
||||||
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
constraints.next_day_outside_exit_symbols = execution_state.next_day_exit_symbols(date);
|
||||||
let account = pool::AccountSnapshot {
|
let account = pool::AccountSnapshot {
|
||||||
total_equity: contract.frozen_equity,
|
total_equity: contract.frozen_equity,
|
||||||
|
|||||||
@@ -3666,6 +3666,8 @@ where
|
|||||||
|
|
||||||
let split_ratio = action.split_ratio();
|
let split_ratio = action.split_ratio();
|
||||||
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
if (split_ratio - 1.0).abs() > f64::EPSILON {
|
||||||
|
portfolio.adjust_stock_pool_split(&action.symbol, split_ratio)
|
||||||
|
.map_err(BacktestError::Execution)?;
|
||||||
let (delta_quantity, quantity_after, average_cost) = {
|
let (delta_quantity, quantity_after, average_cost) = {
|
||||||
let position = portfolio
|
let position = portfolio
|
||||||
.position_mut_if_exists(&action.symbol)
|
.position_mut_if_exists(&action.symbol)
|
||||||
|
|||||||
@@ -624,6 +624,7 @@ pub struct PlatformPositionTargetRule {
|
|||||||
pub when_expr: String,
|
pub when_expr: String,
|
||||||
pub remaining_position_bps: u32,
|
pub remaining_position_bps: u32,
|
||||||
pub reason: String,
|
pub reason: String,
|
||||||
|
pub stock_pool_role: crate::stock_pool_execution::StockPoolExitRole,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -10146,6 +10147,22 @@ impl PlatformExprStrategy {
|
|||||||
factor_date: NaiveDate,
|
factor_date: NaiveDate,
|
||||||
day: &DayExpressionState,
|
day: &DayExpressionState,
|
||||||
) -> Result<BTreeMap<String, (u32, String)>, BacktestError> {
|
) -> Result<BTreeMap<String, (u32, String)>, BacktestError> {
|
||||||
|
let mut targets = BTreeMap::new();
|
||||||
|
for (_, scoped) in self.current_position_target_rules_by_role(ctx, signal_date, factor_date, day)? {
|
||||||
|
for (symbol, value) in scoped {
|
||||||
|
if targets.get(&symbol).is_none_or(|(bps, _)| value.0 < *bps) { targets.insert(symbol, value); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(targets)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_position_target_rules_by_role(
|
||||||
|
&self,
|
||||||
|
ctx: &StrategyContext<'_>,
|
||||||
|
signal_date: NaiveDate,
|
||||||
|
factor_date: NaiveDate,
|
||||||
|
day: &DayExpressionState,
|
||||||
|
) -> Result<BTreeMap<crate::stock_pool_execution::StockPoolExitRole, BTreeMap<String, (u32, String)>>, BacktestError> {
|
||||||
let mut targets = BTreeMap::new();
|
let mut targets = BTreeMap::new();
|
||||||
if self.config.position_target_rules.is_empty() {
|
if self.config.position_target_rules.is_empty() {
|
||||||
return Ok(targets);
|
return Ok(targets);
|
||||||
@@ -10160,11 +10177,12 @@ impl PlatformExprStrategy {
|
|||||||
if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? {
|
if !self.eval_bool(ctx, &rule.when_expr, day, Some(&stock), None)? {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let replace = targets
|
let scoped = targets.entry(rule.stock_pool_role).or_insert_with(BTreeMap::new);
|
||||||
|
let replace = scoped
|
||||||
.get(&position.symbol)
|
.get(&position.symbol)
|
||||||
.map_or(true, |(bps, _)| rule.remaining_position_bps < *bps);
|
.map_or(true, |(bps, _)| rule.remaining_position_bps < *bps);
|
||||||
if replace {
|
if replace {
|
||||||
targets.insert(
|
scoped.insert(
|
||||||
position.symbol.clone(),
|
position.symbol.clone(),
|
||||||
(rule.remaining_position_bps, rule.reason.clone()),
|
(rule.remaining_position_bps, rule.reason.clone()),
|
||||||
);
|
);
|
||||||
@@ -37826,6 +37844,7 @@ let target_exposure = csi_ready ? dynamic_exposure : 0.0;
|
|||||||
config.rebalance_existing_positions = true;
|
config.rebalance_existing_positions = true;
|
||||||
config.hold_until_exit_enabled = true;
|
config.hold_until_exit_enabled = true;
|
||||||
config.position_target_rules = vec![PlatformPositionTargetRule {
|
config.position_target_rules = vec![PlatformPositionTargetRule {
|
||||||
|
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||||
remaining_position_bps: 5_000,
|
remaining_position_bps: 5_000,
|
||||||
reason: "factor_reduce_position".to_string(),
|
reason: "factor_reduce_position".to_string(),
|
||||||
|
|||||||
@@ -14,6 +14,14 @@ impl PlatformExprStrategy {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
|
.ok_or_else(|| BacktestError::Execution("stock_pool_program_missing".into()))?
|
||||||
.clone();
|
.clone();
|
||||||
|
if !self.config.stop_loss_expr.trim().is_empty() || !self.config.take_profit_expr.trim().is_empty()
|
||||||
|
|| self.config.position_target_rules.len() != program.exit_signals.len()
|
||||||
|
|| self.config.position_target_rules.iter().zip(&program.exit_signals).any(|(compiled, frozen)|
|
||||||
|
compiled.when_expr != frozen.when_expr || compiled.remaining_position_bps != frozen.remaining_position_bps
|
||||||
|
|| compiled.reason != frozen.reason || compiled.stock_pool_role != frozen.role)
|
||||||
|
{
|
||||||
|
return Err(BacktestError::Execution("stock_pool_exit_roles_required: exit rules must remain bound to the frozen stock_pool program".into()));
|
||||||
|
}
|
||||||
let mut constraints = pool::stock_pool_constraints_from_configuration(
|
let mut constraints = pool::stock_pool_constraints_from_configuration(
|
||||||
&program.allocation_policy,
|
&program.allocation_policy,
|
||||||
&program.stop_take_policy,
|
&program.stop_take_policy,
|
||||||
@@ -78,12 +86,11 @@ impl PlatformExprStrategy {
|
|||||||
closes,
|
closes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let rule = pool::normalize_stock_pool_execution_rule(
|
let rule = pool::normalize_stock_pool_execution_rule_with_exit_roles(
|
||||||
Some(&program.timing_policy),
|
Some(&program.timing_policy),
|
||||||
!self.config.buy_filter_expr.trim().is_empty(),
|
!self.config.buy_filter_expr.trim().is_empty(),
|
||||||
!self.config.stop_loss_expr.trim().is_empty()
|
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::OrdinarySell),
|
||||||
|| !self.config.take_profit_expr.trim().is_empty()
|
self.config.position_target_rules.iter().any(|rule| rule.stock_pool_role == pool::StockPoolExitRole::RiskExit),
|
||||||
|| !self.config.position_target_rules.is_empty(),
|
|
||||||
)
|
)
|
||||||
.map_err(BacktestError::Execution)?;
|
.map_err(BacktestError::Execution)?;
|
||||||
if self.config.in_skip_window(ctx.decision_date) {
|
if self.config.in_skip_window(ctx.decision_date) {
|
||||||
@@ -133,23 +140,13 @@ impl PlatformExprStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let native_exits = self.current_stop_take_exit_symbols(ctx, ctx.decision_date, &day)?;
|
for (role, targets) in self.current_position_target_rules_by_role(ctx, ctx.decision_date, factor_date, &day)? {
|
||||||
for symbol in native_exits {
|
let output = match role { pool::StockPoolExitRole::OrdinarySell => &mut constraints.position_target_bps, pool::StockPoolExitRole::RiskExit => &mut constraints.independent_position_target_bps };
|
||||||
constraints.position_target_bps.insert(symbol, 0);
|
for (symbol, (bps, _)) in targets { output.insert(symbol, bps); }
|
||||||
}
|
|
||||||
for (symbol, (bps, _)) in
|
|
||||||
self.current_position_target_rules(ctx, ctx.decision_date, factor_date, &day)?
|
|
||||||
{
|
|
||||||
constraints
|
|
||||||
.position_target_bps
|
|
||||||
.entry(symbol)
|
|
||||||
.and_modify(|old| *old = (*old).min(bps))
|
|
||||||
.or_insert(bps);
|
|
||||||
}
|
}
|
||||||
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
|
let limit = constraints.target_holding_count.unwrap_or(ranked.len());
|
||||||
let final_symbols = ranked
|
let final_symbols = ranked
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|symbol| !constraints.position_target_bps.contains_key(*symbol))
|
|
||||||
.take(limit)
|
.take(limit)
|
||||||
.cloned()
|
.cloned()
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -2339,6 +2339,7 @@ pub fn platform_expr_config_from_spec(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
cfg.position_target_rules.push(PlatformPositionTargetRule {
|
||||||
|
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||||
when_expr: when_expr.to_string(),
|
when_expr: when_expr.to_string(),
|
||||||
remaining_position_bps: rule.remaining_position_bps,
|
remaining_position_bps: rule.remaining_position_bps,
|
||||||
reason: rule
|
reason: rule
|
||||||
@@ -2712,9 +2713,14 @@ pub fn platform_expr_config_from_spec(
|
|||||||
}
|
}
|
||||||
if let Some(pool)=&spec.stock_pool {
|
if let Some(pool)=&spec.stock_pool {
|
||||||
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
|
if cfg.signal_book.is_some() || spec.signal_book_ref.is_some() || !cfg.explicit_actions.is_empty(){return Err("stock_pool_program_cannot_mix_other_order_programs".into())}
|
||||||
|
let legacy_exit = !cfg.stop_loss_expr.trim().is_empty() || !cfg.take_profit_expr.trim().is_empty() || !cfg.position_target_rules.is_empty();
|
||||||
|
if legacy_exit { return Err("stock_pool_exit_roles_required: regenerate this historical stock-pool strategy from its saved configuration; legacy risk expressions do not preserve ordinary/risk exit roles".into()); }
|
||||||
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
|
let secondary_buy=!cfg.buy_filter_expr.trim().is_empty();
|
||||||
let secondary_sell=spec.runtime_expressions.as_ref().and_then(|runtime|runtime.risk.as_ref()).is_some_and(|risk|risk.stop_loss_expr.is_some()||risk.take_profit_expr.is_some()) || !cfg.position_target_rules.is_empty();
|
pool.validate(secondary_buy,false)?;
|
||||||
pool.validate(secondary_buy,secondary_sell)?;
|
cfg.position_target_rules.extend(pool.exit_signals.iter().map(|signal| PlatformPositionTargetRule {
|
||||||
|
when_expr: signal.when_expr.clone(), remaining_position_bps: signal.remaining_position_bps,
|
||||||
|
reason: signal.reason.clone(), stock_pool_role: signal.role,
|
||||||
|
}));
|
||||||
cfg.stock_pool=Some(pool.clone());
|
cfg.stock_pool=Some(pool.clone());
|
||||||
cfg.hold_until_exit_enabled=false;
|
cfg.hold_until_exit_enabled=false;
|
||||||
cfg.daily_top_up_enabled=false;
|
cfg.daily_top_up_enabled=false;
|
||||||
@@ -3456,6 +3462,7 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
cfg.position_target_rules,
|
cfg.position_target_rules,
|
||||||
vec![PlatformPositionTargetRule {
|
vec![PlatformPositionTargetRule {
|
||||||
|
stock_pool_role: crate::stock_pool_execution::StockPoolExitRole::OrdinarySell,
|
||||||
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
when_expr: "factors[\"reduce_signal\"] == 1".to_string(),
|
||||||
remaining_position_bps: 5000,
|
remaining_position_bps: 5000,
|
||||||
reason: "factor_reduce_position".to_string(),
|
reason: "factor_reduce_position".to_string(),
|
||||||
|
|||||||
@@ -732,6 +732,16 @@ impl PortfolioState {
|
|||||||
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
|
state.validate()?;self.stock_pool_states.insert(pool_id.into(),state);Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn adjust_stock_pool_split(&mut self, symbol: &str, ratio: f64) -> Result<(), String> {
|
||||||
|
let ratio = rust_decimal::Decimal::from_str_exact(&ratio.to_string())
|
||||||
|
.map_err(|_| "stock_pool_execution_state_split_invalid".to_string())?;
|
||||||
|
let adjusted = self.stock_pool_states.iter()
|
||||||
|
.map(|(pool, state)| Ok((pool.clone(), state.adjust_for_split(symbol, ratio)?)))
|
||||||
|
.collect::<Result<BTreeMap<_, _>, String>>()?;
|
||||||
|
self.stock_pool_states = adjusted;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn initial_cash(&self) -> f64 {
|
pub fn initial_cash(&self) -> f64 {
|
||||||
self.initial_cash.to_f64()
|
self.initial_cash.to_f64()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ pub enum QuoteConditionScope {
|
|||||||
AnyTarget,
|
AnyTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum StockPoolExitRole {
|
||||||
|
OrdinarySell,
|
||||||
|
RiskExit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct StockPoolExitSignal {
|
||||||
|
pub role: StockPoolExitRole,
|
||||||
|
pub when_expr: String,
|
||||||
|
pub remaining_position_bps: u32,
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> {
|
pub fn stock_pool_target_holding_count(policy: &Value) -> Result<Option<usize>, String> {
|
||||||
let object = policy
|
let object = policy
|
||||||
.as_object()
|
.as_object()
|
||||||
@@ -399,6 +415,8 @@ pub struct StockPoolExecutionRule {
|
|||||||
pub sell_condition_scope: Option<QuoteConditionScope>,
|
pub sell_condition_scope: Option<QuoteConditionScope>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub secondary_sell_condition: bool,
|
pub secondary_sell_condition: bool,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub independent_sell_condition: bool,
|
||||||
#[serde(
|
#[serde(
|
||||||
default,
|
default,
|
||||||
deserialize_with = "crate::holding_policy::deserialize_optional_policy"
|
deserialize_with = "crate::holding_policy::deserialize_optional_policy"
|
||||||
@@ -476,6 +494,10 @@ pub struct StockPoolDecisionConstraints {
|
|||||||
pub default_stop_loss: Option<Decimal>,
|
pub default_stop_loss: Option<Decimal>,
|
||||||
pub default_take_profit: Option<Decimal>,
|
pub default_take_profit: Option<Decimal>,
|
||||||
pub position_target_bps: BTreeMap<String, u32>,
|
pub position_target_bps: BTreeMap<String, u32>,
|
||||||
|
pub independent_position_target_bps: BTreeMap<String, u32>,
|
||||||
|
/// First actually planned holding quantity for this generation. Retries
|
||||||
|
/// apply percentages to this basis, never to the remaining holding.
|
||||||
|
pub position_action_bases: BTreeMap<String, Decimal>,
|
||||||
pub buy_denials: BTreeMap<String, Vec<String>>,
|
pub buy_denials: BTreeMap<String, Vec<String>>,
|
||||||
pub same_day_sold_symbols: BTreeSet<String>,
|
pub same_day_sold_symbols: BTreeSet<String>,
|
||||||
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
|
pub automatic_permissions: BTreeMap<String, crate::holding_policy::AutomaticTradePermission>,
|
||||||
@@ -508,6 +530,8 @@ pub struct StockPoolPlanRow {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct StockPoolPlan {
|
pub struct StockPoolPlan {
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub position_action_bases: BTreeMap<String, Decimal>,
|
||||||
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
pub market_timing: Option<crate::stock_pool_index_policy::MarketTimingEvaluation>,
|
||||||
pub rows: Vec<StockPoolPlanRow>,
|
pub rows: Vec<StockPoolPlanRow>,
|
||||||
pub budget: Decimal,
|
pub budget: Decimal,
|
||||||
@@ -549,6 +573,8 @@ pub struct StockPoolProgram {
|
|||||||
pub timing_policy: Value,
|
pub timing_policy: Value,
|
||||||
pub stop_take_policy: Value,
|
pub stop_take_policy: Value,
|
||||||
pub out_of_pool_policy: String,
|
pub out_of_pool_policy: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub exit_signals: Vec<StockPoolExitSignal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StockPoolProgram {
|
impl StockPoolProgram {
|
||||||
@@ -562,10 +588,19 @@ impl StockPoolProgram {
|
|||||||
normalize_stock_pool_members(&self.members)?;
|
normalize_stock_pool_members(&self.members)?;
|
||||||
stock_pool_funding_from_configuration(&self.allocation_policy)?;
|
stock_pool_funding_from_configuration(&self.allocation_policy)?;
|
||||||
stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?;
|
stock_pool_constraints_from_configuration(&self.allocation_policy, &self.stop_take_policy)?;
|
||||||
normalize_stock_pool_execution_rule(
|
let mut identities = BTreeSet::new();
|
||||||
|
for signal in &self.exit_signals {
|
||||||
|
if signal.when_expr.trim().is_empty() || signal.reason.trim().is_empty() || signal.remaining_position_bps >= 10000 {
|
||||||
|
return Err("stock_pool_exit_signal_invalid".into());
|
||||||
|
}
|
||||||
|
let identity = serde_json::to_string(signal).map_err(|error| error.to_string())?;
|
||||||
|
if !identities.insert(identity) { return Err("stock_pool_exit_signal_duplicate".into()); }
|
||||||
|
}
|
||||||
|
normalize_stock_pool_execution_rule_with_exit_roles(
|
||||||
Some(&self.timing_policy),
|
Some(&self.timing_policy),
|
||||||
secondary_buy,
|
secondary_buy,
|
||||||
secondary_sell,
|
secondary_sell || self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::OrdinarySell),
|
||||||
|
self.exit_signals.iter().any(|signal| signal.role == StockPoolExitRole::RiskExit),
|
||||||
)?;
|
)?;
|
||||||
if !matches!(
|
if !matches!(
|
||||||
self.out_of_pool_policy.as_str(),
|
self.out_of_pool_policy.as_str(),
|
||||||
@@ -583,6 +618,7 @@ impl Default for StockPoolExecutionRule {
|
|||||||
buy_condition_scope: None,
|
buy_condition_scope: None,
|
||||||
sell_condition_scope: None,
|
sell_condition_scope: None,
|
||||||
secondary_sell_condition: false,
|
secondary_sell_condition: false,
|
||||||
|
independent_sell_condition: false,
|
||||||
automatic_trade_protection: Default::default(),
|
automatic_trade_protection: Default::default(),
|
||||||
schema_version: STOCK_POOL_SCHEMA_VERSION,
|
schema_version: STOCK_POOL_SCHEMA_VERSION,
|
||||||
auto_execute: true,
|
auto_execute: true,
|
||||||
@@ -671,12 +707,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
}
|
}
|
||||||
// Validate source targets before a stronger stop/expiry can replace them.
|
// Validate source targets before a stronger stop/expiry can replace them.
|
||||||
// Otherwise an invalid ratio could be hidden by target consolidation.
|
// Otherwise an invalid ratio could be hidden by target consolidation.
|
||||||
for (symbol, target) in &constraints.position_target_bps {
|
for (symbol, target) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
|
||||||
if *target >= 10_000 {
|
if *target >= 10_000 {
|
||||||
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
|
return Err(format!("factor position target for {symbol} must be below 10000 bps"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut effective_position_targets = constraints.position_target_bps.clone();
|
let mut effective_position_targets = constraints.position_target_bps.clone();
|
||||||
|
for (symbol, target) in &constraints.independent_position_target_bps {
|
||||||
|
effective_position_targets.entry(symbol.clone()).and_modify(|current| *current = (*current).min(*target)).or_insert(*target);
|
||||||
|
}
|
||||||
for (symbol, permission) in &constraints.automatic_permissions {
|
for (symbol, permission) in &constraints.automatic_permissions {
|
||||||
if permission.max_holding_exit {
|
if permission.max_holding_exit {
|
||||||
effective_position_targets.insert(symbol.clone(), 0);
|
effective_position_targets.insert(symbol.clone(), 0);
|
||||||
@@ -771,6 +810,19 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
if quote_map.len() != quotes.len() {
|
if quote_map.len() != quotes.len() {
|
||||||
return Err("duplicate or invalid stock pool execution quotes".into());
|
return Err("duplicate or invalid stock pool execution quotes".into());
|
||||||
}
|
}
|
||||||
|
let declared_symbols = normalized_members.iter().map(|member| member.symbol.as_str()).collect::<BTreeSet<_>>();
|
||||||
|
for (symbol, quantity) in &constraints.position_action_bases {
|
||||||
|
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) || *quantity <= Decimal::ZERO {
|
||||||
|
return Err(format!("invalid stock pool position-action basis:{symbol}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (symbol, _) in constraints.position_target_bps.iter().chain(constraints.independent_position_target_bps.iter()) {
|
||||||
|
if normalize_stock_symbol(symbol).as_deref() != Some(symbol.as_str()) || (!declared_symbols.contains(symbol.as_str()) && !current.contains_key(symbol)) {
|
||||||
|
return Err(format!("position action is outside declared candidates and managed holdings:{symbol}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Exit rules act on managed holdings, not on an unheld candidate's entry.
|
||||||
|
effective_position_targets.retain(|symbol, _| current.get(symbol).is_some_and(|position| position.0 > Decimal::ZERO));
|
||||||
frozen::validate(selection.trade_date, constraints, ¤t)?;
|
frozen::validate(selection.trade_date, constraints, ¤t)?;
|
||||||
for symbol in constraints.frozen_positions.keys() {
|
for symbol in constraints.frozen_positions.keys() {
|
||||||
effective_position_targets.remove(symbol);
|
effective_position_targets.remove(symbol);
|
||||||
@@ -880,6 +932,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let mut quote_sell_exits = BTreeSet::new();
|
let mut quote_sell_exits = BTreeSet::new();
|
||||||
let mut sell_condition_denials = BTreeSet::new();
|
let mut sell_condition_denials = BTreeSet::new();
|
||||||
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
||||||
|
let ordinary_enabled = !rule.sell_condition.trim().is_empty() || rule.secondary_sell_condition;
|
||||||
// Ordinary sell predicates only depend on positions participating in
|
// Ordinary sell predicates only depend on positions participating in
|
||||||
// that stage. Independent stops/expiry and protected holdings were
|
// that stage. Independent stops/expiry and protected holdings were
|
||||||
// already decided above; unrelated quote fields must not block them.
|
// already decided above; unrelated quote fields must not block them.
|
||||||
@@ -889,24 +942,29 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
row.0 > Decimal::ZERO
|
row.0 > Decimal::ZERO
|
||||||
&& !protected_positions.contains(*symbol)
|
&& !protected_positions.contains(*symbol)
|
||||||
&& !global_stop_hits.contains(*symbol)
|
&& !global_stop_hits.contains(*symbol)
|
||||||
|
&& constraints.independent_position_target_bps.get(*symbol) != Some(&0)
|
||||||
&& !constraints.automatic_permissions.get(*symbol)
|
&& !constraints.automatic_permissions.get(*symbol)
|
||||||
.is_some_and(|permission| permission.max_holding_exit)
|
.is_some_and(|permission| permission.max_holding_exit)
|
||||||
})
|
})
|
||||||
.map(|(symbol, _)| symbol.clone())
|
.map(|(symbol, _)| symbol.clone())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let qualified = quote_condition_results(
|
let qualified = if ordinary_enabled { quote_condition_results(
|
||||||
&rule.sell_condition,
|
&rule.sell_condition,
|
||||||
rule.sell_condition_scope,
|
rule.sell_condition_scope,
|
||||||
&held,
|
&held,
|
||||||
"e_map,
|
"e_map,
|
||||||
)?;
|
)? } else { BTreeMap::new() };
|
||||||
for symbol in held {
|
for symbol in held {
|
||||||
let permitted = qualified.get(&symbol) == Some(&true)
|
let permitted = ordinary_enabled && qualified.get(&symbol) == Some(&true)
|
||||||
&& (!rule.secondary_sell_condition
|
&& (!rule.secondary_sell_condition
|
||||||
|| constraints.position_target_bps.contains_key(&symbol));
|
|| constraints.position_target_bps.contains_key(&symbol));
|
||||||
if !permitted {
|
if !permitted {
|
||||||
sell_condition_denials.insert(symbol.clone());
|
|
||||||
effective_position_targets.remove(&symbol);
|
effective_position_targets.remove(&symbol);
|
||||||
|
if let Some(target) = constraints.independent_position_target_bps.get(&symbol) {
|
||||||
|
effective_position_targets.insert(symbol.clone(), *target);
|
||||||
|
} else {
|
||||||
|
sell_condition_denials.insert(symbol.clone());
|
||||||
|
}
|
||||||
} else if !rule.secondary_sell_condition {
|
} else if !rule.secondary_sell_condition {
|
||||||
quote_sell_exits.insert(symbol.clone());
|
quote_sell_exits.insert(symbol.clone());
|
||||||
effective_position_targets.insert(symbol, 0);
|
effective_position_targets.insert(symbol, 0);
|
||||||
@@ -957,6 +1015,7 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
let normalized_same_day_sold =
|
let normalized_same_day_sold =
|
||||||
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?;
|
normalize_symbol_set(&same_day_sold_symbols.iter().cloned().collect::<Vec<_>>())?;
|
||||||
let mut rebuy_exclusions = stop_take_exits.clone();
|
let mut rebuy_exclusions = stop_take_exits.clone();
|
||||||
|
rebuy_exclusions.extend(effective_position_targets.keys().cloned());
|
||||||
rebuy_exclusions.extend(
|
rebuy_exclusions.extend(
|
||||||
normalized_same_day_sold
|
normalized_same_day_sold
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1346,14 +1405,6 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
"factor position-action symbol {symbol} is outside candidates and managed holdings"
|
"factor position-action symbol {symbol} is outside candidates and managed holdings"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if selection.final_symbols.contains(symbol)
|
|
||||||
&& !maximum_holding_exits.contains(symbol)
|
|
||||||
&& !quote_sell_exits.contains(symbol)
|
|
||||||
{
|
|
||||||
return Err(format!(
|
|
||||||
"factor position-action symbol {symbol} cannot remain in final selection"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let current_quantity = current
|
let current_quantity = current
|
||||||
.get(symbol)
|
.get(symbol)
|
||||||
.map(|value| value.0)
|
.map(|value| value.0)
|
||||||
@@ -1375,10 +1426,11 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
Decimal::ZERO
|
Decimal::ZERO
|
||||||
} else {
|
} else {
|
||||||
floor_step(
|
floor_step(
|
||||||
current_quantity * Decimal::from(*target_bps) / Decimal::from(10_000),
|
constraints.position_action_bases.get(symbol).copied().unwrap_or(current_quantity)
|
||||||
|
* Decimal::from(*target_bps) / Decimal::from(10_000),
|
||||||
step,
|
step,
|
||||||
)
|
)
|
||||||
};
|
}.min(current_quantity);
|
||||||
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
|
let desired_reduction = (current_quantity - requested_target).max(Decimal::ZERO);
|
||||||
let executable = if *target_bps == 0 {
|
let executable = if *target_bps == 0 {
|
||||||
closable_quantity.min(current_quantity).max(Decimal::ZERO)
|
closable_quantity.min(current_quantity).max(Decimal::ZERO)
|
||||||
@@ -1392,13 +1444,33 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
if current_quantity == Decimal::ZERO {
|
if current_quantity == Decimal::ZERO {
|
||||||
(
|
(
|
||||||
"FACTOR_EXIT_ALREADY_SATISFIED",
|
"FACTOR_EXIT_ALREADY_SATISFIED",
|
||||||
"生产因子持仓动作命中,当前无持仓",
|
"持仓退出规则命中,当前无持仓",
|
||||||
Decimal::ZERO,
|
Decimal::ZERO,
|
||||||
Decimal::ZERO,
|
Decimal::ZERO,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
} else if desired_reduction == Decimal::ZERO {
|
||||||
|
(
|
||||||
|
"FACTOR_EXIT_ALREADY_SATISFIED",
|
||||||
|
"本次信号的持仓退出目标已达到,不重复减仓",
|
||||||
|
Decimal::ZERO,
|
||||||
|
current_quantity,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
} else if executable == Decimal::ZERO && closable_quantity >= desired_reduction {
|
||||||
|
(
|
||||||
|
"BELOW_MINIMUM_TRADE_UNIT_ALREADY_SATISFIED",
|
||||||
|
"目标持仓差额不足最小交易单位,无需重复委托",
|
||||||
|
Decimal::ZERO,
|
||||||
|
current_quantity,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
} else if executable == Decimal::ZERO {
|
} else if executable == Decimal::ZERO {
|
||||||
(
|
(
|
||||||
"DEFERRED_T_PLUS_ONE",
|
"DEFERRED_T_PLUS_ONE",
|
||||||
@@ -1429,6 +1501,8 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
"卖出行情条件命中"
|
"卖出行情条件命中"
|
||||||
} else if stop_take_exits.contains(symbol) {
|
} else if stop_take_exits.contains(symbol) {
|
||||||
"止损/止盈触发,覆盖较弱的减仓目标"
|
"止损/止盈触发,覆盖较弱的减仓目标"
|
||||||
|
} else if constraints.independent_position_target_bps.get(symbol) == Some(target_bps) {
|
||||||
|
"独立风险退出条件命中"
|
||||||
} else if *target_bps == 0 {
|
} else if *target_bps == 0 {
|
||||||
"生产因子退出条件命中"
|
"生产因子退出条件命中"
|
||||||
} else {
|
} else {
|
||||||
@@ -1913,7 +1987,15 @@ pub fn build_stock_pool_target_plan_with_fee_model(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.sum();
|
.sum();
|
||||||
let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount;
|
let estimated_cash_after = available_cash - estimated_buy_amount + estimated_sell_amount;
|
||||||
|
let position_action_bases = rows.iter()
|
||||||
|
.filter(|row| effective_position_targets.get(&row.symbol).is_some_and(|bps| *bps > 0)
|
||||||
|
&& row.current_quantity > Decimal::ZERO
|
||||||
|
&& row.status != "AUTOMATIC_TRADE_PROTECTED"
|
||||||
|
&& !constraints.frozen_positions.contains_key(&row.symbol))
|
||||||
|
.map(|row| (row.symbol.clone(), constraints.position_action_bases.get(&row.symbol).copied().unwrap_or(row.current_quantity)))
|
||||||
|
.collect();
|
||||||
Ok(StockPoolPlan {
|
Ok(StockPoolPlan {
|
||||||
|
position_action_bases,
|
||||||
market_timing,
|
market_timing,
|
||||||
rows,
|
rows,
|
||||||
budget,
|
budget,
|
||||||
@@ -2190,6 +2272,15 @@ pub fn normalize_stock_pool_execution_rule(
|
|||||||
raw: Option<&Value>,
|
raw: Option<&Value>,
|
||||||
secondary_buy_condition: bool,
|
secondary_buy_condition: bool,
|
||||||
secondary_sell_condition: bool,
|
secondary_sell_condition: bool,
|
||||||
|
) -> Result<StockPoolExecutionRule, String> {
|
||||||
|
normalize_stock_pool_execution_rule_with_exit_roles(raw, secondary_buy_condition, secondary_sell_condition, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_stock_pool_execution_rule_with_exit_roles(
|
||||||
|
raw: Option<&Value>,
|
||||||
|
secondary_buy_condition: bool,
|
||||||
|
secondary_sell_condition: bool,
|
||||||
|
independent_sell_condition: bool,
|
||||||
) -> Result<StockPoolExecutionRule, String> {
|
) -> Result<StockPoolExecutionRule, String> {
|
||||||
let mut rule = match raw {
|
let mut rule = match raw {
|
||||||
None | Some(Value::Null) => StockPoolExecutionRule::default(),
|
None | Some(Value::Null) => StockPoolExecutionRule::default(),
|
||||||
@@ -2197,6 +2288,7 @@ pub fn normalize_stock_pool_execution_rule(
|
|||||||
.map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?,
|
.map_err(|err| format!("stock pool execution_rule is invalid: {err}"))?,
|
||||||
};
|
};
|
||||||
rule.secondary_sell_condition = secondary_sell_condition;
|
rule.secondary_sell_condition = secondary_sell_condition;
|
||||||
|
rule.independent_sell_condition = independent_sell_condition;
|
||||||
rule.automatic_trade_protection.validate()?;
|
rule.automatic_trade_protection.validate()?;
|
||||||
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
|
if rule.schema_version != STOCK_POOL_SCHEMA_VERSION {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
@@ -2283,7 +2375,7 @@ pub fn normalize_stock_pool_execution_rule(
|
|||||||
return Err("stock pool buy_condition is not supported".to_string());
|
return Err("stock pool buy_condition is not supported".to_string());
|
||||||
}
|
}
|
||||||
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
if rule.sell_trigger_mode == POOL_SELL_CONDITION {
|
||||||
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition)
|
if (rule.sell_condition.trim().is_empty() && !secondary_sell_condition && !independent_sell_condition)
|
||||||
|| (!rule.sell_condition.trim().is_empty()
|
|| (!rule.sell_condition.trim().is_empty()
|
||||||
&& parse_stock_pool_condition(&rule.sell_condition).is_none())
|
&& parse_stock_pool_condition(&rule.sell_condition).is_none())
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -870,6 +870,64 @@ fn quote_field_operator_side_and_scope_matrix_matches_the_configured_predicate()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_exit_roles_merge_only_satisfied_ordinary_actions_with_independent_risk() {
|
||||||
|
for risk in [None,Some(0),Some(5000)] {
|
||||||
|
for ordinary in [None,Some(0),Some(7500)] {
|
||||||
|
for quote in ["","price<9","price>9"] {
|
||||||
|
for locked in [false,true] {
|
||||||
|
for closable in [0,400,1000] {
|
||||||
|
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":quote})),false,true,true).unwrap();
|
||||||
|
let mut constraints=StockPoolDecisionConstraints {portfolio_policy:Some(StockPoolPortfolioPolicy{schema_version:1,membership:MembershipPolicy::RetainHoldings,rebalance_weights:false}),..Default::default()};
|
||||||
|
if let Some(target)=ordinary {constraints.position_target_bps.insert(symbol(1),target);}
|
||||||
|
if let Some(target)=risk {constraints.independent_position_target_bps.insert(symbol(1),target);}
|
||||||
|
if locked {constraints.automatic_permissions.insert(symbol(1),crate::holding_policy::AutomaticTradePermission{sell_denial:Some("automatic_trade_locked"),buy_denial:Some("automatic_trade_locked"),..Default::default()});}
|
||||||
|
let mut held=position(1);held.closable_quantity=closable.into();
|
||||||
|
let plan=condition_plan(&selection(1,1),&rule,&[held],"es(1),&constraints);
|
||||||
|
assert_eq!(plan.rows.len(),1,"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
|
||||||
|
let ordinary=if quote=="price<9" {None} else {ordinary};
|
||||||
|
let target_bps=risk.into_iter().chain(ordinary).min().unwrap_or(10000);
|
||||||
|
let desired=if target_bps==0 {0} else {(1000*target_bps/10000)/100*100};
|
||||||
|
let sold=if locked {0} else {(1000-desired).min(closable)};
|
||||||
|
assert_eq!(plan.rows[0].delta_quantity,-Decimal::from(sold),"{risk:?}/{ordinary:?}/{quote}: {plan:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn risk_only_configuration_never_turns_into_an_unconditional_ordinary_exit() {
|
||||||
|
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition"})),false,false,true).unwrap();
|
||||||
|
let mut constraints=StockPoolDecisionConstraints::default();
|
||||||
|
let hold=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&constraints);
|
||||||
|
assert_ne!(hold.rows[0].side,Some(OrderSide::Sell),"a risk-only configuration must not manufacture an exit: {hold:?}");
|
||||||
|
constraints.independent_position_target_bps.insert(symbol(1),5000);
|
||||||
|
let exit=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&constraints);
|
||||||
|
assert_eq!(exit.rows[0].delta_quantity,Decimal::from(-500),"{exit:?}");
|
||||||
|
let unheld=condition_plan(&selection(1,1),&rule,&[],"es(1),&constraints);
|
||||||
|
assert_eq!(unheld.rows[0].side,Some(OrderSide::Buy),"an exit-only rule must not secretly become a selection/buy filter: {unheld:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quote_only_exit_still_works_when_independent_risk_rules_are_configured() {
|
||||||
|
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"price>9"})),false,false,true).unwrap();
|
||||||
|
let plan=condition_plan(&selection(1,1),&rule,&[position(1)],"es(1),&StockPoolDecisionConstraints::default());
|
||||||
|
assert_eq!(plan.rows.len(),1);assert_eq!(plan.rows[0].delta_quantity,Decimal::from(-1000),"{plan:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_full_exit_has_no_ordinary_quote_dependency_but_partial_risk_does_not_fake_missing_facts() {
|
||||||
|
let rule=normalize_stock_pool_execution_rule_with_exit_roles(Some(&json!({"sell_trigger_mode":"condition","sell_condition":"volume>100"})),false,true,true).unwrap();
|
||||||
|
let mut market=quotes(1);market[0].volume=None;
|
||||||
|
let mut constraints=StockPoolDecisionConstraints {position_target_bps:BTreeMap::from([(symbol(1),0)]),independent_position_target_bps:BTreeMap::from([(symbol(1),0)]),..Default::default()};
|
||||||
|
let complete=condition_plan(&selection(1,1),&rule,&[position(1)],&market,&constraints);
|
||||||
|
assert_eq!(complete.rows[0].delta_quantity,Decimal::from(-1000));
|
||||||
|
constraints.independent_position_target_bps.insert(symbol(1),5000);
|
||||||
|
assert!(condition_plan_result(&selection(1,1),&rule,&[position(1)],&market,&constraints).unwrap_err().contains("requires volume"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
fn partial_sell_cooldown_restricts_increases_without_clearing_the_remainder() {
|
||||||
let mut constraints = StockPoolDecisionConstraints::default();
|
let mut constraints = StockPoolDecisionConstraints::default();
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ pub struct StockPoolEntryProgress {
|
|||||||
pub completion_quantity: Option<Decimal>,
|
pub completion_quantity: Option<Decimal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct StockPoolPositionActionBasis {
|
||||||
|
pub generation: String,
|
||||||
|
pub first_execution_date: NaiveDate,
|
||||||
|
pub quantity: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StockPoolExecutionState {
|
pub struct StockPoolExecutionState {
|
||||||
@@ -34,6 +42,10 @@ pub struct StockPoolExecutionState {
|
|||||||
pub last_target_weights: BTreeMap<String, i32>,
|
pub last_target_weights: BTreeMap<String, i32>,
|
||||||
/// First signal excluding an actually held member; not an acquisition date.
|
/// First signal excluding an actually held member; not an acquisition date.
|
||||||
pub removed_since: BTreeMap<String, NaiveDate>,
|
pub removed_since: BTreeMap<String, NaiveDate>,
|
||||||
|
/// Signal progress, not a fill or holding-period fact. Kept across retries
|
||||||
|
/// and later execution sessions until a new generation supersedes it.
|
||||||
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
|
pub position_action_bases: BTreeMap<String, StockPoolPositionActionBasis>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct StockPoolGoalObservation<'a> {
|
pub struct StockPoolGoalObservation<'a> {
|
||||||
@@ -53,6 +65,7 @@ impl Default for StockPoolExecutionState {
|
|||||||
entries: BTreeMap::new(),
|
entries: BTreeMap::new(),
|
||||||
last_target_weights: BTreeMap::new(),
|
last_target_weights: BTreeMap::new(),
|
||||||
removed_since: BTreeMap::new(),
|
removed_since: BTreeMap::new(),
|
||||||
|
position_action_bases: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,6 +75,7 @@ impl StockPoolExecutionState {
|
|||||||
if self.schema_version != 1
|
if self.schema_version != 1
|
||||||
|| self.entries.len() > 10000
|
|| self.entries.len() > 10000
|
||||||
|| self.removed_since.len() > 10000
|
|| self.removed_since.len() > 10000
|
||||||
|
|| self.position_action_bases.len() > 10000
|
||||||
{
|
{
|
||||||
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
|
return Err("stock_pool_execution_state_invalid_schema_or_size".into());
|
||||||
}
|
}
|
||||||
@@ -70,6 +84,7 @@ impl StockPoolExecutionState {
|
|||||||
.keys()
|
.keys()
|
||||||
.chain(self.removed_since.keys())
|
.chain(self.removed_since.keys())
|
||||||
.chain(self.last_target_weights.keys())
|
.chain(self.last_target_weights.keys())
|
||||||
|
.chain(self.position_action_bases.keys())
|
||||||
{
|
{
|
||||||
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
if normalize_stock_symbol(symbol).as_ref() != Some(symbol) {
|
||||||
return Err("stock_pool_execution_state_invalid_symbol".into());
|
return Err("stock_pool_execution_state_invalid_symbol".into());
|
||||||
@@ -97,6 +112,12 @@ impl StockPoolExecutionState {
|
|||||||
{
|
{
|
||||||
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
|
return Err("stock_pool_execution_state_invalid_goal_or_clock".into());
|
||||||
}
|
}
|
||||||
|
if self.position_action_bases.values().any(|basis| {
|
||||||
|
basis.generation.trim().is_empty() || basis.quantity <= Decimal::ZERO
|
||||||
|
|| self.last_execution_date.is_none_or(|date| basis.first_execution_date > date)
|
||||||
|
}) {
|
||||||
|
return Err("stock_pool_execution_state_invalid_action_basis".into());
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +207,7 @@ impl StockPoolExecutionState {
|
|||||||
self.record_targets(
|
self.record_targets(
|
||||||
decision_date,
|
decision_date,
|
||||||
generation,
|
generation,
|
||||||
plan.rows.iter().map(|row| StockPoolGoalObservation {
|
plan.rows.iter().filter(|row| !plan.position_action_bases.contains_key(&row.symbol)).map(|row| StockPoolGoalObservation {
|
||||||
symbol: &row.symbol,
|
symbol: &row.symbol,
|
||||||
target_weight_bps: row.target_weight_bps,
|
target_weight_bps: row.target_weight_bps,
|
||||||
target_value: row.target_value,
|
target_value: row.target_value,
|
||||||
@@ -194,7 +215,67 @@ impl StockPoolExecutionState {
|
|||||||
target_quantity: row.target_quantity,
|
target_quantity: row.target_quantity,
|
||||||
status: &row.status,
|
status: &row.status,
|
||||||
}),
|
}),
|
||||||
)
|
)?.record_position_action_bases(generation, &plan.position_action_bases)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position_action_bases_for(&self, generation: &str) -> BTreeMap<String, Decimal> {
|
||||||
|
self.position_action_bases.iter()
|
||||||
|
.filter(|(_, basis)| basis.generation == generation)
|
||||||
|
.map(|(symbol, basis)| (symbol.clone(), basis.quantity))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A verified split changes the share unit, not the intended reduction or
|
||||||
|
/// entry completion. Never infer a split from a changed holding quantity.
|
||||||
|
pub fn adjust_for_split(&self, symbol: &str, ratio: Decimal) -> Result<Self, String> {
|
||||||
|
self.validate()?;
|
||||||
|
if ratio <= Decimal::ZERO || normalize_stock_symbol(symbol).as_deref() != Some(symbol) {
|
||||||
|
return Err("stock_pool_execution_state_split_invalid".into());
|
||||||
|
}
|
||||||
|
let scale = |quantity: Decimal| quantity.checked_mul(ratio)
|
||||||
|
.map(|value| value.round_dp_with_strategy(0, rust_decimal::RoundingStrategy::MidpointAwayFromZero))
|
||||||
|
.ok_or_else(|| "stock_pool_execution_state_split_overflow".to_string());
|
||||||
|
let mut next = self.clone();
|
||||||
|
if let Some(entry) = next.entries.get_mut(symbol) {
|
||||||
|
if let Some(quantity) = entry.completion_quantity {
|
||||||
|
let quantity = scale(quantity)?;
|
||||||
|
entry.completion_quantity = (quantity > Decimal::ZERO).then_some(quantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(basis) = next.position_action_bases.get_mut(symbol) {
|
||||||
|
basis.quantity = scale(basis.quantity)?;
|
||||||
|
if basis.quantity == Decimal::ZERO { next.position_action_bases.remove(symbol); }
|
||||||
|
}
|
||||||
|
next.validate()?;
|
||||||
|
Ok(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn record_position_action_bases(
|
||||||
|
&self,
|
||||||
|
generation: &str,
|
||||||
|
quantities: &BTreeMap<String, Decimal>,
|
||||||
|
) -> Result<Self, String> {
|
||||||
|
self.validate()?;
|
||||||
|
if generation.trim().is_empty() {
|
||||||
|
return Err("stock_pool_execution_state_action_generation_missing".into());
|
||||||
|
}
|
||||||
|
let first_execution_date = self.last_execution_date
|
||||||
|
.ok_or("stock_pool_execution_state_action_clock_missing")?;
|
||||||
|
let mut next = self.clone();
|
||||||
|
next.position_action_bases.retain(|_, basis| basis.generation == generation);
|
||||||
|
for (symbol, quantity) in quantities {
|
||||||
|
if let Some(basis) = next.position_action_bases.get(symbol) {
|
||||||
|
if basis.quantity != *quantity {
|
||||||
|
return Err(format!("stock_pool_execution_state_action_basis_changed:{symbol}"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.position_action_bases.insert(symbol.clone(), StockPoolPositionActionBasis {
|
||||||
|
generation: generation.into(), first_execution_date, quantity: *quantity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next.validate()?;
|
||||||
|
Ok(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_targets<'a>(
|
pub fn record_targets<'a>(
|
||||||
@@ -213,6 +294,9 @@ impl StockPoolExecutionState {
|
|||||||
}
|
}
|
||||||
let mut next = self.clone();
|
let mut next = self.clone();
|
||||||
for row in rows {
|
for row in rows {
|
||||||
|
if row.status == "AUTOMATIC_TRADE_PROTECTED" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if row.target_weight_bps > 0 {
|
if row.target_weight_bps > 0 {
|
||||||
next.last_target_weights
|
next.last_target_weights
|
||||||
.insert(row.symbol.into(), row.target_weight_bps);
|
.insert(row.symbol.into(), row.target_weight_bps);
|
||||||
|
|||||||
@@ -470,11 +470,31 @@ fn ordinary_sell_has_one_order_owner_before_broker_execution() {
|
|||||||
assert_eq!(report.account_events[1].cash_before,40000.);
|
assert_eq!(report.account_events[1].cash_before,40000.);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repeating_the_same_partial_exit_generation_does_not_reduce_again() {
|
||||||
|
let data=data(false);let broker=broker(false);let mut account=PortfolioState::new(20000.);
|
||||||
|
account.position_mut(&code(1)).buy(day(2),1000,10.);
|
||||||
|
let mut intent=contract(day(2),1,true);
|
||||||
|
intent.constraints.independent_position_target_bps.insert(code(1),5000);
|
||||||
|
let first=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||||
|
assert_eq!(first.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),500);
|
||||||
|
let repeated=broker.execute_with_event_dates(day(5),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||||
|
assert!(repeated.fill_events.iter().all(|fill|fill.symbol!=code(1)),"same generation must keep its first partial-exit target: {repeated:?}");
|
||||||
|
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
|
||||||
|
let next_day=broker.execute_with_event_dates(day(6),day(2),day(2),&mut account,&data,&decision(intent.clone())).unwrap();
|
||||||
|
assert!(next_day.fill_events.iter().all(|fill|fill.symbol!=code(1)),"{next_day:?}");
|
||||||
|
assert_eq!(account.position(&code(1)).unwrap().quantity,500);
|
||||||
|
intent.generation="a-new-reduction-signal".into();
|
||||||
|
let new_signal=broker.execute_with_event_dates(day(6),day(6),day(6),&mut account,&data,&decision(intent)).unwrap();
|
||||||
|
assert_eq!(new_signal.fill_events.iter().filter(|fill|fill.symbol==code(1)).map(|fill|fill.quantity).sum::<u32>(),300);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translation() {
|
||||||
let intent = contract(day(2), 1, false);
|
let intent = contract(day(2), 1, false);
|
||||||
for quote_condition in ["", "price<5"] {
|
for quote_condition in ["", "price<5"] {
|
||||||
let program = StockPoolProgram {
|
let program = StockPoolProgram {
|
||||||
|
exit_signals: vec![],
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
pool_id: "pool-fixture".into(),
|
pool_id: "pool-fixture".into(),
|
||||||
version_id: "version-fixture".into(),
|
version_id: "version-fixture".into(),
|
||||||
@@ -541,6 +561,34 @@ fn parsed_pool_program_executes_daily_membership_changes_without_legacy_translat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parsed_typed_exit_program_keeps_ordinary_gates_and_independent_risk_targets_separate() {
|
||||||
|
for (ordinary, risk, quote, sold) in [
|
||||||
|
(Some(0),None,"price<1",0),
|
||||||
|
(None,Some(0),"price<1",3000),
|
||||||
|
(Some(0),Some(5000),"price<1",1500),
|
||||||
|
(Some(0),Some(5000),"price>1",3000),
|
||||||
|
(None,Some(5000),"",1500),
|
||||||
|
] {
|
||||||
|
let exits=ordinary.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::OrdinarySell,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"ordinary fixture".into()})
|
||||||
|
.chain(risk.into_iter().map(|remaining_position_bps|StockPoolExitSignal{role:StockPoolExitRole::RiskExit,when_expr:"decision_date == \"2026-01-05\"".into(),remaining_position_bps,reason:"risk fixture".into()})).collect::<Vec<_>>();
|
||||||
|
let program=StockPoolProgram{schema_version:1,pool_id:"typed-exits".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
||||||
|
allocation_policy:serde_json::json!({"target_holding_count":1,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
||||||
|
timing_policy:serde_json::json!({"pricing_mode":"first_tick","sell_trigger_mode":"condition","sell_condition":quote}),
|
||||||
|
stop_take_policy:serde_json::json!({"stop_loss":null,"take_profit":null}),out_of_pool_policy:"hold".into(),exit_signals:exits};
|
||||||
|
let mut config=platform_expr_config_from_value("typed-exits","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]}})).unwrap();
|
||||||
|
config.market_cap_field="close".into();config.market_cap_lower_expr="0".into();config.market_cap_upper_expr="1000000".into();
|
||||||
|
config.stock_filter_expr="close>0".into();config.selection_limit_expr="1".into();config.selection_candidate_limit_expr="2".into();config.rank_expr=format!("symbol == {:?} ? 0 : 1",code(1));
|
||||||
|
config.matching_type=MatchingType::CurrentBarClose;
|
||||||
|
let result=BacktestEngine::new(data(false),PlatformExprStrategy::new(config),broker(false).with_matching_type(MatchingType::CurrentBarClose),BacktestConfig{
|
||||||
|
initial_cash:30000.,benchmark_code:"000300.SH".into(),start_date:Some(day(2)),end_date:Some(day(5)),decision_lag_trading_days:0,execution_price_field:PriceField::Close,
|
||||||
|
}).run().unwrap();
|
||||||
|
assert_eq!(result.fills.iter().filter(|fill|fill.date==day(2)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Buy).map(|fill|fill.quantity).sum::<u32>(),3000,"exit-only criteria must not suppress a new entry: {result:?}");
|
||||||
|
let sold_quantity=result.fills.iter().filter(|fill|fill.date==day(5)&&fill.symbol==code(1)&&fill.side==fidc_core::OrderSide::Sell).map(|fill|fill.quantity).sum::<u32>();
|
||||||
|
assert_eq!(sold_quantity,sold,"ordinary={ordinary:?} risk={risk:?} quote={quote}: {result:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
|
fn frontend_compiled_unset_stops_only_builds_positions_and_keeps_holding() {
|
||||||
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
|
// Generated by OmniQuant's actual handoff and compiler, not a hand-written
|
||||||
@@ -764,6 +812,7 @@ fn compiled_pool_price_screen_does_not_require_unconfigured_etf_market_cap() {
|
|||||||
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
let time=chrono::NaiveTime::from_hms_opt(9,30,0).unwrap();
|
||||||
let intent=contract(day(2),1,true);
|
let intent=contract(day(2),1,true);
|
||||||
let program=StockPoolProgram {
|
let program=StockPoolProgram {
|
||||||
|
exit_signals: vec![],
|
||||||
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
|
schema_version:1,pool_id:"typed-mixed-pool".into(),version_id:"v1".into(),members:intent.members,
|
||||||
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":false}}),
|
||||||
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
|
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"09:30"}),
|
||||||
@@ -798,7 +847,7 @@ fn etf_signal_budget_does_not_read_the_current_sessions_future_close() {
|
|||||||
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
|
if row.symbol==code(2)&&row.date==day(5) {row.close=future_close;row.last_price=future_close;row.high=future_close.max(row.open);}
|
||||||
}
|
}
|
||||||
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
|
let data=DataSet::from_components_with_actions_and_quotes(parts.instruments,parts.market,parts.factors,parts.candidates,parts.benchmarks,parts.corporate_actions,parts.execution_quotes).unwrap();
|
||||||
let program=StockPoolProgram{schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
let program=StockPoolProgram{exit_signals:vec![],schema_version:1,pool_id:"budget-no-future".into(),version_id:"v1".into(),members:contract(day(2),1,true).members,
|
||||||
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
|
allocation_policy:serde_json::json!({"target_holding_count":2,"invest_ratio_bps":10000,"portfolio_policy":{"schema_version":1,"membership":"retain_holdings","rebalance_weights":true}}),
|
||||||
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
|
timing_policy:serde_json::json!({"pricing_mode":"first_tick","window_start":"13:00","window_end":"14:55"}),stop_take_policy:serde_json::json!({}),out_of_pool_policy:"hold".into()};
|
||||||
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
|
let mut config=platform_expr_config_from_value("etf-budget","000300.SH",&serde_json::json!({"stockPool":program,"universe":{"include":[code(1),code(2)]},"runtimeExpressions":{"schedule":{"frequency":"daily","time":"13:00"}}})).unwrap();
|
||||||
|
|||||||
@@ -158,6 +158,42 @@ fn legacy_state_without_quantity_keeps_its_serialized_identity() {
|
|||||||
assert_eq!(serde_json::to_value(state).unwrap(), original);
|
assert_eq!(serde_json::to_value(state).unwrap(), original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn partial_exit_basis_is_immutable_restart_safe_and_scoped_to_the_signal() {
|
||||||
|
let original = StockPoolExecutionState::default()
|
||||||
|
.observe(day(11), day(11), &[day(11), day(14)], &[member()], &[held(1000, 1000)]).unwrap();
|
||||||
|
let basis = BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))]);
|
||||||
|
let saved = original.record_position_action_bases("sell-signal", &basis).unwrap();
|
||||||
|
assert!(original.position_action_bases.is_empty(), "a preview must not mutate its input");
|
||||||
|
let restored: StockPoolExecutionState = serde_json::from_slice(&serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||||
|
let next_day = restored.observe(day(11), day(14), &[day(11), day(14)], &[member()], &[held(500, 500)]).unwrap();
|
||||||
|
assert_eq!(next_day.position_action_bases_for("sell-signal"), basis);
|
||||||
|
assert!(next_day.position_action_bases_for("new-signal").is_empty());
|
||||||
|
assert!(next_day.record_position_action_bases("sell-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap_err().contains("basis_changed"));
|
||||||
|
let new_signal = next_day.record_position_action_bases("new-signal", &BTreeMap::from([("000001.SZ".into(), Decimal::from(500))])).unwrap();
|
||||||
|
assert!(new_signal.position_action_bases_for("sell-signal").is_empty());
|
||||||
|
assert_eq!(new_signal.position_action_bases_for("new-signal")["000001.SZ"], Decimal::from(500));
|
||||||
|
for invalid in [Decimal::ZERO, Decimal::NEGATIVE_ONE] {
|
||||||
|
assert!(original.record_position_action_bases("signal", &BTreeMap::from([("000001.SZ".into(), invalid)])).is_err());
|
||||||
|
}
|
||||||
|
assert!(original.record_position_action_bases(" ", &basis).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_split_adjusts_exit_basis_and_entry_completion_not_generation() {
|
||||||
|
let initial = StockPoolExecutionState::default()
|
||||||
|
.observe(day(11), day(11), &[day(11)], &[member()], &[]).unwrap();
|
||||||
|
let entry_plan = plan(&initial, day(11), &[member()], &[], 10000, "hold");
|
||||||
|
let entered = initial.record_plan(day(11), "entry", &entry_plan).unwrap();
|
||||||
|
let saved = entered.record_position_action_bases("sell", &BTreeMap::from([("000001.SZ".into(), Decimal::from(1000))])).unwrap();
|
||||||
|
let adjusted = saved.adjust_for_split("000001.SZ", Decimal::new(15,1)).unwrap();
|
||||||
|
assert_eq!(adjusted.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1500));
|
||||||
|
assert_eq!(adjusted.entries["000001.SZ"].completion_quantity, Some(Decimal::from(1500)));
|
||||||
|
assert_eq!(adjusted.position_action_bases["000001.SZ"].first_execution_date, day(11));
|
||||||
|
assert_eq!(saved.position_action_bases_for("sell")["000001.SZ"], Decimal::from(1000));
|
||||||
|
assert!(saved.adjust_for_split("000001.SZ", Decimal::ZERO).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
fn partial_entry_continues_after_restart_then_completed_holdings_are_preserved() {
|
||||||
let members = vec![member()];
|
let members = vec![member()];
|
||||||
|
|||||||
Reference in New Issue
Block a user