修正AiQuant兼容持仓成本止损口径

This commit is contained in:
boris
2026-07-10 10:35:58 +08:00
parent f7d0889bbc
commit e396c895dc
3 changed files with 255 additions and 9 deletions
+182 -2
View File
@@ -8105,7 +8105,7 @@ impl PlatformExprStrategy {
} }
Err(error) => return Err(error), Err(error) => return Err(error),
}; };
let current_price = stock.last; let current_price = self.stop_take_current_price(ctx, signal_date, symbol, &stock)?;
let holding_return = if stop_take_base_price > 0.0 { let holding_return = if stop_take_base_price > 0.0 {
current_price / stop_take_base_price - 1.0 current_price / stop_take_base_price - 1.0
} else { } else {
@@ -8197,6 +8197,24 @@ impl PlatformExprStrategy {
}; };
Ok((stop_hit, profit_hit)) Ok((stop_hit, profit_hit))
} }
fn stop_take_current_price(
&self,
ctx: &StrategyContext<'_>,
signal_date: NaiveDate,
symbol: &str,
stock: &StockExpressionState,
) -> Result<f64, BacktestError> {
if self.config.aiquant_transaction_cost
&& self.config.matching_type == MatchingType::NextBarOpen
{
let market = ctx.data.require_market(signal_date, symbol)?;
if market.close.is_finite() && market.close > 0.0 {
return Ok(market.close);
}
}
Ok(stock.last)
}
} }
impl Strategy for PlatformExprStrategy { impl Strategy for PlatformExprStrategy {
@@ -13474,6 +13492,167 @@ mod tests {
); );
} }
#[test]
fn platform_aiquant_next_open_stop_loss_uses_signal_close_not_execution_last_price() {
let date = d(2022, 12, 22);
let symbol = "300405.SZ";
let data = DataSet::from_components(
vec![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(),
}],
vec![DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2022-12-22 15:00:00".to_string()),
day_open: 5.28,
open: 5.28,
high: 5.28,
low: 5.05,
close: 5.08,
// In next-open handoff this field may carry the projected
// execution quote. Stop/take checks must still use the
// signal-day close, matching the generated AiQuant strategy.
last_price: 5.11,
bid1: 5.11,
ask1: 5.11,
prev_close: 5.24,
volume: 4_724_443,
minute_volume: 0,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("close".to_string()),
paused: false,
upper_limit: 6.29,
lower_limit: 4.19,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 3.2,
free_float_cap_bn: 2.1,
pe_ttm: 8.0,
turnover_ratio: Some(3.0),
effective_turnover_ratio: Some(3.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_star_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
risk_level_code: None,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
{
let position = portfolio.position_mut(symbol);
position.buy(d(2022, 11, 21), 43_300, 5.75575);
position.record_buy_trade_cost(43_300, 74.7671925);
position.buy(d(2022, 11, 23), 900, 5.60560);
position.record_buy_trade_cost(900, 5.0);
position.sell(300, 5.63436).expect("sell 2022-11-25");
position.buy(d(2022, 11, 28), 800, 5.50550);
position.record_buy_trade_cost(800, 5.0);
position.sell(100, 5.55444).expect("sell 2022-11-29");
position.buy(d(2022, 11, 30), 100, 5.62562);
position.record_buy_trade_cost(100, 5.0);
position.sell(400, 5.68431).expect("sell 2022-12-02");
position.buy(d(2022, 12, 5), 200, 5.74574);
position.record_buy_trade_cost(200, 5.0);
position.buy(d(2022, 12, 6), 200, 5.73573);
position.record_buy_trade_cost(200, 5.0);
position.buy(d(2022, 12, 7), 400, 5.67567);
position.record_buy_trade_cost(400, 5.0);
position.buy(d(2022, 12, 8), 100, 5.68568);
position.record_buy_trade_cost(100, 5.0);
position.buy(d(2022, 12, 9), 100, 5.66566);
position.record_buy_trade_cost(100, 5.0);
position.buy(d(2022, 12, 12), 200, 5.58558);
position.record_buy_trade_cost(200, 5.0);
position.buy(d(2022, 12, 13), 9_000, 4.60460);
position.record_buy_trade_cost(9_000, 12.43242);
position.sell(7_500, 5.35464).expect("sell 2022-12-14");
position.sell(400, 5.38461).expect("sell 2022-12-15");
position.sell(2_400, 5.70429).expect("sell 2022-12-16");
position.buy(d(2022, 12, 19), 1_700, 5.45545);
position.record_buy_trade_cost(1_700, 5.0);
position.buy(d(2022, 12, 20), 400, 5.26526);
position.record_buy_trade_cost(400, 5.0);
position.sell(400, 5.30469).expect("sell 2022-12-21");
}
let position = portfolio.position(symbol).expect("position");
assert_eq!(position.quantity, 45_900);
assert!(
5.08 < position.average_cost * 0.92,
"average_cost={} threshold={}",
position.average_cost,
position.average_cost * 0.92
);
assert!(
5.11 >= position.average_cost * 0.92,
"average_cost={} threshold={}",
position.average_cost,
position.average_cost * 0.92
);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 40,
data: &data,
portfolio: &portfolio,
futures_account: None,
open_orders: &[],
dynamic_universe: None,
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
order_events: &[],
fills: &[],
};
let mut cfg = PlatformExprStrategyConfig::microcap_rotation();
cfg.rotation_enabled = false;
cfg.aiquant_transaction_cost = true;
cfg.matching_type = MatchingType::NextBarOpen;
cfg.signal_symbol = symbol.to_string();
cfg.prelude = "let stop_loss = 0.92;".to_string();
cfg.stop_loss_expr = "stop_loss".to_string();
cfg.take_profit_expr.clear();
let strategy = PlatformExprStrategy::new(cfg);
let day = strategy.day_state(&ctx, date).expect("day state");
let (stop_hit, profit_hit) = strategy
.stop_take_action(&ctx, date, date, &day, symbol)
.expect("stop take action");
assert!(stop_hit);
assert!(!profit_hit);
}
#[test] #[test]
fn platform_aiquant_minute_stop_loss_requires_intraday_quote() { fn platform_aiquant_minute_stop_loss_requires_intraday_quote() {
let prev_date = d(2025, 3, 13); let prev_date = d(2025, 3, 13);
@@ -21130,6 +21309,7 @@ mod tests {
cfg.take_profit_expr.clear(); cfg.take_profit_expr.clear();
cfg.daily_top_up_enabled = true; cfg.daily_top_up_enabled = true;
cfg.aiquant_transaction_cost = true; cfg.aiquant_transaction_cost = true;
cfg.matching_type = MatchingType::NextBarOpen;
let mut strategy = PlatformExprStrategy::new(cfg); let mut strategy = PlatformExprStrategy::new(cfg);
strategy.rebalance_day_counter = 2; strategy.rebalance_day_counter = 2;
@@ -28297,7 +28477,7 @@ mod tests {
cfg.benchmark_short_ma_days = 1; cfg.benchmark_short_ma_days = 1;
cfg.benchmark_long_ma_days = 1; cfg.benchmark_long_ma_days = 1;
cfg.stop_loss_expr = concat!( cfg.stop_loss_expr = concat!(
"order_book_id == \"000001.SZ\" && avg_price == avg_cost", "order_book_id == \"000001.SZ\" && avg_price > avg_cost",
" && sellable == sellable_qty && closable == sellable_qty", " && sellable == sellable_qty && closable == sellable_qty",
" && equity == position_market_value", " && equity == position_market_value",
" && position_prev_close == prev_position_close", " && position_prev_close == prev_position_close",
+70 -4
View File
@@ -82,6 +82,8 @@ impl Position {
return; return;
} }
let previous_quantity = self.quantity;
let previous_average_cost = self.average_cost;
self.lots.push(PositionLot { self.lots.push(PositionLot {
acquired_date: date, acquired_date: date,
quantity, quantity,
@@ -93,7 +95,18 @@ impl Position {
self.day_trade_quantity_delta += quantity as i32; self.day_trade_quantity_delta += quantity as i32;
self.day_buy_quantity += quantity; self.day_buy_quantity += quantity;
self.day_buy_value += execution_price * quantity as f64; self.day_buy_value += execution_price * quantity as f64;
self.recalculate_average_cost(); if previous_quantity > 0
&& previous_average_cost.is_finite()
&& previous_average_cost > 0.0
&& execution_price.is_finite()
&& execution_price > 0.0
{
self.average_cost = (previous_average_cost * previous_quantity as f64
+ execution_price * quantity as f64)
/ self.quantity as f64;
} else {
self.recalculate_average_cost();
}
self.refresh_day_pnl(); self.refresh_day_pnl();
} }
@@ -259,7 +272,11 @@ impl Position {
} }
if let Some(lot) = self.lots.last_mut() { if let Some(lot) = self.lots.last_mut() {
lot.price += cost / quantity as f64; lot.price += cost / quantity as f64;
self.recalculate_average_cost(); if self.quantity > 0 && self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost += cost / self.quantity as f64;
} else {
self.recalculate_average_cost();
}
} }
self.day_trade_cost += cost; self.day_trade_cost += cost;
self.refresh_day_pnl(); self.refresh_day_pnl();
@@ -377,7 +394,11 @@ impl Position {
self.lots = scaled_lots; self.lots = scaled_lots;
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum(); self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
self.last_price /= ratio; self.last_price /= ratio;
self.recalculate_average_cost(); if self.average_cost.is_finite() && self.average_cost > 0.0 {
self.average_cost /= ratio;
} else {
self.recalculate_average_cost();
}
self.day_split_ratio *= ratio; self.day_split_ratio *= ratio;
self.refresh_day_pnl(); self.refresh_day_pnl();
self.quantity as i32 - old_quantity as i32 self.quantity as i32 - old_quantity as i32
@@ -844,6 +865,7 @@ impl PortfolioState {
let old_quantity = old_position.quantity; let old_quantity = old_position.quantity;
let last_price = old_position.last_price; let last_price = old_position.last_price;
let old_average_cost = old_position.average_cost;
let realized_pnl = old_position.realized_pnl; let realized_pnl = old_position.realized_pnl;
let realized_entry_pnl = old_position.realized_entry_pnl; let realized_entry_pnl = old_position.realized_entry_pnl;
let mut converted_lots = old_position let mut converted_lots = old_position
@@ -879,6 +901,8 @@ impl PortfolioState {
.positions .positions
.entry(new_symbol.to_string()) .entry(new_symbol.to_string())
.or_insert_with(|| Position::new(new_symbol)); .or_insert_with(|| Position::new(new_symbol));
let successor_quantity_before = successor.quantity;
let successor_average_cost_before = successor.average_cost;
successor.lots.extend(converted_lots); successor.lots.extend(converted_lots);
successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum(); successor.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
successor.realized_pnl += realized_pnl; successor.realized_pnl += realized_pnl;
@@ -886,7 +910,30 @@ impl PortfolioState {
if converted_last_price > 0.0 { if converted_last_price > 0.0 {
successor.last_price = converted_last_price; successor.last_price = converted_last_price;
} }
successor.recalculate_average_cost(); let converted_average_cost = if old_average_cost.is_finite()
&& old_average_cost > 0.0
&& ratio.is_finite()
&& ratio > 0.0
{
Some(old_average_cost / ratio)
} else {
None
};
if let Some(converted_average_cost) = converted_average_cost {
if successor_quantity_before > 0
&& successor_average_cost_before.is_finite()
&& successor_average_cost_before > 0.0
{
successor.average_cost = (successor_average_cost_before
* successor_quantity_before as f64
+ converted_average_cost * converted_quantity as f64)
/ successor.quantity as f64;
} else {
successor.average_cost = converted_average_cost;
}
} else {
successor.recalculate_average_cost();
}
successor.refresh_day_pnl(); successor.refresh_day_pnl();
Some(SuccessorConversionOutcome { Some(SuccessorConversionOutcome {
@@ -982,6 +1029,25 @@ mod tests {
assert!((position.average_cost - average_cost_before).abs() < 1e-12); assert!((position.average_cost - average_cost_before).abs() < 1e-12);
} }
#[test]
fn buy_after_partial_sell_continues_moving_average_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("300405.SZ");
position.buy(date, 100, 10.0);
position.buy(date, 100, 5.0);
assert!((position.average_cost - 7.5).abs() < 1e-12);
position.sell(100, 6.0).expect("partial sell");
assert_eq!(position.quantity, 100);
assert!((position.average_cost - 7.5).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
position.buy(date, 100, 5.0);
assert_eq!(position.quantity, 200);
assert!((position.average_cost - 6.25).abs() < 1e-12);
assert!((position.average_entry_price().unwrap() - 5.0).abs() < 1e-12);
}
#[test] #[test]
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() { fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap(); let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
@@ -3349,7 +3349,7 @@ fn rebalance_optimizer_skips_unfunded_buy_when_existing_position_cannot_sell() {
} }
#[test] #[test]
fn rebalance_uses_prev_close_for_open_auction_valuation() { fn rebalance_uses_day_open_for_open_auction_valuation() {
let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap(); let prev_date = NaiveDate::from_ymd_opt(2024, 1, 9).unwrap();
let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap(); let date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
let data = DataSet::from_components( let data = DataSet::from_components(
@@ -3515,7 +3515,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
let held = portfolio.position("000001.SZ").expect("held position"); let held = portfolio.position("000001.SZ").expect("held position");
let target = portfolio.position("000002.SZ").expect("target position"); let target = portfolio.position("000002.SZ").expect("target position");
assert_eq!(held.quantity, 500); assert_eq!(held.quantity, 500);
assert_eq!(target.quantity, 400); assert_eq!(target.quantity, 900);
assert_eq!(report.fill_events.len(), 2); assert_eq!(report.fill_events.len(), 2);
assert!( assert!(
report report
@@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
.iter() .iter()
.any(|fill| fill.symbol == "000002.SZ" .any(|fill| fill.symbol == "000002.SZ"
&& fill.side == fidc_core::OrderSide::Buy && fill.side == fidc_core::OrderSide::Buy
&& fill.quantity == 400) && fill.quantity == 900)
); );
} }