修复平台策略部分清仓跨日续卖

This commit is contained in:
boris
2026-07-06 08:52:53 +08:00
parent c3ab279d7d
commit 831edfc8c6
@@ -562,6 +562,7 @@ pub struct PlatformExprStrategy {
last_rebalance_date: Option<NaiveDate>, last_rebalance_date: Option<NaiveDate>,
last_trading_ratio: Option<f64>, last_trading_ratio: Option<f64>,
pending_highlimit_holdings: BTreeSet<String>, pending_highlimit_holdings: BTreeSet<String>,
pending_full_close_symbols: BTreeSet<String>,
position_entry_dates: BTreeMap<String, NaiveDate>, position_entry_dates: BTreeMap<String, NaiveDate>,
/// 已编译表达式 AST 缓存。 /// 已编译表达式 AST 缓存。
/// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本, /// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本,
@@ -741,6 +742,7 @@ impl PlatformExprStrategy {
last_rebalance_date: None, last_rebalance_date: None,
last_trading_ratio: None, last_trading_ratio: None,
pending_highlimit_holdings: BTreeSet::new(), pending_highlimit_holdings: BTreeSet::new(),
pending_full_close_symbols: BTreeSet::new(),
position_entry_dates: BTreeMap::new(), position_entry_dates: BTreeMap::new(),
compiled_cache: RefCell::new(HashMap::new()), compiled_cache: RefCell::new(HashMap::new()),
cache_hits: RefCell::new(0), cache_hits: RefCell::new(0),
@@ -8024,6 +8026,57 @@ impl Strategy for PlatformExprStrategy {
.collect::<BTreeSet<_>>(); .collect::<BTreeSet<_>>();
let mut pending_full_close_symbols = BTreeSet::<String>::new(); let mut pending_full_close_symbols = BTreeSet::<String>::new();
let risk_level_forced_exit_time = self.risk_level_forced_exit_time(); let risk_level_forced_exit_time = self.risk_level_forced_exit_time();
self.pending_full_close_symbols.retain(|symbol| {
ctx.portfolio
.position(symbol)
.is_some_and(|position| position.quantity > 0)
});
let carried_full_close_symbols = self
.pending_full_close_symbols
.iter()
.cloned()
.collect::<Vec<_>>();
for symbol in carried_full_close_symbols {
if delayed_sold_symbols.contains(&symbol)
|| self.pending_highlimit_holdings.contains(&symbol)
{
continue;
}
if self.regular_sell_should_wait_due_to_highlimit(
ctx,
projection_date,
&symbol,
self.intraday_execution_start_time(),
)? {
continue;
}
pending_full_close_symbols.insert(symbol.clone());
exit_symbols.insert(symbol.clone());
order_intents.push(OrderIntent::TargetValue {
symbol: symbol.clone(),
target_value: 0.0,
reason: "pending_full_close_exit".to_string(),
});
self.forget_position_entry_date(&symbol);
let can_sell =
defer_execution_risk || self.can_sell_position(ctx, execution_date, &symbol);
if can_sell
&& self
.project_target_zero(
ctx,
&mut projected,
projection_date,
&symbol,
&mut projected_execution_state,
)
.is_some()
&& Self::projected_position_is_flat(&projected, &symbol)
{
same_day_sold_symbols.insert(symbol.clone());
slot_working_symbols.remove(&symbol);
self.pending_full_close_symbols.remove(&symbol);
}
}
if self.config.rotation_enabled if self.config.rotation_enabled
&& let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0) && let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0)
{ {
@@ -8037,6 +8090,8 @@ impl Strategy for PlatformExprStrategy {
continue; continue;
}; };
pending_full_close_symbols.insert(position.symbol.clone()); pending_full_close_symbols.insert(position.symbol.clone());
self.pending_full_close_symbols
.insert(position.symbol.clone());
exit_symbols.insert(position.symbol.clone()); exit_symbols.insert(position.symbol.clone());
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
symbol: position.symbol.clone(), symbol: position.symbol.clone(),
@@ -8060,6 +8115,7 @@ impl Strategy for PlatformExprStrategy {
{ {
same_day_sold_symbols.insert(position.symbol.clone()); same_day_sold_symbols.insert(position.symbol.clone());
slot_working_symbols.remove(&position.symbol); slot_working_symbols.remove(&position.symbol);
self.pending_full_close_symbols.remove(&position.symbol);
} }
if debug_projection { if debug_projection {
projection_debug_notes.push(format!( projection_debug_notes.push(format!(
@@ -8093,6 +8149,8 @@ impl Strategy for PlatformExprStrategy {
defer_execution_risk, defer_execution_risk,
)? { )? {
pending_full_close_symbols.insert(position.symbol.clone()); pending_full_close_symbols.insert(position.symbol.clone());
self.pending_full_close_symbols
.insert(position.symbol.clone());
pending_risk_level_forced_exit_symbols.insert(position.symbol.clone()); pending_risk_level_forced_exit_symbols.insert(position.symbol.clone());
continue; continue;
} }
@@ -8136,6 +8194,7 @@ impl Strategy for PlatformExprStrategy {
continue; continue;
} }
exit_symbols.insert(symbol.clone()); exit_symbols.insert(symbol.clone());
self.pending_full_close_symbols.insert(symbol.clone());
order_intents.push(OrderIntent::TimedTargetValue { order_intents.push(OrderIntent::TimedTargetValue {
symbol: symbol.clone(), symbol: symbol.clone(),
target_value: 0.0, target_value: 0.0,
@@ -8159,6 +8218,7 @@ impl Strategy for PlatformExprStrategy {
{ {
same_day_sold_symbols.insert(symbol.clone()); same_day_sold_symbols.insert(symbol.clone());
slot_working_symbols.remove(&symbol); slot_working_symbols.remove(&symbol);
self.pending_full_close_symbols.remove(&symbol);
} }
} }
@@ -8272,6 +8332,8 @@ impl Strategy for PlatformExprStrategy {
let can_sell = defer_execution_risk let can_sell = defer_execution_risk
|| self.can_sell_position(ctx, execution_date, &position.symbol); || self.can_sell_position(ctx, execution_date, &position.symbol);
if stop_hit { if stop_hit {
self.pending_full_close_symbols
.insert(position.symbol.clone());
exit_symbols.insert(position.symbol.clone()); exit_symbols.insert(position.symbol.clone());
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
symbol: position.symbol.clone(), symbol: position.symbol.clone(),
@@ -8293,6 +8355,7 @@ impl Strategy for PlatformExprStrategy {
{ {
same_day_sold_symbols.insert(position.symbol.clone()); same_day_sold_symbols.insert(position.symbol.clone());
slot_working_symbols.remove(&position.symbol); slot_working_symbols.remove(&position.symbol);
self.pending_full_close_symbols.remove(&position.symbol);
} }
} else { } else {
unresolved_stop_loss_symbols.insert(position.symbol.clone()); unresolved_stop_loss_symbols.insert(position.symbol.clone());
@@ -8301,6 +8364,8 @@ impl Strategy for PlatformExprStrategy {
} }
if profit_hit { if profit_hit {
self.pending_full_close_symbols
.insert(position.symbol.clone());
exit_symbols.insert(position.symbol.clone()); exit_symbols.insert(position.symbol.clone());
order_intents.push(OrderIntent::TargetValue { order_intents.push(OrderIntent::TargetValue {
symbol: position.symbol.clone(), symbol: position.symbol.clone(),
@@ -8322,6 +8387,7 @@ impl Strategy for PlatformExprStrategy {
{ {
same_day_sold_symbols.insert(position.symbol.clone()); same_day_sold_symbols.insert(position.symbol.clone());
slot_working_symbols.remove(&position.symbol); slot_working_symbols.remove(&position.symbol);
self.pending_full_close_symbols.remove(&position.symbol);
} }
} }
} }
@@ -8491,6 +8557,7 @@ impl Strategy for PlatformExprStrategy {
target_value: 0.0, target_value: 0.0,
reason: "periodic_rebalance_sell".to_string(), reason: "periodic_rebalance_sell".to_string(),
}); });
self.pending_full_close_symbols.insert(symbol.clone());
self.forget_position_entry_date(symbol); self.forget_position_entry_date(symbol);
if self if self
.project_target_zero( .project_target_zero(
@@ -8504,6 +8571,7 @@ impl Strategy for PlatformExprStrategy {
&& Self::projected_position_is_flat(&projected, symbol) && Self::projected_position_is_flat(&projected, symbol)
{ {
same_day_sold_symbols.insert(symbol.clone()); same_day_sold_symbols.insert(symbol.clone());
self.pending_full_close_symbols.remove(symbol);
} }
slot_working_symbols.remove(symbol); slot_working_symbols.remove(symbol);
} }
@@ -12693,6 +12761,177 @@ mod tests {
); );
} }
#[test]
fn platform_pending_full_close_carries_after_partial_stop_loss() {
let prev_date = d(2025, 1, 6);
let first_date = d(2025, 1, 7);
let second_date = d(2025, 1, 8);
let symbol = "301129.SZ";
let instrument = Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
};
let market = |date: NaiveDate, last_price: f64| DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some(format!("{date} 10:15:00")),
day_open: last_price,
open: last_price,
high: last_price,
low: last_price,
close: last_price,
last_price,
bid1: last_price,
ask1: last_price,
prev_close: 10.0,
volume: 1_000_000,
minute_volume: 0,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 20.0,
lower_limit: 1.0,
price_tick: 0.01,
};
let factor = |date: NaiveDate| 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::new(),
};
let candidate = |date: NaiveDate| 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,
};
let benchmark = |date: NaiveDate| BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 998.0,
volume: 1_000_000,
};
let quote = |date: NaiveDate, last_price: f64, volume_delta: u64| IntradayExecutionQuote {
date,
symbol: symbol.to_string(),
timestamp: date.and_hms_opt(10, 15, 0).expect("timestamp"),
last_price,
bid1: last_price,
ask1: last_price,
bid1_volume: 10_000,
ask1_volume: 10_000,
volume_delta,
amount_delta: last_price * volume_delta as f64,
trading_phase: Some("continuous".to_string()),
};
let data = DataSet::from_components_with_actions_and_quotes(
vec![instrument],
vec![market(first_date, 9.0), market(second_date, 10.5)],
vec![factor(first_date), factor(second_date)],
vec![candidate(first_date), candidate(second_date)],
vec![benchmark(first_date), benchmark(second_date)],
Vec::new(),
vec![
quote(first_date, 9.0, 2_800),
quote(second_date, 10.5, 100_000),
],
)
.expect("dataset");
let subscriptions = BTreeSet::new();
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.rotation_enabled = false;
cfg.aiquant_transaction_cost = true;
cfg.matching_type = MatchingType::MinuteLast;
cfg.risk_config.trading_constraints.volume_limit_enabled = true;
cfg.risk_config.trading_constraints.volume_percent = 0.25;
cfg.risk_config.trading_constraints.liquidity_limit_enabled = false;
cfg.intraday_execution_time = Some(NaiveTime::from_hms_opt(10, 15, 0).expect("time"));
cfg.signal_symbol = symbol.to_string();
cfg.stop_loss_expr = "0.95".to_string();
cfg.take_profit_expr.clear();
let mut strategy = PlatformExprStrategy::new(cfg);
let mut first_portfolio = PortfolioState::new(1_000_000.0);
first_portfolio
.position_mut(symbol)
.buy(prev_date, 1_900, 10.0);
let first_ctx = StrategyContext {
execution_date: first_date,
decision_date: first_date,
decision_index: 0,
data: &data,
portfolio: &first_portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let first_decision = strategy.on_day(&first_ctx).expect("first decision");
assert!(first_decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::TargetValue { symbol: intent_symbol, target_value, reason }
if intent_symbol == symbol && *target_value == 0.0 && reason == "stop_loss_exit"
)));
assert!(strategy.pending_full_close_symbols.contains(symbol));
let mut second_portfolio = PortfolioState::new(1_000_000.0);
second_portfolio
.position_mut(symbol)
.buy(prev_date, 1_200, 10.0);
let second_ctx = StrategyContext {
execution_date: second_date,
decision_date: second_date,
decision_index: 1,
data: &data,
portfolio: &second_portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let second_decision = strategy.on_day(&second_ctx).expect("second decision");
assert!(second_decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::TargetValue { symbol: intent_symbol, target_value, reason }
if intent_symbol == symbol && *target_value == 0.0 && reason == "pending_full_close_exit"
)));
assert!(!second_decision.order_intents.iter().any(|intent| matches!(
intent,
OrderIntent::Shares { symbol: intent_symbol, quantity, reason }
if intent_symbol == symbol && *quantity > 0 && reason == "daily_position_target_adjust"
)));
assert!(!strategy.pending_full_close_symbols.contains(symbol));
}
#[test] #[test]
fn platform_stop_take_fraction_values_are_return_thresholds() { fn platform_stop_take_fraction_values_are_return_thresholds() {
let prev_date = d(2025, 3, 13); let prev_date = d(2025, 3, 13);