← Назад к главному
# 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