# Анализ моделей MoE Regression и план улучшений (v13 Regression)

**Дата:** 2026-08-03  
**Основание:** ревью моделей `*_moe_regression.joblib` (18 тикеров, обучение 31.07-01.08), сделок `trades_closed_regression` (11 записей), метрик из лога тренировки 31.07.2026 16:47

## 1. Текущее состояние

### Сводка моделей и сделок

| Метрика | Значение |
|---|---|
| Тикеров обучено | 18 (mode: regression) |
| Размер моделей | 7.6-8.1 MB каждая |
| Сделок закрыто (regression live) | 11 (vs 121 classifier) |
| Winrate live | 5/11 = 45% (малая выборка) |
| PnL (regression live) | +152 ₽ (5655 TP, -5655 SL, +152 TIME_STOP) |
| Открытых сделок regression | 0 |

### Источниковая аномалия в live-сделках

6 из 6 SL имеют **одинаковый pnl=−942.52 ₽** (LKOH, MOEX, VTBR, NVTK, PLZL, X5). Это указывает на single-batch тест с одинаковым депозитом ~94K × 1% risk. Сделки открывались почти одновременно, что нетипично для параллельного monitor-режима.

## 2. Критическая находка: отрицательные R²

**81% регрессоров (88 из 108) имеют отрицательный R²!**

| Метрика | Значение |
|---|---|
| Всего peak/trough регрессоров | 108 (18 тикеров × 3 horizons × 2) |
| **Отрицательный R²** («модель вредна») | **88/108 = 81%** |
| Near zero R² (no info) | 25/108 = 23% |
| Положительный R² (модель работает) | 20/108 = 19% |
| Средний R² | **-0.257** |
| Минимальный R² | **-2.795** (!) |
| Максимальный R² | +0.201 |
| **Mean DirAcc** | **50.2%** (random = 50%) |
| DirAcc > 50% (better than random) | 32/54 (59%) |

### Пример: ASTR (v12 regression)

```
H=10:  peak R²=-0.22  trough R²=-0.18  DirAcc=45.9%  (worse than random!)
H=30:  peak R²=-0.12  trough R²=-0.09  DirAcc=53.1%
H=60:  peak R²=-0.17  trough R²=-0.20  DirAcc=48.4%  (worse than random!)
```

### Пример: ROSN (v12 regression, худший)

```
H=60: peak MAE=6.97  R²=-1.06  (!)   ← модель даёт ошибку >6 ATR
```

**Вывод:** регрессионные модели v12 практически бесполезны. Средний R² отрицательный — модель работает **хуже, чем простое предсказание среднего значения**. DirAcc = 50.2% — это случайный уровень.

## 3. Корневые причины

После изучения кода `models/moe_regression.py` (929 строк) и `features/targets.py:compute_enhanced_regression_targets`:

### 3.1. Target design (ФОКУС №1)

1. **`pct_to_peak_h` / `pct_to_trough_h` — не ограничены**. На 60-бар окне возможны движения ±20%+. XGBoost Regressor обучается на экстремальных значениях и не может их предсказать по features.

2. **ATR-нормализация даёт выбросы**: `peak_norm_h = pct_to_peak_h / atr_pct`. Если ATR_pct ≈ 0.001 (крайне низкая волатильность), `peak_norm` может быть 20-30 ATR units. Это убивает регрессию.

3. **Нет winsorize на target** (`_prepare_regression_df` line 142 — `winsorize_features(df)` только на features, не на targets).

4. **NaN target → fillna(0)** создаёт ложные «нулевые движения» на последних барах (target_norm=0), модель переобучается на нули.

### 3.2. Архитектура моделей

5. **`directional_mse_loss` ОПРЕДЕЛЁН, но НЕ ИСПОЛЬЗУЕТСЯ.** В `train()` (line 451-462) создаётся `XGBRegressor(objective='reg:squarederror')` — стандартный MSE. Custom loss с directional-штрафом определён в line 86-108, но не подключён.

6. **`cls_move` имеет pos_rate 70-94%** (depending on horizon). Бинарный классификатор бесполезен — он предсказывает "1" в 80%+ случаев. Причина: `move_exists_thresholds = {10: 1.5, 30: 2.0, 60: 2.5}` × ATR — слишком низкий порог для "движения". Любое движение >1.5×ATR засчитывается, а это происходит почти всегда.

7. **Horizon weights `[0.5, 0.3, 0.2]` фиксированы** — но H=10 (вес 0.5) имеет ХУДШИЙ R²! Самый шумный горизонт получает двойной вес. Решение: adaptive weights по val R².

8. **Bars-to-extreme регрессор (`reg_bars_p / reg_bars_t`)**: создаётся только если `valid.sum() > 30` (bars > 0 для обоих экстремумов). На данных с 20-30K строк это исчезающе меньше, модель обучается на ~30 сэмплах — бесполезно.

9. **Peak и trough регрессоры обучаются независимо**. Нет связи sign(peak) >= 0 и sign(trough) <= 0 — модель может предсказать peak=-3 (отрицательный пик!), что абсурдно.

### 3.3. v13 features НЕ применены

10. **Regression-mode не получает Hurst и VWAP фичи**. Проверка показала: только 4 из 11 v13 features попадают в regression-feature_cols (через MTF_CONTEXT_COLS). Отсутствуют:
    - `hurst_100`, `is_trending`, `is_mean_reverting`, `hurst_confidence` (regime detection)
    - `vwap_24`, `vwap_dist_pct`, `above_vwap` (volume-price)

11. Причина: `_get_feature_cols()` в `moe_regression.py` (line 96) использует **оригинальный `DIRECTIONAL_COLS + FEATURE_COLS + ...`**, а НЕ `clean_base_features()` из `features/feature_cols.py` (который включает EXTRA_FEATURE_COLS — там Hurst и VWAP).

### 3.4. Threshold search (ФОКУС №2)

12. **Ratio sweep `np.arange(MIN_RATIO=3.0, 6.5, 0.5)` — 8 точек.** Слишком грубо. В v12 classifier мы расширили до [0.50, 0.85] шаг 0.01 (36 точек). Для regression sweep [1.5, 8.0] шаг 0.25 (27 точек) даст значительно лучшую оптимизацию.

13. **`trades >= 10` min sample** — слишком мало. v13 classifier использует 15, regression поднять до 20.

14. **Нет WR-фильтра для "no-trade protection"**. Если все ratio sweeps дают WR < 33% (>breakeven), модель всё равно открывает сделки (берётся best_net из худших). Решение: при WR ≤ breakeven → ratio_threshold = 99.0 (no-trade).

### 3.5. Live trade integration (ФОКУС №3)

15. **`SL_pct = pred_peak * 0.3`** — произвольный выбор, не согласованный с RR-конфигом classifier. При `pred_peak = 1%`, SL = 0.3% что **меньше ATR** → мгновенный стоп.

16. **Нет RR-синхронизации с v13**. Regression-режим игнорирует `get_risk_params(ticker)`, `MARKET_TARGET_CONFIGS`, MOEX session filter, auto-disable.

17. **`SHORT_BLOCKLIST` и `LONG_BLOCKLIST`** — единственная защита regression-режима, но она ручная (28 тикеров SBER/GAZP/...). Нет auto-disable по PnL.

18. **TIME_STOP mismatch** — regression targets смотрят на 200-бар окно max (в `compute_enhanced_regression_targets` нет max_bars), но trade_manager закрывает через 48ч. Target = «есть движение через 200ч», но trade закрыт на 48ч → target/label mismatch.

## 4. План улучшений v13 Regression

### Этап A — Target redesign (1 ч, HIGH PRIORITY)

#### A.1. Winsorize target outliers

В `features/targets.py:compute_enhanced_regression_targets` после цикла по horizons:

```python
# Winsorize top/bottom 1% — защита от экстремальных движений
for h in horizons:
    df[f'peak_norm_{h}'] = np.clip(df[f'peak_norm_{h}'], 0, 10)   # max 10×ATR
    df[f'trough_norm_{h}'] = np.clip(df[f'trough_norm_{h}'], -10, 0)  # min -10×ATR
    df[f'pct_to_peak_{h}'] = np.clip(df[f'pct_to_peak_{h}'], 0, 0.10) # max 10% move
    df[f'pct_to_trough_{h}'] = np.clip(df[f'pct_to_trough_{h}'], -0.10, 0)
```

#### A.2. Поднять `move_exists_thresholds`

В `config.py:REGRESSION_CONFIG`:

```python
# Было: {10: 1.5, 30: 2.0, 60: 2.5} — pos_rate 86-94%
# Стало (audit 2026-08-03): поднять порог для информативного binary classifier
'move_exists_thresholds': {10: 2.0, 30: 3.0, 60: 4.0},
```

Ожидаемый эффект: pos_rate 70-94% → 40-65%, классификатор станет дифференцирующим.

### Этап B — Архитектура (2 ч, HIGH PRIORITY)

#### B.1. Подключить directional MSE Loss через DMatrix API

Custom loss требует xgb.train() (core API), а не sklearn.XGBRegressor. Решение — переписать train-loop через DMatrix:

```python
# Вместо:
reg_peak = xgb.XGBRegressor(**params_peak)
reg_peak.fit(X_tr, y_tr_p)

# Использовать:
dtr = xgb.DMatrix(X_tr, label=y_tr_p)
# Двух-стадийный: сначала warmup на обычном MSE, потом directional MSE
params_warmup = params_peak.copy()
params_warmup.pop('objective')
params_warmup['objective'] = 'reg:squarederror'
bst_warm = xgb.train(params_warmup, dtr, num_boost_round=100)

# Directional MSE through custom objective
def _dir_mse_obj(ytrue, ypred):
    # ytrue, ypred — raw arrays
    grad, hess = directional_mse_loss(ytrue, ypred)
    return grad, hess

params_dir = params_peak.copy()
params_dir['objective'] = _dir_mse_obj
bst_dir = xgb.train(params_dir, dtr, num_boost_round=200, xgb_model=bst_warm)
```

#### B.2. Adaptive horizon weights по val R²

В `_ensemble_predict_single` (line 636), заменить фиксированные `horizon_weights` на:

```python
def _adaptive_weights(self):
    """Возвращает веса горизонтов, обратно-пропорциональные val R².
    
    Если все R²<0 → вес 1/n (uniform fallback).
    Leader horizon (highest R²) получает ~60% веса, остальные делят остаток.
    """
    if not hasattr(self, '_horizon_r2'):
        return self.horizon_weights
    r2_pos = [max(self._horizon_r2.get(h, 0), 0) for h in self.horizons]
    if sum(r2_pos) < 1e-9:
        return [1/len(self.horizons)] * len(self.horizons)
    return [r/sum(r2_pos) for r in r2_pos]
```

Запомнить `self._horizon_r2[h] = r2_peak` после тренировки каждого horizon.

#### B.3. Перенос v13 features (Hurst, VWAP) в regression

В `models/moe_regression.py:_get_feature_cols`:

```python
def _get_feature_cols() -> list[str]:
    # Используем clean_base_features() (там v13 EXTRA_FEATURE_COLS уже добавлены)
    from features.feature_cols import clean_base_features
    return clean_base_features()
```

Это включает `hurst_100`, `is_trending`, `is_mean_reverting`, `hurst_confidence`, `vwap_24`, `vwap_dist_pct`, `above_vwap` + 4 MTF divergence.

### Этап C — Threshold search (0.5 ч, HIGH PRIORITY)

#### C.1. Расширить ratio sweep с NO-TRADE защитой

В `_simulate_trades` (line 560):

```python
# Было: for ratio_thr in np.arange(3.0, 6.5, 0.5)
# Стало:
for ratio_thr in np.arange(1.5, 8.01, 0.25):  # 27 точек вместо 8
    ...
    # Минимум 20 трейдов (было 10)
    if trades >= 20 and net > best_net and wr > breakeven_wr_plus2:
        best_net = net
        best_ratio = ratio_thr

# NO-TRADE защита: если ни один ratio не прошёл WR > breakeven+2%
if best_net == -float('inf'):
    best_net = 0.0
    best_ratio = 99.0  # no-trade (вариант huge ratio, никогда не сработает)
    logger.warning(f'    ⚠ _simulate_trades: NO-TRADE — WR<breakeven при всех ratio')
```

### Этап D — Live trade integration (1 ч, MEDIUM PRIORITY)

#### D.1. Sync SL/TP с v13 risk params

В `predict()` (line 700+), заменить:
```python
# Было:
sl_pct = pred_peak * 0.3      # arbitrary 0.3 multiplier
```
На:
```python
from config import get_risk_params
sl_mult, tp_mult, _ = get_risk_params(self.ticker)
atr_abs = close * atr_pct
sl_pct = sl_mult * atr_pct   # RR-согласованный с v13 classifier
tp_pct = tp_mult * atr_pct
```

#### D.2. MOEX session filter и auto-disable

Add в Monitor.process_signal:
- Same `get_market(ticker) == 'moex'` check 10-19 MSK
- Same auto-disable losers check (TICKER_AUTO_DISABLE)

#### D.3. Restore NaN targets вместо fillna(0)

В `_prepare_regression_df` (line 156):
```python
# Было:
for tc in target_cols:
    df[tc] = df[tc].fillna(0.0)

# Стало — оставляем NaN, чтобы train_mask их исключал:
# (далее в train() уже есть train_mask = ~(np.isnan(y_tr_p) | np.isnan(y_tr_t)))
# Ничего не делаем — NaN останется на конце датасета (last H bars)
```

### Этап E — Confidence & signal quality (0.5 ч, LOW PRIORITY)

#### E.1. Confidence-based signal gate

В `predict()`:
```python
# После получения pred_peak, pred_trough, confidence:
min_conf = 0.4  # threshold для NO-TRADE
if confidence < min_conf:
    return {'signal': 'NEUTRAL', 'reason': f'low_confidence={confidence:.2f}'}

# + RR check: pred_peak / pred_trough > ratio_thr УЖЕ есть,
# но добавить: max(pred_peak, abs(pred_trough)) >= MIN_ABS_MOVE_ATR * atr_pct
# это уже сделано (line 750-752)
```

#### E.2. Bars-to-extreme как классификатор

Вместо регрессора `bars_to_peak`, сделать классификатор:
```python
# Раннее достижение (<=5 bars from now) vs позднее (>5 bars)
# Информативнее как binary than as continuous
df[f'early_peak_{h}'] = (df[f'bars_to_peak_{h}'] <= h * 0.25).astype(int)  # <25% гор
```

### Этап F — Retraining и валидация (4-5 ч, последовательный)

```bash
# С .training_lock
touch /home/ai/projects/AI_Strategy/.training_lock
python3 -u train_all_rr1x2.py --regression --ticker ALL \
  > logs/retrain_v13_regression_$(date +%Y%m%d_%H%M).log 2>&1
rm /home/ai/projects/AI_Strategy/.training_lock
```

Ожидаемое время: 18 тикеров × ~15 мин = 4.5 ч.

**Критерии успеха:**
- avg R² peak/trough > 0 (vs -0.257 сейчас) — любая положительная метрика
- avg DirAcc > 55% (vs 50.2% сейчас)
- ≥ 12/18 тикеров с WR > 35% в threshold sweep
- ≥ 50% тикеров с positive val NetPnL sim

## 5. Резюме плана внедрения

| # | Что | Файл | Этап | Эффект приоритет |
|---|---|---|---|:---:|
| A.1 | Winsorize target outliers | `features/targets.py` | Target | 🔴 |
| A.2 | Поднять move_exists_thresholds | `config.py:REGRESSION_CONFIG` | Target | 🔴 |
| B.1 | Directional MSE loss через DMatrix | `models/moe_regression.py` | Model | 🔴 |
| B.2 | Adaptive horizon weights by R² | `models/moe_regression.py` | Model | 🔴 |
| B.3 | Перенос v13 features (Hurst, VWAP) | `models/moe_regression.py:_get_feature_cols` | Features | 🔴 |
| C.1 | Расширить ratio sweep [1.5, 8.0] шаг 0.25 + NO-TRADE | `models/moe_regression.py:_simulate_trades` | Threshold | 🔴 |
| D.1 | Sync SL/TP с v13 risk params | `models/moe_regression.py:predict` | Live | 🟡 |
| D.2 | MOEX session filter + auto-disable | `models/moe_regression.py + monitor.py` | Live | 🟡 |
| D.3 | Restore NaN targets (не fillna(0)) | `models/moe_regression.py:_prepare_regression_df` | Data | 🟡 |
| E.1 | Confidence-based NO-TRADE | `models/moe_regression.py:predict` | Signal | 🟢 |
| E.2 | Bars-to-extreme как классификатор | `features/targets.py` | Target | 🟢 |
| F | Retrain 18 тикеров + WF CV | bulk | Validation | 🔴 |

**Итого:** ~5-6 ч кода + 4.5 ч обучения = 1 рабочий день.

## 6. Ожидаемые результаты

| Метрика | v12 Regression | v13 Regression (цель) |
|---|---|---|
| Средний R² peak/trough | -0.257 | **> +0.05** |
| Тикеров с R² > 0 | 19% | **≥ 60%** |
| Mean DirAcc | 50.2% | **≥ 55%** |
| Tuner ratio sweep points | 8 (3.0-6.0 шаг 0.5) | 27 (1.5-8.0 шаг 0.25) |
| Min trades в sweep | 10 | 20 |
| NO-TRADE защита | нет | есть |
| v13 features (Hurst+VWAP) | отсутствуют | включены |
| Live SL/TP sync с classifier | нет (arbitrary 0.3×peak) | да (get_risk_params) |
| Live session filter | нет | да (10-19 MSK MOEX) |
| Live auto-disable losers | нет | да |

## 7. Рекомендация по порядку работ

День 1: Этапы A + B (target + architecture) → ~3 ч кода  
День 1: Этап C (threshold) → 0.5 ч  
День 1: Этап D (live integration) → 1 ч  
День 1: Юнит-тесты → 0.5 ч  
День 2: Этап F (retrain 18 тикеров + WF CV) → ~5 ч  

## 8. Альтернатива: Возможный rollback regression-mode

Если после v13 retrain regression-модели не достигают avg R² > 0, рекомендуется **выключить regression-mode в production** (удалить `--regression` из cron или web-панели), оставив только classifier-mode (v13) как основную стратегию. Текущие результаты regression-mode worse than random — это safe fallback.