修复next open首日未来函数

This commit is contained in:
boris
2026-06-27 23:57:09 +08:00
parent e83856baa9
commit 9562b8a280
+286 -2
View File
@@ -1726,8 +1726,93 @@ where
let decision_slot = execution_idx
.checked_sub(self.config.decision_lag_trading_days)
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
let (decision_index, decision_date) =
decision_slot.unwrap_or((execution_idx, execution_date));
let Some((decision_index, decision_date)) = decision_slot else {
let mut process_events = Vec::new();
let mut report = BrokerExecutionReport::default();
portfolio.update_prices_with_options(
execution_date,
&self.data,
PriceField::Close,
self.broker.same_day_buy_close_mark_at_fill(),
)?;
let close_report = self.broker.after_trading(execution_date);
merge_broker_report(&mut report, close_report);
let futures_daily_settlement_report = self.settle_futures_daily(execution_date);
merge_broker_report(&mut report, futures_daily_settlement_report);
let futures_expiration_report = self.settle_futures_expirations(execution_date);
merge_broker_report(&mut report, futures_expiration_report);
let daily_fill_count = report.fill_events.len();
let day_orders = report.order_events.clone();
let day_fills = report.fill_events.clone();
let broker_diagnostics = report.diagnostics.clone();
self.extend_result(&mut result, report);
let benchmark =
self.data
.benchmark(execution_date)
.ok_or(BacktestError::MissingBenchmark {
date: execution_date,
})?;
let notes = corporate_action_notes.join(" | ");
let diagnostics = std::iter::once(format!(
"decision_lag_warmup lag_days={} execution_index={}",
self.config.decision_lag_trading_days, execution_idx
))
.chain(broker_diagnostics.into_iter())
.collect::<Vec<_>>()
.join(" | ");
let holdings_for_day = portfolio.holdings_summary(execution_date);
let day_process_events = process_events.clone();
let aggregate_initial_cash = self.aggregate_initial_cash();
let aggregate_cash = self.aggregate_cash(&portfolio);
let aggregate_market_value = self.aggregate_market_value(&portfolio);
let aggregate_total_equity = self.aggregate_total_equity(&portfolio);
result.equity_curve.push(DailyEquityPoint {
date: execution_date,
cash: aggregate_cash,
market_value: aggregate_market_value,
total_equity: aggregate_total_equity,
benchmark_close: benchmark.close,
benchmark_prev_close: benchmark.prev_close,
notes,
diagnostics,
});
result.daily_holdings.extend(holdings_for_day.clone());
let latest = result
.equity_curve
.last()
.expect("equity point pushed for progress event");
on_progress(&BacktestDayProgress {
date: execution_date,
cash: latest.cash,
market_value: latest.market_value,
total_equity: latest.total_equity,
unit_nav: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
latest.total_equity / aggregate_initial_cash
},
total_return: if aggregate_initial_cash.abs() < f64::EPSILON {
0.0
} else {
(latest.total_equity / aggregate_initial_cash) - 1.0
},
benchmark_close: latest.benchmark_close,
daily_fill_count,
cumulative_trade_count: result.fills.len(),
holding_count: holdings_for_day.len(),
notes: latest.notes.clone(),
diagnostics: latest.diagnostics.clone(),
orders: day_orders,
fills: day_fills,
holdings: holdings_for_day,
process_events: day_process_events,
});
result.process_events.append(&mut process_events);
continue;
};
let mut process_events = Vec::new();
let mut directive_report = BrokerExecutionReport::default();
let pre_open_orders = self.open_order_views();
@@ -3852,3 +3937,202 @@ mod date_format {
serializer.serialize_str(&date.format(FORMAT).to_string())
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use chrono::NaiveDate;
use super::{BacktestConfig, BacktestEngine};
use crate::broker::{BrokerSimulator, MatchingType};
use crate::cost::ChinaAShareCostModel;
use crate::data::{
BenchmarkSnapshot, CandidateEligibility, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
PriceField,
};
use crate::instrument::Instrument;
use crate::rules::ChinaEquityRuleHooks;
use crate::strategy::{OrderIntent, Strategy, StrategyContext, StrategyDecision};
const SYMBOL: &str = "000001.SZ";
#[derive(Debug)]
struct BuyWhenDecisionDateStrategy {
decision_date: NaiveDate,
}
impl Strategy for BuyWhenDecisionDateStrategy {
fn name(&self) -> &str {
"buy_when_decision_date"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, super::BacktestError> {
if ctx.decision_date == self.decision_date && ctx.portfolio.position(SYMBOL).is_none() {
return Ok(StrategyDecision {
order_intents: vec![OrderIntent::Shares {
symbol: SYMBOL.to_string(),
quantity: 100,
reason: "test_buy".to_string(),
}],
..StrategyDecision::default()
});
}
Ok(StrategyDecision::default())
}
}
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(year, month, day).expect("valid date")
}
fn market(date: NaiveDate, open: f64, close: f64) -> DailyMarketSnapshot {
DailyMarketSnapshot {
date,
symbol: SYMBOL.to_string(),
timestamp: Some(format!("{date} 15:00:00")),
day_open: open,
open,
high: close.max(open) + 1.0,
low: close.min(open) - 1.0,
close,
last_price: close,
bid1: close - 0.01,
ask1: close + 0.01,
prev_close: 10.0,
volume: 1_000_000,
minute_volume: 10_000,
bid1_volume: 10_000,
ask1_volume: 10_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 200.0,
lower_limit: 1.0,
price_tick: 0.01,
}
}
fn factor(date: NaiveDate) -> DailyFactorSnapshot {
DailyFactorSnapshot {
date,
symbol: SYMBOL.to_string(),
market_cap_bn: 10.0,
free_float_cap_bn: 8.0,
pe_ttm: 12.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}
}
fn candidate(date: NaiveDate) -> CandidateEligibility {
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,
risk_level_code: None,
}
}
fn benchmark(date: NaiveDate) -> BenchmarkSnapshot {
BenchmarkSnapshot {
date,
benchmark: "000852.SH".to_string(),
open: 1000.0,
close: 1000.0,
prev_close: 1000.0,
volume: 1_000_000,
}
}
fn dataset() -> DataSet {
let first = d(2025, 1, 2);
let second = d(2025, 1, 3);
DataSet::from_components(
vec![Instrument {
symbol: SYMBOL.to_string(),
name: "Test Stock".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
}],
vec![market(first, 10.0, 11.5), market(second, 12.0, 99.0)],
vec![factor(first), factor(second)],
vec![candidate(first), candidate(second)],
vec![benchmark(first), benchmark(second)],
)
.expect("dataset")
}
fn run_with_matching(
matching_type: MatchingType,
execution_price_field: PriceField,
decision_lag_trading_days: usize,
) -> super::BacktestResult {
let first = d(2025, 1, 2);
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks,
execution_price_field,
)
.with_matching_type(matching_type)
.with_volume_limit(false)
.with_liquidity_limit(false)
.with_inactive_limit(false);
let config = BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000852.SH".to_string(),
start_date: Some(first),
end_date: Some(d(2025, 1, 3)),
decision_lag_trading_days,
execution_price_field,
};
BacktestEngine::new(
dataset(),
BuyWhenDecisionDateStrategy {
decision_date: first,
},
broker,
config,
)
.run()
.expect("backtest run")
}
#[test]
fn current_bar_close_uses_decision_day_close_for_fill() {
let result = run_with_matching(MatchingType::CurrentBarClose, PriceField::Close, 0);
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, d(2025, 1, 2));
assert_eq!(result.fills[0].price, 11.5);
}
#[test]
fn next_bar_open_skips_unavailable_lag_day_and_fills_next_open() {
let result = run_with_matching(MatchingType::NextBarOpen, PriceField::Open, 1);
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].date, d(2025, 1, 3));
assert_eq!(result.fills[0].price, 12.0);
assert!(
result.equity_curve[0]
.diagnostics
.contains("decision_lag_warmup"),
"{}",
result.equity_curve[0].diagnostics
);
}
}