// =================================
// File: src/live/volatility_cycle.rs
// =================================
//
// VolatilityCycle — резка в BOTH по ЧИСТОЙ прибыли в %.
//
// УСЛОВИЕ РЕЗКИ (ВСЁ В ПРОЦЕНТАХ):
//
// profit_pct_clean ≥ T // прибыльная сторона ПОСЛЕ вычета комиссий и фандинга
// loss_pct_total < 0 // убыточная сторона ПОСЛЕ добавления всех расходов
//
// T = 3.0% при первом цикле (boosted_once == false)
// T = 1.5% при последующих циклах (boosted_once == true)
//
// Расходы = maker + taker + funding (двойной оборот по ОБЕИМ сторонам).
// Расходы уменьшают прибыль и УВЕЛИЧИВАЮТ убыток.
//
// РЕЗКА КОЛИЧЕСТВ:
//
// • прибыльная сторона → закрыть 100% её объёма
// • убыточная сторона → закрыть 50% ОТ ОБЪЁМА ПРИБЫЛЬНОЙ
// (но не больше её фактического объёма)
//
// ДОКУПКА ПОСЛЕ РЕЗКИ:
//
// Пусть до резки было:
// q_profit, q_loss
// base = min(q_profit, q_loss)
//
// После резки осталось:
// rem_loss = q_loss - 0.5 * q_profit (обрезанный хвост)
//
// Первый цикл (BOOST, boosted_once = false):
// profit_new = 1.5 * base
// loss_new = 2.0 * rem_loss // удваиваем остаток (подтягиваем цену к рынку)
//
// Последующие циклы (WINDDOWN, boosted_once = true):
// profit_new = 1.0 * base
// loss_new = 2.0 * rem_loss
//
// После каждого цикла обе стороны считаются открытыми по текущей цене.
// Вся предыдущая дельта зафиксирована в realized_pnl.
//
// =================================
use crate::live::state::PositionSnapshot;
use crate::live::meta::TradingMeta;
use crate::live::actions::LiveAction;
const FIRST_TRIGGER_PCT: f64 = 3.0; // первый цикл: 3% чистой прибыли
const NEXT_TRIGGER_PCT: f64 = 2.0; // последующие: 2.0% чистой прибыли
const MIN_AMPLITUDE_PCT: f64 = 0.3; // минимальная волатильность по сторонам
#[derive(Clone, Copy, Debug)]
pub enum Side {
Long,
Short,
}
#[derive(Clone, Debug)]
pub struct CutSignal {
pub side: Side,
pub profit_pnl: f64,
pub loss_pnl: f64,
}
pub struct VolatilityCycle;
impl VolatilityCycle {
pub fn check(
pos: &PositionSnapshot,
meta: &TradingMeta,
entry_long: f64,
entry_short: f64,
boosted_once: bool,
) -> Option<CutSignal> {
// Резка возможна только в BOTH
if !(pos.long_qty > 0.0 && pos.short_qty > 0.0) {
return None;
}
let price = if pos.last_price > 0.0 { pos.last_price } else { meta.price };
if price <= 0.0 {
return None;
}
let cv = pos.contract_value.max(1.0);
// ----------------------------------
// PnL двух сторон
// ----------------------------------
let pnl_long = if entry_long > 0.0 { pos.long_qty * (price - entry_long) * cv } else { 0.0 };
let pnl_short = if entry_short > 0.0 { pos.short_qty * (entry_short - price) * cv } else { 0.0 };
// Определяем прибыльную/убыточную сторону
let (side, profit_pnl, loss_pnl, profit_qty, loss_qty, profit_entry, loss_entry) =
if pnl_long > 0.0 && pnl_short < 0.0 {
(
Side::Long,
pnl_long,
pnl_short,
pos.long_qty,
pos.short_qty,
entry_long,
entry_short,
)
} else if pnl_short > 0.0 && pnl_long < 0.0 {
(
Side::Short,
pnl_short,
pnl_long,
pos.short_qty,
pos.long_qty,
entry_short,
entry_long,
)
} else {
// либо обе в плюс, либо обе в минус → резка не нужна
return None;
};
let profit_notional = profit_qty * profit_entry * cv;
let loss_notional = loss_qty * loss_entry * cv;
if profit_notional <= 0.0 || loss_notional <= 0.0 {
return None;
}
// ------------------------
// PnL% по сторонам
// ------------------------
let profit_pct_raw = (profit_pnl / profit_notional) * 100.0;
let loss_pct_raw = (loss_pnl / loss_notional) * 100.0; // отрицательный
// Минимальная амплитуда (срезаем шум)
if profit_pct_raw.abs() < MIN_AMPLITUDE_PCT || loss_pct_raw.abs() < MIN_AMPLITUDE_PCT {
return None;
}
// ----------------------------------
// Комиссии + фандинг (обе стороны, полный оборот)
// ----------------------------------
let fee_rate = meta
.taker_fee_rate
.abs()
.max(meta.maker_fee_rate.abs())
.max(0.0003);
let funding = meta.funding_rate.abs();
// maker + taker + funding, туда-обратно, обе стороны
let cost_pct = (fee_rate * 2.0 + funding * 2.0) * 100.0;
// Чистая прибыль и "усиленный" убыток
let profit_pct_clean = profit_pct_raw - cost_pct; // уменьшаем прибыль
let loss_pct_total = loss_pct_raw - cost_pct; // делаем убыток ещё хуже
// ----------------------------------
// Порог триггера: первый цикл 3%, дальше 1.5%
// ----------------------------------
let trigger = if boosted_once { NEXT_TRIGGER_PCT } else { FIRST_TRIGGER_PCT };
if profit_pct_clean < trigger {
return None;
}
if loss_pct_total >= 0.0 {
return None;
}
Some(CutSignal {
side,
profit_pnl,
loss_pnl,
})
}
pub fn execute(
pos: &mut PositionSnapshot,
sig: &CutSignal,
price: f64,
meta: &TradingMeta,
) -> Vec<LiveAction> {
let mut actions = Vec::new();
if !(pos.long_qty > 0.0 && pos.short_qty > 0.0) {
return actions;
}
let cv = pos.contract_value.max(1.0);
let q_long = pos.long_qty;
let q_short = pos.short_qty;
let base = q_long.min(q_short);
if base <= 0.0 {
return actions;
}
// 🔥🔥 ДИНАМИЧЕСКИЙ РАСЧЁТ ПРОЦЕНТА РЕЗКИ УБЫТОЧНОЙ СТОРОНЫ
// На основе min_contracts и безубыточности
let min_contracts = meta.min_contracts.max(1.0);
let (profit_qty, loss_qty, profit_entry, loss_entry) = match sig.side {
Side::Long => (q_long, q_short, pos.entry_long, pos.entry_short),
Side::Short => (q_short, q_long, pos.entry_short, pos.entry_long),
};
let loss_cut_pct = if profit_entry > 0.0 && loss_entry > 0.0 {
// 🔥🔥 ПЕРЕБОР КОЛИЧЕСТВА КОНТРАКТОВ: 1, 2, 3, 4, 5...
let min_contracts_i64 = min_contracts.round() as i64;
let mut best_contracts_to_cut = min_contracts_i64; // default
let mut contracts = min_contracts_i64;
// Расчитываем чистые PnL (с учётом комиссий и фандинга)
let fee_rate = meta.taker_fee_rate.abs().max(meta.maker_fee_rate.abs()).max(0.0003);
let funding = meta.funding_rate.abs();
let cost_pct = (fee_rate * 2.0 + funding * 2.0) * 100.0;
let profit_notional = profit_qty * profit_entry * cv;
let profit_pct_clean = (sig.profit_pnl / profit_notional * 100.0) - cost_pct;
let loss_notional = loss_qty * loss_entry * cv;
let loss_pct_total = (sig.loss_pnl / loss_notional * 100.0) - cost_pct;
let profit_clean = profit_notional * profit_pct_clean / 100.0;
let loss_clean = loss_notional * loss_pct_total / 100.0;
// 🔥🔥 ВАРИАНТ B: ДИНАМИЧЕСКИЙ МАКСИМУМ НА ОСНОВЕ ПРИБЫЛИ
// Убыток на 1 контракт убыточной стороны (абсолютное значение в USDT)
let loss_pnl_per_contract = (sig.loss_pnl / loss_qty).abs();
// Максимальное количество контрактов которое можно резать:
// Не больше чем убыточная позиция (loss_qty)
// Не больше чем можем перекрыть прибылью (profit_clean / loss_pnl_per_contract)
// Не меньше чем min_contracts (для избежания 0)
let max_by_profit = (profit_clean / loss_pnl_per_contract) as i64;
let max_contracts = max_by_profit
.min(loss_qty as i64)
.max(min_contracts_i64);
println!("🔥 ДИНАМИЧЕСКИЙ МАКСИМУМ: loss_pnl_total={:.4}, loss_qty={:.0}, loss_per_contract={:.6}, profit_clean={:.4}, max_by_profit={:.0}, loss_qty_i64={:.0}, final_max={:.0}",
sig.loss_pnl, loss_qty, loss_pnl_per_contract, profit_clean, max_by_profit, loss_qty as i64, max_contracts);
// Перебираем: min_contracts, *2, *3, *4... пока net >= 0
loop {
if contracts > max_contracts {
break;
}
// Доля убыточной стороны которую закрываем этим количеством контрактов
let loss_fraction = contracts as f64 / loss_qty;
// Убыток от этой доли (УЖЕ с комиссиями и фандингом!)
let loss_part = loss_clean.abs() * loss_fraction;
// Проверяем: перекроет ли прибыль этот убыток?
let net = profit_clean - loss_part;
if net >= 0.0 {
// Отлично! Запоминаем и пробуем ещё больше
best_contracts_to_cut = contracts;
contracts += min_contracts_i64;
} else {
// Убыток - СТОП!
break;
}
}
// Переводим в % от убыточной стороны
(best_contracts_to_cut as f64 / loss_qty) * 100.0
} else {
0.50 // fallback если entry == 0
};
println!("🔥 ДИНАМИЧЕСКАЯ РЕЗКА: {:.2}% убыточной стороны = {:.0} контрактов (min={:.0}, max 60%)",
loss_cut_pct,
(loss_qty * loss_cut_pct / 100.0).round(),
min_contracts
);
match sig.side {
Side::Long => {
// LONG — прибыльная, SHORT — убыточная
let entry_long = pos.entry_long;
let entry_short = pos.entry_short;
// --- 1) Реализация PnL: закрываем 100% прибыльной и X% убыточной (динамически!) ---
let close_profit_qty = q_long;
let close_loss_qty = (loss_cut_pct / 100.0 * q_short).round().min(q_short).max(0.0);
let realized_profit = (price - entry_long) * close_profit_qty * cv;
let realized_loss = (entry_short - price) * close_loss_qty * cv;
pos.realized_pnl += realized_profit + realized_loss;
let rem_loss = (q_short - close_loss_qty).max(0.0);
// 🔥🔥 Если rem_loss == 0 → переход из BOTH в Single
if rem_loss <= f64::EPSILON {
pos.transitioning_to_single_from_both = true;
println!("🔄 ПЕРЕХОД В SINGLE: убыточная сторона полностью закрыта (rem_loss=0)");
}
// ---------- НОВАЯ ЛОГИКА: ----------
// Прибыльную сторону перезакупаем до base (сохраняем позицию)
// Убыточную сторону НЕ ПЕРЕЗАКУПАЕМ вообще! (постепенно режем до нуля)
let new_profit_qty = base; // Перезакупаем прибыльную
let new_loss_qty = 0.0; // Убыточную НЕ докупаем!
// 🔥🔥 Создаём actions вместо прямого изменения позиции
// 1) Закрываем 100% прибыльной (LONG) - is_profit_side=true
if close_profit_qty > 0.0 {
actions.push(LiveAction::CloseLong(close_profit_qty, price, true));
}
// 2) Закрываем X% убыточной (SHORT) - is_profit_side=false
if close_loss_qty > 0.0 {
actions.push(LiveAction::CloseShort(close_loss_qty, price, false));
}
// 3) Перезакупаем прибыльную сторону (LONG) до base
if new_profit_qty > 0.0 {
actions.push(LiveAction::OpenLong(new_profit_qty, price));
}
// 4) Перезакупаем убыточную сторону (SHORT) - rem_loss
if new_loss_qty > 0.0 {
actions.push(LiveAction::OpenShort(new_loss_qty, price));
}
// Сохраняем цену перезакупки прибыльной стороны (для разворота -1%)
pos.last_profit_rebuy_price = price;
}
Side::Short => {
// SHORT — прибыльная, LONG — убыточная
let entry_long = pos.entry_long;
let entry_short = pos.entry_short;
// --- 1) Реализация PnL: закрываем 100% прибыльной и X% убыточной (динамически!) ---
let close_profit_qty = q_short;
let close_loss_qty = (loss_cut_pct / 100.0 * q_long).round().min(q_long).max(0.0);
let realized_profit = (entry_short - price) * close_profit_qty * cv;
let realized_loss = (price - entry_long) * close_loss_qty * cv;
pos.realized_pnl += realized_profit + realized_loss;
let rem_loss = (q_long - close_loss_qty).max(0.0);
// 🔥🔥 Если rem_loss == 0 → переход из BOTH в Single
if rem_loss <= f64::EPSILON {
pos.transitioning_to_single_from_both = true;
println!("🔄 ПЕРЕХОД В SINGLE: убыточная сторона полностью закрыта (rem_loss=0)");
}
// ---------- НОВАЯ ЛОГИКА: ----------
// Прибыльную сторону перезакупаем до base (сохраняем позицию)
// Убыточную сторону НЕ ПЕРЕЗАКУПАЕМ вообще! (постепенно режем до нуля)
let new_profit_qty = base; // Перезакупаем прибыльную
let new_loss_qty = 0.0; // Убыточную НЕ докупаем!
// 🔥🔥 Создаём actions вместо прямого изменения позиции
// 1) Закрываем 100% прибыльной (SHORT) - is_profit_side=true
if close_profit_qty > 0.0 {
actions.push(LiveAction::CloseShort(close_profit_qty, price, true));
}
// 2) Закрываем X% убыточной (LONG) - is_profit_side=false
if close_loss_qty > 0.0 {
actions.push(LiveAction::CloseLong(close_loss_qty, price, false));
}
// 3) Перезакупаем прибыльную сторону (SHORT) до base
if new_profit_qty > 0.0 {
actions.push(LiveAction::OpenShort(new_profit_qty, price));
}
// 4) Перезакупаем убыточную сторону (LONG) - rem_loss
if new_loss_qty > 0.0 {
actions.push(LiveAction::OpenLong(new_loss_qty, price));
}
// Сохраняем цену перезакупки прибыльной стороны (для разворота -1%)
pos.last_profit_rebuy_price = price;
}
}
actions
}
}