修复平台策略部分清仓跨日续卖
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user