修复平台策略部分清仓跨日续卖
This commit is contained in:
@@ -562,6 +562,7 @@ pub struct PlatformExprStrategy {
|
||||
last_rebalance_date: Option<NaiveDate>,
|
||||
last_trading_ratio: Option<f64>,
|
||||
pending_highlimit_holdings: BTreeSet<String>,
|
||||
pending_full_close_symbols: BTreeSet<String>,
|
||||
position_entry_dates: BTreeMap<String, NaiveDate>,
|
||||
/// 已编译表达式 AST 缓存。
|
||||
/// Key 是经过 normalize/expand_runtime_helpers 之后的完整 script 文本,
|
||||
@@ -741,6 +742,7 @@ impl PlatformExprStrategy {
|
||||
last_rebalance_date: None,
|
||||
last_trading_ratio: None,
|
||||
pending_highlimit_holdings: BTreeSet::new(),
|
||||
pending_full_close_symbols: BTreeSet::new(),
|
||||
position_entry_dates: BTreeMap::new(),
|
||||
compiled_cache: RefCell::new(HashMap::new()),
|
||||
cache_hits: RefCell::new(0),
|
||||
@@ -8024,6 +8026,57 @@ impl Strategy for PlatformExprStrategy {
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut pending_full_close_symbols = BTreeSet::<String>::new();
|
||||
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
|
||||
&& let Some(max_holding_days) = self.config.max_holding_days.filter(|value| *value > 0)
|
||||
{
|
||||
@@ -8037,6 +8090,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
continue;
|
||||
};
|
||||
pending_full_close_symbols.insert(position.symbol.clone());
|
||||
self.pending_full_close_symbols
|
||||
.insert(position.symbol.clone());
|
||||
exit_symbols.insert(position.symbol.clone());
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: position.symbol.clone(),
|
||||
@@ -8060,6 +8115,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
same_day_sold_symbols.insert(position.symbol.clone());
|
||||
slot_working_symbols.remove(&position.symbol);
|
||||
self.pending_full_close_symbols.remove(&position.symbol);
|
||||
}
|
||||
if debug_projection {
|
||||
projection_debug_notes.push(format!(
|
||||
@@ -8093,6 +8149,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
defer_execution_risk,
|
||||
)? {
|
||||
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());
|
||||
continue;
|
||||
}
|
||||
@@ -8136,6 +8194,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
continue;
|
||||
}
|
||||
exit_symbols.insert(symbol.clone());
|
||||
self.pending_full_close_symbols.insert(symbol.clone());
|
||||
order_intents.push(OrderIntent::TimedTargetValue {
|
||||
symbol: symbol.clone(),
|
||||
target_value: 0.0,
|
||||
@@ -8159,6 +8218,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
same_day_sold_symbols.insert(symbol.clone());
|
||||
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
|
||||
|| self.can_sell_position(ctx, execution_date, &position.symbol);
|
||||
if stop_hit {
|
||||
self.pending_full_close_symbols
|
||||
.insert(position.symbol.clone());
|
||||
exit_symbols.insert(position.symbol.clone());
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: position.symbol.clone(),
|
||||
@@ -8293,6 +8355,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
same_day_sold_symbols.insert(position.symbol.clone());
|
||||
slot_working_symbols.remove(&position.symbol);
|
||||
self.pending_full_close_symbols.remove(&position.symbol);
|
||||
}
|
||||
} else {
|
||||
unresolved_stop_loss_symbols.insert(position.symbol.clone());
|
||||
@@ -8301,6 +8364,8 @@ impl Strategy for PlatformExprStrategy {
|
||||
}
|
||||
|
||||
if profit_hit {
|
||||
self.pending_full_close_symbols
|
||||
.insert(position.symbol.clone());
|
||||
exit_symbols.insert(position.symbol.clone());
|
||||
order_intents.push(OrderIntent::TargetValue {
|
||||
symbol: position.symbol.clone(),
|
||||
@@ -8322,6 +8387,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
{
|
||||
same_day_sold_symbols.insert(position.symbol.clone());
|
||||
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,
|
||||
reason: "periodic_rebalance_sell".to_string(),
|
||||
});
|
||||
self.pending_full_close_symbols.insert(symbol.clone());
|
||||
self.forget_position_entry_date(symbol);
|
||||
if self
|
||||
.project_target_zero(
|
||||
@@ -8504,6 +8571,7 @@ impl Strategy for PlatformExprStrategy {
|
||||
&& Self::projected_position_is_flat(&projected, symbol)
|
||||
{
|
||||
same_day_sold_symbols.insert(symbol.clone());
|
||||
self.pending_full_close_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]
|
||||
fn platform_stop_take_fraction_values_are_return_thresholds() {
|
||||
let prev_date = d(2025, 3, 13);
|
||||
|
||||
Reference in New Issue
Block a user