修复next open首日未来函数
This commit is contained in:
@@ -1726,8 +1726,93 @@ where
|
|||||||
let decision_slot = execution_idx
|
let decision_slot = execution_idx
|
||||||
.checked_sub(self.config.decision_lag_trading_days)
|
.checked_sub(self.config.decision_lag_trading_days)
|
||||||
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
|
.map(|decision_idx| (decision_idx, execution_dates[decision_idx]));
|
||||||
let (decision_index, decision_date) =
|
let Some((decision_index, decision_date)) = decision_slot else {
|
||||||
decision_slot.unwrap_or((execution_idx, execution_date));
|
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 process_events = Vec::new();
|
||||||
let mut directive_report = BrokerExecutionReport::default();
|
let mut directive_report = BrokerExecutionReport::default();
|
||||||
let pre_open_orders = self.open_order_views();
|
let pre_open_orders = self.open_order_views();
|
||||||
@@ -3852,3 +3937,202 @@ mod date_format {
|
|||||||
serializer.serialize_str(&date.format(FORMAT).to_string())
|
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