Expose strategy runtime data APIs

This commit is contained in:
boris
2026-04-23 19:29:12 -07:00
parent 1760fc6cd1
commit c3ef0bd49a
9 changed files with 678 additions and 6 deletions

View File

@@ -144,6 +144,11 @@ struct TickProbeStrategy {
ordered: bool,
}
struct DataApiProbeStrategy {
target_date: NaiveDate,
snapshots: Rc<RefCell<Vec<String>>>,
}
impl Strategy for ScheduledProbeStrategy {
fn name(&self) -> &str {
"scheduled-probe"
@@ -325,8 +330,20 @@ impl Strategy for TickProbeStrategy {
ctx: &StrategyContext<'_>,
quote: &IntradayExecutionQuote,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
let visible_last = ctx
.history_bars(&quote.symbol, 9, "tick", "last", true)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let previous_last = ctx
.history_bars(&quote.symbol, 9, "tick", "last", false)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
self.seen_ticks.borrow_mut().push(format!(
"{}:{}:{}",
"{}:{}:{}:visible={visible_last}:previous={previous_last}",
quote.symbol,
quote.timestamp.time(),
ctx.is_subscribed(&quote.symbol)
@@ -350,6 +367,68 @@ impl Strategy for TickProbeStrategy {
}
}
impl Strategy for DataApiProbeStrategy {
fn name(&self) -> &str {
"data-api-probe"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date == self.target_date {
let daily_close = ctx
.history_bars("000001.SZ", 2, "1d", "close", true)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let previous_close = ctx
.history_bars("000001.SZ", 2, "daily", "close", false)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let tick_last = ctx
.history_bars("000001.SZ", 2, "1m", "last", true)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let previous_tick_last = ctx
.history_bars("000001.SZ", 2, "1m", "last", false)
.iter()
.map(|value| format!("{value:.2}"))
.collect::<Vec<_>>()
.join(",");
let current_close = ctx
.current_snapshot("000001.SZ")
.map(|snapshot| format!("{:.2}", snapshot.close))
.unwrap_or_default();
let instrument_name = ctx
.instrument("000001.SZ")
.map(|instrument| instrument.name.clone())
.unwrap_or_default();
let prev_date = ctx
.get_previous_trading_date(ctx.execution_date, 1)
.map(|date| date.to_string())
.unwrap_or_default();
let next_date = ctx
.get_next_trading_date(d(2025, 1, 3), 1)
.map(|date| date.to_string())
.unwrap_or_default();
let trading_date_count = ctx
.get_trading_dates(d(2025, 1, 2), ctx.execution_date)
.len();
self.snapshots.borrow_mut().push(format!(
"daily={daily_close};previous={previous_close};tick={tick_last};previous_tick={previous_tick_last};current={current_close};instrument={instrument_name};all={};range={trading_date_count};prev={prev_date};next={next_date}",
ctx.all_instruments().len()
));
}
Ok(StrategyDecision::default())
}
}
#[test]
fn engine_runs_strategy_hooks_in_daily_order() {
let date1 = d(2025, 1, 2);
@@ -769,7 +848,10 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
assert_eq!(
seen_ticks.borrow().as_slice(),
["000001.SZ:10:18:00:true", "000001.SZ:10:19:00:true"]
[
"000001.SZ:10:18:00:true:visible=10.20:previous=",
"000001.SZ:10:19:00:true:visible=10.20,10.30:previous=10.20"
]
);
assert_eq!(result.fills.len(), 1);
assert_eq!(result.fills[0].reason, "tick_buy");
@@ -794,6 +876,180 @@ fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
);
}
#[test]
fn strategy_context_exposes_rqalpha_style_data_helpers() {
let date1 = d(2025, 1, 2);
let date2 = d(2025, 1, 3);
let date3 = d(2025, 1, 6);
let instrument = Instrument {
symbol: "000001.SZ".to_string(),
name: "Anchor".to_string(),
board: "SZ".to_string(),
round_lot: 100,
listed_at: Some(d(2020, 1, 1)),
delisted_at: None,
status: "active".to_string(),
};
let market = [
(date1, 10.0, 10.0, 10.0, 100_000),
(date2, 10.1, 10.1, 10.0, 110_000),
(date3, 10.2, 10.2, 10.1, 120_000),
]
.into_iter()
.map(
|(date, open, close, prev_close, volume)| DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some(format!("{date} 10:18:00")),
day_open: open,
open,
high: close + 0.2,
low: close - 0.2,
close,
last_price: close,
bid1: close - 0.01,
ask1: close + 0.01,
prev_close,
volume,
tick_volume: volume,
bid1_volume: volume,
ask1_volume: volume,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: prev_close * 1.1,
lower_limit: prev_close * 0.9,
price_tick: 0.01,
},
)
.collect::<Vec<_>>();
let factors = [date1, date2, date3]
.into_iter()
.map(|date| DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 20.0,
free_float_cap_bn: 18.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
})
.collect::<Vec<_>>();
let candidates = [date1, date2, date3]
.into_iter()
.map(|date| CandidateEligibility {
date,
symbol: "000001.SZ".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,
})
.collect::<Vec<_>>();
let benchmarks = [
(date1, 100.0, 99.0),
(date2, 101.0, 100.0),
(date3, 102.0, 101.0),
]
.into_iter()
.map(|(date, close, prev_close)| BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: close,
close,
prev_close,
volume: 1_000_000,
})
.collect::<Vec<_>>();
let quotes = vec![
IntradayExecutionQuote {
date: date2,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 3, 14, 30, 0),
last_price: 10.15,
bid1: 10.14,
ask1: 10.15,
bid1_volume: 1000,
ask1_volume: 1000,
volume_delta: 1000,
amount_delta: 10_150.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date: date3,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 18, 0),
last_price: 10.25,
bid1: 10.24,
ask1: 10.25,
bid1_volume: 1000,
ask1_volume: 1000,
volume_delta: 1000,
amount_delta: 10_250.0,
trading_phase: Some("continuous".to_string()),
},
IntradayExecutionQuote {
date: date3,
symbol: "000001.SZ".to_string(),
timestamp: dt(2025, 1, 6, 10, 19, 0),
last_price: 10.26,
bid1: 10.25,
ask1: 10.26,
bid1_volume: 1000,
ask1_volume: 1000,
volume_delta: 1000,
amount_delta: 10_260.0,
trading_phase: Some("continuous".to_string()),
},
];
let data = DataSet::from_components_with_actions_and_quotes(
vec![instrument],
market,
factors,
candidates,
benchmarks,
Vec::new(),
quotes,
)
.expect("dataset");
let snapshots = Rc::new(RefCell::new(Vec::new()));
let strategy = DataApiProbeStrategy {
target_date: date3,
snapshots: snapshots.clone(),
};
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
data,
strategy,
broker,
BacktestConfig {
initial_cash: 10_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date1),
end_date: Some(date3),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
);
engine.run().expect("backtest run");
assert_eq!(
snapshots.borrow().as_slice(),
[
"daily=10.10,10.20;previous=10.00,10.10;tick=10.15,10.25;previous_tick=10.15;current=10.20;instrument=Anchor;all=1;range=3;prev=2025-01-03;next=2025-01-06"
]
);
}
#[test]
fn engine_rejects_pending_limit_orders_at_market_close() {
let date1 = d(2025, 1, 2);

View File

@@ -32,6 +32,7 @@ fn strategy_emits_target_weights_and_diagnostics() {
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
})
.expect("decision");
@@ -75,6 +76,7 @@ fn jq_strategy_emits_same_day_decision() {
subscriptions: &subscriptions,
process_events: &[],
active_process_event: None,
active_datetime: None,
})
.expect("jq decision");