1283 lines
40 KiB
Rust
1283 lines
40 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use chrono::NaiveDate;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::events::{
|
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
|
ProcessEventKind,
|
|
};
|
|
use crate::fixed_point::FixedMoney;
|
|
|
|
fn futures_money(value: f64, label: &str) -> Result<FixedMoney, String> {
|
|
FixedMoney::from_f64(value)
|
|
.ok_or_else(|| format!("{label} is not representable as fixed-point money: {value}"))
|
|
}
|
|
|
|
fn futures_money_or_panic(value: f64, label: &str) -> FixedMoney {
|
|
futures_money(value, label).unwrap_or_else(|error| panic!("{error}"))
|
|
}
|
|
|
|
fn sum_futures_money(values: impl IntoIterator<Item = FixedMoney>, label: &str) -> FixedMoney {
|
|
values.into_iter().fold(FixedMoney::ZERO, |total, value| {
|
|
total
|
|
.checked_add(value)
|
|
.unwrap_or_else(|| panic!("fixed-point {label} overflow"))
|
|
})
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
|
|
fn open_side(&self) -> OrderSide {
|
|
match self {
|
|
Self::Long => OrderSide::Buy,
|
|
Self::Short => OrderSide::Sell,
|
|
}
|
|
}
|
|
|
|
fn close_side(&self) -> OrderSide {
|
|
match self {
|
|
Self::Long => OrderSide::Sell,
|
|
Self::Short => OrderSide::Buy,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum FuturesPositionEffect {
|
|
Open,
|
|
Close,
|
|
CloseToday,
|
|
CloseYesterday,
|
|
}
|
|
|
|
impl FuturesPositionEffect {
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::Open => "open",
|
|
Self::Close => "close",
|
|
Self::CloseToday => "close_today",
|
|
Self::CloseYesterday => "close_yesterday",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct FuturesContractSpec {
|
|
pub contract_multiplier: f64,
|
|
pub long_margin_rate: f64,
|
|
pub short_margin_rate: f64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum FuturesCommissionType {
|
|
ByMoney,
|
|
ByVolume,
|
|
}
|
|
|
|
impl FuturesCommissionType {
|
|
pub fn parse(value: &str) -> Self {
|
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
"by_volume" | "volume" | "byvolume" => Self::ByVolume,
|
|
_ => Self::ByMoney,
|
|
}
|
|
}
|
|
|
|
pub fn as_str(&self) -> &'static str {
|
|
match self {
|
|
Self::ByMoney => "by_money",
|
|
Self::ByVolume => "by_volume",
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct FuturesTradingParameter {
|
|
pub symbol: String,
|
|
pub effective_date: Option<NaiveDate>,
|
|
pub contract_multiplier: f64,
|
|
pub long_margin_rate: f64,
|
|
pub short_margin_rate: f64,
|
|
pub commission_type: FuturesCommissionType,
|
|
pub open_commission_ratio: f64,
|
|
pub close_commission_ratio: f64,
|
|
pub close_today_commission_ratio: f64,
|
|
pub price_tick: f64,
|
|
}
|
|
|
|
impl FuturesTradingParameter {
|
|
pub fn spec(&self) -> FuturesContractSpec {
|
|
FuturesContractSpec::new(
|
|
self.contract_multiplier,
|
|
self.long_margin_rate,
|
|
self.short_margin_rate,
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
pub struct FuturesTransactionCostModel {
|
|
pub commission_multiplier: f64,
|
|
}
|
|
|
|
impl Default for FuturesTransactionCostModel {
|
|
fn default() -> Self {
|
|
Self {
|
|
commission_multiplier: 1.0,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl FuturesTransactionCostModel {
|
|
pub fn calculate(
|
|
&self,
|
|
params: &FuturesTradingParameter,
|
|
effect: FuturesPositionEffect,
|
|
price: f64,
|
|
quantity: u32,
|
|
close_today_quantity: u32,
|
|
) -> f64 {
|
|
if quantity == 0 || !price.is_finite() || price <= 0.0 {
|
|
return 0.0;
|
|
}
|
|
let quantity = quantity as f64;
|
|
let close_today_quantity = close_today_quantity.min(quantity as u32) as f64;
|
|
let close_yesterday_quantity = (quantity - close_today_quantity).max(0.0);
|
|
let raw = match params.commission_type {
|
|
FuturesCommissionType::ByMoney => match effect {
|
|
FuturesPositionEffect::Open => {
|
|
price * quantity * params.contract_multiplier * params.open_commission_ratio
|
|
}
|
|
FuturesPositionEffect::Close
|
|
| FuturesPositionEffect::CloseToday
|
|
| FuturesPositionEffect::CloseYesterday => {
|
|
price
|
|
* params.contract_multiplier
|
|
* (close_yesterday_quantity * params.close_commission_ratio
|
|
+ close_today_quantity * params.close_today_commission_ratio)
|
|
}
|
|
},
|
|
FuturesCommissionType::ByVolume => match effect {
|
|
FuturesPositionEffect::Open => quantity * params.open_commission_ratio,
|
|
FuturesPositionEffect::Close
|
|
| FuturesPositionEffect::CloseToday
|
|
| FuturesPositionEffect::CloseYesterday => {
|
|
close_yesterday_quantity * params.close_commission_ratio
|
|
+ close_today_quantity * params.close_today_commission_ratio
|
|
}
|
|
},
|
|
};
|
|
raw.max(0.0) * self.commission_multiplier.max(0.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FuturesOrderIntent {
|
|
pub symbol: String,
|
|
pub direction: FuturesDirection,
|
|
pub effect: FuturesPositionEffect,
|
|
pub spec: FuturesContractSpec,
|
|
pub quantity: u32,
|
|
pub price: f64,
|
|
pub transaction_cost: f64,
|
|
pub limit_price: Option<f64>,
|
|
pub allow_pending: bool,
|
|
pub reason: String,
|
|
}
|
|
|
|
impl FuturesOrderIntent {
|
|
pub fn open(
|
|
symbol: impl Into<String>,
|
|
direction: FuturesDirection,
|
|
spec: FuturesContractSpec,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
reason: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
symbol: symbol.into(),
|
|
direction,
|
|
effect: FuturesPositionEffect::Open,
|
|
spec,
|
|
quantity,
|
|
price,
|
|
transaction_cost,
|
|
limit_price: None,
|
|
allow_pending: false,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
|
|
pub fn close(
|
|
symbol: impl Into<String>,
|
|
direction: FuturesDirection,
|
|
effect: FuturesPositionEffect,
|
|
spec: FuturesContractSpec,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
reason: impl Into<String>,
|
|
) -> Self {
|
|
Self {
|
|
symbol: symbol.into(),
|
|
direction,
|
|
effect,
|
|
spec,
|
|
quantity,
|
|
price,
|
|
transaction_cost,
|
|
limit_price: None,
|
|
allow_pending: false,
|
|
reason: reason.into(),
|
|
}
|
|
}
|
|
|
|
pub fn limit_open(
|
|
symbol: impl Into<String>,
|
|
direction: FuturesDirection,
|
|
spec: FuturesContractSpec,
|
|
quantity: u32,
|
|
limit_price: f64,
|
|
transaction_cost: f64,
|
|
reason: impl Into<String>,
|
|
) -> Self {
|
|
Self::open(
|
|
symbol,
|
|
direction,
|
|
spec,
|
|
quantity,
|
|
limit_price,
|
|
transaction_cost,
|
|
reason,
|
|
)
|
|
.with_limit_price(limit_price)
|
|
}
|
|
|
|
pub fn limit_close(
|
|
symbol: impl Into<String>,
|
|
direction: FuturesDirection,
|
|
effect: FuturesPositionEffect,
|
|
spec: FuturesContractSpec,
|
|
quantity: u32,
|
|
limit_price: f64,
|
|
transaction_cost: f64,
|
|
reason: impl Into<String>,
|
|
) -> Self {
|
|
Self::close(
|
|
symbol,
|
|
direction,
|
|
effect,
|
|
spec,
|
|
quantity,
|
|
limit_price,
|
|
transaction_cost,
|
|
reason,
|
|
)
|
|
.with_limit_price(limit_price)
|
|
}
|
|
|
|
pub fn with_limit_price(mut self, limit_price: f64) -> Self {
|
|
self.limit_price = limit_price
|
|
.is_finite()
|
|
.then_some(limit_price)
|
|
.filter(|v| *v > 0.0);
|
|
self.allow_pending = self.limit_price.is_some();
|
|
self
|
|
}
|
|
|
|
pub fn with_allow_pending(mut self, allow_pending: bool) -> Self {
|
|
self.allow_pending = allow_pending;
|
|
self
|
|
}
|
|
|
|
pub fn with_price(mut self, price: f64) -> Self {
|
|
self.price = price;
|
|
self
|
|
}
|
|
|
|
pub fn with_transaction_cost(mut self, transaction_cost: f64) -> Self {
|
|
self.transaction_cost = transaction_cost;
|
|
self
|
|
}
|
|
|
|
pub fn side(&self) -> OrderSide {
|
|
if self.effect == FuturesPositionEffect::Open {
|
|
self.direction.open_side()
|
|
} else {
|
|
self.direction.close_side()
|
|
}
|
|
}
|
|
|
|
pub fn with_trading_parameter(
|
|
mut self,
|
|
params: &FuturesTradingParameter,
|
|
cost_model: FuturesTransactionCostModel,
|
|
) -> Self {
|
|
self.spec = params.spec();
|
|
if self.transaction_cost <= 0.0 {
|
|
let close_today_quantity = if self.effect == FuturesPositionEffect::CloseToday {
|
|
self.quantity
|
|
} else {
|
|
0
|
|
};
|
|
self.transaction_cost = cost_model.calculate(
|
|
params,
|
|
self.effect,
|
|
self.price,
|
|
self.quantity,
|
|
close_today_quantity,
|
|
);
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct FuturesExecutionReport {
|
|
pub order_events: Vec<OrderEvent>,
|
|
pub fill_events: Vec<FillEvent>,
|
|
pub position_events: Vec<PositionEvent>,
|
|
pub account_events: Vec<AccountEvent>,
|
|
pub process_events: Vec<ProcessEvent>,
|
|
pub diagnostics: Vec<String>,
|
|
}
|
|
|
|
impl FuturesContractSpec {
|
|
pub fn unresolved() -> Self {
|
|
Self {
|
|
contract_multiplier: f64::NAN,
|
|
long_margin_rate: f64::NAN,
|
|
short_margin_rate: f64::NAN,
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
pub fn is_resolved(&self) -> bool {
|
|
self.contract_multiplier.is_finite()
|
|
&& self.contract_multiplier > 0.0
|
|
&& self.long_margin_rate.is_finite()
|
|
&& self.long_margin_rate >= 0.0
|
|
&& self.short_margin_rate.is_finite()
|
|
&& self.short_margin_rate >= 0.0
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FuturesPosition {
|
|
pub symbol: String,
|
|
pub direction: FuturesDirection,
|
|
pub old_quantity: u32,
|
|
day_start_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,
|
|
transaction_cost: FixedMoney,
|
|
trade_quantity_delta: i32,
|
|
trade_value: FixedMoney,
|
|
}
|
|
|
|
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,
|
|
day_start_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: FixedMoney::ZERO,
|
|
trade_quantity_delta: 0,
|
|
trade_value: FixedMoney::ZERO,
|
|
}
|
|
}
|
|
|
|
pub fn today_quantity(&self) -> u32 {
|
|
self.quantity.saturating_sub(self.old_quantity)
|
|
}
|
|
|
|
pub fn market_value(&self) -> f64 {
|
|
self.market_value_money().to_f64()
|
|
}
|
|
|
|
fn market_value_money(&self) -> FixedMoney {
|
|
futures_money_or_panic(
|
|
self.quantity as f64 * self.last_price * self.contract_multiplier,
|
|
"futures position market value",
|
|
)
|
|
}
|
|
|
|
pub fn margin(&self) -> f64 {
|
|
self.margin_money().to_f64()
|
|
}
|
|
|
|
fn margin_money(&self) -> FixedMoney {
|
|
futures_money_or_panic(
|
|
self.market_value_money().to_f64() * self.margin_rate,
|
|
"futures position margin",
|
|
)
|
|
}
|
|
|
|
pub fn equity(&self) -> f64 {
|
|
self.equity_money().to_f64()
|
|
}
|
|
|
|
fn equity_money(&self) -> FixedMoney {
|
|
futures_money_or_panic(
|
|
(self.last_price - self.avg_price)
|
|
* self.quantity as f64
|
|
* self.contract_multiplier
|
|
* self.direction.factor(),
|
|
"futures position equity",
|
|
)
|
|
}
|
|
|
|
pub fn pnl(&self) -> f64 {
|
|
self.equity()
|
|
}
|
|
|
|
pub fn trading_pnl(&self) -> f64 {
|
|
self.trading_pnl_money().to_f64()
|
|
}
|
|
|
|
fn trading_pnl_money(&self) -> FixedMoney {
|
|
let marked_trade_value = futures_money_or_panic(
|
|
self.trade_quantity_delta as f64 * self.last_price * self.contract_multiplier,
|
|
"futures marked trade value",
|
|
);
|
|
let pnl = marked_trade_value
|
|
.checked_sub(self.trade_value)
|
|
.expect("fixed-point futures trading PnL overflow");
|
|
if self.direction == FuturesDirection::Short {
|
|
pnl.checked_neg()
|
|
.expect("fixed-point futures short trading PnL overflow")
|
|
} else {
|
|
pnl
|
|
}
|
|
}
|
|
|
|
pub fn position_pnl(&self) -> f64 {
|
|
self.position_pnl_money().to_f64()
|
|
}
|
|
|
|
fn position_pnl_money(&self) -> FixedMoney {
|
|
if self.day_start_quantity == 0 {
|
|
FixedMoney::ZERO
|
|
} else {
|
|
futures_money_or_panic(
|
|
self.day_start_quantity as f64
|
|
* (self.last_price - self.prev_close)
|
|
* self.contract_multiplier
|
|
* self.direction.factor(),
|
|
"futures position daily PnL",
|
|
)
|
|
}
|
|
}
|
|
|
|
pub fn transaction_cost(&self) -> f64 {
|
|
self.transaction_cost.to_f64()
|
|
}
|
|
|
|
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;
|
|
let transaction_cost =
|
|
futures_money_or_panic(transaction_cost.max(0.0), "futures open transaction cost");
|
|
self.transaction_cost = self
|
|
.transaction_cost
|
|
.checked_add(transaction_cost)
|
|
.expect("fixed-point futures transaction cost overflow");
|
|
self.trade_quantity_delta += quantity as i32;
|
|
self.trade_value = self
|
|
.trade_value
|
|
.checked_add(futures_money_or_panic(
|
|
price * quantity as f64 * self.contract_multiplier,
|
|
"futures open trade value",
|
|
))
|
|
.expect("fixed-point futures trade value overflow");
|
|
}
|
|
|
|
pub fn close(
|
|
&mut self,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
) -> Result<f64, String> {
|
|
self.close_with_effect(
|
|
quantity,
|
|
price,
|
|
transaction_cost,
|
|
FuturesPositionEffect::Close,
|
|
)
|
|
}
|
|
|
|
pub fn close_with_effect(
|
|
&mut self,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
effect: FuturesPositionEffect,
|
|
) -> Result<f64, String> {
|
|
self.close_with_effect_money(quantity, price, transaction_cost, effect)
|
|
.map(FixedMoney::to_f64)
|
|
}
|
|
|
|
fn close_with_effect_money(
|
|
&mut self,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
effect: FuturesPositionEffect,
|
|
) -> Result<FixedMoney, String> {
|
|
if effect == FuturesPositionEffect::Open {
|
|
return Err("close_with_effect does not accept open effect".to_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(FixedMoney::ZERO);
|
|
}
|
|
match effect {
|
|
FuturesPositionEffect::Open => unreachable!(),
|
|
FuturesPositionEffect::Close => {
|
|
let old_closed = quantity.min(self.old_quantity);
|
|
self.old_quantity -= old_closed;
|
|
}
|
|
FuturesPositionEffect::CloseToday => {
|
|
let today_quantity = self.today_quantity();
|
|
if quantity > today_quantity {
|
|
return Err(format!(
|
|
"close today quantity {} exceeds today quantity {} for {} {}",
|
|
quantity,
|
|
today_quantity,
|
|
self.symbol,
|
|
self.direction.as_str()
|
|
));
|
|
}
|
|
}
|
|
FuturesPositionEffect::CloseYesterday => {
|
|
if quantity > self.old_quantity {
|
|
return Err(format!(
|
|
"close yesterday quantity {} exceeds old quantity {} for {} {}",
|
|
quantity,
|
|
self.old_quantity,
|
|
self.symbol,
|
|
self.direction.as_str()
|
|
));
|
|
}
|
|
self.old_quantity -= quantity;
|
|
}
|
|
}
|
|
|
|
let transaction_cost =
|
|
futures_money(transaction_cost.max(0.0), "futures close transaction cost")?;
|
|
let realized = futures_money(
|
|
(price - self.avg_price)
|
|
* quantity as f64
|
|
* self.contract_multiplier
|
|
* self.direction.factor(),
|
|
"futures realized PnL",
|
|
)?
|
|
.checked_sub(transaction_cost)
|
|
.ok_or_else(|| "fixed-point futures realized PnL overflow".to_string())?;
|
|
self.quantity -= quantity;
|
|
if self.quantity == 0 {
|
|
self.avg_price = 0.0;
|
|
}
|
|
self.last_price = price;
|
|
self.transaction_cost = self
|
|
.transaction_cost
|
|
.checked_add(transaction_cost)
|
|
.ok_or_else(|| "fixed-point futures transaction cost overflow".to_string())?;
|
|
self.trade_quantity_delta -= quantity as i32;
|
|
self.trade_value = self
|
|
.trade_value
|
|
.checked_sub(futures_money(
|
|
price * quantity as f64 * self.contract_multiplier,
|
|
"futures close trade value",
|
|
)?)
|
|
.ok_or_else(|| "fixed-point futures trade value overflow".to_string())?;
|
|
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.day_start_quantity = self.quantity;
|
|
self.prev_close = self.last_price;
|
|
self.transaction_cost = FixedMoney::ZERO;
|
|
self.trade_quantity_delta = 0;
|
|
self.trade_value = FixedMoney::ZERO;
|
|
}
|
|
|
|
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
|
|
self.settlement_money(settlement_price).to_f64()
|
|
}
|
|
|
|
fn settlement_money(&mut self, settlement_price: f64) -> FixedMoney {
|
|
self.mark_price(settlement_price);
|
|
let cash_delta = self.equity_money();
|
|
self.avg_price = self.last_price;
|
|
cash_delta
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct FuturesAccountState {
|
|
starting_cash: FixedMoney,
|
|
total_cash: FixedMoney,
|
|
frozen_cash: FixedMoney,
|
|
closed_day_trading_pnl: FixedMoney,
|
|
closed_day_position_pnl: FixedMoney,
|
|
closed_day_transaction_cost: FixedMoney,
|
|
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
|
|
}
|
|
|
|
impl FuturesAccountState {
|
|
pub fn new(total_cash: f64) -> Self {
|
|
let total_cash = futures_money_or_panic(total_cash, "futures starting cash");
|
|
Self {
|
|
starting_cash: total_cash,
|
|
total_cash,
|
|
frozen_cash: FixedMoney::ZERO,
|
|
closed_day_trading_pnl: FixedMoney::ZERO,
|
|
closed_day_position_pnl: FixedMoney::ZERO,
|
|
closed_day_transaction_cost: FixedMoney::ZERO,
|
|
positions: BTreeMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn starting_cash(&self) -> f64 {
|
|
self.starting_cash.to_f64()
|
|
}
|
|
|
|
pub fn total_cash(&self) -> f64 {
|
|
self.total_cash.to_f64()
|
|
}
|
|
|
|
pub fn frozen_cash(&self) -> f64 {
|
|
self.frozen_cash.to_f64()
|
|
}
|
|
|
|
pub fn cash(&self) -> f64 {
|
|
self.cash_money().to_f64()
|
|
}
|
|
|
|
fn cash_money(&self) -> FixedMoney {
|
|
self.total_cash
|
|
.checked_sub(self.margin_money())
|
|
.and_then(|cash| cash.checked_sub(self.frozen_cash))
|
|
.expect("fixed-point futures available cash overflow")
|
|
}
|
|
|
|
pub fn margin(&self) -> f64 {
|
|
self.margin_money().to_f64()
|
|
}
|
|
|
|
fn margin_money(&self) -> FixedMoney {
|
|
sum_futures_money(
|
|
self.positions.values().map(FuturesPosition::margin_money),
|
|
"futures account margin",
|
|
)
|
|
}
|
|
|
|
pub fn market_value(&self) -> f64 {
|
|
sum_futures_money(
|
|
self.positions
|
|
.values()
|
|
.map(FuturesPosition::market_value_money),
|
|
"futures account market value",
|
|
)
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn position_equity(&self) -> f64 {
|
|
self.position_equity_money().to_f64()
|
|
}
|
|
|
|
fn position_equity_money(&self) -> FixedMoney {
|
|
sum_futures_money(
|
|
self.positions.values().map(FuturesPosition::equity_money),
|
|
"futures account position equity",
|
|
)
|
|
}
|
|
|
|
pub fn total_value(&self) -> f64 {
|
|
self.total_cash
|
|
.checked_add(self.position_equity_money())
|
|
.expect("fixed-point futures total value overflow")
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn daily_pnl(&self) -> f64 {
|
|
self.trading_pnl_money()
|
|
.checked_add(self.position_pnl_money())
|
|
.and_then(|pnl| pnl.checked_sub(self.transaction_cost_money()))
|
|
.expect("fixed-point futures daily PnL overflow")
|
|
.to_f64()
|
|
}
|
|
|
|
pub fn trading_pnl(&self) -> f64 {
|
|
self.trading_pnl_money().to_f64()
|
|
}
|
|
|
|
fn trading_pnl_money(&self) -> FixedMoney {
|
|
sum_futures_money(
|
|
std::iter::once(self.closed_day_trading_pnl).chain(
|
|
self.positions
|
|
.values()
|
|
.map(FuturesPosition::trading_pnl_money),
|
|
),
|
|
"futures account trading PnL",
|
|
)
|
|
}
|
|
|
|
pub fn position_pnl(&self) -> f64 {
|
|
self.position_pnl_money().to_f64()
|
|
}
|
|
|
|
fn position_pnl_money(&self) -> FixedMoney {
|
|
sum_futures_money(
|
|
std::iter::once(self.closed_day_position_pnl).chain(
|
|
self.positions
|
|
.values()
|
|
.map(FuturesPosition::position_pnl_money),
|
|
),
|
|
"futures account position PnL",
|
|
)
|
|
}
|
|
|
|
pub fn transaction_cost(&self) -> f64 {
|
|
self.transaction_cost_money().to_f64()
|
|
}
|
|
|
|
fn transaction_cost_money(&self) -> FixedMoney {
|
|
sum_futures_money(
|
|
std::iter::once(self.closed_day_transaction_cost).chain(
|
|
self.positions
|
|
.values()
|
|
.map(|position| position.transaction_cost),
|
|
),
|
|
"futures account transaction cost",
|
|
)
|
|
}
|
|
|
|
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 = self
|
|
.total_cash
|
|
.checked_sub(futures_money_or_panic(
|
|
transaction_cost.max(0.0),
|
|
"futures open transaction cost",
|
|
))
|
|
.expect("fixed-point futures cash overflow");
|
|
}
|
|
|
|
pub fn close(
|
|
&mut self,
|
|
symbol: &str,
|
|
direction: FuturesDirection,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
) -> Result<f64, String> {
|
|
self.close_with_effect(
|
|
symbol,
|
|
direction,
|
|
quantity,
|
|
price,
|
|
transaction_cost,
|
|
FuturesPositionEffect::Close,
|
|
)
|
|
}
|
|
|
|
pub fn close_with_effect(
|
|
&mut self,
|
|
symbol: &str,
|
|
direction: FuturesDirection,
|
|
quantity: u32,
|
|
price: f64,
|
|
transaction_cost: f64,
|
|
effect: FuturesPositionEffect,
|
|
) -> 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_with_effect_money(quantity, price, transaction_cost, effect)?;
|
|
self.total_cash = self
|
|
.total_cash
|
|
.checked_add(cash_delta)
|
|
.ok_or_else(|| "fixed-point futures cash overflow".to_string())?;
|
|
if position.quantity == 0 {
|
|
self.closed_day_trading_pnl = self
|
|
.closed_day_trading_pnl
|
|
.checked_add(position.trading_pnl_money())
|
|
.ok_or_else(|| "fixed-point closed futures trading PnL overflow".to_string())?;
|
|
self.closed_day_position_pnl = self
|
|
.closed_day_position_pnl
|
|
.checked_add(position.position_pnl_money())
|
|
.ok_or_else(|| "fixed-point closed futures position PnL overflow".to_string())?;
|
|
self.closed_day_transaction_cost = self
|
|
.closed_day_transaction_cost
|
|
.checked_add(position.transaction_cost)
|
|
.ok_or_else(|| {
|
|
"fixed-point closed futures transaction cost overflow".to_string()
|
|
})?;
|
|
self.positions.remove(&key);
|
|
}
|
|
Ok(cash_delta.to_f64())
|
|
}
|
|
|
|
pub fn execute_order(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
order_id: Option<u64>,
|
|
intent: FuturesOrderIntent,
|
|
) -> FuturesExecutionReport {
|
|
let mut report = FuturesExecutionReport::default();
|
|
let side = intent.side();
|
|
push_futures_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::OrderPendingNew,
|
|
order_id,
|
|
&intent.symbol,
|
|
side,
|
|
format!(
|
|
"requested_quantity={} direction={} effect={} reason={}",
|
|
intent.quantity,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str(),
|
|
intent.reason
|
|
),
|
|
);
|
|
|
|
if intent.quantity == 0 || !intent.price.is_finite() || intent.price <= 0.0 {
|
|
push_futures_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::OrderCreationReject,
|
|
order_id,
|
|
&intent.symbol,
|
|
side,
|
|
"invalid futures order",
|
|
);
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id,
|
|
symbol: intent.symbol,
|
|
side,
|
|
requested_quantity: intent.quantity,
|
|
filled_quantity: 0,
|
|
status: OrderStatus::Rejected,
|
|
reason: format!(
|
|
"{}: invalid futures order effect={} price={} quantity={}",
|
|
intent.reason,
|
|
intent.effect.as_str(),
|
|
intent.price,
|
|
intent.quantity
|
|
),
|
|
});
|
|
return report;
|
|
}
|
|
|
|
let cash_before = self.total_cash();
|
|
let position_before = self
|
|
.position(&intent.symbol, intent.direction)
|
|
.map(|position| position.quantity)
|
|
.unwrap_or(0);
|
|
let result = match intent.effect {
|
|
FuturesPositionEffect::Open => {
|
|
let mut projected = self.clone();
|
|
projected.open(
|
|
intent.symbol.clone(),
|
|
intent.direction,
|
|
intent.spec,
|
|
intent.quantity,
|
|
intent.price,
|
|
intent.transaction_cost,
|
|
);
|
|
if projected.cash_money().raw() < 0 {
|
|
Err(format!(
|
|
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
|
|
self.cash(),
|
|
projected.margin()
|
|
))
|
|
} else {
|
|
self.open(
|
|
intent.symbol.clone(),
|
|
intent.direction,
|
|
intent.spec,
|
|
intent.quantity,
|
|
intent.price,
|
|
intent.transaction_cost,
|
|
);
|
|
Ok(futures_money_or_panic(
|
|
intent.transaction_cost.max(0.0),
|
|
"futures open transaction cost",
|
|
)
|
|
.checked_neg()
|
|
.expect("fixed-point futures open cash delta overflow")
|
|
.to_f64())
|
|
}
|
|
}
|
|
FuturesPositionEffect::Close
|
|
| FuturesPositionEffect::CloseToday
|
|
| FuturesPositionEffect::CloseYesterday => self.close_with_effect(
|
|
&intent.symbol,
|
|
intent.direction,
|
|
intent.quantity,
|
|
intent.price,
|
|
intent.transaction_cost,
|
|
intent.effect,
|
|
),
|
|
};
|
|
|
|
match result {
|
|
Ok(cash_delta) => {
|
|
let position_after = self
|
|
.position(&intent.symbol, intent.direction)
|
|
.map(|position| position.quantity)
|
|
.unwrap_or(0);
|
|
let avg_price_after = self
|
|
.position(&intent.symbol, intent.direction)
|
|
.map(|position| position.avg_price)
|
|
.unwrap_or(0.0);
|
|
let notional = futures_money_or_panic(
|
|
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier,
|
|
"futures fill notional",
|
|
)
|
|
.to_f64();
|
|
let transaction_cost = futures_money_or_panic(
|
|
intent.transaction_cost.max(0.0),
|
|
"futures fill transaction cost",
|
|
)
|
|
.to_f64();
|
|
report.fill_events.push(FillEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
execution_start_timestamp: None,
|
|
execution_timestamp: None,
|
|
order_id,
|
|
symbol: intent.symbol.clone(),
|
|
side,
|
|
quantity: intent.quantity,
|
|
price: intent.price,
|
|
gross_amount: notional,
|
|
commission: transaction_cost,
|
|
stamp_tax: 0.0,
|
|
transfer_fee: 0.0,
|
|
net_cash_flow: cash_delta,
|
|
reason: format!(
|
|
"{} direction={} effect={}",
|
|
intent.reason,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
push_futures_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::OrderCreationPass,
|
|
order_id,
|
|
&intent.symbol,
|
|
side,
|
|
"futures order passed account checks",
|
|
);
|
|
push_futures_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::Trade,
|
|
order_id,
|
|
&intent.symbol,
|
|
side,
|
|
format!("filled_quantity={} price={}", intent.quantity, intent.price),
|
|
);
|
|
report.position_events.push(PositionEvent {
|
|
date,
|
|
symbol: intent.symbol.clone(),
|
|
delta_quantity: position_after as i32 - position_before as i32,
|
|
quantity_after: position_after,
|
|
average_cost: avg_price_after,
|
|
realized_pnl_delta: if intent.effect == FuturesPositionEffect::Open {
|
|
0.0
|
|
} else {
|
|
cash_delta
|
|
},
|
|
reason: format!(
|
|
"{} direction={} effect={}",
|
|
intent.reason,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
report.account_events.push(AccountEvent {
|
|
date,
|
|
cash_before,
|
|
cash_after: self.total_cash(),
|
|
total_equity: self.total_value(),
|
|
note: format!(
|
|
"futures {} {} {}",
|
|
intent.symbol,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id,
|
|
symbol: intent.symbol,
|
|
side,
|
|
requested_quantity: intent.quantity,
|
|
filled_quantity: intent.quantity,
|
|
status: OrderStatus::Filled,
|
|
reason: format!(
|
|
"{} direction={} effect={}",
|
|
intent.reason,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
}
|
|
Err(reason) => {
|
|
push_futures_process_event(
|
|
&mut report,
|
|
date,
|
|
ProcessEventKind::OrderCreationReject,
|
|
order_id,
|
|
&intent.symbol,
|
|
side,
|
|
reason.clone(),
|
|
);
|
|
report.order_events.push(OrderEvent {
|
|
date,
|
|
decision_date: None,
|
|
order_created_date: None,
|
|
execution_date: None,
|
|
order_id,
|
|
symbol: intent.symbol,
|
|
side,
|
|
requested_quantity: intent.quantity,
|
|
filled_quantity: 0,
|
|
status: OrderStatus::Rejected,
|
|
reason: format!(
|
|
"{}: {} direction={} effect={}",
|
|
intent.reason,
|
|
reason,
|
|
intent.direction.as_str(),
|
|
intent.effect.as_str()
|
|
),
|
|
});
|
|
}
|
|
}
|
|
report
|
|
}
|
|
|
|
pub fn expire_contract(
|
|
&mut self,
|
|
date: NaiveDate,
|
|
symbol: &str,
|
|
settlement_price: f64,
|
|
reason: impl Into<String>,
|
|
) -> FuturesExecutionReport {
|
|
let reason = reason.into();
|
|
let keys = self
|
|
.positions
|
|
.keys()
|
|
.filter(|(position_symbol, _)| position_symbol == symbol)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
let mut combined = FuturesExecutionReport::default();
|
|
for (position_symbol, direction) in keys {
|
|
let Some(position) = self.position(&position_symbol, direction) else {
|
|
continue;
|
|
};
|
|
if position.quantity == 0 {
|
|
continue;
|
|
}
|
|
let price = if settlement_price.is_finite() && settlement_price > 0.0 {
|
|
settlement_price
|
|
} else {
|
|
position.last_price
|
|
};
|
|
let intent = FuturesOrderIntent::close(
|
|
position_symbol.clone(),
|
|
direction,
|
|
FuturesPositionEffect::Close,
|
|
FuturesContractSpec::new(
|
|
position.contract_multiplier,
|
|
position.margin_rate,
|
|
position.margin_rate,
|
|
),
|
|
position.quantity,
|
|
price,
|
|
0.0,
|
|
format!("{reason}: futures_expiration_settlement"),
|
|
);
|
|
let report = self.execute_order(date, None, intent);
|
|
combined.order_events.extend(report.order_events);
|
|
combined.fill_events.extend(report.fill_events);
|
|
combined.position_events.extend(report.position_events);
|
|
combined.account_events.extend(report.account_events);
|
|
combined.process_events.extend(report.process_events);
|
|
combined.diagnostics.extend(report.diagnostics);
|
|
}
|
|
combined.diagnostics.push(format!(
|
|
"futures_expiration_settlement symbol={symbol} closed_orders={}",
|
|
combined.order_events.len()
|
|
));
|
|
combined
|
|
}
|
|
|
|
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) {
|
|
self.closed_day_trading_pnl = FixedMoney::ZERO;
|
|
self.closed_day_position_pnl = FixedMoney::ZERO;
|
|
self.closed_day_transaction_cost = FixedMoney::ZERO;
|
|
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 = FixedMoney::ZERO;
|
|
for position in self.positions.values_mut() {
|
|
let price = settlement_prices
|
|
.get(&position.symbol)
|
|
.copied()
|
|
.unwrap_or(position.last_price);
|
|
cash_delta = cash_delta
|
|
.checked_add(position.settlement_money(price))
|
|
.expect("fixed-point futures settlement overflow");
|
|
}
|
|
self.total_cash = self
|
|
.total_cash
|
|
.checked_add(cash_delta)
|
|
.expect("fixed-point futures cash settlement overflow");
|
|
cash_delta.to_f64()
|
|
}
|
|
}
|
|
|
|
fn push_futures_process_event(
|
|
report: &mut FuturesExecutionReport,
|
|
date: NaiveDate,
|
|
kind: ProcessEventKind,
|
|
order_id: Option<u64>,
|
|
symbol: &str,
|
|
side: OrderSide,
|
|
detail: impl Into<String>,
|
|
) {
|
|
report.process_events.push(ProcessEvent {
|
|
date,
|
|
kind,
|
|
order_id,
|
|
symbol: Some(symbol.to_string()),
|
|
side: Some(side),
|
|
detail: detail.into(),
|
|
});
|
|
}
|