← Назад к документации
volatility_cycle
Исходный код Rust - Trading AI
📄 Rust
📦 Модуль
🔧 Исходный код
// =================================
// 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
    }
}