← Назад к главному
# volatility_cycle.rs - Модуль резок в BOTH режиме ## 📋 Назначение модуля `VolatilityCycle` определяет момент резки в BOTH режиме и генерирует actions для её исполнения. **Основная логика:** 1. **Проверка условий резки** через `check()` - достигнут ли порог чистого PnL% 2. **Динамический расчёт % резки** убыточной стороны (на основе безубыточности) 3. **Исполнение резки** через `execute()` - закрытие и перезакупка 4. **Переход из BOTH в Single** если убыточная сторона полностью закрыта **Условия резки (всё в %):** - `profit_pct_clean ≥ T` - прибыльная сторона ПОСЛЕ вычета комиссий и funding - `loss_pct_total < 0` - убыточная сторона ПОСЛЕ добавления расходов **Пороги:** - Первый цикл: `T = 3.0%` (boosted_once == false) - Последующие: `T = 2.0%` (boosted_once == true) **Резка количеств:** - Прибыльная сторона → закрыть 100% - Убыточная сторона → закрыть динамический % (на основе безубыточности) **Перезакупка:** - Прибыльную сторону → до `base = min(q_profit, q_loss)` - Убыточную сторону → НЕ докупаем (постепенно режем до нуля) --- ## 🏗️ Структура модуля ### Константы ```rust const FIRST_TRIGGER_PCT: f64 = 3.0; // Первый цикл: 3% чистой прибыли const NEXT_TRIGGER_PCT: f64 = 2.0; // Последующие: 2.0% чистой прибыли const MIN_AMPLITUDE_PCT: f64 = 0.3; // Минимальная волатильность по сторонам (срезаем шум) ``` --- ### Enum `Side` **Строки:** 54-58 ```rust #[derive(Clone, Copy, Debug)] pub enum Side { Long, Short, } ``` **Назначение:** Определяет какая сторона прибыльная/убыточная --- ### Структура `CutSignal` **Строки:** 60-65 ```rust #[derive(Clone, Debug)] pub struct CutSignal { pub side: Side, // Прибыльная сторона pub profit_pnl: f64, // Абсолютный PnL прибыльной стороны (USDT) pub loss_pnl: f64, // Абсолютный PnL убыточной стороны (USDT, отрицательный) } ``` **Назначение:** Сигнал о необходимости резки --- ### Структура `VolatilityCycle` **Строка:** 67 ```rust pub struct VolatilityCycle; ``` **Особенности:** Unit struct (без полей) - вся логика в функциях --- ## 🔄 Основные функции ### Функция `check()` **Строки:** 70-175 **Сигнатура:** ```rust pub fn check( pos: &PositionSnapshot, // Состояние позиции meta: &TradingMeta, // Метаданные контракта entry_long: f64, // Entry цена LONG entry_short: f64, // Entry цена SHORT boosted_once: bool, // Был ли первый BOOST-цикл? ) -> Option ``` **Возвращает:** - `Some(CutSignal)` - резка нужна - `None` - ещё не время или условия не выполнены **Алгоритм (10 этапов):** --- #### Этап 1: Проверка BOTH режима ```rust if !(pos.long_qty > 0.0 && pos.short_qty > 0.0) { return None; } ``` **Условие:** ОБЕ стороны должны быть активны --- #### Этап 2: Подготовка цены ```rust let price = if pos.last_price > 0.0 { pos.last_price } else { meta.price }; if price <= 0.0 { return None; } ``` --- #### Этап 3: Расчёт PnL по сторонам ```rust 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 }; ``` **Формулы:** - `pnl_long = long_qty × (price - entry_long) × contract_value` - `pnl_short = short_qty × (entry_short - price) × contract_value` **Пример:** ``` LONG: qty=1000, entry=0.002000, price=0.002060, cv=1.0 pnl_long = 1000 × (0.002060 - 0.002000) × 1.0 = 0.06 USDT SHORT: qty=1000, entry=0.002000, price=0.001940, cv=1.0 pnl_short = 1000 × (0.002000 - 0.001940) × 1.0 = 0.06 USDT ``` --- #### Этап 4: Определение прибыльной/убыточной стороны ```rust 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; }; ``` **Логика:** - `pnl_long > 0` **и** `pnl_short < 0` → LONG прибыльная, SHORT убыточная - `pnl_short > 0` **и** `pnl_long < 0` → SHORT прибыльная, LONG убыточная - Иначе → обе в плюсе или обе в минусе → резка не нужна **⚠️ КРИТИЧНОЕ ОГРАНИЧЕНИЕ:** ```rust // либо обе в плюс, либо обе в минус → резка не нужна return None; ``` **Что это значит:** - Если **ОБЕ** стороны в плюсе → НЕ резаем - Если **ОБЕ** стороны в минусе → НЕ резаем - Резаем ТОЛЬКО когда **ОДНА** в плюсе, **ДРУГАЯ** в минусе **Почему?** - Обе в плюсе → нет смысла резать, ждём разворота - Обе в минусе → резать нечего, нужно хеджироваться --- #### Этап 5: Расчёт notional по сторонам ```rust 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; } ``` **Формула:** - `notional = qty × entry_price × contract_value` **Пример:** ``` LONG: qty=1000, entry=0.002000, cv=1.0 profit_notional = 1000 × 0.002000 × 1.0 = 2.0 USDT ``` --- #### Этап 6: Расчёт PnL% по сторонам ```rust let profit_pct_raw = (profit_pnl / profit_notional) * 100.0; let loss_pct_raw = (loss_pnl / loss_notional) * 100.0; // отрицательный ``` **Формула:** - `pnl_pct = pnl_abs / notional × 100` **Пример:** ``` pnl_long = 0.06 USDT notional = 2.0 USDT pnl_long_pct = 0.06 / 2.0 × 100 = +3.0% ``` --- #### Этап 7: Минимальная амплитуда (срезаем шум) ```rust // Минимальная амплитуда (срезаем шум) if profit_pct_raw.abs() < MIN_AMPLITUDE_PCT || loss_pct_raw.abs() < MIN_AMPLITUDE_PCT { return None; } ``` **Порог:** `0.3%` **Логика:** - Если `|profit_pct_raw| < 0.3%` → слишком маленькая прибыль → резка не нужна - Если `|loss_pct_raw| < 0.3%` → слишком маленький убыток → резка не нужна **Пример:** ``` profit_pct_raw = +0.2% → 0.2 < 0.3 → НЕ резаем (шум) loss_pct_raw = -0.4% → 0.4 >= 0.3 → OK profit_pct_raw = +0.5% → 0.5 >= 0.3 → OK loss_pct_raw = -0.2% → 0.2 < 0.3 → НЕ резаем (шум) ``` **Зачем:** Защита от резок на мелких колебаниях цены (шум) --- #### Этап 8: Комиссии и funding ```rust // Комиссии + фандинг (обе стороны, полный оборот) 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; // делаем убыток ещё хуже ``` **Формула:** - `cost_pct = (taker_fee × 2 + funding × 2) × 100` - `profit_pct_clean = profit_pct_raw - cost_pct` - `loss_pct_total = loss_pct_raw - cost_pct` **Пример:** ``` taker_fee_rate = 0.00075 funding_rate = 0.000012 cost_pct = (0.00075 × 2 + 0.000012 × 2) × 100 = 0.1524% profit_pct_raw = +3.0% profit_pct_clean = 3.0 - 0.1524 = 2.8476% loss_pct_raw = -2.0% loss_pct_total = -2.0 - 0.1524 = -2.1524% ``` **Важно:** - Комиссии уменьшают прибыль - Комиссии делают убыток ещё больше --- #### Этап 9: Порог триггера ```rust // Порог триггера: первый цикл 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; } ``` **Пороги:** - Первый цикл: `trigger = 3.0%` - После BOOST: `trigger = 2.0%` **Условия резки:** 1. `profit_pct_clean >= trigger` - прибыльная сторона достаточно прибыльная 2. `loss_pct_total < 0` - убыточная сторона действительно в убытке **⚠️ ВТОРОЕ КРИТИЧЕСКОЕ ОГРАНИЧЕНИЕ:** ```rust if loss_pct_total >= 0.0 { return None; } ``` **Что это значит:** - Если `loss_pct_total >= 0` (убыточная сторона НЕ в убытке) → НЕ резаем - Это означает что резка невозможна когда **ОБЕ** стороны в минусе! **Пример:** ``` Сценарий 1 (резка нужна): profit_pct_clean = +3.0% loss_pct_total = -2.0% Проверка: - 3.0 >= 3.0 ✅ - -2.0 < 0 ✅ → РЕЗКА ✅ Сценарий 2 (НЕ резаем): profit_pct_clean = +3.0% loss_pct_total = +0.5% Проверка: - 3.0 >= 3.0 ✅ - +0.5 < 0 ❌ → НЕ РЕЗАЕМ ❌ Сценарий 3 (ОБЕ в минусе): profit_pct_clean = -1.0% loss_pct_total = -2.0% Проверка: - -1.0 >= 3.0 ❌ → НЕ РЕЗАЕМ ❌ Сценарий 4 (ОБЕ в минусе, но profit > 0 сырой): profit_pct_raw = +0.5% loss_pct_raw = -2.0% cost_pct = 0.1524% profit_pct_clean = 0.5 - 0.1524 = 0.3476% loss_pct_total = -2.0 - 0.1524 = -2.1524% Проверка: - 0.3476 >= 3.0 ❌ → НЕ РЕЗАЕМ ❌ ``` **Почему это ограничение?** - Резка когда обе стороны в минусе → только увеличивает убыток - Лучше хеджироваться или ждать разворота --- #### Этап 10: Возврат CutSignal ```rust Some(CutSignal { side, profit_pnl, loss_pnl, }) ``` --- ### Функция `execute()` **Строки:** 177-389 **Сигнатура:** ```rust pub fn execute( pos: &mut PositionSnapshot, // Состояние позиции (изменяемое) sig: &CutSignal, // Сигнал резки price: f64, // Текущая цена meta: &TradingMeta, // Метаданные контракта ) -> Vec ``` **Алгоритм (7 этапов):** --- #### Этап 1: Проверка BOTH режима ```rust 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; } ``` **base** - минимальная сторона (зеркальная часть позиции) --- #### Этап 2: 🔥🔥 Динамический расчёт % резки убыточной стороны **Строки:** 198-271 ```rust // На основе 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 }; ``` **Логика динамического расчёта:** 1. **Расчёт чистых PnL:** ``` profit_clean = profit_notional × profit_pct_clean / 100 loss_clean = loss_notional × loss_pct_total / 100 ``` 2. **Убыток на 1 контракт:** ``` loss_pnl_per_contract = |loss_pnl| / loss_qty ``` 3. **Максимальное количество контрактов для резки:** ``` max_by_profit = profit_clean / loss_pnl_per_contract max_contracts = min(max_by_profit, loss_qty, min_contracts) ``` 4. **Перебор контрактов (min_contracts, *2, *3...):** ``` Для каждого количества: loss_fraction = contracts / loss_qty loss_part = |loss_clean| × loss_fraction net = profit_clean - loss_part Если net >= 0: best_contracts_to_cut = contracts Пробуем больше Иначе: STOP ``` 5. **Перевод в %:** ``` loss_cut_pct = (best_contracts_to_cut / loss_qty) × 100 ``` **Пример:** ``` До резки: - LONG (прибыльный): qty=1000, entry=0.002000, price=0.002060 - SHORT (убыточный): qty=1000, entry=0.002000, price=0.002060 - base = min(1000, 1000) = 1000 - min_contracts = 1 Расчёт: - profit_pnl = 1000 × (0.002060 - 0.002000) × 1.0 = 0.06 USDT - loss_pnl = 1000 × (0.002000 - 0.002060) × 1.0 = -0.06 USDT - profit_notional = 1000 × 0.002000 × 1.0 = 2.0 USDT - loss_notional = 2.0 USDT - profit_pct_raw = 0.06 / 2.0 × 100 = 3.0% - loss_pct_raw = -0.06 / 2.0 × 100 = -3.0% - cost_pct = 0.1524% - profit_pct_clean = 3.0 - 0.1524 = 2.8476% - loss_pct_total = -3.0 - 0.1524 = -3.1524% - profit_clean = 2.0 × 2.8476 / 100 = 0.056952 USDT - loss_clean = 2.0 × -3.1524 / 100 = -0.063048 USDT - loss_pnl_per_contract = 0.06 / 1000 = 0.00006 USDT - max_by_profit = 0.056952 / 0.00006 = 949.2 → 949 контрактов - max_contracts = min(949, 1000, 1) = 949 Перебор: contracts=1: loss_fraction = 1/1000 = 0.001 loss_part = 0.063048 × 0.001 = 0.000063 USDT net = 0.056952 - 0.000063 = 0.056889 >= 0 ✅ best = 1 contracts=2: loss_fraction = 2/1000 = 0.002 loss_part = 0.063048 × 0.002 = 0.000126 USDT net = 0.056952 - 0.000126 = 0.056826 >= 0 ✅ best = 2 ... (перебор продолжается) ... contracts=949: loss_fraction = 949/1000 = 0.949 loss_part = 0.063048 × 0.949 = 0.059832 USDT net = 0.056952 - 0.059832 = -0.002880 < 0 ❌ STOP final best = 948 (предыдущий) loss_cut_pct = 948 / 1000 × 100 = 94.8% ``` **Результат:** Резать 94.8% убыточной стороны! **Почему именно так:** - Перебираем от `min_contracts` до максимума - Каждую проверку на `net >= 0` - Запоминаем последнее успешное количество - Гарантируем что резка будет безубыточной (net >= 0) --- #### Этап 3: Логирование резки ```rust println!("🔥 ДИНАМИЧЕСКАЯ РЕЗКА: {:.2}% убыточной стороны = {:.0} контрактов (min={:.0}, max 60%)", loss_cut_pct, (loss_qty * loss_cut_pct / 100.0).round(), min_contracts ); ``` --- #### Этап 4: Исполнение резки (Side::Long) **Строки:** 279-331 ```rust 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; } ``` **Логика (LONG прибыльная, SHORT убыточная):** 1. **Закрытие 100% прибыльной (LONG):** ``` close_profit_qty = q_long realized_profit = (price - entry_long) × q_long × cv ``` 2. **Закрытие X% убыточной (SHORT):** ``` close_loss_qty = loss_cut_pct / 100 × q_short realized_loss = (entry_short - price) × close_loss_qty × cv ``` 3. **Реализация PnL:** ``` realized_pnl += realized_profit + realized_loss ``` 4. **Проверка перехода в Single:** ``` rem_loss = q_short - close_loss_qty Если rem_loss == 0: transitioning_to_single_from_both = true ``` 5. **Перезакупка (НОВАЯ ЛОГИКА):** ``` new_profit_qty = base = min(q_long, q_short) // Перезакупаем прибыльную new_loss_qty = 0.0 // Убыточную НЕ докупаем! ``` 6. **Генерация actions:** - `CloseLong(close_profit_qty, price, true)` - закрыть LONG 100% - `CloseShort(close_loss_qty, price, false)` - закрыть SHORT X% - `OpenLong(new_profit_qty, price)` - открыть LONG до base - `OpenShort(new_loss_qty, price)` - открыть SHORT до rem_loss (0.0) 7. **Сохранение цены перезакупки:** ``` last_profit_rebuy_price = price ``` **Пример:** ``` До резки: - LONG: qty=1000, entry=0.002000 (прибыльный) - SHORT: qty=1000, entry=0.002000 (убыточный) - base = 1000 - price = 0.002060 - loss_cut_pct = 94.8% Резка: 1. close_profit_qty = 1000 realized_profit = (0.002060 - 0.002000) × 1000 × 1.0 = 0.06 USDT 2. close_loss_qty = 94.8 / 100 × 1000 = 948 контрактов realized_loss = (0.002000 - 0.002060) × 948 × 1.0 = -0.05688 USDT 3. realized_pnl = 0.06 + (-0.05688) = 0.00312 USDT 4. rem_loss = 1000 - 948 = 52 контракта 5. new_profit_qty = base = 1000 new_loss_qty = 0.0 6. Actions: - CloseLong(1000, 0.002060, true) - CloseShort(948, 0.002060, false) - OpenLong(1000, 0.002060) - OpenShort(0, 0.002060) → пропускаем 7. last_profit_rebuy_price = 0.002060 После резки: - LONG: qty=1000, entry=0.002060 (новая цена) - SHORT: qty=52, entry=0.002000 (старая цена) - base = min(1000, 52) = 52 ``` --- #### Этап 5: Исполнение резки (Side::Short) **Строки:** 333-384 ```rust 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; } ``` **Логика аналогична Side::Long, только зеркально:** - SHORT прибыльная, LONG убыточный **Пример:** ``` До резки: - SHORT: qty=1000, entry=0.002000 (прибыльный) - LONG: qty=1000, entry=0.002000 (убыточный) - base = 1000 - price = 0.001940 - loss_cut_pct = 94.8% Резка: 1. close_profit_qty = 1000 realized_profit = (0.002000 - 0.001940) × 1000 × 1.0 = 0.06 USDT 2. close_loss_qty = 94.8 / 100 × 1000 = 948 контрактов realized_loss = (0.001940 - 0.002000) × 948 × 1.0 = -0.05688 USDT 3. realized_pnl = 0.06 + (-0.05688) = 0.00312 USDT 4. rem_loss = 1000 - 948 = 52 контракта 5. new_profit_qty = base = 1000 new_loss_qty = 0.0 6. Actions: - CloseShort(1000, 0.001940, true) - CloseLong(948, 0.001940, false) - OpenShort(1000, 0.001940) - OpenLong(0, 0.001940) → пропускаем 7. last_profit_rebuy_price = 0.001940 После резки: - SHORT: qty=1000, entry=0.001940 (новая цена) - LONG: qty=52, entry=0.002000 (старая цена) - base = min(52, 1000) = 52 ``` --- ## 📊 Полный граф работы ``` check() ↓ ├─→ Проверка BOTH режима ├─→ Расчёт PnL по сторонам ├─→ Определение прибыльной/убыточной │ ├─→ Проверка: обе в плюсе/минусе? │ ├─→ Да → return None │ └─→ Нет → продолжаем │ ├─→ Расчёт PnL% ├─→ Проверка минимальной амплитуды (0.3%) │ ├─→ Расчёт комиссий и funding ├─→ Расчёт чистых PnL% │ ├─→ Проверка порога резки: │ ├─→ profit_pct_clean >= 3%/2%? │ └─→ loss_pct_total < 0? │ └─→ return Some(CutSignal) или None execute(sig) ↓ ├─→ Динамический расчёт % резки убыточной │ ├─→ Расчёт чистых PnL │ ├─→ Расчёт loss_pnl_per_contract │ ├─→ Расчёт max_contracts │ ├─→ Перебор: min_contracts, *2, *3... │ └─→ loss_cut_pct = best / loss_qty × 100 │ ├─→ Логирование │ ├─→ Исполнение резки: │ ├─→ Close{ProfitSide}(100%, price, true) │ ├─→ Close{LossSide}(loss_cut_pct, price, false) │ ├─→ Open{ProfitSide}(base, price) │ └─→ Open{LossSide}(rem_loss, price) // rem_loss ≈ 0 │ ├─→ Проверка: rem_loss == 0? │ └─→ Да → transitioning_to_single_from_both = true │ └─→ Сохранение: last_profit_rebuy_price = price ``` --- ## 🎯 Сценарии работы ### Сценарий 1: Обычная резка (первая) ``` 00:00 → BOTH: LONG 1000, SHORT 1000 entry_long = 0.002000, entry_short = 0.002000 00:30 → price = 0.002060 (+3.0%) pnl_long = +0.06 USDT (+3.0%) pnl_short = -0.06 USDT (-3.0%) check(): profit_pct_raw = +3.0% loss_pct_raw = -3.0% cost_pct = 0.1524% profit_pct_clean = 3.0 - 0.1524 = 2.8476% loss_pct_total = -3.0 - 0.1524 = -3.1524% trigger = 3.0% (первый цикл) Проверка: 2.8476 >= 3.0? ❌ Ещё подождём... 00:35 → price = 0.002062 (+3.1%) profit_pct_raw = +3.1% profit_pct_clean = 3.1 - 0.1524 = 2.9476% Проверка: 2.9476 >= 3.0? ❌ Ещё подождём... 00:40 → price = 0.002065 (+3.25%) profit_pct_raw = +3.25% profit_pct_clean = 3.25 - 0.1524 = 3.0976% Проверка: 3.0976 >= 3.0? ✅ -3.2524 < 0? ✅ → Some(CutSignal) ✅ execute(): Динамический расчёт: loss_pnl_per_contract = 0.000065 USDT profit_clean = 0.061952 USDT max_by_profit = 953 контракта max_contracts = 953 Перебор... best = 952 loss_cut_pct = 95.2% close_profit_qty = 1000 close_loss_qty = 952 rem_loss = 1000 - 952 = 48 new_profit_qty = base = 1000 new_loss_qty = 0.0 Actions: - CloseLong(1000, 0.002065, true) - CloseShort(952, 0.002065, false) - OpenLong(1000, 0.002065) - OpenShort(0, 0.002065) → пропускаем realized_pnl = 0.065 + (-0.06188) = 0.00312 USDT last_profit_rebuy_price = 0.002065 После резки: - LONG: qty=1000, entry=0.002065 - SHORT: qty=48, entry=0.002000 - base = 48 ``` --- ### Сценарий 2: Резка с переходом в Single ``` 00:00 → BOTH: LONG 1000, SHORT 1000 01:00 → Первая резка → LONG 1000, SHORT 500 02:00 → Вторая резка: price = 0.002080 loss_cut_pct = 100% (можно полностью закрыть SHORT) close_loss_qty = 500 rem_loss = 500 - 500 = 0 Если rem_loss == 0: transitioning_to_single_from_both = true 🔄 ПЕРЕХОД В SINGLE: убыточная сторона полностью закрыта После резки: - LONG: qty=1000 - SHORT: qty=0 → SingleStrategy берёт управление ``` --- ### Сценарий 3: НЕ резаем (обе в минусе) ``` 00:00 → BOTH: LONG 1000, SHORT 1000 00:30 → price = 0.001990 (-0.5%) pnl_long = -0.01 USDT (-0.5%) pnl_short = +0.01 USDT (+0.5%) profit_side = SHORT loss_side = LONG profit_pct_raw = +0.5% loss_pct_raw = -0.5% 00:35 → price = 0.001980 (-1.0%) pnl_long = -0.02 USDT (-1.0%) pnl_short = +0.02 USDT (+1.0%) profit_pct_raw = +1.0% loss_pct_raw = -1.0% 00:45 → price = 0.001970 (-1.5%) pnl_long = -0.03 USDT (-1.5%) pnl_short = +0.03 USDT (+1.5%) profit_pct_raw = +1.5% loss_pct_raw = -1.5% check(): profit_pct_clean = 1.5 - 0.1524 = 1.3476% loss_pct_total = -1.5 - 0.1524 = -1.6524% trigger = 3.0% (первый цикл) Проверка: 1.3476 >= 3.0? ❌ Ещё не достигли порога... 01:00 → price = 0.001950 (-2.5%) pnl_long = -0.05 USDT (-2.5%) pnl_short = +0.05 USDT (+2.5%) profit_pct_raw = +2.5% loss_pct_raw = -2.5% profit_pct_clean = 2.5 - 0.1524 = 2.3476% loss_pct_total = -2.5 - 0.1524 = -2.6524% Проверка: 2.3476 >= 3.0? ❌ Ещё не достигли порога... 01:30 → price = 0.001930 (-3.5%) pnl_long = -0.07 USDT (-3.5%) pnl_short = +0.07 USDT (+3.5%) profit_pct_raw = +3.5% loss_pct_raw = -3.5% profit_pct_clean = 3.5 - 0.1524 = 3.3476% loss_pct_total = -3.5 - 0.1524 = -3.6524% Проверка: 3.3476 >= 3.0? ✅ -3.6524 < 0? ✅ → Some(CutSignal) ✅ execute(): loss_cut_pct = 100% (max_by_profit > loss_qty) ... ``` --- ### Сценарий 4: НЕ резаем (обе в плюсе) ``` 00:00 → BOTH: LONG 1000, SHORT 1000 00:30 → price = 0.002010 (+0.5%) pnl_long = +0.01 USDT (+0.5%) pnl_short = -0.01 USDT (-0.5%) 01:00 → price = 0.002020 (+1.0%) pnl_long = +0.02 USDT (+1.0%) pnl_short = -0.02 USDT (-1.0%) 02:00 → price = 0.002040 (+2.0%) pnl_long = +0.04 USDT (+2.0%) pnl_short = -0.04 USDT (-2.0%) 02:30 → price = 0.002060 (+3.0%) pnl_long = +0.06 USDT (+3.0%) pnl_short = -0.06 USDT (-3.0%) 03:00 → price = 0.002080 (+4.0%) pnl_long = +0.08 USDT (+4.0%) pnl_short = -0.08 USDT (-4.0%) ✂️ Резка при +4.0% (profit_pct_clean >= 3.0%) 04:00 → После резки: LONG: qty=1000, entry=0.002080 SHORT: qty=48, entry=0.002000 04:30 → price = 0.002090 (+4.5% от entry_short) price = 0.002010 (+0.1% от entry_long_new) pnl_long = 1000 × (0.002090 - 0.002080) = +0.01 USDT (+0.1%) pnl_short = 48 × (0.002000 - 0.002090) = -0.00432 USDT (-4.5%) profit_side = LONG loss_side = SHORT profit_pct_raw = +0.1% loss_pct_raw = -4.5% 05:00 → price = 0.002070 (+3.5% от entry_short) price = 0.002000 (-0.4% от entry_long_new) pnl_long = 1000 × (0.002070 - 0.002080) = -0.01 USDT (-0.4%) pnl_short = 48 × (0.002000 - 0.002070) = -0.00336 USDT (-3.5%) check(): pnl_long = -0.01 USDT ❌ pnl_short = -0.00336 USDT ❌ Обе в минусе → return None НЕ РЕЗАЕМ ❌ ``` --- ## ⚠️ Критические моменты ### 1. 🔥🔥 НЕ резаем когда обе стороны в минусе **Условие (строка 117-120):** ```rust } else { // либо обе в плюс, либо обе в минус → резка не нужна return None; }; ``` **Вторая проверка (строка 166-168):** ```rust if loss_pct_total >= 0.0 { return None; } ``` **Что это значит:** - Если обе стороны в минусе → НЕ резаем - Если прибыльная сторона < порога → НЕ резаем - Если убыточная сторона НЕ в убытке (loss_pct_total >= 0) → НЕ резаем **Почему?** - Резка когда обе в минусе → только увеличивает убыток - Лучше хеджироваться или ждать разворота --- ### 2. 🔥🔥 Динамический расчёт % резки **Перебор контрактов (строки 242-265):** ```rust 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; } } ``` **Логика:** - Перебираем: `min_contracts, *2, *3, *4...` - Каждую проверку на `net >= 0` - Запоминаем последнее успешное - Гарантируем безубыточность **Пример:** ``` min_contracts = 1 max_contracts = 1000 profit_clean = 0.06 USDT loss_clean = -0.06 USDT Перебор: contracts=1: loss_part=0.00006, net=0.05994 >= 0 ✅ best=1 contracts=2: loss_part=0.00012, net=0.05988 >= 0 ✅ best=2 contracts=3: loss_part=0.00018, net=0.05982 >= 0 ✅ best=3 ... contracts=998: loss_part=0.05988, net=0.00012 >= 0 ✅ best=998 contracts=999: loss_part=0.05994, net=0.00006 >= 0 ✅ best=999 contracts=1000: loss_part=0.06000, net=0.00000 >= 0 ✅ best=1000 contracts=1001: > max_contracts → break loss_cut_pct = 1000 / 1000 × 100 = 100% ``` --- ### 3. 🔥🔥 Убыточную сторону НЕ перезакупаем **Новая логика (строки 305-306, 358-359):** ```rust let new_profit_qty = base; // Перезакупаем прибыльную let new_loss_qty = 0.0; // Убыточную НЕ докупаем! ``` **Старая логика (была):** ```rust let new_profit_qty = base; // Перезакупаем прибыльную let new_loss_qty = 2.0 * rem_loss; // Удваивали остаток ``` **Почему изменили:** - Постепенное сужение убыточной стороны - Несколько резок → BOTH → Single - Уменьшение риска --- ### 4. Минимальная амплитуда (срезаем шум) **Условие (строки 136-138):** ```rust if profit_pct_raw.abs() < MIN_AMPLITUDE_PCT || loss_pct_raw.abs() < MIN_AMPLITUDE_PCT { return None; } ``` **Порог:** `0.3%` **Пример:** ``` profit_pct_raw = +0.2% → 0.2 < 0.3 → НЕ резаем (шум) loss_pct_raw = -0.4% → 0.4 >= 0.3 → OK profit_pct_raw = +0.5% → 0.5 >= 0.3 → OK loss_pct_raw = -0.2% → 0.2 < 0.3 → НЕ резаем (шум) ``` --- ## 📊 Параметры и их источники | Параметр | Значение | Источник | |----------|----------|----------| | `FIRST_TRIGGER_PCT` | 3.0% | Hardcoded const | | `NEXT_TRIGGER_PCT` | 2.0% | Hardcoded const | | `MIN_AMPLITUDE_PCT` | 0.3% | Hardcoded const | | `boosted_once` | true/false | PositionMachine | | `taker_fee_rate` | из TradingMeta | Redis / Gate.io API | | `maker_fee_rate` | из TradingMeta | Redis / Gate.io API | | `funding_rate` | из TradingMeta | Redis / Gate.io API | | `contract_value` | из PositionSnapshot | Redis / расчёты | | `entry_long` / `entry_short` | из PositionSnapshot | Redis / резки | | `long_qty` / `short_qty` | из PositionSnapshot | Redis / биржа | | `last_price` | из PositionSnapshot | Redis / биржа | | `min_contracts` | из TradingMeta | Gate.io API | --- ## 🔗 Интеграция с другими модулями ### 1. BothStrategy **Используется:** Для определения момента резки **Вызовы:** - `VolatilityCycle::check()` - проверяет условие резки - `VolatilityCycle::execute()` - генерирует actions для резки **См. подробнее в `strategy_both.md`** --- ### 2. PositionSnapshot **Используемые поля:** ```rust pos.long_qty pos.short_qty pos.entry_long pos.entry_short pos.last_price pos.contract_value pos.realized_pnl pos.transitioning_to_single_from_both pos.last_profit_rebuy_price ``` **Изменяемые поля:** ```rust pos.realized_pnl += realized_profit + realized_loss pos.transitioning_to_single_from_both = true (если rem_loss == 0) pos.last_profit_rebuy_price = price ``` --- ### 3. TradingMeta **Используемые поля:** ```rust meta.price meta.taker_fee_rate meta.maker_fee_rate meta.funding_rate meta.min_contracts ``` --- ## 🔄 Поток данных ``` BothStrategy::process() ↓ VolatilityCycle::check() ↓ ├─→ Проверка BOTH режима ├─→ Расчёт PnL по сторонам ├─→ Определение прибыльной/убыточной ├─→ Проверка: обе в плюсе/минусе? ├─→ Расчёт PnL% ├─→ Проверка минимальной амплитуды (0.3%) ├─→ Расчёт комиссий и funding ├─→ Расчёт чистых PnL% ├─→ Проверка порога резки (3%/2%) └─→ return Some(CutSignal) или None If Some(CutSignal): ↓ VolatilityCycle::execute() ↓ ├─→ Динамический расчёт % резки │ ├─→ Расчёт чистых PnL │ ├─→ Перебор контрактов (min, *2, *3...) │ └─→ loss_cut_pct │ ├─→ Исполнение резки: │ ├─→ Close{ProfitSide}(100%, price, true) │ ├─→ Close{LossSide}(loss_cut_pct, price, false) │ ├─→ Open{ProfitSide}(base, price) │ └─→ Open{LossSide}(0, price) │ ├─→ Проверка перехода в Single └─→ Сохранение last_profit_rebuy_price ``` --- ## 📝 Логирование ### Динамический максимум: ``` 🔥 ДИНАМИЧЕСКИЙ МАКСИМУМ: loss_pnl_total=-0.0600, loss_qty=1000, loss_per_contract=0.000060, profit_clean=0.0569, max_by_profit=949, loss_qty_i64=1000, final_max=949 ``` ### Динамическая резка: ``` 🔥 ДИНАМИЧЕСКАЯ РЕЗКА: 94.80% убыточной стороны = 948 контрактов (min=1, max 60%) ``` ### Переход в Single: ``` 🔄 ПЕРЕХОД В SINGLE: убыточная сторона полностью закрыта (rem_loss=0) ``` --- ## 🚀 Использование в коде ### Пример вызова в strategy_both.rs: ```rust if let Some(sig) = VolatilityCycle::check( pos, meta, pos.entry_long, pos.entry_short, machine.boosted_once, ) { let cut_actions = VolatilityCycle::execute(pos, &sig, price, meta); actions.extend(cut_actions); machine.on_cut(); machine.update_phase(pos); } ``` --- ## 📊 Важные формулы ### 1. PnL LONG ``` pnl_long = long_qty × (price - entry_long) × contract_value ``` **Пример:** ``` long_qty = 1000 entry_long = 0.002000 price = 0.002060 contract_value = 1.0 pnl_long = 1000 × (0.002060 - 0.002000) × 1.0 = 0.06 USDT ``` ### 2. PnL SHORT ``` pnl_short = short_qty × (entry_short - price) × contract_value ``` **Пример:** ``` short_qty = 1000 entry_short = 0.002000 price = 0.001940 contract_value = 1.0 pnl_short = 1000 × (0.002000 - 0.001940) × 1.0 = 0.06 USDT ``` ### 3. PnL% по сторонам ``` pnl_pct = pnl_abs / notional × 100 notional = qty × entry × contract_value ``` **Пример:** ``` pnl_long = 0.06 USDT notional = 1000 × 0.002000 × 1.0 = 2.0 USDT pnl_long_pct = 0.06 / 2.0 × 100 = 3.0% ``` ### 4. Комиссии и funding ``` cost_pct = (taker_fee_rate × 2.0 + funding_rate × 2.0) × 100.0 ``` **Пример:** ``` taker_fee_rate = 0.00075 funding_rate = 0.000012 cost_pct = (0.00075 × 2.0 + 0.000012 × 2.0) × 100.0 = (0.0015 + 0.000024) × 100.0 = 0.1524% ``` ### 5. Чистый PnL ``` profit_pct_clean = profit_pct_raw - cost_pct loss_pct_total = loss_pct_raw - cost_pct ``` **Пример:** ``` profit_pct_raw = +3.0% loss_pct_raw = -3.0% cost_pct = 0.1524% profit_pct_clean = 3.0 - 0.1524 = 2.8476% loss_pct_total = -3.0 - 0.1524 = -3.1524% ``` ### 6. Убыток на 1 контракт ``` loss_pnl_per_contract = |loss_pnl| / loss_qty ``` **Пример:** ``` loss_pnl = -0.06 USDT loss_qty = 1000 loss_pnl_per_contract = 0.06 / 1000 = 0.00006 USDT ``` ### 7. Максимальное количество контрактов для резки ``` max_by_profit = profit_clean / loss_pnl_per_contract max_contracts = min(max_by_profit, loss_qty, min_contracts) ``` **Пример:** ``` profit_clean = 0.056952 USDT loss_pnl_per_contract = 0.00006 USDT loss_qty = 1000 min_contracts = 1 max_by_profit = 0.056952 / 0.00006 = 949.2 max_contracts = min(949, 1000, 1) = 949 ``` --- ## 🔍 Проверка работы ### Проверка 1: Правильная работа резки 1. Открыть BOTH позицию 2. Дождаться +3% PnL по одной стороне 3. Проверить лог: ``` 🔥 ДИНАМИЧЕСКАЯ РЕЗКА: 94.80% убыточной стороны = 948 контрактов (min=1, max 60%) ``` 4. Проверить что: - Прибыльная сторона закрыта 100% - Убыточная сторона закрыта ~95% - Прибыльная сторона перезакуплена до base - Убыточная сторона НЕ перезакуплена ### Проверка 2: Переход в Single 1. Сделать несколько резок 2. Дождаться когда убыточная сторона полностью закроется 3. Проверить лог: ``` 🔄 ПЕРЕХОД В SINGLE: убыточная сторона полностью закрыта (rem_loss=0) ``` 4. Проверить что: - `transitioning_to_single_from_both = true` - SingleStrategy берёт управление ### Проверка 3: НЕ резаем когда обе в минусе 1. Открыть BOTH позицию 2. Дождаться когда обе стороны будут в минусе 3. Проверить что резка НЕ происходит 4. Проверить лог: НЕ должно быть "Динамическая резка" --- ## 📚 Связанные файлы | Файл | Связь | |------|-------| | `src/live/state.rs` | PositionSnapshot структура | | `src/live/strategy_both.rs` | BothStrategy (использует VolatilityCycle) | | `src/live/actions.rs` | LiveAction enum | | `src/live/meta.rs` | TradingMeta структура | --- ## 🎯 Резюме **Что делает VolatilityCycle:** 1. ✅ Проверяет условия резки (3%/2% чистого PnL) 2. ✅ НЕ резает когда обе стороны в минусе 3. ✅ Динамически рассчитывает % резки убыточной стороны 4. ✅ Закрывает 100% прибыльной стороны 5. ✅ Закрывает X% убыточной стороны (на основе безубыточности) 6. ✅ Перезакупает только прибыльную сторону (до base) 7. ✅ Убыточную сторону НЕ перезакупает (постепенно режет до нуля) 8. ✅ Переход из BOTH в Single если убыточная сторона полностью закрыта 9. ✅ Учёт комиссий и funding 10. ✅ Минимальная амплитуда 0.3% (срезаем шум) **Когда резаем:** - ✅ Прибыльная сторона >= 3%/2% (чистый PnL) - ✅ Убыточная сторона < 0 (в убытке) - ✅ Минимальная амплитуда >= 0.3% **Когда НЕ резаем:** - ❌ Обе стороны в плюсе - ❌ Обе стороны в минусе - ❌ Прибыльная сторона < порога - ❌ Минимальная амплитуда < 0.3% **Особенности:** - 🔥 Динамический % резки (на основе безубыточности) - 🔥 Убыточную сторону НЕ перезакупаем (постепенное сужение) - 🔥 Переход в Single при полной резке убыточной стороны --- **Дата создания:** 2026-02-22 **Автор:** Claude Code Assistant **Версия:** 1.0