Wire futures order intents into engine

This commit is contained in:
boris
2026-04-23 20:38:36 -07:00
parent db4e385308
commit 3439b5d8d0
6 changed files with 214 additions and 14 deletions

View File

@@ -939,6 +939,16 @@ where
));
Ok(())
}
OrderIntent::Futures { intent } => {
report.diagnostics.push(format!(
"engine_futures_intent_skipped symbol={} direction={} effect={} reason={}",
intent.symbol,
intent.direction.as_str(),
intent.effect.as_str(),
intent.reason
));
Ok(())
}
}
}

View File

@@ -12,7 +12,7 @@ use crate::events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind,
};
use crate::futures::FuturesAccountState;
use crate::futures::{FuturesAccountState, FuturesExecutionReport};
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
use crate::rules::EquityRuleHooks;
@@ -103,6 +103,7 @@ pub struct BacktestEngine<S, C, R> {
dynamic_universe: Option<BTreeSet<String>>,
subscriptions: BTreeSet<String>,
futures_account: Option<FuturesAccountState>,
next_futures_order_id: u64,
}
impl<S, C, R> BacktestEngine<S, C, R> {
@@ -122,6 +123,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
dynamic_universe: None,
subscriptions: BTreeSet::new(),
futures_account: None,
next_futures_order_id: 1,
}
}
@@ -454,6 +456,37 @@ where
},
)?;
}
crate::strategy::OrderIntent::Futures { intent } => {
let order_id = self.next_futures_order_id;
self.next_futures_order_id += 1;
let report = if let Some(account) = self.futures_account.as_mut() {
account.execute_order(execution_date, Some(order_id), intent)
} else {
let mut report = FuturesExecutionReport::default();
let side = intent.side();
report.order_events.push(OrderEvent {
date: execution_date,
order_id: Some(order_id),
symbol: intent.symbol,
side,
requested_quantity: intent.quantity,
filled_quantity: 0,
status: OrderStatus::Rejected,
reason: format!(
"{}: futures account is not enabled direction={} effect={}",
intent.reason,
intent.direction.as_str(),
intent.effect.as_str()
),
});
report
};
decision.diagnostics.push(format!(
"futures_order order_id={order_id} events={}",
report.order_events.len()
));
merge_futures_report(directive_report, report);
}
other => retained.push(other),
}
}
@@ -2246,6 +2279,14 @@ fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecu
target.diagnostics.extend(incoming.diagnostics);
}
fn merge_futures_report(target: &mut BrokerExecutionReport, incoming: FuturesExecutionReport) {
target.order_events.extend(incoming.order_events);
target.fill_events.extend(incoming.fill_events);
target.position_events.extend(incoming.position_events);
target.account_events.extend(incoming.account_events);
target.diagnostics.extend(incoming.diagnostics);
}
mod date_format {
use chrono::NaiveDate;
use serde::Serializer;

View File

@@ -121,6 +121,14 @@ impl FuturesOrderIntent {
reason: reason.into(),
}
}
pub fn side(&self) -> OrderSide {
if self.effect == FuturesPositionEffect::Open {
self.direction.open_side()
} else {
self.direction.close_side()
}
}
}
#[derive(Debug, Clone, Default)]
@@ -505,11 +513,7 @@ impl FuturesAccountState {
intent: FuturesOrderIntent,
) -> FuturesExecutionReport {
let mut report = FuturesExecutionReport::default();
let side = if intent.effect == FuturesPositionEffect::Open {
intent.direction.open_side()
} else {
intent.direction.close_side()
};
let side = intent.side();
if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 {
report.order_events.push(OrderEvent {

View File

@@ -10,7 +10,7 @@ use crate::cost::ChinaAShareCostModel;
use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField};
use crate::engine::BacktestError;
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
use crate::futures::FuturesAccountState;
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
use crate::instrument::Instrument;
use crate::portfolio::PortfolioState;
use crate::scheduler::ScheduleRule;
@@ -902,6 +902,9 @@ pub enum OrderIntent {
rate: f64,
reason: String,
},
Futures {
intent: FuturesOrderIntent,
},
}
#[derive(Debug, Clone)]

View File

@@ -6,10 +6,10 @@ use chrono::{NaiveDate, NaiveDateTime};
use fidc_core::{
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
FuturesAccountState, FuturesContractSpec, FuturesDirection, Instrument, IntradayExecutionQuote,
OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField,
ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
StrategyDecision,
FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, Instrument,
IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState,
PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
StrategyContext, StrategyDecision,
};
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
@@ -132,6 +132,41 @@ impl Strategy for AuctionOrderStrategy {
}
}
struct FuturesOrderStrategy;
impl Strategy for FuturesOrderStrategy {
fn name(&self) -> &str {
"futures-order"
}
fn on_day(
&mut self,
ctx: &StrategyContext<'_>,
) -> Result<StrategyDecision, fidc_core::BacktestError> {
if ctx.execution_date != d(2025, 1, 2) {
return Ok(StrategyDecision::default());
}
Ok(StrategyDecision {
rebalance: false,
target_weights: BTreeMap::new(),
exit_symbols: BTreeSet::new(),
order_intents: vec![OrderIntent::Futures {
intent: FuturesOrderIntent::open(
"IF2501",
FuturesDirection::Long,
FuturesContractSpec::new(300.0, 0.12, 0.14),
1,
4000.0,
12.0,
"open index future",
),
}],
notes: Vec::new(),
diagnostics: Vec::new(),
})
}
}
struct ScheduledProbeStrategy {
log: Rc<RefCell<Vec<String>>>,
process_log: Rc<RefCell<Vec<String>>>,
@@ -839,6 +874,112 @@ fn engine_executes_open_auction_decisions_before_on_day() {
assert_eq!(result.fills[0].quantity, 100);
}
#[test]
fn engine_executes_futures_order_intents_against_future_account() {
let date = d(2025, 1, 2);
let data = DataSet::from_components(
vec![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(),
}],
vec![DailyMarketSnapshot {
date,
symbol: "000001.SZ".to_string(),
timestamp: Some("2025-01-02 10:18:00".to_string()),
day_open: 10.0,
open: 10.0,
high: 10.0,
low: 10.0,
close: 10.0,
last_price: 10.0,
bid1: 10.0,
ask1: 10.0,
prev_close: 9.9,
volume: 1_000_000,
tick_volume: 1_000_000,
bid1_volume: 1_000_000,
ask1_volume: 1_000_000,
trading_phase: Some("continuous".to_string()),
paused: false,
upper_limit: 10.89,
lower_limit: 8.91,
price_tick: 0.01,
}],
vec![DailyFactorSnapshot {
date,
symbol: "000001.SZ".to_string(),
market_cap_bn: 100.0,
free_float_cap_bn: 80.0,
pe_ttm: 10.0,
turnover_ratio: Some(1.0),
effective_turnover_ratio: Some(1.0),
extra_factors: BTreeMap::new(),
}],
vec![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,
}],
vec![BenchmarkSnapshot {
date,
benchmark: "000300.SH".to_string(),
open: 100.0,
close: 100.0,
prev_close: 99.0,
volume: 1_000_000,
}],
)
.expect("dataset");
let broker = BrokerSimulator::new_with_execution_price(
ChinaAShareCostModel::default(),
ChinaEquityRuleHooks::default(),
PriceField::Open,
);
let mut engine = BacktestEngine::new(
data,
FuturesOrderStrategy,
broker,
BacktestConfig {
initial_cash: 100_000.0,
benchmark_code: "000300.SH".to_string(),
start_date: Some(date),
end_date: Some(date),
decision_lag_trading_days: 0,
execution_price_field: PriceField::Open,
},
)
.with_futures_initial_cash(500_000.0);
let result = engine.run().expect("backtest succeeds");
assert!(result.order_events.iter().any(|event| {
event.symbol == "IF2501"
&& event.status == OrderStatus::Filled
&& event.filled_quantity == 1
}));
assert!(result.fills.iter().any(|fill| {
fill.symbol == "IF2501" && fill.quantity == 1 && (fill.commission - 12.0).abs() < 1e-6
}));
let futures_account = engine.futures_account().expect("future account");
let position = futures_account
.position("IF2501", FuturesDirection::Long)
.expect("long futures position");
assert_eq!(position.quantity, 1);
assert!((futures_account.total_cash() - 499_988.0).abs() < 1e-6);
assert!((futures_account.cash() - 355_988.0).abs() < 1e-6);
}
#[test]
fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
let date = d(2025, 1, 2);