Add futures account model
This commit is contained in:
347
crates/fidc-core/src/futures.rs
Normal file
347
crates/fidc-core/src/futures.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub mod data;
|
||||
pub mod engine;
|
||||
pub mod event_bus;
|
||||
pub mod events;
|
||||
pub mod futures;
|
||||
pub mod instrument;
|
||||
pub mod metrics;
|
||||
pub mod platform_expr_strategy;
|
||||
@@ -32,6 +33,7 @@ pub use events::{
|
||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||
ProcessEventKind,
|
||||
};
|
||||
pub use futures::{FuturesAccountState, FuturesContractSpec, FuturesDirection, FuturesPosition};
|
||||
pub use instrument::Instrument;
|
||||
pub use metrics::{BacktestMetrics, compute_backtest_metrics};
|
||||
pub use platform_expr_strategy::{
|
||||
|
||||
52
crates/fidc-core/tests/futures_account.rs
Normal file
52
crates/fidc-core/tests/futures_account.rs
Normal 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);
|
||||
}
|
||||
@@ -85,7 +85,11 @@ current alignment pass.
|
||||
- [x] management-fee rate and callback parity
|
||||
- [x] stock account map/accessor surface (`accounts`, `stock_account`,
|
||||
`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
|
||||
|
||||
@@ -103,5 +107,6 @@ current alignment pass.
|
||||
|
||||
Active implementation target: continue account parity after exposing the stock
|
||||
account runtime view, core Portfolio fields, deposit/withdraw, financing
|
||||
liability APIs, management-fee callbacks, and stock account accessors; next gap
|
||||
is the full futures account, margin, and short-position execution model.
|
||||
liability APIs, management-fee callbacks, stock account accessors, and the
|
||||
standalone futures account model; next gap is wiring futures into the generic
|
||||
engine runtime and adding futures-specific order matching semantics.
|
||||
|
||||
Reference in New Issue
Block a user