修正AiQuant兼容回测盘中估值口径

This commit is contained in:
boris
2026-06-13 23:32:24 +08:00
parent 9512a5dd2f
commit 4c3653e009
2 changed files with 456 additions and 150 deletions
+85 -38
View File
@@ -361,31 +361,13 @@ where
symbol: &str,
snapshot: &crate::data::DailyMarketSnapshot,
) -> f64 {
if self.execution_price_field == PriceField::Last {
let start_cursor = self
.runtime_intraday_start_time
.get()
.or(self.intraday_execution_start_time)
.map(|start_time| date.and_time(start_time));
if let Some(price) = self
.latest_known_quote_at_or_before(
data.execution_quotes_on(date, symbol),
start_cursor,
snapshot,
OrderSide::Buy,
MatchingType::NextTickLast,
false,
)
.and_then(|quote| {
(quote.last_price.is_finite() && quote.last_price > 0.0)
.then_some(quote.last_price)
})
{
return price;
}
}
let _ = (date, data, symbol);
if snapshot.close.is_finite() && snapshot.close > 0.0 {
snapshot.close
} else {
self.sizing_price(snapshot)
}
}
fn value_order_sizing_price(
&self,
@@ -3675,16 +3657,11 @@ where
requested_qty
}
fn value_buy_gross_limit(
&self,
value_budget: Option<f64>,
requested_qty: u32,
reference_price: f64,
) -> Option<f64> {
fn value_buy_gross_limit(&self, value_budget: Option<f64>) -> Option<f64> {
if !self.strict_value_budget {
return None;
}
value_budget.map(|budget| budget.max(reference_price * requested_qty as f64))
value_budget.filter(|budget| budget.is_finite() && *budget > 0.0)
}
fn process_buy(
@@ -3843,8 +3820,15 @@ where
return Ok(());
}
};
let value_gross_limit =
self.value_buy_gross_limit(value_budget, constrained_qty, self.sizing_price(snapshot));
let value_gross_limit = self.value_buy_gross_limit(value_budget);
let buy_cash_limit = if self.strict_value_budget {
value_budget
.filter(|budget| budget.is_finite() && *budget > 0.0)
.map(|budget| portfolio.cash().min(budget))
.unwrap_or_else(|| portfolio.cash())
} else {
portfolio.cash()
};
let fill = self.resolve_execution_fill(
date,
@@ -3859,7 +3843,7 @@ where
false,
execution_cursors,
None,
Some(portfolio.cash()),
Some(buy_cash_limit),
value_gross_limit,
algo_request,
limit_price,
@@ -3896,7 +3880,7 @@ where
self.execution_price_with_limit_slippage(execution_price, limit_price);
let filled_qty = self.affordable_buy_quantity(
date,
portfolio.cash(),
buy_cash_limit,
value_gross_limit,
execution_price,
constrained_qty,
@@ -3913,7 +3897,7 @@ where
partial_fill_reason = merge_partial_fill_reason(
partial_fill_reason,
self.buy_reduction_reason(
portfolio.cash(),
buy_cash_limit,
value_gross_limit,
execution_price,
constrained_qty,
@@ -5119,7 +5103,7 @@ mod tests {
}
#[test]
fn target_value_sizing_uses_intraday_tick_instead_of_daily_snapshot() {
fn target_value_valuation_uses_daily_snapshot_but_value_order_sizing_uses_intraday_tick() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
@@ -5128,7 +5112,7 @@ mod tests {
)
.with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time());
let mut snapshot = limit_test_snapshot();
snapshot.last_price = 10.0;
snapshot.last_price = 9.0;
snapshot.close = 10.0;
let mut quote = limit_test_quote(11.0, 10.99, 11.01);
quote.timestamp = date.and_hms_opt(9, 32, 58).expect("valid timestamp");
@@ -5146,7 +5130,7 @@ mod tests {
assert_eq!(
broker.target_value_valuation_price(date, &data, "000001.SZ", snapshot),
11.0
10.0
);
assert_eq!(
broker.value_sell_sizing_price(date, &data, "000001.SZ", snapshot),
@@ -5266,6 +5250,69 @@ mod tests {
assert_eq!(report.order_events[0].filled_quantity, 14_300);
}
#[test]
fn strict_value_buy_budget_includes_commission_at_execution_price() {
let date = chrono::NaiveDate::from_ymd_opt(2025, 1, 2).expect("valid date");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
PriceField::Last,
)
.with_intraday_execution_start_time(date.and_hms_opt(9, 33, 0).unwrap().time())
.with_slippage_model(SlippageModel::PriceRatio(0.002))
.with_strict_value_budget(true)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let mut snapshot = limit_test_snapshot();
snapshot.day_open = 7.14;
snapshot.open = 7.14;
snapshot.high = 7.20;
snapshot.low = 7.10;
snapshot.last_price = 7.14;
snapshot.bid1 = 7.14;
snapshot.ask1 = 7.15;
snapshot.close = 7.14;
snapshot.upper_limit = 7.85;
snapshot.lower_limit = 6.43;
let mut quote = limit_test_quote(7.14, 7.14, 7.15);
quote.timestamp = date.and_hms_opt(9, 32, 55).expect("valid timestamp");
let data = DataSet::from_components_with_actions_and_quotes(
vec![limit_test_instrument()],
vec![snapshot],
Vec::new(),
vec![limit_test_candidate(true, true)],
vec![limit_test_benchmark()],
Vec::new(),
vec![quote],
)
.expect("valid dataset");
let value_budget = 125_216.8131;
let mut portfolio = PortfolioState::new(10_000_000.0);
let mut report = BrokerExecutionReport::default();
broker
.process_value(
date,
&mut portfolio,
&data,
"000001.SZ",
value_budget,
"periodic_rebalance_buy",
&mut BTreeMap::new(),
&mut BTreeMap::new(),
&mut None,
&mut BTreeMap::new(),
&mut report,
)
.expect("value buy processed");
let fill = report.fill_events.first().expect("fill event");
assert_eq!(fill.quantity, 17_400);
assert!(fill.gross_amount + fill.commission <= value_budget + 1e-6);
assert!((fill.price - 7.15428).abs() < 1e-6);
}
#[test]
fn instantaneous_twap_without_limits_does_not_cap_quote_quantity() {
let broker = BrokerSimulator::new(ChinaAShareCostModel::default(), ChinaEquityRuleHooks)
+312 -53
View File
@@ -915,8 +915,17 @@ impl PlatformExprStrategy {
if position.quantity == 0 {
continue;
}
let mark_price = ctx
.data
let mark_price = if self.config.aiquant_transaction_cost {
ctx.data
.price(date, &position.symbol, PriceField::Last)
.or_else(|| {
ctx.data
.price_on_or_before(date, &position.symbol, PriceField::Last)
})
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(position.last_price)
} else {
ctx.data
.price(date, &position.symbol, PriceField::Close)
.or_else(|| ctx.data.price(date, &position.symbol, PriceField::Last))
.or_else(|| {
@@ -928,7 +937,8 @@ impl PlatformExprStrategy {
.price_on_or_before(date, &position.symbol, PriceField::Last)
})
.filter(|price| price.is_finite() && *price > 0.0)
.unwrap_or(position.last_price);
.unwrap_or(position.last_price)
};
if mark_price.is_finite() && mark_price > 0.0 {
total += mark_price * position.quantity as f64;
}
@@ -1279,6 +1289,108 @@ impl PlatformExprStrategy {
Some(fill.quantity)
}
fn project_target_value(
&self,
ctx: &StrategyContext<'_>,
projected: &mut PortfolioState,
date: NaiveDate,
symbol: &str,
target_value: f64,
execution_state: &mut ProjectedExecutionState,
) -> Option<u32> {
let current_qty = projected.position(symbol)?.quantity;
if current_qty == 0 {
return None;
}
if target_value <= f64::EPSILON {
return self.project_target_zero(ctx, projected, date, symbol, execution_state);
}
let market = ctx.data.market(date, symbol)?;
let valuation_price = if market.close.is_finite() && market.close > 0.0 {
market.close
} else {
self.projected_execution_price(market, OrderSide::Buy)
};
if !valuation_price.is_finite() || valuation_price <= 0.0 {
return None;
}
let current_value = valuation_price * current_qty as f64;
let cash_delta = target_value.max(0.0) - current_value;
if cash_delta.abs() <= f64::EPSILON {
return None;
}
if cash_delta > 0.0 {
return Some(self.project_order_value(
ctx,
projected,
date,
symbol,
cash_delta,
execution_state,
));
}
if !self.can_sell_position(ctx, date, symbol) {
return None;
}
let sizing_price = self
.aiquant_scheduled_quote(ctx, date, symbol)
.and_then(|quote| {
if quote.last_price.is_finite() && quote.last_price > 0.0 {
Some(quote.last_price)
} else {
quote.sell_price()
}
})
.unwrap_or_else(|| self.projected_execution_price(market, OrderSide::Sell));
if !sizing_price.is_finite() || sizing_price <= 0.0 {
return None;
}
let round_lot = self.projected_round_lot(ctx, symbol);
let minimum_order_quantity = self.projected_minimum_order_quantity(ctx, symbol);
let order_step_size = self.projected_order_step_size(ctx, symbol);
let requested_qty = self
.round_lot_quantity(
((cash_delta.abs()) / sizing_price).floor() as u32,
minimum_order_quantity,
order_step_size,
)
.min(current_qty);
if requested_qty == 0 {
return None;
}
let fill = self.projected_select_execution_fill(
ctx,
date,
symbol,
OrderSide::Sell,
requested_qty,
round_lot,
minimum_order_quantity,
order_step_size,
requested_qty >= current_qty,
None,
None,
execution_state,
)?;
let gross_amount = fill.price * fill.quantity as f64;
let sell_cost = self.sell_cost(date, gross_amount);
projected
.position_mut(symbol)
.sell(fill.quantity, fill.price)
.ok()?;
projected.apply_cash_delta(gross_amount - sell_cost);
*execution_state
.intraday_turnover
.entry(symbol.to_string())
.or_default() += fill.quantity;
execution_state
.execution_cursors
.insert(symbol.to_string(), fill.next_cursor);
projected.prune_flat_positions();
Some(fill.quantity)
}
fn projected_position_count_excluding(
projected: &PortfolioState,
excluded_symbols: &BTreeSet<String>,
@@ -1317,6 +1429,17 @@ impl PlatformExprStrategy {
let Some(market) = ctx.data.market(date, symbol) else {
return position.market_value();
};
if self.config.aiquant_transaction_cost {
if let Some(price) = self
.aiquant_scheduled_quote(ctx, date, symbol)
.and_then(|quote| {
(quote.last_price.is_finite() && quote.last_price > 0.0)
.then_some(quote.last_price)
})
{
return position.quantity as f64 * price;
}
}
let execution_price = self.projected_execution_price(market, OrderSide::Buy);
if execution_price.is_finite() && execution_price > 0.0 {
position.quantity as f64 * execution_price
@@ -1325,24 +1448,6 @@ impl PlatformExprStrategy {
}
}
fn projected_position_value_excluding(
&self,
ctx: &StrategyContext<'_>,
projected: &PortfolioState,
date: NaiveDate,
excluded_symbols: &BTreeSet<String>,
) -> f64 {
projected
.positions()
.keys()
.filter(|symbol| !excluded_symbols.contains(*symbol))
.map(|symbol| {
self.projected_position_value_at_execution_price(ctx, projected, date, symbol)
})
.filter(|value| value.is_finite() && *value > 0.0)
.sum()
}
fn remaining_buy_cash_per_slot(
&self,
ctx: &StrategyContext<'_>,
@@ -1350,20 +1455,27 @@ impl PlatformExprStrategy {
date: NaiveDate,
target_budget: f64,
selection_limit: usize,
excluded_symbols: &BTreeSet<String>,
working_symbols: &BTreeSet<String>,
value_symbols: &BTreeSet<String>,
pending_buy_value: f64,
) -> f64 {
if selection_limit == 0 || !target_budget.is_finite() || target_budget <= 0.0 {
return 0.0;
}
let active_count = Self::projected_position_count_excluding(projected, excluded_symbols);
if active_count >= selection_limit {
if working_symbols.len() >= selection_limit {
return 0.0;
}
let slots_remaining = selection_limit.saturating_sub(active_count).max(1);
let active_value =
self.projected_position_value_excluding(ctx, projected, date, excluded_symbols);
let remaining_budget = (target_budget - active_value).max(0.0);
remaining_budget / slots_remaining as f64
let slots_remaining = selection_limit.saturating_sub(working_symbols.len()).max(1);
let active_value = working_symbols
.iter()
.filter(|symbol| value_symbols.contains(*symbol))
.map(|symbol| {
self.projected_position_value_at_execution_price(ctx, projected, date, symbol)
})
.filter(|value| value.is_finite() && *value > 0.0)
.sum::<f64>();
let working_value = active_value + pending_buy_value.max(0.0);
(target_budget - working_value).max(0.0) / slots_remaining as f64
}
fn project_order_value(
@@ -1413,14 +1525,19 @@ impl PlatformExprStrategy {
);
let mut quantity = snapshot_requested_qty;
let gross_limit = if self.config.strict_value_budget {
Some(order_value.max(execution_price * quantity as f64))
Some(order_value)
} else {
None
};
let cash_limit = if self.config.strict_value_budget {
projected.cash().min(order_value)
} else {
projected.cash()
};
while quantity > 0 {
let gross_amount = execution_price * quantity as f64;
if gross_limit.map_or(true, |limit| gross_amount <= limit + 1e-6)
&& gross_amount + self.buy_commission(gross_amount) <= projected.cash() + 1e-6
&& gross_amount + self.buy_commission(gross_amount) <= cash_limit + 1e-6
{
break;
}
@@ -1441,7 +1558,7 @@ impl PlatformExprStrategy {
minimum_order_quantity,
order_step_size,
false,
Some(projected.cash()),
Some(cash_limit),
gross_limit,
execution_state,
)
@@ -1462,7 +1579,7 @@ impl PlatformExprStrategy {
};
let gross_amount = fill.price * fill.quantity as f64;
let cash_out = gross_amount + self.buy_commission(gross_amount);
if cash_out > projected.cash() + 1e-6 {
if cash_out > cash_limit + 1e-6 {
return 0;
}
projected.apply_cash_delta(-cash_out);
@@ -5926,7 +6043,16 @@ impl Strategy for PlatformExprStrategy {
target_value,
reason: "daily_position_target_adjust".to_string(),
});
self.project_target_value(
ctx,
&mut projected,
execution_date,
&position.symbol,
target_value,
&mut projected_execution_state,
);
}
aiquant_available_cash = projected.cash();
}
}
@@ -5992,39 +6118,57 @@ impl Strategy for PlatformExprStrategy {
.insert(position.symbol.clone());
}
}
}
if self.config.daily_top_up_enabled
&& self.config.rotation_enabled
&& trading_ratio > 0.0
&& {
let excluded_symbols = Self::pending_exit_exclusion_symbols(
&unresolved_stop_loss_symbols,
&exit_symbols,
&delayed_sold_symbols,
);
Self::projected_position_count_excluding(&projected, &excluded_symbols)
< selection_limit
}
&& selection_limit > 0
{
let target_budget = aiquant_total_value * trading_ratio;
let mut working_symbols = projected
.positions()
.keys()
.filter(|symbol| {
!same_day_sold_symbols.contains(*symbol)
&& !exit_symbols.contains(*symbol)
&& !delayed_sold_symbols.contains(*symbol)
&& !unresolved_stop_loss_symbols.contains(*symbol)
})
.cloned()
.collect::<BTreeSet<_>>();
let value_symbols = working_symbols.clone();
let mut pending_buy_value = 0.0_f64;
loop {
let excluded_symbols = Self::pending_exit_exclusion_symbols(
&unresolved_stop_loss_symbols,
&exit_symbols,
&delayed_sold_symbols,
);
if Self::projected_position_count_excluding(&projected, &excluded_symbols)
>= selection_limit
{
break;
}
let slot_buy_cash = self.remaining_buy_cash_per_slot(
ctx,
&projected,
execution_date,
target_budget,
selection_limit,
&excluded_symbols,
&working_symbols,
&value_symbols,
pending_buy_value,
);
let available_buy_cash = slot_buy_cash.min(aiquant_available_cash);
if slot_buy_cash > 0.0 && available_buy_cash >= slot_buy_cash * 0.5 {
if slot_buy_cash <= 0.0 || available_buy_cash < slot_buy_cash * 0.5 {
break;
}
let mut attempted_any = false;
let mut filled_any = false;
for symbol in &stock_list {
if symbol == &position.symbol
|| projected.positions().contains_key(symbol)
if projected.positions().contains_key(symbol)
|| same_day_sold_symbols.contains(symbol)
|| exit_symbols.contains(symbol)
|| delayed_sold_symbols.contains(symbol)
@@ -6055,6 +6199,8 @@ impl Strategy for PlatformExprStrategy {
value: buy_cash,
reason: "daily_top_up_buy".to_string(),
});
intraday_attempted_buys.insert(symbol.clone());
attempted_any = true;
let cash_before_buy = projected.cash();
let filled_qty = self.project_order_value(
ctx,
@@ -6067,11 +6213,15 @@ impl Strategy for PlatformExprStrategy {
if filled_qty > 0 {
let spent = (cash_before_buy - projected.cash()).max(0.0);
aiquant_available_cash = (aiquant_available_cash - spent).max(0.0);
intraday_attempted_buys.insert(symbol.clone());
}
working_symbols.insert(symbol.clone());
pending_buy_value += available_buy_cash;
filled_any = true;
break;
}
}
if !attempted_any || !filled_any {
break;
}
}
}
@@ -6112,6 +6262,19 @@ impl Strategy for PlatformExprStrategy {
}
let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64;
let target_budget = aiquant_total_value * trading_ratio;
let mut rebalance_working_symbols = projected
.positions()
.keys()
.filter(|symbol| {
!same_day_sold_symbols.contains(*symbol)
&& !exit_symbols.contains(*symbol)
&& !delayed_sold_symbols.contains(*symbol)
&& !unresolved_stop_loss_symbols.contains(*symbol)
})
.cloned()
.collect::<BTreeSet<_>>();
let rebalance_value_symbols = rebalance_working_symbols.clone();
let mut rebalance_pending_buy_value = 0.0_f64;
for symbol in stock_list.iter().take(selection_limit) {
let decision_stock = self.stock_state_with_factor_date(
ctx,
@@ -6191,11 +6354,9 @@ impl Strategy for PlatformExprStrategy {
execution_date,
target_budget,
selection_limit,
&Self::pending_exit_exclusion_symbols(
&unresolved_stop_loss_symbols,
&exit_symbols,
&delayed_sold_symbols,
),
&rebalance_working_symbols,
&rebalance_value_symbols,
rebalance_pending_buy_value,
);
let target_cash = slot_buy_cash * stock_scale;
let buy_cash = target_cash.min(aiquant_available_cash);
@@ -6232,6 +6393,8 @@ impl Strategy for PlatformExprStrategy {
if filled_qty > 0 {
let spent = (cash_before_buy - projected.cash()).max(0.0);
aiquant_available_cash = (aiquant_available_cash - spent).max(0.0);
rebalance_working_symbols.insert(symbol.clone());
rebalance_pending_buy_value += buy_cash;
}
}
}
@@ -7986,6 +8149,102 @@ mod tests {
assert_eq!(quote.last_price, 19.8);
}
#[test]
fn platform_aiquant_marked_total_uses_intraday_last_not_close() {
let date = d(2025, 1, 6);
let symbol = "603779.SH";
let data = DataSet::from_components_with_actions_and_quotes(
vec![Instrument {
symbol: symbol.to_string(),
name: symbol.to_string(),
board: "SH".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("2025-01-06 10:40:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 22.0,
low: 9.8,
close: 21.5,
last_price: 10.0,
bid1: 9.99,
ask1: 10.0,
prev_close: 10.0,
volume: 1_000_000,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 22.0,
lower_limit: 8.0,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: true,
allow_sell: true,
is_kcb: false,
is_one_yuan: false,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1002.0,
prev_close: 998.0,
volume: 1_000_000,
}],
Vec::new(),
Vec::new(),
)
.expect("dataset");
let subscriptions = BTreeSet::new();
let mut portfolio = PortfolioState::new(1_000.0);
portfolio.position_mut(symbol).buy(date, 100, 9.0);
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 1,
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.aiquant_transaction_cost = true;
let strategy = PlatformExprStrategy::new(cfg);
assert_eq!(strategy.marked_total_value(&ctx, date), 2_000.0);
}
#[test]
fn platform_aiquant_intraday_selection_filters_limits_on_execution_date() {
let factor_date = d(2023, 11, 10);