Wire futures order intents into engine
This commit is contained in:
@@ -939,6 +939,16 @@ where
|
|||||||
));
|
));
|
||||||
Ok(())
|
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,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
ProcessEventKind,
|
||||||
};
|
};
|
||||||
use crate::futures::FuturesAccountState;
|
use crate::futures::{FuturesAccountState, FuturesExecutionReport};
|
||||||
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
use crate::metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||||
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
use crate::portfolio::{CashReceivable, HoldingSummary, PortfolioState};
|
||||||
use crate::rules::EquityRuleHooks;
|
use crate::rules::EquityRuleHooks;
|
||||||
@@ -103,6 +103,7 @@ pub struct BacktestEngine<S, C, R> {
|
|||||||
dynamic_universe: Option<BTreeSet<String>>,
|
dynamic_universe: Option<BTreeSet<String>>,
|
||||||
subscriptions: BTreeSet<String>,
|
subscriptions: BTreeSet<String>,
|
||||||
futures_account: Option<FuturesAccountState>,
|
futures_account: Option<FuturesAccountState>,
|
||||||
|
next_futures_order_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<S, C, R> BacktestEngine<S, C, R> {
|
impl<S, C, R> BacktestEngine<S, C, R> {
|
||||||
@@ -122,6 +123,7 @@ impl<S, C, R> BacktestEngine<S, C, R> {
|
|||||||
dynamic_universe: None,
|
dynamic_universe: None,
|
||||||
subscriptions: BTreeSet::new(),
|
subscriptions: BTreeSet::new(),
|
||||||
futures_account: None,
|
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),
|
other => retained.push(other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2246,6 +2279,14 @@ fn merge_broker_report(target: &mut BrokerExecutionReport, incoming: BrokerExecu
|
|||||||
target.diagnostics.extend(incoming.diagnostics);
|
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 {
|
mod date_format {
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::Serializer;
|
use serde::Serializer;
|
||||||
|
|||||||
@@ -121,6 +121,14 @@ impl FuturesOrderIntent {
|
|||||||
reason: reason.into(),
|
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)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -505,11 +513,7 @@ impl FuturesAccountState {
|
|||||||
intent: FuturesOrderIntent,
|
intent: FuturesOrderIntent,
|
||||||
) -> FuturesExecutionReport {
|
) -> FuturesExecutionReport {
|
||||||
let mut report = FuturesExecutionReport::default();
|
let mut report = FuturesExecutionReport::default();
|
||||||
let side = if intent.effect == FuturesPositionEffect::Open {
|
let side = intent.side();
|
||||||
intent.direction.open_side()
|
|
||||||
} else {
|
|
||||||
intent.direction.close_side()
|
|
||||||
};
|
|
||||||
|
|
||||||
if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 {
|
if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 {
|
||||||
report.order_events.push(OrderEvent {
|
report.order_events.push(OrderEvent {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use crate::cost::ChinaAShareCostModel;
|
|||||||
use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField};
|
use crate::data::{DailyMarketSnapshot, DataSet, IntradayExecutionQuote, PriceBar, PriceField};
|
||||||
use crate::engine::BacktestError;
|
use crate::engine::BacktestError;
|
||||||
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
|
use crate::events::{FillEvent, OrderEvent, OrderSide, OrderStatus, ProcessEvent};
|
||||||
use crate::futures::FuturesAccountState;
|
use crate::futures::{FuturesAccountState, FuturesOrderIntent};
|
||||||
use crate::instrument::Instrument;
|
use crate::instrument::Instrument;
|
||||||
use crate::portfolio::PortfolioState;
|
use crate::portfolio::PortfolioState;
|
||||||
use crate::scheduler::ScheduleRule;
|
use crate::scheduler::ScheduleRule;
|
||||||
@@ -902,6 +902,9 @@ pub enum OrderIntent {
|
|||||||
rate: f64,
|
rate: f64,
|
||||||
reason: String,
|
reason: String,
|
||||||
},
|
},
|
||||||
|
Futures {
|
||||||
|
intent: FuturesOrderIntent,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use chrono::{NaiveDate, NaiveDateTime};
|
|||||||
use fidc_core::{
|
use fidc_core::{
|
||||||
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
BacktestConfig, BacktestEngine, BenchmarkSnapshot, BrokerSimulator, CandidateEligibility,
|
||||||
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
ChinaAShareCostModel, ChinaEquityRuleHooks, DailyFactorSnapshot, DailyMarketSnapshot, DataSet,
|
||||||
FuturesAccountState, FuturesContractSpec, FuturesDirection, Instrument, IntradayExecutionQuote,
|
FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesOrderIntent, Instrument,
|
||||||
OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState, PriceField,
|
IntradayExecutionQuote, OpenOrderView, OrderIntent, OrderSide, OrderStatus, PortfolioState,
|
||||||
ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy, StrategyContext,
|
PriceField, ProcessEventKind, ScheduleRule, ScheduleStage, ScheduleTimeRule, Strategy,
|
||||||
StrategyDecision,
|
StrategyContext, StrategyDecision,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn d(year: i32, month: u32, day: u32) -> NaiveDate {
|
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 {
|
struct ScheduledProbeStrategy {
|
||||||
log: Rc<RefCell<Vec<String>>>,
|
log: Rc<RefCell<Vec<String>>>,
|
||||||
process_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);
|
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]
|
#[test]
|
||||||
fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
fn engine_runs_subscribed_tick_hooks_and_executes_tick_orders() {
|
||||||
let date = d(2025, 1, 2);
|
let date = d(2025, 1, 2);
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ current alignment pass.
|
|||||||
- [x] wire futures account runtime view into `BacktestEngine` and
|
- [x] wire futures account runtime view into `BacktestEngine` and
|
||||||
`StrategyContext` (`future_account`, `account_by_type("FUTURE")`,
|
`StrategyContext` (`future_account`, `account_by_type("FUTURE")`,
|
||||||
`accounts`)
|
`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
|
- [ ] futures intraday matching integration and expiration settlement
|
||||||
|
|
||||||
## Execution Order
|
## 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
|
account runtime view, core Portfolio fields, deposit/withdraw, financing
|
||||||
liability APIs, management-fee callbacks, stock account accessors, and the
|
liability APIs, management-fee callbacks, stock account accessors, and the
|
||||||
standalone futures account/order execution model plus generic engine runtime
|
standalone futures account/order execution model plus generic engine runtime
|
||||||
account visibility; next gap is wiring futures order intents into the generic
|
account visibility and account-level futures order intents; next gap is adding
|
||||||
engine execution loop and adding futures intraday/expiration semantics.
|
futures intraday matching and expiration settlement semantics.
|
||||||
|
|||||||
Reference in New Issue
Block a user