将期货现金账本切换为定点并修正日度盈亏
This commit is contained in:
+289
-75
@@ -7,6 +7,24 @@ use crate::events::{
|
|||||||
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
AccountEvent, FillEvent, OrderEvent, OrderSide, OrderStatus, PositionEvent, ProcessEvent,
|
||||||
ProcessEventKind,
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
pub enum FuturesDirection {
|
pub enum FuturesDirection {
|
||||||
@@ -366,15 +384,16 @@ pub struct FuturesPosition {
|
|||||||
pub symbol: String,
|
pub symbol: String,
|
||||||
pub direction: FuturesDirection,
|
pub direction: FuturesDirection,
|
||||||
pub old_quantity: u32,
|
pub old_quantity: u32,
|
||||||
|
day_start_quantity: u32,
|
||||||
pub quantity: u32,
|
pub quantity: u32,
|
||||||
pub avg_price: f64,
|
pub avg_price: f64,
|
||||||
pub last_price: f64,
|
pub last_price: f64,
|
||||||
pub prev_close: f64,
|
pub prev_close: f64,
|
||||||
pub contract_multiplier: f64,
|
pub contract_multiplier: f64,
|
||||||
pub margin_rate: f64,
|
pub margin_rate: f64,
|
||||||
pub transaction_cost: f64,
|
transaction_cost: FixedMoney,
|
||||||
trade_quantity_delta: i32,
|
trade_quantity_delta: i32,
|
||||||
trade_cost: f64,
|
trade_value: FixedMoney,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FuturesPosition {
|
impl FuturesPosition {
|
||||||
@@ -390,15 +409,16 @@ impl FuturesPosition {
|
|||||||
symbol: symbol.into(),
|
symbol: symbol.into(),
|
||||||
direction,
|
direction,
|
||||||
old_quantity: init_quantity,
|
old_quantity: init_quantity,
|
||||||
|
day_start_quantity: init_quantity,
|
||||||
quantity: init_quantity,
|
quantity: init_quantity,
|
||||||
avg_price: init_price.max(0.0),
|
avg_price: init_price.max(0.0),
|
||||||
last_price: init_price.max(0.0),
|
last_price: init_price.max(0.0),
|
||||||
prev_close: init_price.max(0.0),
|
prev_close: init_price.max(0.0),
|
||||||
contract_multiplier: spec.contract_multiplier,
|
contract_multiplier: spec.contract_multiplier,
|
||||||
margin_rate,
|
margin_rate,
|
||||||
transaction_cost: 0.0,
|
transaction_cost: FixedMoney::ZERO,
|
||||||
trade_quantity_delta: 0,
|
trade_quantity_delta: 0,
|
||||||
trade_cost: 0.0,
|
trade_value: FixedMoney::ZERO,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,18 +427,39 @@ impl FuturesPosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn market_value(&self) -> f64 {
|
pub fn market_value(&self) -> f64 {
|
||||||
self.quantity as f64 * self.last_price * self.contract_multiplier
|
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 {
|
pub fn margin(&self) -> f64 {
|
||||||
self.market_value() * self.margin_rate
|
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 {
|
pub fn equity(&self) -> f64 {
|
||||||
(self.last_price - self.avg_price)
|
self.equity_money().to_f64()
|
||||||
* self.quantity as f64
|
}
|
||||||
* self.contract_multiplier
|
|
||||||
* self.direction.factor()
|
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 {
|
pub fn pnl(&self) -> f64 {
|
||||||
@@ -426,22 +467,47 @@ impl FuturesPosition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn trading_pnl(&self) -> f64 {
|
pub fn trading_pnl(&self) -> f64 {
|
||||||
(self.trade_quantity_delta as f64 * self.last_price - self.trade_cost)
|
self.trading_pnl_money().to_f64()
|
||||||
* self.contract_multiplier
|
}
|
||||||
* self.direction.factor()
|
|
||||||
|
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 {
|
pub fn position_pnl(&self) -> f64 {
|
||||||
if self.old_quantity == 0 {
|
self.position_pnl_money().to_f64()
|
||||||
0.0
|
}
|
||||||
|
|
||||||
|
fn position_pnl_money(&self) -> FixedMoney {
|
||||||
|
if self.day_start_quantity == 0 {
|
||||||
|
FixedMoney::ZERO
|
||||||
} else {
|
} else {
|
||||||
self.old_quantity as f64
|
futures_money_or_panic(
|
||||||
* (self.last_price - self.prev_close)
|
self.day_start_quantity as f64
|
||||||
* self.contract_multiplier
|
* (self.last_price - self.prev_close)
|
||||||
* self.direction.factor()
|
* 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) {
|
pub fn open(&mut self, quantity: u32, price: f64, transaction_cost: f64) {
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
return;
|
return;
|
||||||
@@ -450,9 +516,20 @@ impl FuturesPosition {
|
|||||||
self.quantity += quantity;
|
self.quantity += quantity;
|
||||||
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
|
self.avg_price = (old_value + price * quantity as f64) / self.quantity as f64;
|
||||||
self.last_price = price;
|
self.last_price = price;
|
||||||
self.transaction_cost += transaction_cost.max(0.0);
|
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_quantity_delta += quantity as i32;
|
||||||
self.trade_cost += price * quantity as f64;
|
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(
|
pub fn close(
|
||||||
@@ -476,6 +553,17 @@ impl FuturesPosition {
|
|||||||
transaction_cost: f64,
|
transaction_cost: f64,
|
||||||
effect: FuturesPositionEffect,
|
effect: FuturesPositionEffect,
|
||||||
) -> Result<f64, String> {
|
) -> 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 {
|
if effect == FuturesPositionEffect::Open {
|
||||||
return Err("close_with_effect does not accept open effect".to_string());
|
return Err("close_with_effect does not accept open effect".to_string());
|
||||||
}
|
}
|
||||||
@@ -489,7 +577,7 @@ impl FuturesPosition {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
if quantity == 0 {
|
if quantity == 0 {
|
||||||
return Ok(0.0);
|
return Ok(FixedMoney::ZERO);
|
||||||
}
|
}
|
||||||
match effect {
|
match effect {
|
||||||
FuturesPositionEffect::Open => unreachable!(),
|
FuturesPositionEffect::Open => unreachable!(),
|
||||||
@@ -523,19 +611,34 @@ impl FuturesPosition {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let realized = (price - self.avg_price)
|
let transaction_cost =
|
||||||
* quantity as f64
|
futures_money(transaction_cost.max(0.0), "futures close transaction cost")?;
|
||||||
* self.contract_multiplier
|
let realized = futures_money(
|
||||||
* self.direction.factor()
|
(price - self.avg_price)
|
||||||
- transaction_cost.max(0.0);
|
* 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;
|
self.quantity -= quantity;
|
||||||
if self.quantity == 0 {
|
if self.quantity == 0 {
|
||||||
self.avg_price = 0.0;
|
self.avg_price = 0.0;
|
||||||
}
|
}
|
||||||
self.last_price = price;
|
self.last_price = price;
|
||||||
self.transaction_cost += transaction_cost.max(0.0);
|
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_quantity_delta -= quantity as i32;
|
||||||
self.trade_cost -= price * quantity as f64;
|
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)
|
Ok(realized)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,98 +650,163 @@ impl FuturesPosition {
|
|||||||
|
|
||||||
pub fn begin_trading_day(&mut self) {
|
pub fn begin_trading_day(&mut self) {
|
||||||
self.old_quantity = self.quantity;
|
self.old_quantity = self.quantity;
|
||||||
|
self.day_start_quantity = self.quantity;
|
||||||
self.prev_close = self.last_price;
|
self.prev_close = self.last_price;
|
||||||
self.transaction_cost = 0.0;
|
self.transaction_cost = FixedMoney::ZERO;
|
||||||
self.trade_quantity_delta = 0;
|
self.trade_quantity_delta = 0;
|
||||||
self.trade_cost = 0.0;
|
self.trade_value = FixedMoney::ZERO;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn settlement(&mut self, settlement_price: f64) -> f64 {
|
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);
|
self.mark_price(settlement_price);
|
||||||
let cash_delta = self.equity();
|
let cash_delta = self.equity_money();
|
||||||
self.avg_price = self.last_price;
|
self.avg_price = self.last_price;
|
||||||
self.prev_close = self.last_price;
|
|
||||||
self.old_quantity = self.quantity;
|
|
||||||
cash_delta
|
cash_delta
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct FuturesAccountState {
|
pub struct FuturesAccountState {
|
||||||
starting_cash: f64,
|
starting_cash: FixedMoney,
|
||||||
total_cash: f64,
|
total_cash: FixedMoney,
|
||||||
frozen_cash: f64,
|
frozen_cash: FixedMoney,
|
||||||
|
closed_day_trading_pnl: FixedMoney,
|
||||||
|
closed_day_position_pnl: FixedMoney,
|
||||||
|
closed_day_transaction_cost: FixedMoney,
|
||||||
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
|
positions: BTreeMap<(String, FuturesDirection), FuturesPosition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FuturesAccountState {
|
impl FuturesAccountState {
|
||||||
pub fn new(total_cash: f64) -> Self {
|
pub fn new(total_cash: f64) -> Self {
|
||||||
|
let total_cash = futures_money_or_panic(total_cash, "futures starting cash");
|
||||||
Self {
|
Self {
|
||||||
starting_cash: total_cash,
|
starting_cash: total_cash,
|
||||||
total_cash,
|
total_cash,
|
||||||
frozen_cash: 0.0,
|
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(),
|
positions: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn starting_cash(&self) -> f64 {
|
pub fn starting_cash(&self) -> f64 {
|
||||||
self.starting_cash
|
self.starting_cash.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn total_cash(&self) -> f64 {
|
pub fn total_cash(&self) -> f64 {
|
||||||
self.total_cash
|
self.total_cash.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn frozen_cash(&self) -> f64 {
|
pub fn frozen_cash(&self) -> f64 {
|
||||||
self.frozen_cash
|
self.frozen_cash.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn cash(&self) -> f64 {
|
pub fn cash(&self) -> f64 {
|
||||||
self.total_cash - self.margin() - self.frozen_cash
|
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 {
|
pub fn margin(&self) -> f64 {
|
||||||
self.positions.values().map(FuturesPosition::margin).sum()
|
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 {
|
pub fn market_value(&self) -> f64 {
|
||||||
self.positions
|
sum_futures_money(
|
||||||
.values()
|
self.positions
|
||||||
.map(FuturesPosition::market_value)
|
.values()
|
||||||
.sum()
|
.map(FuturesPosition::market_value_money),
|
||||||
|
"futures account market value",
|
||||||
|
)
|
||||||
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn position_equity(&self) -> f64 {
|
pub fn position_equity(&self) -> f64 {
|
||||||
self.positions.values().map(FuturesPosition::equity).sum()
|
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 {
|
pub fn total_value(&self) -> f64 {
|
||||||
self.total_cash + self.position_equity()
|
self.total_cash
|
||||||
|
.checked_add(self.position_equity_money())
|
||||||
|
.expect("fixed-point futures total value overflow")
|
||||||
|
.to_f64()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn daily_pnl(&self) -> f64 {
|
pub fn daily_pnl(&self) -> f64 {
|
||||||
self.trading_pnl() + self.position_pnl() - self.transaction_cost()
|
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 {
|
pub fn trading_pnl(&self) -> f64 {
|
||||||
self.positions
|
self.trading_pnl_money().to_f64()
|
||||||
.values()
|
}
|
||||||
.map(FuturesPosition::trading_pnl)
|
|
||||||
.sum()
|
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 {
|
pub fn position_pnl(&self) -> f64 {
|
||||||
self.positions
|
self.position_pnl_money().to_f64()
|
||||||
.values()
|
}
|
||||||
.map(FuturesPosition::position_pnl)
|
|
||||||
.sum()
|
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 {
|
pub fn transaction_cost(&self) -> f64 {
|
||||||
self.positions
|
self.transaction_cost_money().to_f64()
|
||||||
.values()
|
}
|
||||||
.map(|position| position.transaction_cost)
|
|
||||||
.sum()
|
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> {
|
pub fn positions(&self) -> &BTreeMap<(String, FuturesDirection), FuturesPosition> {
|
||||||
@@ -667,7 +835,13 @@ impl FuturesAccountState {
|
|||||||
.entry((symbol.clone(), direction))
|
.entry((symbol.clone(), direction))
|
||||||
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
|
.or_insert_with(|| FuturesPosition::new(symbol, direction, spec, 0, price));
|
||||||
position.open(quantity, price, transaction_cost);
|
position.open(quantity, price, transaction_cost);
|
||||||
self.total_cash -= transaction_cost.max(0.0);
|
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(
|
pub fn close(
|
||||||
@@ -702,12 +876,30 @@ impl FuturesAccountState {
|
|||||||
.positions
|
.positions
|
||||||
.get_mut(&key)
|
.get_mut(&key)
|
||||||
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
|
.ok_or_else(|| format!("missing futures position {symbol} {}", direction.as_str()))?;
|
||||||
let cash_delta = position.close_with_effect(quantity, price, transaction_cost, effect)?;
|
let cash_delta =
|
||||||
self.total_cash += 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 {
|
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);
|
self.positions.remove(&key);
|
||||||
}
|
}
|
||||||
Ok(cash_delta)
|
Ok(cash_delta.to_f64())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn execute_order(
|
pub fn execute_order(
|
||||||
@@ -782,7 +974,7 @@ impl FuturesAccountState {
|
|||||||
intent.price,
|
intent.price,
|
||||||
intent.transaction_cost,
|
intent.transaction_cost,
|
||||||
);
|
);
|
||||||
if projected.cash() < -1e-8 {
|
if projected.cash_money().raw() < 0 {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
|
"insufficient futures margin available_cash={:.2} required_margin_after={:.2}",
|
||||||
self.cash(),
|
self.cash(),
|
||||||
@@ -797,7 +989,13 @@ impl FuturesAccountState {
|
|||||||
intent.price,
|
intent.price,
|
||||||
intent.transaction_cost,
|
intent.transaction_cost,
|
||||||
);
|
);
|
||||||
Ok(-intent.transaction_cost.max(0.0))
|
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::Close
|
||||||
@@ -822,8 +1020,16 @@ impl FuturesAccountState {
|
|||||||
.position(&intent.symbol, intent.direction)
|
.position(&intent.symbol, intent.direction)
|
||||||
.map(|position| position.avg_price)
|
.map(|position| position.avg_price)
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let notional =
|
let notional = futures_money_or_panic(
|
||||||
intent.price * intent.quantity as f64 * intent.spec.contract_multiplier;
|
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 {
|
report.fill_events.push(FillEvent {
|
||||||
date,
|
date,
|
||||||
decision_date: None,
|
decision_date: None,
|
||||||
@@ -835,7 +1041,7 @@ impl FuturesAccountState {
|
|||||||
quantity: intent.quantity,
|
quantity: intent.quantity,
|
||||||
price: intent.price,
|
price: intent.price,
|
||||||
gross_amount: notional,
|
gross_amount: notional,
|
||||||
commission: intent.transaction_cost.max(0.0),
|
commission: transaction_cost,
|
||||||
stamp_tax: 0.0,
|
stamp_tax: 0.0,
|
||||||
transfer_fee: 0.0,
|
transfer_fee: 0.0,
|
||||||
net_cash_flow: cash_delta,
|
net_cash_flow: cash_delta,
|
||||||
@@ -1010,22 +1216,30 @@ impl FuturesAccountState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn begin_trading_day(&mut self) {
|
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() {
|
for position in self.positions.values_mut() {
|
||||||
position.begin_trading_day();
|
position.begin_trading_day();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
|
pub fn settle(&mut self, settlement_prices: &BTreeMap<String, f64>) -> f64 {
|
||||||
let mut cash_delta = 0.0;
|
let mut cash_delta = FixedMoney::ZERO;
|
||||||
for position in self.positions.values_mut() {
|
for position in self.positions.values_mut() {
|
||||||
let price = settlement_prices
|
let price = settlement_prices
|
||||||
.get(&position.symbol)
|
.get(&position.symbol)
|
||||||
.copied()
|
.copied()
|
||||||
.unwrap_or(position.last_price);
|
.unwrap_or(position.last_price);
|
||||||
cash_delta += position.settlement(price);
|
cash_delta = cash_delta
|
||||||
|
.checked_add(position.settlement_money(price))
|
||||||
|
.expect("fixed-point futures settlement overflow");
|
||||||
}
|
}
|
||||||
self.total_cash += cash_delta;
|
self.total_cash = self
|
||||||
cash_delta
|
.total_cash
|
||||||
|
.checked_add(cash_delta)
|
||||||
|
.expect("fixed-point futures cash settlement overflow");
|
||||||
|
cash_delta.to_f64()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -208,3 +208,134 @@ fn futures_expiration_settlement_closes_all_contract_directions() {
|
|||||||
);
|
);
|
||||||
assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6);
|
assert!((account.total_cash() - 1_003_000.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_full_close_preserves_closed_position_daily_metrics() {
|
||||||
|
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||||
|
let mut account = FuturesAccountState::new(100_000.0);
|
||||||
|
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
|
||||||
|
account.begin_trading_day();
|
||||||
|
|
||||||
|
let realized = account
|
||||||
|
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
|
||||||
|
.expect("close overnight position");
|
||||||
|
|
||||||
|
assert!(account.positions().is_empty());
|
||||||
|
assert!((realized - 98.0).abs() < 1e-12);
|
||||||
|
assert!((account.position_pnl() - 100.0).abs() < 1e-12);
|
||||||
|
assert!(account.trading_pnl().abs() < 1e-12);
|
||||||
|
assert!((account.transaction_cost() - 2.0).abs() < 1e-12);
|
||||||
|
assert!((account.daily_pnl() - 98.0).abs() < 1e-12);
|
||||||
|
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
|
||||||
|
|
||||||
|
account.begin_trading_day();
|
||||||
|
assert!(account.daily_pnl().abs() < 1e-12);
|
||||||
|
assert!(account.transaction_cost().abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_intraday_roundtrip_preserves_closed_trading_pnl() {
|
||||||
|
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||||
|
let mut account = FuturesAccountState::new(100_000.0);
|
||||||
|
account.begin_trading_day();
|
||||||
|
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 1.0);
|
||||||
|
account
|
||||||
|
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 2.0)
|
||||||
|
.expect("close intraday position");
|
||||||
|
|
||||||
|
assert!(account.positions().is_empty());
|
||||||
|
assert!((account.trading_pnl() - 100.0).abs() < 1e-12);
|
||||||
|
assert!(account.position_pnl().abs() < 1e-12);
|
||||||
|
assert!((account.transaction_cost() - 3.0).abs() < 1e-12);
|
||||||
|
assert!((account.daily_pnl() - 97.0).abs() < 1e-12);
|
||||||
|
assert!((account.total_cash() - 100_097.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_partial_close_offsets_later_mark_with_trading_pnl() {
|
||||||
|
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||||
|
let mut account = FuturesAccountState::new(100_000.0);
|
||||||
|
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 2, 100.0, 0.0);
|
||||||
|
account.begin_trading_day();
|
||||||
|
account
|
||||||
|
.close("IF2506.CCFX", FuturesDirection::Long, 1, 110.0, 0.0)
|
||||||
|
.expect("partially close overnight position");
|
||||||
|
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 120.0);
|
||||||
|
|
||||||
|
assert!((account.position_pnl() - 400.0).abs() < 1e-12);
|
||||||
|
assert!((account.trading_pnl() + 100.0).abs() < 1e-12);
|
||||||
|
assert!((account.daily_pnl() - 300.0).abs() < 1e-12);
|
||||||
|
assert!((account.total_value() - 100_300.0).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_settlement_keeps_same_day_pnl_visible_until_next_day() {
|
||||||
|
let spec = FuturesContractSpec::new(10.0, 0.1, 0.1);
|
||||||
|
let mut account = FuturesAccountState::new(100_000.0);
|
||||||
|
account.open("IF2506.CCFX", FuturesDirection::Long, spec, 1, 100.0, 0.0);
|
||||||
|
account.begin_trading_day();
|
||||||
|
account.mark_price("IF2506.CCFX", FuturesDirection::Long, 110.0);
|
||||||
|
|
||||||
|
let settled = account.settle(&BTreeMap::from([("IF2506.CCFX".to_string(), 110.0)]));
|
||||||
|
|
||||||
|
assert!((settled - 100.0).abs() < 1e-12);
|
||||||
|
assert!((account.daily_pnl() - 100.0).abs() < 1e-12);
|
||||||
|
assert!((account.total_cash() - 100_100.0).abs() < 1e-12);
|
||||||
|
assert!((account.total_value() - 100_100.0).abs() < 1e-12);
|
||||||
|
|
||||||
|
account.begin_trading_day();
|
||||||
|
assert!(account.daily_pnl().abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_cash_and_closed_cost_accumulate_micro_yuan_exactly() {
|
||||||
|
let spec = FuturesContractSpec::new(1.0, 0.0, 0.0);
|
||||||
|
let mut account = FuturesAccountState::new(1_000_000.0);
|
||||||
|
account.begin_trading_day();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
account.open(
|
||||||
|
"IF2506.CCFX",
|
||||||
|
FuturesDirection::Long,
|
||||||
|
spec,
|
||||||
|
1,
|
||||||
|
100.0,
|
||||||
|
0.000001,
|
||||||
|
);
|
||||||
|
account
|
||||||
|
.close("IF2506.CCFX", FuturesDirection::Long, 1, 100.0, 0.000001)
|
||||||
|
.expect("close micro-cost position");
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!((account.total_cash() - 999_999.98).abs() < 1e-12);
|
||||||
|
assert!((account.transaction_cost() - 0.02).abs() < 1e-12);
|
||||||
|
assert!((account.daily_pnl() + 0.02).abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn futures_margin_gate_and_fill_cash_use_exact_micro_yuan() {
|
||||||
|
let date = d(2025, 1, 2);
|
||||||
|
let spec = FuturesContractSpec::new(1.0, 1.0, 1.0);
|
||||||
|
let intent = FuturesOrderIntent::open(
|
||||||
|
"IF2506.CCFX",
|
||||||
|
FuturesDirection::Long,
|
||||||
|
spec,
|
||||||
|
1,
|
||||||
|
100.0,
|
||||||
|
0.000001,
|
||||||
|
"micro margin boundary",
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut insufficient = FuturesAccountState::new(100.0);
|
||||||
|
let rejected = insufficient.execute_order(date, Some(1), intent.clone());
|
||||||
|
assert_eq!(rejected.order_events[0].status, OrderStatus::Rejected);
|
||||||
|
assert!((insufficient.total_cash() - 100.0).abs() < 1e-12);
|
||||||
|
|
||||||
|
let mut exact = FuturesAccountState::new(100.000001);
|
||||||
|
let filled = exact.execute_order(date, Some(2), intent);
|
||||||
|
assert_eq!(filled.order_events[0].status, OrderStatus::Filled);
|
||||||
|
assert_eq!(filled.fill_events.len(), 1);
|
||||||
|
assert!((filled.fill_events[0].gross_amount - 100.0).abs() < 1e-12);
|
||||||
|
assert!((filled.fill_events[0].commission - 0.000001).abs() < 1e-12);
|
||||||
|
assert!((filled.fill_events[0].net_cash_flow + 0.000001).abs() < 1e-12);
|
||||||
|
assert!(exact.cash().abs() < 1e-12);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user