Wire futures order intents into engine
This commit is contained in:
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -92,7 +92,8 @@ current alignment pass.
|
||||
- [x] wire futures account runtime view into `BacktestEngine` and
|
||||
`StrategyContext` (`future_account`, `account_by_type("FUTURE")`,
|
||||
`accounts`)
|
||||
- [ ] wire futures order intents into the generic `BacktestEngine` execution loop
|
||||
- [x] wire futures order intents into the generic `BacktestEngine` execution
|
||||
loop for account-level open/close execution
|
||||
- [ ] futures intraday matching integration and expiration settlement
|
||||
|
||||
## Execution Order
|
||||
@@ -113,5 +114,5 @@ Active implementation target: continue account parity after exposing the stock
|
||||
account runtime view, core Portfolio fields, deposit/withdraw, financing
|
||||
liability APIs, management-fee callbacks, stock account accessors, and the
|
||||
standalone futures account/order execution model plus generic engine runtime
|
||||
account visibility; next gap is wiring futures order intents into the generic
|
||||
engine execution loop and adding futures intraday/expiration semantics.
|
||||
account visibility and account-level futures order intents; next gap is adding
|
||||
futures intraday matching and expiration settlement semantics.
|
||||
|
||||
Reference in New Issue
Block a user