修正AiQuant兼容持仓成本止损口径
This commit is contained in:
@@ -8105,7 +8105,7 @@ impl PlatformExprStrategy {
|
||||
}
|
||||
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 {
|
||||
current_price / stop_take_base_price - 1.0
|
||||
} else {
|
||||
@@ -8197,6 +8197,24 @@ impl PlatformExprStrategy {
|
||||
};
|
||||
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 {
|
||||
@@ -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]
|
||||
fn platform_aiquant_minute_stop_loss_requires_intraday_quote() {
|
||||
let prev_date = d(2025, 3, 13);
|
||||
@@ -21130,6 +21309,7 @@ mod tests {
|
||||
cfg.take_profit_expr.clear();
|
||||
cfg.daily_top_up_enabled = true;
|
||||
cfg.aiquant_transaction_cost = true;
|
||||
cfg.matching_type = MatchingType::NextBarOpen;
|
||||
let mut strategy = PlatformExprStrategy::new(cfg);
|
||||
strategy.rebalance_day_counter = 2;
|
||||
|
||||
@@ -28297,7 +28477,7 @@ mod tests {
|
||||
cfg.benchmark_short_ma_days = 1;
|
||||
cfg.benchmark_long_ma_days = 1;
|
||||
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",
|
||||
" && equity == position_market_value",
|
||||
" && position_prev_close == prev_position_close",
|
||||
|
||||
@@ -82,6 +82,8 @@ impl Position {
|
||||
return;
|
||||
}
|
||||
|
||||
let previous_quantity = self.quantity;
|
||||
let previous_average_cost = self.average_cost;
|
||||
self.lots.push(PositionLot {
|
||||
acquired_date: date,
|
||||
quantity,
|
||||
@@ -93,7 +95,18 @@ impl Position {
|
||||
self.day_trade_quantity_delta += quantity as i32;
|
||||
self.day_buy_quantity += quantity;
|
||||
self.day_buy_value += execution_price * quantity as f64;
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -259,8 +272,12 @@ impl Position {
|
||||
}
|
||||
if let Some(lot) = self.lots.last_mut() {
|
||||
lot.price += cost / quantity as f64;
|
||||
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.refresh_day_pnl();
|
||||
}
|
||||
@@ -377,7 +394,11 @@ impl Position {
|
||||
self.lots = scaled_lots;
|
||||
self.quantity = self.lots.iter().map(|lot| lot.quantity).sum();
|
||||
self.last_price /= ratio;
|
||||
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.refresh_day_pnl();
|
||||
self.quantity as i32 - old_quantity as i32
|
||||
@@ -844,6 +865,7 @@ impl PortfolioState {
|
||||
|
||||
let old_quantity = old_position.quantity;
|
||||
let last_price = old_position.last_price;
|
||||
let old_average_cost = old_position.average_cost;
|
||||
let realized_pnl = old_position.realized_pnl;
|
||||
let realized_entry_pnl = old_position.realized_entry_pnl;
|
||||
let mut converted_lots = old_position
|
||||
@@ -879,6 +901,8 @@ impl PortfolioState {
|
||||
.positions
|
||||
.entry(new_symbol.to_string())
|
||||
.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.quantity = successor.lots.iter().map(|lot| lot.quantity).sum();
|
||||
successor.realized_pnl += realized_pnl;
|
||||
@@ -886,7 +910,30 @@ impl PortfolioState {
|
||||
if converted_last_price > 0.0 {
|
||||
successor.last_price = converted_last_price;
|
||||
}
|
||||
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();
|
||||
|
||||
Some(SuccessorConversionOutcome {
|
||||
@@ -982,6 +1029,25 @@ mod tests {
|
||||
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]
|
||||
fn holdings_summary_reports_entry_price_pnl_excluding_buy_commission() {
|
||||
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]
|
||||
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 date = NaiveDate::from_ymd_opt(2024, 1, 10).unwrap();
|
||||
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 target = portfolio.position("000002.SZ").expect("target position");
|
||||
assert_eq!(held.quantity, 500);
|
||||
assert_eq!(target.quantity, 400);
|
||||
assert_eq!(target.quantity, 900);
|
||||
assert_eq!(report.fill_events.len(), 2);
|
||||
assert!(
|
||||
report
|
||||
@@ -3531,7 +3531,7 @@ fn rebalance_uses_prev_close_for_open_auction_valuation() {
|
||||
.iter()
|
||||
.any(|fill| fill.symbol == "000002.SZ"
|
||||
&& fill.side == fidc_core::OrderSide::Buy
|
||||
&& fill.quantity == 400)
|
||||
&& fill.quantity == 900)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user