use std::{
cmp::Ordering,
collections::{BTreeMap, HashMap},
fmt,
num::NonZeroU64,
panic::{self, AssertUnwindSafe},
sync::{Arc, OnceLock},
};
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use futures::FutureExt;
use uuid::Uuid;
use lnm_sdk::rest::v3::{
error::TradeValidationError,
models::{
ClientId, CrossExposure, CrossLeverage, CrossQuantity, Leverage, Margin, OrderQuantity,
Percentage, PercentageCapped, Price, SATS_PER_BTC, Trade, TradeSide, TradeSize, trade_util,
},
};
use crate::{
db::models::OhlcCandleRow,
error::Result as GeneralResult,
shared::{Lookback, MinIterationInterval},
signal::Signal,
util::DateTimeExt,
};
use super::error::{
IsolatedOrderValidationError, TradeCoreError, TradeCoreResult, TradeExecutorResult,
};
impl crate::sealed::Sealed for Trade {}
pub trait TradeCore: crate::sealed::Sealed + Send + Sync + fmt::Debug + 'static {
fn id(&self) -> Uuid;
fn side(&self) -> TradeSide;
fn opening_fee(&self) -> u64;
fn closing_fee(&self) -> u64;
fn maintenance_margin(&self) -> i64;
fn quantity(&self) -> OrderQuantity;
fn margin(&self) -> Margin;
fn leverage(&self) -> Leverage;
fn price(&self) -> Price;
fn liquidation(&self) -> Price;
fn stoploss(&self) -> Option<Price>;
fn takeprofit(&self) -> Option<Price>;
fn exit_price(&self) -> Option<Price>;
fn created_at(&self) -> DateTime<Utc>;
fn filled_at(&self) -> Option<DateTime<Utc>>;
fn closed_at(&self) -> Option<DateTime<Utc>>;
fn closed(&self) -> bool;
fn client_id(&self) -> Option<&ClientId>;
}
impl TradeCore for Trade {
fn id(&self) -> Uuid {
self.id()
}
fn side(&self) -> TradeSide {
self.side()
}
fn opening_fee(&self) -> u64 {
self.opening_fee()
}
fn closing_fee(&self) -> u64 {
self.closing_fee()
}
fn maintenance_margin(&self) -> i64 {
self.maintenance_margin()
}
fn quantity(&self) -> OrderQuantity {
self.quantity()
}
fn margin(&self) -> Margin {
self.margin()
}
fn leverage(&self) -> Leverage {
self.leverage()
}
fn price(&self) -> Price {
self.price()
}
fn liquidation(&self) -> Price {
self.liquidation()
}
fn stoploss(&self) -> Option<Price> {
self.stoploss()
}
fn takeprofit(&self) -> Option<Price> {
self.takeprofit()
}
fn exit_price(&self) -> Option<Price> {
self.exit_price()
}
fn created_at(&self) -> DateTime<Utc> {
self.created_at()
}
fn filled_at(&self) -> Option<DateTime<Utc>> {
self.filled_at()
}
fn closed_at(&self) -> Option<DateTime<Utc>> {
self.closed_at()
}
fn closed(&self) -> bool {
self.closed()
}
fn client_id(&self) -> Option<&ClientId> {
self.client_id()
}
}
pub trait TradeRunning: TradeCore {
fn est_pl(&self, market_price: Price) -> f64;
fn est_max_additional_margin(&self) -> u64 {
if self.leverage() == Leverage::MIN {
return 0;
}
let max_margin = Margin::calculate(self.quantity(), self.price(), Leverage::MIN);
max_margin.as_u64().saturating_sub(self.margin().as_u64())
}
fn est_max_cash_in(&self, market_price: Price) -> u64 {
let extractable_pl = self.est_pl(market_price).max(0.) as u64;
let min_margin = Margin::calculate(self.quantity(), self.price(), Leverage::MAX);
let excess_margin = self.margin().as_u64().saturating_sub(min_margin.as_u64());
excess_margin + extractable_pl
}
fn est_collateral_delta_for_liquidation(
&self,
target_liquidation: Price,
market_price: Price,
) -> Result<i64, TradeValidationError> {
trade_util::evaluate_collateral_delta_for_liquidation(
self.side(),
self.quantity(),
self.margin(),
self.price(),
self.liquidation(),
target_liquidation,
market_price,
)
}
}
impl TradeRunning for Trade {
fn est_pl(&self, market_price: Price) -> f64 {
trade_util::estimate_pl(self.side(), self.quantity(), self.price(), market_price)
}
}
pub trait TradeClosed: TradeCore {
fn pl(&self) -> i64;
}
impl TradeClosed for Trade {
fn pl(&self) -> i64 {
self.pl()
}
}
pub type TradeReference = (DateTime<Utc>, Uuid);
#[derive(Debug)]
pub struct RunningTradesMap<T: TradeRunning + ?Sized> {
trades: BTreeMap<TradeReference, (Arc<T>, Option<TradeTrailingStoploss>)>,
id_to_time: HashMap<Uuid, DateTime<Utc>>,
}
pub type DynRunningTradesMap = RunningTradesMap<dyn TradeRunning>;
impl<T: TradeRunning + ?Sized> RunningTradesMap<T> {
pub(super) fn new() -> Self {
Self {
trades: BTreeMap::new(),
id_to_time: HashMap::new(),
}
}
pub fn is_empty(&self) -> bool {
self.trades.is_empty()
}
pub(super) fn add(&mut self, trade: Arc<T>, trade_tsl: Option<TradeTrailingStoploss>) {
self.id_to_time.insert(trade.id(), trade.created_at());
self.trades
.insert((trade.created_at(), trade.id()), (trade, trade_tsl));
}
pub fn len(&self) -> usize {
self.id_to_time.len()
}
pub fn contains(&self, trade_id: &Uuid) -> bool {
self.id_to_time.contains_key(trade_id)
}
pub fn get_by_id(&self, id: Uuid) -> Option<&(Arc<T>, Option<TradeTrailingStoploss>)> {
self.id_to_time
.get(&id)
.and_then(|creation_ts| self.trades.get(&(*creation_ts, id)))
}
pub(super) fn get_by_id_mut(
&mut self,
id: Uuid,
) -> Option<&mut (Arc<T>, Option<TradeTrailingStoploss>)> {
self.id_to_time
.get(&id)
.and_then(|creation_ts| self.trades.get_mut(&(*creation_ts, id)))
}
pub fn iter(
&self,
) -> impl Iterator<Item = (&TradeReference, &(Arc<T>, Option<TradeTrailingStoploss>))> {
self.trades.iter()
}
pub fn keys(&self) -> impl Iterator<Item = &TradeReference> {
self.trades.keys()
}
pub fn values(&self) -> impl Iterator<Item = &(Arc<T>, Option<TradeTrailingStoploss>)> {
self.trades.values()
}
pub fn trades_desc(&self) -> impl Iterator<Item = &(Arc<T>, Option<TradeTrailingStoploss>)> {
self.trades.iter().rev().map(|(_, trade_tuple)| trade_tuple)
}
pub(super) fn trades_desc_mut(
&mut self,
) -> impl Iterator<Item = &mut (Arc<T>, Option<TradeTrailingStoploss>)> {
self.trades
.iter_mut()
.rev()
.map(|(_, trade_tuple)| trade_tuple)
}
}
impl<T: TradeRunning> RunningTradesMap<T> {
pub(super) fn into_dyn(self) -> DynRunningTradesMap {
let dyn_trades = self
.trades
.into_iter()
.map(|(key, (trade, stoploss))| {
let dyn_trade: Arc<dyn TradeRunning> = trade;
(key, (dyn_trade, stoploss))
})
.collect();
RunningTradesMap {
trades: dyn_trades,
id_to_time: self.id_to_time,
}
}
}
impl<T: TradeRunning + ?Sized> Clone for RunningTradesMap<T> {
fn clone(&self) -> Self {
Self {
trades: self.trades.clone(),
id_to_time: self.id_to_time.clone(),
}
}
}
impl<'a, T: TradeRunning + ?Sized> IntoIterator for &'a RunningTradesMap<T> {
type Item = (
&'a TradeReference,
&'a (Arc<T>, Option<TradeTrailingStoploss>),
);
type IntoIter = std::collections::btree_map::Iter<
'a,
TradeReference,
(Arc<T>, Option<TradeTrailingStoploss>),
>;
fn into_iter(self) -> Self::IntoIter {
self.trades.iter()
}
}
#[derive(Debug, Clone)]
struct RunningStats {
long_len: usize,
long_margin: u64,
long_quantity: u64,
short_len: usize,
short_margin: u64,
short_quantity: u64,
pl: i64,
fees: u64,
}
pub trait CrossPositionCore: crate::sealed::Sealed + Send + Sync + fmt::Debug + 'static {
fn margin(&self) -> u64;
fn leverage(&self) -> CrossLeverage;
fn exposure(&self) -> CrossExposure;
fn realized_pl(&self) -> i64;
fn session_funding_fees(&self) -> i64;
fn trading_fees(&self) -> u64;
fn quantity(&self) -> i64 {
match self.exposure() {
CrossExposure::Neutral => 0,
CrossExposure::Running(exposure) => {
let quantity = exposure.quantity().as_i64();
match exposure.side() {
TradeSide::Buy => quantity,
TradeSide::Sell => -quantity,
}
}
}
}
fn entry_price(&self) -> Option<Price> {
match self.exposure() {
CrossExposure::Neutral => None,
CrossExposure::Running(exposure) => Some(exposure.entry_price()),
}
}
fn liquidation(&self) -> Option<Price> {
match self.exposure() {
CrossExposure::Neutral => None,
CrossExposure::Running(exposure) => Some(exposure.liquidation()),
}
}
fn initial_margin(&self) -> u64 {
self.running_margin()
}
fn running_margin(&self) -> u64 {
match self.exposure() {
CrossExposure::Neutral => 0,
CrossExposure::Running(exposure) => exposure.running_margin().as_u64(),
}
}
fn maintenance_margin(&self) -> u64 {
match self.exposure() {
CrossExposure::Neutral => 0,
CrossExposure::Running(exposure) => exposure.maintenance_margin().as_u64(),
}
}
fn est_running_pl(&self, market_price: Price) -> i64 {
match self.exposure() {
CrossExposure::Neutral => 0,
CrossExposure::Running(exposure) => trade_util::estimate_pl(
exposure.side(),
exposure.quantity(),
exposure.entry_price(),
market_price,
)
.floor() as i64,
}
}
fn est_net_value(&self, market_price: Price) -> u64 {
self.margin()
.saturating_add_signed(self.est_running_pl(market_price))
}
fn est_free_margin(&self, market_price: Price) -> u64 {
let loss = self.est_running_pl(market_price).min(0).unsigned_abs();
let excess_loss = loss.saturating_sub(self.running_margin());
self.margin()
.saturating_sub(self.running_margin())
.saturating_sub(self.maintenance_margin())
.saturating_sub(excess_loss)
}
fn est_entry_price_for_exposure(
&self,
new_side: TradeSide,
new_quantity: CrossQuantity,
market_price: Price,
) -> Option<Price> {
let CrossExposure::Running(exposure) = self.exposure() else {
return Some(market_price);
};
if exposure.side() != new_side {
return Some(market_price);
}
match new_quantity.cmp(&exposure.quantity()) {
Ordering::Greater => {
let added_quantity = new_quantity.try_sub(exposure.quantity()).ok()?;
Some(trade_util::aggregate_cross_entry_price(
exposure.quantity(),
exposure.entry_price(),
added_quantity,
market_price,
))
}
Ordering::Equal => Some(exposure.entry_price()),
Ordering::Less => {
let reduced_quantity = exposure.quantity().try_sub(new_quantity).ok()?;
let realized_pl = trade_util::estimate_pl(
exposure.side(),
reduced_quantity,
exposure.entry_price(),
market_price,
)
.floor() as i64;
if realized_pl >= 0 {
return Some(exposure.entry_price());
}
let full_position_pl = trade_util::estimate_pl(
exposure.side(),
exposure.quantity(),
exposure.entry_price(),
market_price,
);
let inverse_market_price = SATS_PER_BTC / market_price.as_f64();
let carried_inverse_entry_price = match exposure.side() {
TradeSide::Buy => {
inverse_market_price + full_position_pl / new_quantity.as_f64()
}
TradeSide::Sell => {
inverse_market_price - full_position_pl / new_quantity.as_f64()
}
};
Some(Price::bounded(SATS_PER_BTC / carried_inverse_entry_price))
}
}
}
fn est_margin_for_exposure(
&self,
new_side: TradeSide,
new_quantity: CrossQuantity,
market_price: Price,
) -> Option<i64> {
let current_margin = i64::try_from(self.margin()).ok()?;
let CrossExposure::Running(exposure) = self.exposure() else {
return Some(current_margin);
};
if exposure.side() != new_side {
let realized_pl = trade_util::estimate_pl(
exposure.side(),
exposure.quantity(),
exposure.entry_price(),
market_price,
)
.floor() as i64;
return current_margin.checked_add(realized_pl);
}
if new_quantity >= exposure.quantity() {
return Some(current_margin);
}
let reduced_quantity = exposure.quantity().try_sub(new_quantity).ok()?;
let realized_pl = trade_util::estimate_pl(
exposure.side(),
reduced_quantity,
exposure.entry_price(),
market_price,
)
.floor() as i64;
if realized_pl >= 0 {
current_margin.checked_add(realized_pl)
} else {
Some(current_margin)
}
}
fn est_order_fee_for_exposure(
&self,
new_side: TradeSide,
new_quantity: CrossQuantity,
market_price: Price,
fee_perc: PercentageCapped,
) -> Option<u64> {
let target_quantity = match new_side {
TradeSide::Buy => new_quantity.as_i64(),
TradeSide::Sell => new_quantity.as_i64().checked_neg()?,
};
let order_quantity = target_quantity.checked_sub(self.quantity())?.unsigned_abs();
if order_quantity == 0 {
return Some(0);
}
let order_quantity = CrossQuantity::try_from(order_quantity).ok()?;
Some(trade_util::evaluate_order_fee(
fee_perc,
order_quantity,
market_price,
))
}
fn est_collateral_diff_for_exposure(
&self,
new_side: TradeSide,
new_quantity: CrossQuantity,
market_price: Price,
new_liquidation: Price,
fee_perc: PercentageCapped,
) -> Option<i64> {
let new_entry_price =
self.est_entry_price_for_exposure(new_side, new_quantity, market_price)?;
let CrossExposure::Running(target_exposure) = CrossExposure::running(
Margin::MAX.as_u64(),
self.leverage(),
new_side,
new_quantity,
new_entry_price,
)
.ok()?
else {
unreachable!("running exposure requested")
};
let projected_order_fee =
self.est_order_fee_for_exposure(new_side, new_quantity, market_price, fee_perc)?;
let projected_margin = self
.est_margin_for_exposure(new_side, new_quantity, market_price)?
.checked_sub(i64::try_from(projected_order_fee).ok()?)?;
let projected_pl =
trade_util::estimate_pl(new_side, new_quantity, new_entry_price, market_price);
let projected_net_value = projected_margin.checked_add(projected_pl.floor() as i64)?;
let minimum_coherent_margin = target_exposure
.running_margin()
.try_add(target_exposure.maintenance_margin())
.ok()?
.try_add(1_u64)
.ok()?
.as_i64();
let coherence_delta = minimum_coherent_margin.checked_sub(projected_margin)?;
let target_net_collateral = Margin::est_from_liquidation_price(
new_side,
new_quantity,
market_price,
new_liquidation,
)
.ok()?
.try_add(target_exposure.maintenance_margin())
.ok()?
.as_i64();
let liquidation_delta = target_net_collateral.checked_sub(projected_net_value)?;
Some(liquidation_delta.max(coherence_delta))
}
fn est_collateral_diff_for_liquidation(
&self,
market_price: Price,
new_liquidation: Price,
fee_perc: PercentageCapped,
) -> Option<i64> {
let CrossExposure::Running(exposure) = self.exposure() else {
return None;
};
self.est_collateral_diff_for_exposure(
exposure.side(),
exposure.quantity(),
market_price,
new_liquidation,
fee_perc,
)
}
}
#[derive(Debug, Clone)]
pub struct TradingState {
last_tick_time: DateTime<Utc>,
balance: u64,
market_price: Price,
last_trade_time: Option<DateTime<Utc>>,
running_map: DynRunningTradesMap,
running_stats: OnceLock<RunningStats>,
funding_fees: i64,
realized_pl: i64,
closed_history: Arc<ClosedTradeHistory>,
closed_fees: u64,
cross_position: Arc<dyn CrossPositionCore>,
}
impl TradingState {
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
last_tick_time: DateTime<Utc>,
balance: u64,
market_price: Price,
last_trade_time: Option<DateTime<Utc>>,
running_map: DynRunningTradesMap,
funding_fees: i64,
realized_pl: i64,
closed_history: Arc<ClosedTradeHistory>,
closed_fees: u64,
cross_position: Arc<dyn CrossPositionCore>,
) -> Self {
Self {
last_tick_time,
balance,
market_price,
last_trade_time,
running_map,
running_stats: OnceLock::new(),
funding_fees,
realized_pl,
closed_history,
closed_fees,
cross_position,
}
}
fn get_running_stats(&self) -> &RunningStats {
self.running_stats.get_or_init(|| {
let mut long_len = 0;
let mut long_margin = 0;
let mut long_quantity = 0;
let mut short_len = 0;
let mut short_margin = 0;
let mut short_quantity = 0;
let mut pl = 0;
let mut fees = 0;
for (trade, _) in self.running_map.trades_desc() {
match trade.side() {
TradeSide::Buy => {
long_len += 1;
long_margin +=
trade.margin().as_u64() + trade.maintenance_margin().max(0) as u64;
long_quantity += trade.quantity().as_u64();
}
TradeSide::Sell => {
short_len += 1;
short_margin +=
trade.margin().as_u64() + trade.maintenance_margin().max(0) as u64;
short_quantity += trade.quantity().as_u64();
}
}
pl += trade.est_pl(self.market_price).floor() as i64;
fees += trade.opening_fee();
}
RunningStats {
long_len,
long_margin,
long_quantity,
short_len,
short_margin,
short_quantity,
pl,
fees,
}
})
}
pub fn last_tick_time(&self) -> DateTime<Utc> {
self.last_tick_time
}
pub fn total_net_value(&self) -> u64 {
self.balance
.saturating_add(self.running_margin())
.saturating_add_signed(self.running_pl())
.saturating_add(self.cross_position.est_net_value(self.market_price))
}
pub fn balance(&self) -> u64 {
self.balance
}
pub fn market_price(&self) -> Price {
self.market_price
}
pub fn last_trade_time(&self) -> Option<DateTime<Utc>> {
self.last_trade_time
}
pub fn running_map(&self) -> &DynRunningTradesMap {
&self.running_map
}
pub fn running_long_len(&self) -> usize {
self.get_running_stats().long_len
}
pub fn running_long_margin(&self) -> u64 {
self.get_running_stats().long_margin
}
pub fn running_long_quantity(&self) -> u64 {
self.get_running_stats().long_quantity
}
pub fn running_short_len(&self) -> usize {
self.get_running_stats().short_len
}
pub fn running_short_margin(&self) -> u64 {
self.get_running_stats().short_margin
}
pub fn running_short_quantity(&self) -> u64 {
self.get_running_stats().short_quantity
}
pub fn running_margin(&self) -> u64 {
self.running_long_margin() + self.running_short_margin()
}
pub fn running_quantity(&self) -> u64 {
self.running_long_quantity() + self.running_short_quantity()
}
pub fn running_pl(&self) -> i64 {
self.get_running_stats().pl
}
pub fn running_fees(&self) -> u64 {
self.get_running_stats().fees
}
pub fn funding_fees(&self) -> i64 {
self.funding_fees
}
pub fn realized_pl(&self) -> i64 {
self.realized_pl
}
pub fn closed_history(&self) -> &Arc<ClosedTradeHistory> {
&self.closed_history
}
pub fn closed_len(&self) -> usize {
self.closed_history.len()
}
pub fn closed_fees(&self) -> u64 {
self.closed_fees
}
pub fn closed_net_pl(&self) -> i64 {
self.realized_pl - self.closed_fees() as i64
}
pub fn pl(&self) -> i64 {
self.running_pl() + self.realized_pl
}
pub fn fees(&self) -> u64 {
self.running_fees() + self.closed_fees()
}
pub fn cross_position(&self) -> &dyn CrossPositionCore {
self.cross_position.as_ref()
}
pub fn summary(&self) -> String {
let mut result = String::new();
let last_trade_str = self
.last_trade_time()
.map_or("-".to_string(), |t| t.format_local_short());
let tick_str = self.last_tick_time().format_local_short();
let w = tick_str.len().max(last_trade_str.len());
result.push_str("Timestamps:\n");
result.push_str(&format!(" Tick: {:>w$}\n", tick_str));
result.push_str(&format!(" Last trade: {:>w$}\n\n", last_trade_str));
result.push_str(&format!("Price: {:.1} USD\n\n", self.market_price));
let price = self.market_price.as_f64();
let nav_sats = self.total_net_value().to_string();
let nav_usd = format!(
"{:.2}",
self.total_net_value() as f64 * price / SATS_PER_BTC
);
let w = nav_sats.len().max(nav_usd.len());
result.push_str(&format!("Net Asset Value: {:>w$} sats\n", nav_sats));
result.push_str(&format!(" {:>w$} USD\n\n", nav_usd));
let bal_sats = self.balance.to_string();
let bal_usd = format!("{:.2}", self.balance as f64 * price / SATS_PER_BTC);
let w = bal_sats.len().max(bal_usd.len());
result.push_str(&format!("Available balance: {:>w$} sats\n", bal_sats));
result.push_str(&format!(" {:>w$} USD\n\n", bal_usd));
let cross_position = self.cross_position();
let cross_margin = cross_position.margin().to_string();
let cross_free_margin = cross_position
.est_free_margin(self.market_price)
.to_string();
let mut cross_rows = Vec::new();
if let CrossExposure::Running(exposure) = cross_position.exposure() {
cross_rows.extend([
("Side:", exposure.side().to_string(), ""),
(
"Quantity:",
exposure.quantity().as_u64().to_string(),
" USD",
),
(
"Running P/L:",
cross_position.est_running_pl(self.market_price).to_string(),
" sats",
),
]);
}
cross_rows.extend([
(
"Funding fees:",
cross_position.session_funding_fees().to_string(),
" sats",
),
(
"Realized P/L:",
cross_position.realized_pl().to_string(),
" sats",
),
(
"Order fees:",
cross_position.trading_fees().to_string(),
" sats",
),
]);
let label_width = cross_rows
.iter()
.map(|(label, _, _)| label.len())
.max()
.unwrap_or(0);
let margin_label_width = label_width.saturating_sub(2);
let w = cross_rows
.iter()
.map(|(_, value, _)| value.len())
.chain([cross_margin.len(), cross_free_margin.len()])
.max()
.unwrap_or(0);
result.push_str("Cross:\n");
result.push_str(" Margin:\n");
result.push_str(&format!(
" {:<margin_label_width$} {cross_margin:>w$} sats\n",
"Total:"
));
result.push_str(&format!(
" {:<margin_label_width$} {cross_free_margin:>w$} sats\n",
"Free:"
));
for (label, value, suffix) in cross_rows {
result.push_str(&format!(" {label:<label_width$} {value:>w$}{suffix}\n"));
}
result.push('\n');
result.push_str("Isolated:\n");
let running_long_rows = [
(" Trades:", self.running_long_len().to_string(), ""),
(
" Margin:",
self.running_long_margin().to_string(),
" sats",
),
(
" Quantity:",
self.running_long_quantity().to_string(),
" USD",
),
];
let running_short_rows = [
(" Trades:", self.running_short_len().to_string(), ""),
(
" Margin:",
self.running_short_margin().to_string(),
" sats",
),
(
" Quantity:",
self.running_short_quantity().to_string(),
" USD",
),
];
let running_metric_rows = [
(" P/L:", self.running_pl().to_string(), " sats"),
(" Order fees:", self.running_fees().to_string(), " sats"),
(" Margin:", self.running_margin().to_string(), " sats"),
];
let realized_rows = [
(" Funding fees:", self.funding_fees.to_string(), " sats"),
(" Realized P/L:", self.realized_pl.to_string(), " sats"),
];
let closed_rows = [
(" Trades:", self.closed_len().to_string(), ""),
(" Order fees:", self.closed_fees.to_string(), " sats"),
];
let isolated_label_width = running_long_rows
.iter()
.chain(running_short_rows.iter())
.chain(running_metric_rows.iter())
.chain(realized_rows.iter())
.chain(closed_rows.iter())
.map(|(label, _, _)| label.len())
.max()
.unwrap_or(0);
let w = running_long_rows
.iter()
.chain(running_short_rows.iter())
.chain(running_metric_rows.iter())
.chain(realized_rows.iter())
.chain(closed_rows.iter())
.map(|(_, value, _)| value.len())
.max()
.unwrap_or(0);
result.push_str(" Running Positions:\n");
result.push_str(" Long:\n");
for (label, value, suffix) in running_long_rows {
result.push_str(&format!(
"{label:<isolated_label_width$} {value:>w$}{suffix}\n"
));
}
result.push_str(" Short:\n");
for (label, value, suffix) in running_short_rows {
result.push_str(&format!(
"{label:<isolated_label_width$} {value:>w$}{suffix}\n"
));
}
result.push_str(" Running Metrics:\n");
for (label, value, suffix) in running_metric_rows {
result.push_str(&format!(
"{label:<isolated_label_width$} {value:>w$}{suffix}\n"
));
}
for (label, value, suffix) in realized_rows {
result.push_str(&format!(
"{label:<isolated_label_width$} {value:>w$}{suffix}\n"
));
}
result.push_str(" Closed:\n");
for (label, value, suffix) in closed_rows {
result.push_str(&format!(
"{label:<isolated_label_width$} {value:>w$}{suffix}\n"
));
}
result.push('\n');
result
}
pub fn cross_position_table(&self) -> String {
let cross_position = self.cross_position();
let market_price = self.market_price();
let side = cross_position
.exposure()
.as_running_params()
.map_or_else(|| "Neutral".to_string(), |(side, _, _)| side.to_string());
let price_or_na = |price: Option<Price>| {
price.map_or_else(|| "N/A".to_string(), |price| format!("{price:.1}"))
};
let entry = price_or_na(cross_position.entry_price());
let liquidation = price_or_na(cross_position.liquidation());
format!(
"{:>7} | {:>11} | {:>11} | {:>11} | {:>8} | {:>11} | {:>11} | {:>11}\n{}\n{:>7} | {:>11} | {:>11} | {:>11} | {:>8} | {:>11} | {:>11} | {:>11}",
"side",
"quantity",
"entry",
"liquidation",
"leverage",
"margin",
"P/L",
"order fees",
"-".repeat(102),
side,
cross_position.quantity().unsigned_abs(),
entry,
liquidation,
cross_position.leverage().as_u64(),
cross_position.margin(),
cross_position.est_running_pl(market_price),
cross_position.trading_fees(),
)
}
pub fn running_trades_table(&self) -> String {
if self.running_map.is_empty() {
return "No running trades.".to_string();
}
let mut table = String::new();
table.push_str(&format!(
"{:>14} | {:>5} | {:>11} | {:>11} | {:>11} | {:>11} | {:>5} | {:>11} | {:>8} | {:>11} | {:>11} | {:>11}",
"creation time",
"side",
"quantity",
"price",
"liquidation",
"stoploss",
"TSL",
"takeprofit",
"leverage",
"margin",
"P/L",
"order fees"
));
table.push_str(&format!("\n{}", "-".repeat(153)));
for (trade, tsl) in self.running_map.trades_desc() {
let creation_time = trade
.created_at()
.with_timezone(&chrono::Local)
.format("%y-%m-%d %H:%M");
let stoploss_str = trade
.stoploss()
.map_or("N/A".to_string(), |sl| format!("{:.1}", sl));
let tsl_str = tsl.map_or("N/A".to_string(), |tsl| format!("{:.1}%", tsl.as_f64()));
let takeprofit_str = trade
.takeprofit()
.map_or("N/A".to_string(), |sl| format!("{:.1}", sl));
let total_margin = trade.margin().as_i64() + trade.maintenance_margin().max(0);
let pl = trade.est_pl(self.market_price).floor() as i64;
let total_fees = trade.opening_fee() + trade.closing_fee();
table.push_str(&format!(
"\n{:>14} | {:>5} | {:>11} | {:>11.1} | {:>11.1} | {:>11} | {:>5} | {:>11} | {:>8.2} | {:>11} | {:>11} | {:>11}",
creation_time,
trade.side(),
trade.quantity(),
trade.price(),
trade.liquidation(),
stoploss_str,
tsl_str,
takeprofit_str,
trade.leverage(),
total_margin,
pl,
total_fees
));
}
table
}
}
impl fmt::Display for TradingState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "TradingState:")?;
for line in self.summary().lines() {
write!(f, "\n {line}")?;
}
Ok(())
}
}
pub struct ClosedTradeHistory {
trades: BTreeMap<(DateTime<Utc>, Uuid), Arc<dyn TradeClosed>>,
id_to_time: HashMap<Uuid, DateTime<Utc>>,
}
impl ClosedTradeHistory {
pub fn new() -> Self {
Self {
trades: BTreeMap::new(),
id_to_time: HashMap::new(),
}
}
pub(super) fn add(&mut self, trade: Arc<dyn TradeClosed>) -> TradeCoreResult<()> {
if !trade.closed() || trade.exit_price().is_none() || trade.closed_at().is_none() {
return Err(TradeCoreError::TradeNotClosed {
trade_id: trade.id(),
});
}
let id = trade.id();
let created_at = trade.created_at();
self.trades.insert((created_at, id), trade);
self.id_to_time.insert(id, created_at);
Ok(())
}
pub fn get_by_id(&self, id: Uuid) -> Option<&Arc<dyn TradeClosed>> {
let creation_ts = self.id_to_time.get(&id)?;
self.trades.get(&(*creation_ts, id))
}
pub fn is_empty(&self) -> bool {
self.trades.is_empty()
}
pub fn len(&self) -> usize {
self.trades.len()
}
pub fn iter(&self) -> impl Iterator<Item = &Arc<dyn TradeClosed>> {
self.trades.values()
}
pub fn iter_desc(&self) -> impl Iterator<Item = &Arc<dyn TradeClosed>> {
self.trades.values().rev()
}
pub fn to_table(&self) -> String {
if self.trades.is_empty() {
return "No closed trades.".to_string();
}
let mut table = String::new();
table.push_str(&format!(
"{:>14} | {:>5} | {:>11} | {:>11} | {:>11} | {:>11} | {:>14} | {:>11} | {:>11} | {:>11}",
"creation time",
"side",
"quantity",
"margin",
"price",
"exit price",
"exit time",
"pl",
"order fees",
"net P/L"
));
table.push_str(&format!("\n{}", "-".repeat(137)));
for trade in self.trades.values().rev() {
let creation_time = trade
.created_at()
.with_timezone(&chrono::Local)
.format("%y-%m-%d %H:%M");
let exit_price = trade
.exit_price()
.expect("`closed` trade must have `exit_price`");
let exit_time = trade
.closed_at()
.expect("`closed` trade must have `closed_at`")
.with_timezone(&chrono::Local)
.format("%y-%m-%d %H:%M");
let pl = trade.pl();
let total_fees = trade.opening_fee() + trade.closing_fee();
let net_pl = pl - total_fees as i64;
table.push_str(&format!(
"\n{:>14} | {:>5} | {:>11} | {:>11} | {:>11} | {:>11} | {:>14} | {:>11} | {:>11} | {:>11}",
creation_time,
trade.side(),
trade.quantity(),
trade.margin(),
trade.price(),
exit_price,
exit_time,
pl,
total_fees,
net_pl
));
}
table
}
}
impl Clone for ClosedTradeHistory {
fn clone(&self) -> Self {
Self {
trades: self.trades.clone(),
id_to_time: self.id_to_time.clone(),
}
}
}
impl Default for ClosedTradeHistory {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for ClosedTradeHistory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClosedTradeHistory")
.field("len", &self.trades.len())
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Stoploss {
Fixed(Price),
Trailing(PercentageCapped),
}
impl Stoploss {
pub(super) fn evaluate(
&self,
tsl_step_size: PercentageCapped,
side: TradeSide,
market_price: Price,
) -> TradeCoreResult<(Price, Option<TradeTrailingStoploss>)> {
match self {
Self::Fixed(price) => Ok((*price, None)),
Self::Trailing(tsl) => {
if tsl_step_size > *tsl {
return Err(TradeCoreError::InvalidStoplossSmallerThanTrailingStepSize {
tsl: *tsl,
tsl_step_size,
});
}
let initial_stoploss_price = match side {
TradeSide::Buy => market_price.apply_discount(*tsl).map_err(|e| {
TradeCoreError::InvalidPriceApplyDiscount {
price: market_price,
discount: *tsl,
e,
}
})?,
TradeSide::Sell => market_price.apply_gain((*tsl).into()).map_err(|e| {
TradeCoreError::InvalidPriceApplyGain {
price: market_price,
gain: (*tsl).into(),
e,
}
})?,
};
Ok((initial_stoploss_price, Some(TradeTrailingStoploss(*tsl))))
}
}
}
pub fn fixed(stoploss_price: Price) -> Self {
Self::Fixed(stoploss_price)
}
pub fn trailing(stoploss_perc: PercentageCapped) -> Self {
Self::Trailing(stoploss_perc)
}
}
impl From<Price> for Stoploss {
fn from(value: Price) -> Self {
Self::Fixed(value)
}
}
impl From<PercentageCapped> for Stoploss {
fn from(value: PercentageCapped) -> Self {
Self::Trailing(value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
pub struct TradeTrailingStoploss(PercentageCapped);
impl TradeTrailingStoploss {
pub(crate) fn prev_validated(tsl: PercentageCapped) -> Self {
Self(tsl)
}
pub fn as_f64(self) -> f64 {
self.0.as_f64()
}
}
impl From<TradeTrailingStoploss> for f64 {
fn from(value: TradeTrailingStoploss) -> Self {
value.0.as_f64()
}
}
impl From<TradeTrailingStoploss> for PercentageCapped {
fn from(value: TradeTrailingStoploss) -> Self {
value.0
}
}
impl From<TradeTrailingStoploss> for Percentage {
fn from(value: TradeTrailingStoploss) -> Self {
value.0.into()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IsolatedOrderRequest {
side: TradeSide,
size: TradeSize,
leverage: Leverage,
stoploss: Option<Stoploss>,
takeprofit: Option<Price>,
client_id: Option<ClientId>,
}
impl IsolatedOrderRequest {
pub fn market(side: TradeSide, size: TradeSize, leverage: Leverage) -> Self {
Self {
side,
size,
leverage,
stoploss: None,
takeprofit: None,
client_id: None,
}
}
pub fn with_stoploss(
mut self,
stoploss: Stoploss,
) -> Result<Self, IsolatedOrderValidationError> {
Self::validate_fixed_risk_ordering(self.side, Some(&stoploss), self.takeprofit)?;
self.stoploss = Some(stoploss);
Ok(self)
}
pub fn with_takeprofit(
mut self,
takeprofit: Price,
) -> Result<Self, IsolatedOrderValidationError> {
Self::validate_fixed_risk_ordering(self.side, self.stoploss.as_ref(), Some(takeprofit))?;
self.takeprofit = Some(takeprofit);
Ok(self)
}
pub fn with_client_id(mut self, client_id: ClientId) -> Self {
self.client_id = Some(client_id);
self
}
fn validate_fixed_risk_ordering(
side: TradeSide,
stoploss: Option<&Stoploss>,
takeprofit: Option<Price>,
) -> Result<(), IsolatedOrderValidationError> {
let (Some(Stoploss::Fixed(stoploss)), Some(takeprofit)) = (stoploss, takeprofit) else {
return Ok(());
};
let valid = match side {
TradeSide::Buy => *stoploss < takeprofit,
TradeSide::Sell => *stoploss > takeprofit,
};
if !valid {
return Err(IsolatedOrderValidationError::InvalidRiskBounds {
side,
stoploss: *stoploss,
takeprofit,
});
}
Ok(())
}
pub fn side(&self) -> TradeSide {
self.side
}
pub fn size(&self) -> TradeSize {
self.size
}
pub fn leverage(&self) -> Leverage {
self.leverage
}
pub fn stoploss(&self) -> Option<&Stoploss> {
self.stoploss.as_ref()
}
pub fn takeprofit(&self) -> Option<Price> {
self.takeprofit
}
pub fn client_id(&self) -> Option<&ClientId> {
self.client_id.as_ref()
}
pub(crate) fn into_isolated_order_parts(
self,
) -> (
TradeSide,
TradeSize,
Leverage,
Option<Stoploss>,
Option<Price>,
Option<ClientId>,
) {
(
self.side,
self.size,
self.leverage,
self.stoploss,
self.takeprofit,
self.client_id,
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CrossOrderRequest {
side: TradeSide,
quantity: OrderQuantity,
client_id: Option<ClientId>,
}
impl CrossOrderRequest {
pub fn market(side: TradeSide, quantity: OrderQuantity) -> Self {
Self {
side,
quantity,
client_id: None,
}
}
pub fn with_client_id(mut self, client_id: ClientId) -> Self {
self.client_id = Some(client_id);
self
}
pub fn side(&self) -> TradeSide {
self.side
}
pub fn quantity(&self) -> OrderQuantity {
self.quantity
}
pub fn client_id(&self) -> Option<&ClientId> {
self.client_id.as_ref()
}
pub(crate) fn into_cross_order_parts(self) -> (TradeSide, OrderQuantity, Option<ClientId>) {
(self.side, self.quantity, self.client_id)
}
}
#[async_trait]
pub trait TradeExecutor: Send + Sync {
async fn isolated_order(&self, request: IsolatedOrderRequest) -> TradeExecutorResult<Uuid>;
async fn isolated_order_market_long(
&self,
size: TradeSize,
leverage: Leverage,
stoploss: Option<Stoploss>,
takeprofit: Option<Price>,
client_id: Option<ClientId>,
) -> TradeExecutorResult<Uuid> {
let mut request = IsolatedOrderRequest::market(TradeSide::Buy, size, leverage);
if let Some(stoploss) = stoploss {
request = request.with_stoploss(stoploss)?;
}
if let Some(takeprofit) = takeprofit {
request = request.with_takeprofit(takeprofit)?;
}
if let Some(client_id) = client_id {
request = request.with_client_id(client_id);
}
self.isolated_order(request).await
}
async fn isolated_order_market_short(
&self,
size: TradeSize,
leverage: Leverage,
stoploss: Option<Stoploss>,
takeprofit: Option<Price>,
client_id: Option<ClientId>,
) -> TradeExecutorResult<Uuid> {
let mut request = IsolatedOrderRequest::market(TradeSide::Sell, size, leverage);
if let Some(stoploss) = stoploss {
request = request.with_stoploss(stoploss)?;
}
if let Some(takeprofit) = takeprofit {
request = request.with_takeprofit(takeprofit)?;
}
if let Some(client_id) = client_id {
request = request.with_client_id(client_id);
}
self.isolated_order(request).await
}
async fn isolated_trade_add_margin(
&self,
trade_id: Uuid,
amount: NonZeroU64,
) -> TradeExecutorResult<()>;
async fn isolated_trade_cash_in(
&self,
trade_id: Uuid,
amount: NonZeroU64,
) -> TradeExecutorResult<()>;
async fn isolated_order_close(&self, trade_id: Uuid) -> TradeExecutorResult<()>;
async fn isolated_order_close_longs(&self) -> TradeExecutorResult<Vec<Uuid>>;
async fn isolated_order_close_shorts(&self) -> TradeExecutorResult<Vec<Uuid>>;
async fn isolated_order_close_all(&self) -> TradeExecutorResult<Vec<Uuid>>;
async fn cross_deposit(
&self,
amount: NonZeroU64,
) -> TradeExecutorResult<Arc<dyn CrossPositionCore>>;
async fn cross_withdraw(
&self,
amount: NonZeroU64,
) -> TradeExecutorResult<Arc<dyn CrossPositionCore>>;
async fn cross_set_leverage(
&self,
leverage: CrossLeverage,
) -> TradeExecutorResult<Arc<dyn CrossPositionCore>>;
async fn cross_order(&self, request: CrossOrderRequest) -> TradeExecutorResult<Uuid>;
async fn cross_order_market_long(&self, quantity: OrderQuantity) -> TradeExecutorResult<Uuid> {
self.cross_order(CrossOrderRequest::market(TradeSide::Buy, quantity))
.await
}
async fn cross_order_market_short(&self, quantity: OrderQuantity) -> TradeExecutorResult<Uuid> {
self.cross_order(CrossOrderRequest::market(TradeSide::Sell, quantity))
.await
}
async fn cross_order_close_position(&self) -> TradeExecutorResult<Option<Uuid>>;
async fn trading_state(&self) -> TradeExecutorResult<TradingState>;
}
#[async_trait]
pub trait SignalOperator<S: Signal>: Send + Sync {
fn set_trade_executor(&mut self, trade_executor: Arc<dyn TradeExecutor>) -> GeneralResult<()>;
async fn process_signal(&self, signal: &S) -> GeneralResult<()>;
}
pub(crate) struct WrappedSignalOperator<S: Signal>(Box<dyn SignalOperator<S>>);
impl<S: Signal> WrappedSignalOperator<S> {
pub fn set_trade_executor(
&mut self,
trade_executor: Arc<dyn TradeExecutor>,
) -> TradeCoreResult<()> {
panic::catch_unwind(AssertUnwindSafe(|| {
self.0.set_trade_executor(trade_executor)
}))
.map_err(|e| TradeCoreError::SignalOperatorSetTradeExecutorPanicked(e.into()))?
.map_err(|e| TradeCoreError::SignalOperatorSetTradeExecutorError(e.to_string()))
}
pub async fn process_signal(&self, signal: &S) -> TradeCoreResult<()> {
FutureExt::catch_unwind(AssertUnwindSafe(self.0.process_signal(signal)))
.await
.map_err(|e| TradeCoreError::SignalOperatorProcessSignalPanicked(e.into()))?
.map_err(|e| TradeCoreError::SignalOperatorProcessSignalError(e.to_string()))
}
}
impl<S: Signal> From<Box<dyn SignalOperator<S>>> for WrappedSignalOperator<S> {
fn from(value: Box<dyn SignalOperator<S>>) -> Self {
Self(value)
}
}
#[derive(Debug, Clone, Copy)]
pub struct Raw;
impl fmt::Display for Raw {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Raw")
}
}
#[async_trait]
pub trait RawOperator: Send + Sync {
fn set_trade_executor(&mut self, trade_executor: Arc<dyn TradeExecutor>) -> GeneralResult<()>;
fn lookback(&self) -> Option<Lookback>;
fn min_iteration_interval(&self) -> MinIterationInterval;
async fn iterate(&self, candles: &[OhlcCandleRow]) -> GeneralResult<()>;
}
pub(super) struct WrappedRawOperator(Box<dyn RawOperator>);
impl WrappedRawOperator {
pub fn set_trade_executor(
&mut self,
trade_executor: Arc<dyn TradeExecutor>,
) -> TradeCoreResult<()> {
panic::catch_unwind(AssertUnwindSafe(|| {
self.0.set_trade_executor(trade_executor)
}))
.map_err(|e| TradeCoreError::RawOperatorSetTradeExecutorPanicked(e.into()))?
.map_err(|e| TradeCoreError::RawOperatorSetTradeExecutorError(e.to_string()))
}
pub fn lookback(&self) -> TradeCoreResult<Option<Lookback>> {
let lookback = panic::catch_unwind(AssertUnwindSafe(|| self.0.lookback()))
.map_err(|e| TradeCoreError::RawOperatorLookbackPanicked(e.into()))?;
Ok(lookback)
}
pub fn min_iteration_interval(&self) -> TradeCoreResult<MinIterationInterval> {
let interval = panic::catch_unwind(AssertUnwindSafe(|| self.0.min_iteration_interval()))
.map_err(|e| TradeCoreError::RawOperatorMinIterationIntervalPanicked(e.into()))?;
Ok(interval)
}
pub async fn iterate(&self, candles: &[OhlcCandleRow]) -> TradeCoreResult<()> {
FutureExt::catch_unwind(AssertUnwindSafe(self.0.iterate(candles)))
.await
.map_err(|e| TradeCoreError::RawOperatorIteratePanicked(e.into()))?
.map_err(|e| TradeCoreError::RawOperatorIterateError(e.to_string()))
}
}
impl From<Box<dyn RawOperator>> for WrappedRawOperator {
fn from(value: Box<dyn RawOperator>) -> Self {
Self(value)
}
}
pub(super) trait TradeRunningExt: TradeRunning {
fn next_stoploss_update_trigger(
&self,
tsl_step_size: PercentageCapped,
trade_tsl: TradeTrailingStoploss,
) -> TradeCoreResult<Price> {
let tsl = trade_tsl.into();
if tsl_step_size > tsl {
return Err(TradeCoreError::InvalidStoplossSmallerThanTrailingStepSize {
tsl,
tsl_step_size,
});
}
let curr_stoploss =
self.stoploss()
.ok_or_else(|| TradeCoreError::NoNextTriggerTradeStoplossNotSet {
trade_id: self.id(),
})?;
let price_trigger = match self.side() {
TradeSide::Buy => {
let next_stoploss =
curr_stoploss
.apply_gain(tsl_step_size.into())
.map_err(|e| TradeCoreError::InvalidPriceApplyGain {
price: curr_stoploss,
gain: tsl_step_size.into(),
e,
})?;
let tsl_factor = 1.0 - trade_tsl.as_f64() / 100.0;
let trigger_price = next_stoploss.as_f64() / tsl_factor;
Price::round_up(trigger_price).map_err(|e| {
TradeCoreError::InvalidPriceRounding {
price: trigger_price,
e,
}
})?
}
TradeSide::Sell => {
let next_stoploss = curr_stoploss.apply_discount(tsl_step_size).map_err(|e| {
TradeCoreError::InvalidPriceApplyDiscount {
price: curr_stoploss,
discount: tsl_step_size,
e,
}
})?;
let tsl_factor = 1.0 + trade_tsl.as_f64() / 100.0;
let trigger_price = next_stoploss.as_f64() / tsl_factor;
Price::round_down(trigger_price).map_err(|e| {
TradeCoreError::InvalidPriceRounding {
price: trigger_price,
e,
}
})?
}
};
Ok(price_trigger)
}
fn eval_trigger_bounds(
&self,
tsl_step_size: PercentageCapped,
trade_tsl: Option<TradeTrailingStoploss>,
) -> TradeCoreResult<(Price, Price)> {
let next_stoploss_update_trigger = trade_tsl
.map(|tsl| self.next_stoploss_update_trigger(tsl_step_size, tsl))
.transpose()?;
match self.side() {
TradeSide::Buy => {
let lower_bound = self.stoploss().unwrap_or(self.liquidation());
let takeprofit_trigger = self.takeprofit().unwrap_or(Price::MAX);
let upper_bound =
takeprofit_trigger.min(next_stoploss_update_trigger.unwrap_or(Price::MAX));
Ok((lower_bound, upper_bound))
}
TradeSide::Sell => {
let takeprofit_trigger = self.takeprofit().unwrap_or(Price::MIN);
let lower_bound =
takeprofit_trigger.max(next_stoploss_update_trigger.unwrap_or(Price::MIN));
let upper_bound = self.stoploss().unwrap_or(self.liquidation());
Ok((lower_bound, upper_bound))
}
}
}
fn was_closed_on_range(&self, range_min: f64, range_max: f64) -> bool {
match self.side() {
TradeSide::Buy => {
let stoploss_reached = self
.stoploss()
.is_some_and(|stoploss| range_min <= stoploss.as_f64());
let liquidation_reached = range_min <= self.liquidation().as_f64();
let takeprofit_reached = self
.takeprofit()
.is_some_and(|takeprofit| range_max >= takeprofit.as_f64());
stoploss_reached || liquidation_reached || takeprofit_reached
}
TradeSide::Sell => {
let stoploss_reached = self
.stoploss()
.is_some_and(|stoploss| range_max >= stoploss.as_f64());
let liquidation_reached = range_max >= self.liquidation().as_f64();
let takeprofit_reached = self
.takeprofit()
.is_some_and(|takeprofit| range_min <= takeprofit.as_f64());
stoploss_reached || liquidation_reached || takeprofit_reached
}
}
}
fn eval_new_stoploss_on_range(
&self,
tsl_step_size: PercentageCapped,
trade_tsl: TradeTrailingStoploss,
range_min: f64,
range_max: f64,
) -> TradeCoreResult<Option<Price>> {
let next_stoploss_update_trigger = self
.next_stoploss_update_trigger(tsl_step_size, trade_tsl)?
.as_f64();
let new_stoploss = match self.side() {
TradeSide::Buy => {
if range_max >= next_stoploss_update_trigger {
let new_stoploss = Price::round(range_max).map_err(|e| {
TradeCoreError::InvalidPriceRounding {
price: range_max,
e,
}
})?;
let new_stoploss =
new_stoploss.apply_discount(trade_tsl.into()).map_err(|e| {
TradeCoreError::InvalidPriceApplyDiscount {
price: new_stoploss,
discount: trade_tsl.into(),
e,
}
})?;
Some(new_stoploss)
} else {
None
}
}
TradeSide::Sell => {
if range_min <= next_stoploss_update_trigger {
let new_stoploss = Price::round(range_min).map_err(|e| {
TradeCoreError::InvalidPriceRounding {
price: range_min,
e,
}
})?;
let new_stoploss = new_stoploss.apply_gain(trade_tsl.into()).map_err(|e| {
TradeCoreError::InvalidPriceApplyGain {
price: new_stoploss,
gain: trade_tsl.into(),
e,
}
})?;
Some(new_stoploss)
} else {
None
}
}
};
let new_stoploss = new_stoploss.filter(|new_sl| Some(*new_sl) != self.stoploss());
Ok(new_stoploss)
}
}
impl<T: TradeRunning + ?Sized> TradeRunningExt for T {}
#[derive(Debug, Clone)]
pub(super) enum PriceTrigger {
NotSet,
Set { min: Price, max: Price },
}
impl PriceTrigger {
pub fn new() -> Self {
Self::NotSet
}
pub fn update<T: TradeRunningExt + ?Sized>(
&mut self,
tsl_step_size: PercentageCapped,
trade: &T,
trade_tsl: Option<TradeTrailingStoploss>,
) -> TradeCoreResult<()> {
let (mut new_min, mut new_max) = trade.eval_trigger_bounds(tsl_step_size, trade_tsl)?;
if let PriceTrigger::Set { min, max } = *self {
new_min = new_min.max(min);
new_max = new_max.min(max);
}
*self = PriceTrigger::Set {
min: new_min,
max: new_max,
};
Ok(())
}
pub fn was_reached(&self, market_price: f64) -> bool {
match self {
PriceTrigger::NotSet => false,
PriceTrigger::Set { min, max } => {
market_price <= min.as_f64() || market_price >= max.as_f64()
}
}
}
}