修复回测撮合与AiQuant兼容语义

This commit is contained in:
boris
2026-05-18 23:06:47 +08:00
parent 3f383c1a88
commit 6e54471e57
4 changed files with 793 additions and 95 deletions

View File

@@ -452,11 +452,11 @@ struct SymbolPriceSeries {
closes: Vec<f64>,
prev_closes: Vec<f64>,
last_prices: Vec<f64>,
paused: Vec<bool>,
open_prefix: Vec<f64>,
close_prefix: Vec<f64>,
prev_close_prefix: Vec<f64>,
last_prefix: Vec<f64>,
volume_prefix: Vec<f64>,
}
impl SymbolPriceSeries {
@@ -469,15 +469,11 @@ impl SymbolPriceSeries {
let closes = sorted.iter().map(|row| row.close).collect::<Vec<_>>();
let prev_closes = sorted.iter().map(|row| row.prev_close).collect::<Vec<_>>();
let last_prices = sorted.iter().map(|row| row.last_price).collect::<Vec<_>>();
let volumes = sorted
.iter()
.map(|row| row.volume as f64)
.collect::<Vec<_>>();
let paused = sorted.iter().map(|row| row.paused).collect::<Vec<_>>();
let open_prefix = prefix_sums(&opens);
let close_prefix = prefix_sums(&closes);
let prev_close_prefix = prefix_sums(&prev_closes);
let last_prefix = prefix_sums(&last_prices);
let volume_prefix = prefix_sums(&volumes);
Self {
snapshots: sorted,
@@ -486,11 +482,11 @@ impl SymbolPriceSeries {
closes,
prev_closes,
last_prices,
paused,
open_prefix,
close_prefix,
prev_close_prefix,
last_prefix,
volume_prefix,
}
}
@@ -587,15 +583,11 @@ impl SymbolPriceSeries {
}
fn decision_volume_moving_average(&self, date: NaiveDate, lookback: usize) -> Option<f64> {
if lookback == 0 {
let values = self.decision_volume_values(date, lookback)?;
if values.len() < lookback {
return None;
}
let end = self.previous_completed_end_index(date)?;
if end < lookback {
return None;
}
let start = end - lookback;
let sum = self.volume_prefix[end] - self.volume_prefix[start];
let sum = values.iter().sum::<f64>();
Some(sum / lookback as f64)
}
@@ -604,11 +596,11 @@ impl SymbolPriceSeries {
return None;
}
let end = self.end_index(date)?;
if end < lookback {
let values = self.trailing_unpaused_volumes(end, lookback)?;
if values.len() < lookback {
return None;
}
let start = end - lookback;
let sum = self.volume_prefix[end] - self.volume_prefix[start];
let sum = values.iter().sum::<f64>();
Some(sum / lookback as f64)
}
@@ -617,16 +609,33 @@ impl SymbolPriceSeries {
return None;
}
let end = self.previous_completed_end_index(date)?;
if end < lookback {
let values = self.trailing_unpaused_volumes(end, lookback)?;
if values.len() < lookback {
return None;
}
let start = end - lookback;
Some(
self.snapshots[start..end]
.iter()
.map(|snapshot| snapshot.volume as f64)
.collect(),
)
Some(values)
}
fn trailing_unpaused_volumes(&self, end: usize, lookback: usize) -> Option<Vec<f64>> {
if lookback == 0 || end == 0 {
return None;
}
let mut values = Vec::with_capacity(lookback);
for idx in (0..end).rev() {
if self.paused.get(idx).copied().unwrap_or(false) {
continue;
}
values.push(self.snapshots[idx].volume as f64);
if values.len() == lookback {
break;
}
}
if values.len() < lookback {
None
} else {
values.reverse();
Some(values)
}
}
fn end_index(&self, date: NaiveDate) -> Option<usize> {
@@ -3385,6 +3394,33 @@ mod tests {
);
}
#[test]
fn decision_volume_average_skips_paused_days_before_counting_window() {
let mut paused = market_row("2025-01-03", 11.0, 0);
paused.paused = true;
let series = SymbolPriceSeries::new(&[
market_row("2025-01-02", 10.0, 100),
paused,
market_row("2025-01-06", 12.0, 300),
market_row("2025-01-07", 13.0, 10_000),
]);
assert_eq!(
series.decision_volume_moving_average(
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
2
),
Some(200.0)
);
assert_eq!(
series.decision_volume_moving_average(
NaiveDate::parse_from_str("2025-01-07", "%Y-%m-%d").unwrap(),
3
),
None
);
}
#[test]
fn reads_mixed_numeric_and_text_extra_factors_from_quoted_csv_json() {
let path = temp_csv_path("mixed_factor_maps");

View File

@@ -1393,6 +1393,7 @@ impl PlatformExprStrategy {
symbol: &str,
) -> Result<StockExpressionState, BacktestError> {
let market = ctx.data.require_market(date, symbol)?;
let feature_market = ctx.data.market(factor_date, symbol).unwrap_or(market);
let factor = ctx.data.require_factor(factor_date, symbol)?;
let candidate = ctx.data.require_candidate(date, symbol)?;
let instrument = ctx.data.instrument(symbol);
@@ -1440,22 +1441,14 @@ impl PlatformExprStrategy {
.data
.market_decision_volume_moving_average(date, symbol, 60)
.unwrap_or(f64::NAN);
let touched_upper_limit = factor
.extra_factors
.get("touched_upper_limit")
.or_else(|| factor.extra_factors.get("hit_upper_limit"))
.or_else(|| factor.extra_factors.get("limit_up_touched"))
.copied()
.unwrap_or_default()
>= 0.5;
let touched_lower_limit = factor
.extra_factors
.get("touched_lower_limit")
.or_else(|| factor.extra_factors.get("hit_lower_limit"))
.or_else(|| factor.extra_factors.get("limit_down_touched"))
.copied()
.unwrap_or_default()
>= 0.5;
let touched_upper_limit = !market.paused
&& (market.is_at_upper_limit_price(market.close)
|| market.is_at_upper_limit_price(market.open)
|| market.is_at_upper_limit_price(market.day_open));
let touched_lower_limit = !market.paused
&& (market.is_at_lower_limit_price(market.close)
|| market.is_at_lower_limit_price(market.open)
|| market.is_at_lower_limit_price(market.day_open));
let amount = factor.extra_factors.get("amount").copied().unwrap_or(0.0);
Ok(StockExpressionState {
@@ -1463,16 +1456,16 @@ impl PlatformExprStrategy {
market_cap: factor.market_cap_bn,
free_float_cap: factor.free_float_cap_bn,
pe_ttm: factor.pe_ttm,
volume: market.volume as i64,
volume: feature_market.volume as i64,
tick_volume: market.tick_volume as i64,
bid1_volume: market.bid1_volume as i64,
ask1_volume: market.ask1_volume as i64,
turnover_ratio: factor.turnover_ratio.unwrap_or(0.0),
effective_turnover_ratio: factor.effective_turnover_ratio.unwrap_or(0.0),
open: market.day_open,
high: market.high,
low: market.low,
close: market.close,
open: feature_market.day_open,
high: feature_market.high,
low: feature_market.low,
close: feature_market.close,
last: market.last_price,
prev_close: market.prev_close,
amount,
@@ -4563,11 +4556,6 @@ impl PlatformExprStrategy {
{
return Ok(Some("upper_limit".to_string()));
}
if market.is_at_lower_limit_price(market.day_open)
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
{
return Ok(Some("lower_limit".to_string()));
}
Ok(None)
}
@@ -4670,7 +4658,11 @@ impl PlatformExprStrategy {
{
return Ok((false, false));
}
if position.quantity == 0 || position.average_cost <= 0.0 {
let avg_price = position
.average_entry_price()
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(position.average_cost);
if position.quantity == 0 || avg_price <= 0.0 {
return Ok((false, false));
}
let stock = match self.stock_state(ctx, signal_date, symbol) {
@@ -4681,8 +4673,8 @@ impl PlatformExprStrategy {
Err(error) => return Err(error),
};
let current_price = stock.last;
let holding_return = if position.average_cost > 0.0 {
current_price / position.average_cost - 1.0
let holding_return = if avg_price > 0.0 {
current_price / avg_price - 1.0
} else {
0.0
};
@@ -4695,7 +4687,7 @@ impl PlatformExprStrategy {
let position_state = PositionExpressionState {
order_book_id: position.symbol.clone(),
avg_cost: position.average_cost,
avg_price: position.average_cost,
avg_price,
current_price,
prev_close: stock.prev_close,
holding_return,
@@ -4735,9 +4727,9 @@ impl PlatformExprStrategy {
if let Some(boolean) = stop_result.clone().try_cast::<bool>() {
boolean
} else if let Some(multiplier) = stop_result.clone().try_cast::<f64>() {
current_price <= position.average_cost * multiplier
current_price <= avg_price * multiplier
} else if let Some(multiplier) = stop_result.try_cast::<i64>() {
current_price <= position.average_cost * multiplier as f64
current_price <= avg_price * multiplier as f64
} else {
false
}
@@ -4755,15 +4747,9 @@ impl PlatformExprStrategy {
if let Some(boolean) = take_result.clone().try_cast::<bool>() {
boolean
} else if let Some(multiplier) = take_result.clone().try_cast::<f64>() {
!ctx.data
.require_market(signal_date, symbol)?
.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier
current_price / avg_price > multiplier
} else if let Some(multiplier) = take_result.try_cast::<i64>() {
!ctx.data
.require_market(signal_date, symbol)?
.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > multiplier as f64
current_price / avg_price > multiplier as f64
} else {
false
}
@@ -4891,6 +4877,13 @@ impl Strategy for PlatformExprStrategy {
let mut intraday_attempted_buys = BTreeSet::<String>::new();
let mut delayed_sold_symbols = BTreeSet::<String>::new();
let mut unresolved_stop_loss_symbols = BTreeSet::<String>::new();
let initial_position_symbols = ctx
.portfolio
.positions()
.values()
.filter(|position| position.quantity > 0)
.map(|position| position.symbol.clone())
.collect::<BTreeSet<_>>();
let mut pending_symbols = self
.pending_highlimit_holdings
@@ -4900,8 +4893,12 @@ impl Strategy for PlatformExprStrategy {
let take_profit_multiplier = self.config.take_profit_expr.trim().parse::<f64>().ok();
if let Some(multiplier) = take_profit_multiplier {
for position in ctx.portfolio.positions().values() {
let avg_price = position
.average_entry_price()
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(position.average_cost);
if position.quantity == 0
|| position.average_cost <= 0.0
|| avg_price <= 0.0
|| pending_symbols.contains(&position.symbol)
{
continue;
@@ -4928,7 +4925,7 @@ impl Strategy for PlatformExprStrategy {
cursor = ctx.data.market_before(snapshot.date, &position.symbol);
}
if (closed_at_upper_limit || closed_at_day_high)
&& previous.close / position.average_cost > multiplier
&& previous.close / avg_price > multiplier
&& recent_pause_before_previous
{
pending_symbols.insert(position.symbol.clone());
@@ -4994,7 +4991,11 @@ impl Strategy for PlatformExprStrategy {
if delayed_sold_symbols.contains(&position.symbol) {
continue;
}
if position.quantity == 0 || position.average_cost <= 0.0 {
let avg_price = position
.average_entry_price()
.filter(|value| value.is_finite() && *value > 0.0)
.unwrap_or(position.average_cost);
if position.quantity == 0 || avg_price <= 0.0 {
continue;
}
let (stop_hit, profit_hit) =
@@ -5136,8 +5137,15 @@ impl Strategy for PlatformExprStrategy {
}
}
aiquant_available_cash = projected.cash();
let fixed_buy_cash = aiquant_total_value * trading_ratio / selection_limit as f64;
let max_periodic_buys =
selection_limit.saturating_sub(initial_position_symbols.len().min(selection_limit));
let mut periodic_buy_count = 0_usize;
for symbol in stock_list.iter().take(selection_limit) {
if periodic_buy_count >= max_periodic_buys {
break;
}
if Self::projected_position_count_excluding(
&projected,
&unresolved_stop_loss_symbols,
@@ -5151,16 +5159,24 @@ impl Strategy for PlatformExprStrategy {
{
continue;
}
let available_rebalance_cash = fixed_buy_cash.min(aiquant_available_cash);
if available_rebalance_cash < fixed_buy_cash * 0.5 {
break;
}
let slots_remaining = selection_limit
.saturating_sub(Self::projected_position_count_excluding(
&projected,
&unresolved_stop_loss_symbols,
))
.max(1);
let decision_stock = self.stock_state_with_factor_date(
ctx,
decision_date,
selection_factor_date,
symbol,
)?;
let stock_scale = self.buy_scale(ctx, &day, &decision_stock)?;
let buy_cash = (fixed_buy_cash * stock_scale)
.min(aiquant_available_cash / slots_remaining as f64);
if buy_cash <= 0.0 {
break;
}
let execution_stock = self.stock_state(ctx, execution_date, symbol)?;
if self
.buy_rejection_reason(ctx, execution_date, symbol, &execution_stock)?
@@ -5171,11 +5187,6 @@ impl Strategy for PlatformExprStrategy {
if !self.stock_passes_expr(ctx, &day, &decision_stock)? {
continue;
}
let buy_cash =
available_rebalance_cash * self.buy_scale(ctx, &day, &decision_stock)?;
if buy_cash <= 0.0 {
continue;
}
order_intents.push(OrderIntent::Value {
symbol: symbol.clone(),
value: buy_cash,
@@ -5193,16 +5204,13 @@ 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);
periodic_buy_count += 1;
}
}
}
if self.config.rotation_enabled && self.config.rebalance_schedule.is_none() {
if periodic_rebalance {
self.rebalance_day_counter = if projected.positions().is_empty() {
0
} else {
1
};
self.rebalance_day_counter = 1;
} else {
self.rebalance_day_counter = self.rebalance_day_counter.saturating_add(1);
}
@@ -5436,6 +5444,564 @@ mod tests {
assert!((marked - 1_293.0).abs() < 1e-6, "{marked}");
}
#[test]
fn platform_take_profit_sells_position_at_upper_limit() {
let prev_date = d(2025, 2, 6);
let date = d(2025, 2, 7);
let symbol = "001368.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("2025-02-07 10:18:00".to_string()),
day_open: 23.11,
open: 23.11,
high: 23.99,
low: 23.11,
close: 23.99,
last_price: 23.99,
bid1: 23.99,
ask1: 23.99,
prev_close: 21.81,
volume: 100_161,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 23.99,
lower_limit: 19.63,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 24.90,
free_float_cap_bn: 6.10,
pe_ttm: 8.0,
turnover_ratio: Some(35.77),
effective_turnover_ratio: Some(35.77),
extra_factors: BTreeMap::new(),
}],
vec![CandidateEligibility {
date,
symbol: symbol.to_string(),
is_st: false,
is_new_listing: false,
is_paused: false,
allow_buy: false,
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,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut(symbol).buy(prev_date, 6_200, 19.86);
let subscriptions = BTreeSet::new();
let ctx = StrategyContext {
execution_date: date,
decision_date: date,
decision_index: 20,
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.signal_symbol = symbol.to_string();
cfg.stop_loss_expr.clear();
cfg.take_profit_expr = "1.07".to_string();
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(
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 == "take_profit_exit"
)),
"{:?}",
decision.order_intents
);
}
#[test]
fn platform_take_profit_uses_strategy_entry_price_not_fee_cost_basis() {
let prev_date = d(2025, 3, 13);
let date = d(2025, 3, 14);
let symbol = "600561.SH";
let data = DataSet::from_components(
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-03-14 10:18:00".to_string()),
day_open: 5.96,
open: 5.96,
high: 6.08,
low: 5.95,
close: 6.06,
last_price: 6.06,
bid1: 6.05,
ask1: 6.06,
prev_close: 5.86,
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: 6.45,
lower_limit: 5.27,
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_new_listing: false,
is_paused: false,
allow_buy: false,
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,
}],
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_000_000.0);
portfolio.position_mut(symbol).buy(prev_date, 22_200, 5.66);
portfolio
.position_mut(symbol)
.record_buy_trade_cost(22_200, 100.0);
assert!(portfolio.position(symbol).unwrap().average_cost > 5.66);
assert!(6.06 / portfolio.position(symbol).unwrap().average_cost <= 1.07);
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.signal_symbol = symbol.to_string();
cfg.stop_loss_expr.clear();
cfg.take_profit_expr = "1.07".to_string();
let mut strategy = PlatformExprStrategy::new(cfg);
let decision = strategy.on_day(&ctx).expect("platform decision");
assert!(
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 == "take_profit_exit"
)),
"{:?}",
decision.order_intents
);
}
#[test]
fn platform_stock_state_uses_current_day_limit_status_for_buy_scale() {
let date = d(2025, 3, 14);
let symbol = "603813.SH";
let data = DataSet::from_components(
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-03-14 10:18:00".to_string()),
day_open: 14.82,
open: 14.82,
high: 15.98,
low: 14.80,
close: 15.98,
last_price: 15.05,
bid1: 15.04,
ask1: 15.05,
prev_close: 14.53,
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: 15.98,
lower_limit: 13.08,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 8.0,
free_float_cap_bn: 4.0,
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_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,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
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 strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
let stock = strategy
.stock_state_with_factor_date(&ctx, date, date, symbol)
.expect("stock state");
assert!(stock.touched_upper_limit);
}
#[test]
fn platform_stock_state_uses_factor_date_for_selection_market_fields() {
let factor_date = d(2025, 4, 3);
let date = d(2025, 4, 7);
let symbol = "003008.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: factor_date,
symbol: symbol.to_string(),
timestamp: Some("2025-04-03 15:00:00".to_string()),
day_open: 9.8,
open: 9.8,
high: 10.2,
low: 9.6,
close: 9.9,
last_price: 9.9,
bid1: 9.9,
ask1: 9.9,
prev_close: 9.7,
volume: 12_300,
tick_volume: 0,
bid1_volume: 0,
ask1_volume: 0,
trading_phase: Some("close".to_string()),
paused: false,
upper_limit: 10.67,
lower_limit: 8.73,
price_tick: 0.01,
},
DailyMarketSnapshot {
date,
symbol: symbol.to_string(),
timestamp: Some("2025-04-07 10:18:00".to_string()),
day_open: 19.0,
open: 19.0,
high: 20.5,
low: 18.8,
close: 20.0,
last_price: 19.06,
bid1: 19.05,
ask1: 19.06,
prev_close: 9.9,
volume: 45_600,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 20.89,
lower_limit: 17.11,
price_tick: 0.01,
},
],
vec![
DailyFactorSnapshot {
date: factor_date,
symbol: symbol.to_string(),
market_cap_bn: 8.0,
free_float_cap_bn: 4.0,
pe_ttm: 8.0,
turnover_ratio: Some(1.2),
effective_turnover_ratio: Some(1.2),
extra_factors: BTreeMap::from([("touched_upper_limit".to_string(), 1.0)]),
},
DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 9.0,
free_float_cap_bn: 5.0,
pe_ttm: 8.0,
turnover_ratio: Some(2.4),
effective_turnover_ratio: Some(2.4),
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,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
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 strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
let stock = strategy
.stock_state_with_factor_date(&ctx, date, factor_date, symbol)
.expect("stock state");
assert_eq!(stock.close, 9.9);
assert_eq!(stock.volume, 12_300);
assert_eq!(stock.last, 19.06);
assert_eq!(stock.market_cap, 8.0);
assert!(!stock.touched_upper_limit);
}
#[test]
fn platform_buy_rejection_allows_lower_limit_buy_when_ask_is_available() {
let date = d(2025, 4, 7);
let symbol = "003008.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("2025-04-07 10:18:00".to_string()),
day_open: 20.0,
open: 20.0,
high: 20.3,
low: 19.05,
close: 19.05,
last_price: 19.06,
bid1: 19.05,
ask1: 19.06,
prev_close: 21.17,
volume: 16_173,
tick_volume: 1_000,
bid1_volume: 1_000,
ask1_volume: 1_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 23.29,
lower_limit: 19.05,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: symbol.to_string(),
market_cap_bn: 8.0,
free_float_cap_bn: 4.0,
pe_ttm: 8.0,
turnover_ratio: Some(2.0),
effective_turnover_ratio: Some(2.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,
}],
)
.expect("dataset");
let portfolio = PortfolioState::new(1_000_000.0);
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 strategy = PlatformExprStrategy::new(PlatformExprStrategyConfig::microcap_rotation());
let stock = strategy
.stock_state(&ctx, date, symbol)
.expect("stock state");
let rejection = strategy
.buy_rejection_reason(&ctx, date, symbol, &stock)
.expect("rejection");
assert_eq!(rejection, None);
}
fn sample_calendar() -> TradingCalendar {
TradingCalendar::new(vec![
d(2025, 1, 30),
@@ -6627,7 +7193,7 @@ mod tests {
last_price: 10.0 + index as f64,
bid1: 10.0 + index as f64,
ask1: 10.0 + index as f64,
prev_close: 9.8 + index as f64,
prev_close: if index == 0 { 9.8 } else { 9.0 + index as f64 },
volume: 1000 + index as u64 * 100,
tick_volume: 0,
bid1_volume: 0,
@@ -7015,6 +7581,76 @@ mod tests {
"{:?}",
third.diagnostics
);
let mut no_retry_cfg = PlatformExprStrategyConfig::microcap_rotation();
no_retry_cfg.signal_symbol = "000001.SZ".to_string();
no_retry_cfg.refresh_rate = 15;
no_retry_cfg.max_positions = 2;
no_retry_cfg.benchmark_short_ma_days = 1;
no_retry_cfg.benchmark_long_ma_days = 1;
no_retry_cfg.market_cap_lower_expr = "0".to_string();
no_retry_cfg.market_cap_upper_expr = "100".to_string();
no_retry_cfg.selection_limit_expr = "2".to_string();
no_retry_cfg.stock_filter_expr = "close < 0".to_string();
no_retry_cfg.retry_empty_rebalance = false;
let mut no_retry_strategy = PlatformExprStrategy::new(no_retry_cfg);
let empty_portfolio = PortfolioState::new(30_000.0);
let first_empty_ctx = StrategyContext {
execution_date: dates[0],
decision_date: dates[0],
decision_index: 20,
data: &data,
portfolio: &empty_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_empty = no_retry_strategy
.on_day(&first_empty_ctx)
.expect("first empty decision");
assert!(
first_empty
.diagnostics
.iter()
.any(|item| item.contains("periodic_rebalance=true")),
"{:?}",
first_empty.diagnostics
);
assert_eq!(no_retry_strategy.rebalance_day_counter, 1);
let second_empty_ctx = StrategyContext {
execution_date: dates[1],
decision_date: dates[1],
decision_index: 21,
data: &data,
portfolio: &empty_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_empty = no_retry_strategy
.on_day(&second_empty_ctx)
.expect("second empty decision");
assert!(
second_empty
.diagnostics
.iter()
.any(|item| item.contains("periodic_rebalance=false")),
"{:?}",
second_empty.diagnostics
);
}
#[test]
@@ -7492,7 +8128,7 @@ mod tests {
}
#[test]
fn platform_periodic_rebalance_does_not_use_same_day_sell_cash_for_later_buys() {
fn platform_periodic_rebalance_uses_same_day_sell_cash_for_later_buys() {
let prev_date = d(2025, 5, 13);
let date = d(2025, 5, 14);
let symbols = ["000001.SZ", "000002.SZ", "000003.SZ"];
@@ -7595,7 +8231,7 @@ mod tests {
)
.expect("dataset");
let mut portfolio = PortfolioState::new(1_200.0);
let mut portfolio = PortfolioState::new(100.0);
portfolio
.position_mut("000003.SZ")
.buy(prev_date, 100, 10.0);
@@ -7643,7 +8279,7 @@ mod tests {
})
.count();
assert_eq!(periodic_buys, 1, "{:?}", decision.order_intents);
assert_eq!(periodic_buys, 2, "{:?}", decision.order_intents);
assert!(
decision.order_intents.iter().any(|intent| matches!(
intent,

View File

@@ -9,6 +9,7 @@ use crate::data::{DataSet, DataSetError, PriceField};
pub struct PositionLot {
pub acquired_date: NaiveDate,
pub quantity: u32,
pub entry_price: f64,
pub price: f64,
}
@@ -72,6 +73,7 @@ impl Position {
self.lots.push(PositionLot {
acquired_date: date,
quantity,
entry_price: price,
price,
});
self.quantity += quantity;
@@ -230,13 +232,28 @@ impl Position {
}
pub fn holding_return(&self, price: f64) -> Option<f64> {
if self.quantity == 0 || self.average_cost <= 0.0 {
let Some(avg_price) = self.average_entry_price() else {
return None;
};
if avg_price <= 0.0 {
None
} else {
Some((price / self.average_cost) - 1.0)
Some((price / avg_price) - 1.0)
}
}
pub fn average_entry_price(&self) -> Option<f64> {
if self.quantity == 0 {
return None;
}
let total = self
.lots
.iter()
.map(|lot| lot.entry_price * lot.quantity as f64)
.sum::<f64>();
Some(total / self.quantity as f64)
}
fn recalculate_average_cost(&mut self) {
if self.quantity == 0 {
self.average_cost = 0.0;
@@ -258,6 +275,7 @@ impl Position {
}
for lot in &mut self.lots {
lot.entry_price -= dividend_per_share;
lot.price -= dividend_per_share;
}
self.average_cost -= dividend_per_share;
@@ -280,6 +298,7 @@ impl Position {
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio,
price: lot.price / ratio,
})
.collect::<Vec<_>>();
@@ -759,6 +778,7 @@ impl PortfolioState {
.map(|lot| PositionLot {
acquired_date: lot.acquired_date,
quantity: round_half_up_u32(lot.quantity as f64 * ratio),
entry_price: lot.entry_price / ratio,
price: lot.price / ratio,
})
.collect::<Vec<_>>();
@@ -855,6 +875,18 @@ mod tests {
);
}
#[test]
fn strategy_entry_price_excludes_buy_commission_cost_basis() {
let date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();
let mut position = Position::new("600561.SH");
position.buy(date, 22_200, 5.66);
position.record_buy_trade_cost(22_200, 100.0);
assert!(position.average_cost > 5.66);
assert!((position.average_entry_price().unwrap() - 5.66).abs() < 1e-12);
assert!((position.holding_return(6.06).unwrap() - (6.06 / 5.66 - 1.0)).abs() < 1e-12);
}
#[test]
fn portfolio_tracks_dividend_receivable_and_day_pnl() {
let prev_date = NaiveDate::from_ymd_opt(2025, 1, 2).unwrap();

View File

@@ -2388,11 +2388,6 @@ impl OmniMicroCapStrategy {
{
return Ok(Some("upper_limit".to_string()));
}
if market.is_at_lower_limit_price(market.day_open)
|| market.is_at_lower_limit_price(market.sell_price(PriceField::Last))
{
return Ok(Some("lower_limit".to_string()));
}
if market.day_open <= 1.0 {
return Ok(Some("one_yuan".to_string()));
}
@@ -2744,8 +2739,7 @@ impl Strategy for OmniMicroCapStrategy {
let stop_hit = current_price
<= position.average_cost * self.config.stop_loss_ratio
+ self.stop_loss_tolerance(market);
let profit_hit = !market.is_at_upper_limit_price(current_price)
&& current_price / position.average_cost > self.config.take_profit_ratio;
let profit_hit = current_price / position.average_cost > self.config.take_profit_ratio;
let can_sell = self.can_sell_position(ctx, date, &position.symbol);
if stop_hit || profit_hit {
let sell_reason = if stop_hit {