Add futures account model

This commit is contained in:
boris
2026-04-23 20:25:50 -07:00
parent 8e53c995cd
commit 68adc6b25c
4 changed files with 409 additions and 3 deletions

View File

@@ -0,0 +1,347 @@
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FuturesDirection {
Long,
Short,
}
impl FuturesDirection {
pub fn as_str(&self) -> &'static str {
match self {
Self::Long => "long",
Self::Short => "short",
}
}
fn factor(&self) -> f64 {
match self {
Self::Long => 1.0,
Self::Short => -1.0,
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct FuturesContractSpec {
pub contract_multiplier: f64,
pub long_margin_rate: f64,
pub short_margin_rate: f64,
}
impl FuturesContractSpec {
pub fn new(contract_multiplier: f64, long_margin_rate: f64, short_margin_rate: f64) -> Self {
Self {
contract_multiplier: contract_multiplier.max(1.0),
long_margin_rate: long_margin_rate.max(0.0),
short_margin_rate: short_margin_rate.max(0.0),
}
}
pub fn margin_rate(&self, direction: FuturesDirection) -> f64 {
match direction {
FuturesDirection::Long => self.long_margin_rate,
FuturesDirection::Short => self.short_margin_rate,
}
}
}
#[derive(Debug, Clone)]
pub struct FuturesPosition {
pub symbol: String,
pub direction: FuturesDirection,
pub old_quantity: u32,
pub quantity: u32,
pub avg_price: f64,
pub last_price: f64,
pub prev_close: f64,
pub contract_multiplier: f64,
pub margin_rate: f64,
pub transaction_cost: f64,
trade_quantity_delta: i32,
trade_cost: f64,
}
impl FuturesPosition {
pub fn new(
symbol: impl Into<String>,
direction: FuturesDirection,
spec: FuturesContractSpec,
init_quantity: u32,
init_price: f64,
) -> Self {
let margin_rate = spec.margin_rate(direction);
Self {
symbol: symbol.into(),
direction,
old_quantity: init_quantity,
quantity: init_quantity,
avg_price: init_price.max(0.0),
last_price: init_price.max(0.0),
prev_close: init_price.max(0.0),
contract_multiplier: spec.contract_multiplier,
margin_rate,
transaction_cost: 0.0,
trade_quantity_delta: 0,
trade_cost: 0.0,
}
}
pub fn today_quantity(&self) -> u32 {
self.quantity.saturating_sub(self.old_quantity)
}
pub fn market_value(&self) -> f64 {
self.quantity as f64 * self.last_price * self.contract_multiplier
}
pub fn margin(&self) -> f64 {
self.market_value() * self.margin_rate
}
pub fn equity(&self) -> f64 {
(self.last_price - self.avg_price)
* self.quantity as f64
* self.contract_multiplier
* self.direction.factor()
}
pub fn pnl(&self) -> f64 {
self.equity()
}
pub fn trading_pnl(&self) -> f64 {
(self.trade_quantity_delta as f64 * self.last_price - self.trade_cost)
* self.contract_multiplier
* self.direction.factor()
}
pub fn position_pnl(&self) -> f64 {
if self.old_quantity == 0 {
0.0
} else {
self.old_quantity as f64
* (self.last_price - self.prev_close)
* self.contract_multiplier
* self.direction.factor()
}
}
pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) {
if quantity == 0 {
return;
}
let old_value = self.avg_price * self.quantity as f64;
self.quantity += quantity;
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
self.trade_quantity_delta += quantity as i32;
self.trade_cost += price * quantity as f64;
}
pub fn close(
&mut self,
quantity: u32,
price: f64,
transaction_cost: f64,
) -> Result<f64, String> {
if quantity > self.quantity {
return Err(format!(
"close quantity {} exceeds current quantity {} for {} {}",
quantity,
self.quantity,
self.symbol,
self.direction.as_str()
));
}
if quantity == 0 {
return Ok(0.0);
}
let realized = (price - self.avg_price)
* quantity as f64
* self.contract_multiplier
* self.direction.factor()
- transaction_cost.max(0.0);
self.quantity -= quantity;
if self.quantity == 0 {
self.avg_price = 0.0;
}
self.last_price = price;
self.transaction_cost += transaction_cost.max(0.0);
self.trade_quantity_delta -= quantity as i32;
self.trade_cost -= price * quantity as f64;
Ok(realized)
}
pub fn mark_price(&mut self, price: f64) {
if price.is_finite() && price > 0.0 {
self.last_price = price;
}
}
pub fn begin_trading_day(&mut self) {
self.old_quantity = self.quantity;
self.prev_close = self.last_price;
self.transaction_cost = 0.0;
self.trade_quantity_delta = 0;
self.trade_cost = 0.0;
}
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
self.mark_price(settlement_price);
let cash_delta = self.equity();
self.avg_price = self.last_price;
self.prev_close = self.last_price;
cash_delta
}
}
#[derive(Debug, Clone)]
pub struct FuturesAccountState {
total_cash: f64,
frozen_cash: f64,
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
}
impl FuturesAccountState {
pub fn new(total_cash: f64) -> Self {
Self {
total_cash,
frozen_cash: 0.0,
positions: BTreeMap::new(),
}
}
pub fn total_cash(&self) -> f64 {
self.total_cash
}
pub fn frozen_cash(&self) -> f64 {
self.frozen_cash
}
pub fn cash(&self) -> f64 {
self.total_cash - self.margin() - self.frozen_cash
}
pub fn margin(&self) -> f64 {
self.positions.values().map(FuturesPosition::margin).sum()
}
pub fn market_value(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::market_value)
.sum()
}
pub fn position_equity(&self) -> f64 {
self.positions.values().map(FuturesPosition::equity).sum()
}
pub fn total_value(&self) -> f64 {
self.total_cash + self.position_equity()
}
pub fn daily_pnl(&self) -> f64 {
self.trading_pnl() + self.position_pnl() - self.transaction_cost()
}
pub fn trading_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::trading_pnl)
.sum()
}
pub fn position_pnl(&self) -> f64 {
self.positions
.values()
.map(FuturesPosition::position_pnl)
.sum()
}
pub fn transaction_cost(&self) -> f64 {
self.positions
.values()
.map(|position| position.transaction_cost)
.sum()
}
pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> {
&self.positions
}
pub fn position(&self, symbol: &str, direction: FuturesDirection) -> Option<&FuturesPosition> {
self.positions.get(&(symbol.to_string(), direction))
}
pub fn open(
&mut self,
symbol: impl Into<String>,
direction: FuturesDirection,
spec: FuturesContractSpec,
quantity: u32,
price: f64,
transaction_cost: f64,
) {
if quantity == 0 {
return;
}
let symbol = symbol.into();
let position = self
.positions
.entry((symbol.clone(), direction))
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
position.open(quantity, price, transaction_cost);
self.total_cash -= transaction_cost.max(0.0);
}
pub fn close(
&mut self,
symbol: &str,
direction: FuturesDirection,
quantity: u32,
price: f64,
transaction_cost: f64,
) -> Result<f64, String> {
let key = (symbol.to_string(), direction);
let position = self
.positions
.get_mut(&key)
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
let cash_delta = position.close(quantity, price, transaction_cost)?;
self.total_cash += cash_delta;
if position.quantity == 0 {
self.positions.remove(&key);
}
Ok(cash_delta)
}
pub fn mark_price(&mut self, symbol: &str, direction: FuturesDirection, price: f64) {
if let Some(position) = self.positions.get_mut(&(symbol.to_string(), direction)) {
position.mark_price(price);
}
}
pub fn begin_trading_day(&mut self) {
for position in self.positions.values_mut() {
position.begin_trading_day();
}
}
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
let mut cash_delta = 0.0;
for position in self.positions.values_mut() {
let price = settlement_prices
.get(&position.symbol)
.copied()
.unwrap_or(position.last_price);
cash_delta += position.settlement(price);
}
self.total_cash += cash_delta;
cash_delta
}
}

View File

@@ -5,6 +5,7 @@ pub mod data;
pub mod engine; pub mod engine;
pub mod event_bus; pub mod event_bus;
pub mod events; pub mod events;
pub mod futures;
pub mod instrument; pub mod instrument;
pub mod metrics; pub mod metrics;
pub mod platform_expr_strategy; pub mod platform_expr_strategy;
@@ -32,6 +33,7 @@ pub use events::{
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent, AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
ProcessEventKind, ProcessEventKind,
}; };
pub use futures::{FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesPosition};
pub use instrument::Instrument; pub use instrument::Instrument;
pub use metrics::{BacktestMetrics, compute_backtest_metrics}; pub use metrics::{BacktestMetrics, compute_backtest_metrics};
pub use platform_expr_strategy::{ pub use platform_expr_strategy::{

View File

@@ -0,0 +1,52 @@
use std::collections::BTreeMap;
use fidc_core::{FuturesAccountState, FuturesContractSpec, FuturesDirection};
#[test]
fn futures_account_tracks_long_margin_pnl_and_settlement() {
let spec = FuturesContractSpec::new(300.0, 0.12, 0.14);
let mut account = FuturesAccountState::new(1_000_000.0);
account.open("IF2501", FuturesDirection::Long, spec, 2, 4000.0, 12.0);
account.mark_price("IF2501", FuturesDirection::Long, 4010.0);
assert!((account.total_cash() - 999_988.0).abs() < 1e-6);
assert!((account.margin() - 288_720.0).abs() < 1e-6);
assert!((account.cash() - 711_268.0).abs() < 1e-6);
assert!((account.position_equity() - 6_000.0).abs() < 1e-6);
assert!((account.total_value() - 1_005_988.0).abs() < 1e-6);
let settlement = BTreeMap::from([("IF2501".to_string(), 4020.0)]);
let cash_delta = account.settle(&settlement);
assert!((cash_delta - 12_000.0).abs() < 1e-6);
assert!((account.total_cash() - 1_011_988.0).abs() < 1e-6);
let position = account
.position("IF2501", FuturesDirection::Long)
.expect("long position");
assert!((position.avg_price - 4020.0).abs() < 1e-6);
assert!((position.equity()).abs() < 1e-6);
}
#[test]
fn futures_account_tracks_short_close_cash_delta() {
let spec = FuturesContractSpec::new(10.0, 0.1, 0.2);
let mut account = FuturesAccountState::new(100_000.0);
account.open("RB2501", FuturesDirection::Short, spec, 5, 3500.0, 3.0);
account.mark_price("RB2501", FuturesDirection::Short, 3480.0);
assert!((account.margin() - 34_800.0).abs() < 1e-6);
assert!((account.position_equity() - 1_000.0).abs() < 1e-6);
let cash_delta = account
.close("RB2501", FuturesDirection::Short, 2, 3470.0, 2.0)
.expect("close short");
assert!((cash_delta - 598.0).abs() < 1e-6);
assert!((account.total_cash() - 100_595.0).abs() < 1e-6);
let position = account
.position("RB2501", FuturesDirection::Short)
.expect("remaining short position");
assert_eq!(position.quantity, 3);
assert!((position.equity() - 900.0).abs() < 1e-6);
}

View File

@@ -85,7 +85,11 @@ current alignment pass.
- [x] management-fee rate and callback parity - [x] management-fee rate and callback parity
- [x] stock account map/accessor surface (`accounts`, `stock_account`, - [x] stock account map/accessor surface (`accounts`, `stock_account`,
`account_by_type("STOCK")`) `account_by_type("STOCK")`)
- [ ] full futures account, margin, and short-position execution model - [x] standalone futures account model with contract multiplier, long/short
margin, daily mark-to-market settlement, and short close cashflow
- [ ] wire futures account into the generic backtest engine runtime
- [ ] futures order intents, matching, close-today semantics, and expiration
settlement
## Execution Order ## Execution Order
@@ -103,5 +107,6 @@ current alignment pass.
Active implementation target: continue account parity after exposing the stock 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, and stock account accessors; next gap liability APIs, management-fee callbacks, stock account accessors, and the
is the full futures account, margin, and short-position execution model. standalone futures account model; next gap is wiring futures into the generic
engine runtime and adding futures-specific order matching semantics.