修复next open首日未来函数
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user