16d15
< from collections import defaultdict
130,133c129,131
<     Shadow Filter v3.3
<     ВХОД:  3 SL подряд по одной паре  → LOCAL shadow (блок только той пары)
<            3 SL подряд по разным парам → GLOBAL shadow (блок всего бота)
<     ВЫХОД: 3 виртуальных TP подряд ИЛИ виртуальный PnL > 0
---
>     Режимы: SHADOW (наблюдаем) → PAPER (торгуем) → SHADOW ...
>     entry_n: прибыльных подряд в SHADOW → включаем PAPER
>     exit_m:  убыточных подряд  в PAPER  → выключаем → SHADOW
135,146c133,139
<     def __init__(self, entry_n: int = 3, exit_n: int = 3):
<         self.entry_n        = entry_n
<         self.exit_n         = exit_n
<         self.global_shadow  = False
<         self.local_shadow   = set()          # пары в локальном shadow
<         self._g_sl          = 0              # глобальный SL-стрик
<         self._l_sl          = defaultdict(int)   # per-pair SL-стрик
<         self._g_vtp         = 0              # виртуальных TP подряд (global)
<         self._g_vpnl        = 0.0
<         self._l_vtp         = defaultdict(int)
<         self._l_vpnl        = defaultdict(float)
<         self._lock          = threading.Lock()
---
>     def __init__(self, entry_n: int = 1, exit_m: int = 4):
>         self.entry_n      = entry_n
>         self.exit_m       = exit_m
>         self.mode         = 'SHADOW'
>         self.consec_wins  = 0
>         self.consec_losses= 0
>         self._lock        = threading.Lock()
148c141
<     def can_trade(self, symbol: str = '') -> bool:
---
>     def can_trade(self) -> bool:
150,154c143
<             if self.global_shadow:
<                 return False
<             if symbol and symbol in self.local_shadow:
<                 return False
<             return True
---
>             return self.mode == 'PAPER'
156,158c145
<     def on_trade_closed(self, pnl: float, symbol: str = '', reason: str = ''):
<         is_sl = reason == 'SL'
<         is_tp = reason in ('TP', 'TRAIL')
---
>     def on_trade_closed(self, pnl: float):
160,200c147,167
<             in_shadow = self.global_shadow or (symbol and symbol in self.local_shadow)
<             if not in_shadow:
<                 # Реальная сделка — обновляем стрики
<                 if is_sl:
<                     self._g_sl += 1
<                     if symbol: self._l_sl[symbol] += 1
<                 else:
<                     self._g_sl = 0
<                     if symbol: self._l_sl[symbol] = 0
<                 # Локальный триггер (приоритет)
<                 if symbol and self._l_sl[symbol] >= self.entry_n:
<                     self.local_shadow.add(symbol)
<                     self._l_sl[symbol]  = 0
<                     self._l_vpnl[symbol]= 0.0
<                     self._l_vtp[symbol] = 0
<                     self._g_sl          = 0   # поглощаем в локальный
<                     logger.info(f'[SHADOW] LOCAL ON: {symbol}')
<                 elif self._g_sl >= self.entry_n:
<                     self.global_shadow = True
<                     self._g_sl         = 0
<                     self._g_vpnl       = 0.0
<                     self._g_vtp        = 0
<                     logger.info(f'[SHADOW] GLOBAL ON')
<             else:
<                 # Виртуальная сделка
<                 if self.global_shadow:
<                     self._g_vpnl += pnl
<                     if is_tp:   self._g_vtp += 1
<                     elif is_sl: self._g_vtp  = 0
<                     if self._g_vtp >= self.exit_n or self._g_vpnl > 0:
<                         self.global_shadow = False
<                         self._g_sl         = 0
<                         logger.info(f'[SHADOW] GLOBAL OFF (vTP={self._g_vtp} vPnL={self._g_vpnl:+.2f}$)')
<                 elif symbol and symbol in self.local_shadow:
<                     self._l_vpnl[symbol] += pnl
<                     if is_tp:   self._l_vtp[symbol] += 1
<                     elif is_sl: self._l_vtp[symbol]  = 0
<                     if self._l_vtp[symbol] >= self.exit_n or self._l_vpnl[symbol] > 0:
<                         self.local_shadow.discard(symbol)
<                         self._l_sl[symbol] = 0
<                         logger.info(f'[SHADOW] LOCAL OFF: {symbol} (vTP={self._l_vtp[symbol]} vPnL={self._l_vpnl[symbol]:+.2f}$)')
---
>             is_win = pnl > 0
>             if self.mode == 'SHADOW':
>                 if is_win:
>                     self.consec_wins += 1
>                 else:
>                     self.consec_wins = 0
>                 if self.consec_wins >= self.entry_n:
>                     self.mode         = 'PAPER'
>                     self.consec_wins  = 0
>                     self.consec_losses= 0
>                     logger.info(f'[SHADOW] → PAPER (entry_n={self.entry_n} wins)')
>             else:  # PAPER
>                 if is_win:
>                     self.consec_losses = 0
>                 else:
>                     self.consec_losses += 1
>                 if self.consec_losses >= self.exit_m:
>                     self.mode          = 'SHADOW'
>                     self.consec_losses = 0
>                     self.consec_wins   = 0
>                     logger.info(f'[SHADOW] PAPER → SHADOW ({self.exit_m} losses)')
205,210c172,174
<                 'mode':          'GLOBAL' if self.global_shadow else ('LOCAL' if self.local_shadow else 'OFF'),
<                 'global_shadow': self.global_shadow,
<                 'local_shadow':  list(self.local_shadow),
<                 'g_sl_streak':   self._g_sl,
<                 'g_vtp':         self._g_vtp,
<                 'g_vpnl':        round(self._g_vpnl, 2),
---
>                 'mode':          self.mode,
>                 'consec_wins':   self.consec_wins,
>                 'consec_losses': self.consec_losses,
212c176
<                 'exit_n':        self.exit_n,
---
>                 'exit_m':        self.exit_m,
218,222c182,184
<                 'global_shadow':  self.global_shadow,
<                 'local_shadow':   list(self.local_shadow),
<                 'g_sl':           self._g_sl,
<                 'g_vtp':          self._g_vtp,
<                 'g_vpnl':         self._g_vpnl,
---
>                 'mode':           self.mode,
>                 'consec_wins':    self.consec_wins,
>                 'consec_losses':  self.consec_losses,
231,232c193,194
<     def load(cls, entry_n: int, exit_n: int):
<         sf = cls(entry_n, exit_n)
---
>     def load(cls, entry_n: int, exit_m: int):
>         sf = cls(entry_n, exit_m)
236,241c198,201
<                 sf.global_shadow = d.get('global_shadow', False)
<                 sf.local_shadow  = set(d.get('local_shadow', []))
<                 sf._g_sl         = d.get('g_sl', 0)
<                 sf._g_vtp        = d.get('g_vtp', 0)
<                 sf._g_vpnl       = d.get('g_vpnl', 0.0)
<                 logger.info(f'[SHADOW] Loaded: global={sf.global_shadow} local={sf.local_shadow}')
---
>                 sf.mode          = d.get('mode', 'SHADOW')
>                 sf.consec_wins   = d.get('consec_wins', 0)
>                 sf.consec_losses = d.get('consec_losses', 0)
>                 logger.info(f'[SHADOW] Loaded state: mode={sf.mode} wins={sf.consec_wins} losses={sf.consec_losses}')
248,417d207
< class CrossShadowFilter:
<     """
<     Shadow-фильтр NEXUS управляемый результатами Phantom.
<     Phantom словил entry_n SL подряд → NEXUS в shadow (если ≥2 distinct symbols).
<     Phantom сделал exit_n TP подряд (или vPnL > 0) → NEXUS из shadow.
< 
<     Защиты от Phantom-шума (добавлено 2026-05-06 после BTCDOM-инцидента):
<       - Slippage filter: SL с |exit-SL|/entry > sl_slip_x × budgeted считается
<         фейковым (price-feed артефакт) и в счётчик не идёт.
<       - Distinct-symbol gate: если все entry_n SL на одном символе →
<         локальный block только этого символа, БЕЗ global flip и БЕЗ panic-close.
<     """
<     SLIPPAGE_X = 2.0   # |actual_drop| > expected_drop × X → fake SL
< 
<     def __init__(self, phantom_db: str, entry_n: int = 1, exit_n: int = 1,
<                  on_shadow_on=None):
<         self.phantom_db  = phantom_db
<         self.entry_n     = entry_n
<         self.exit_n      = exit_n
<         self.in_shadow   = False
<         self.local_shadow: set = set()       # per-symbol block из distinct-gate
<         self._sl_streak  = 0
<         self._sl_streak_symbols: list = []   # symbols последних _sl_streak SL подряд
<         self._fake_sl_dropped = 0            # счётчик отброшенных фейков
<         self._vtp        = 0
<         self._vpnl       = 0.0
<         self._last_id    = None   # последний обработанный trade id
<         self._lock       = threading.Lock()
<         self.on_shadow_on = on_shadow_on   # callback: вызывается когда shadow OFF→ON
<         self._refresh()
< 
<     @classmethod
<     def _is_fake_by_slippage(cls, entry, sl, exit_) -> bool:
<         try:
<             entry = float(entry); sl = float(sl); exit_ = float(exit_)
<             if not (entry and sl and exit_):
<                 return False
<             expected = abs(sl - entry) / entry
<             actual   = abs(exit_ - entry) / entry
<             return expected > 0 and actual > expected * cls.SLIPPAGE_X
<         except (TypeError, ValueError, ZeroDivisionError):
<             return False
< 
<     def _refresh(self):
<         """Читает новые закрытые сделки Phantom и обновляет shadow-состояние."""
<         try:
<             import sqlite3
<             con = sqlite3.connect(self.phantom_db, timeout=5)
<             cur = con.cursor()
<             COLS = ("id, symbol, close_reason, pnl_usdt, "
<                     "entry_price, exit_price, stop_loss")
<             if self._last_id is None:
<                 # Первый запуск — берём последние 50 для инициализации
<                 rows = cur.execute(
<                     f"SELECT {COLS} FROM positions "
<                     "WHERE status='CLOSED' ORDER BY closed_at DESC LIMIT 50"
<                 ).fetchall()
<                 rows = list(reversed(rows))
<             else:
<                 rows = cur.execute(
<                     f"SELECT {COLS} FROM positions "
<                     "WHERE status='CLOSED' AND rowid > "
<                     "(SELECT rowid FROM positions WHERE id=?) ORDER BY closed_at ASC",
<                     (self._last_id,)
<                 ).fetchall()
<             con.close()
< 
<             for row_id, symbol, reason, pnl, entry_p, exit_p, sl_p in rows:
<                 self._last_id = row_id
<                 is_tp = reason in ('TP', 'TRAIL', 'TIME') and (pnl or 0) > 0
<                 is_sl = reason == 'SL' or (pnl or 0) < 0
< 
<                 # Slippage filter: фейк-SL не участвует ни в streak, ни в v_pnl
<                 if is_sl and self._is_fake_by_slippage(entry_p, sl_p, exit_p):
<                     self._fake_sl_dropped += 1
<                     logger.info(
<                         f'[CROSS-SHADOW] DROP fake SL {symbol} '
<                         f'entry={entry_p} sl={sl_p} exit={exit_p} '
<                         f'(price-feed artifact, slip>{self.SLIPPAGE_X}x)'
<                     )
<                     continue
< 
<                 if not self.in_shadow:
<                     if is_sl:
<                         self._sl_streak += 1
<                         self._sl_streak_symbols.append(symbol)
<                         if self._sl_streak >= self.entry_n:
<                             # Distinct-symbol gate
<                             tail = self._sl_streak_symbols[-self.entry_n:]
<                             distinct = len(set(tail))
<                             if distinct < 2:
<                                 bad = tail[0]
<                                 self.local_shadow.add(bad)
<                                 logger.info(
<                                     f'[CROSS-SHADOW] LOCAL block {bad} '
<                                     f'(single-symbol cluster {self.entry_n}× — not regime change)'
<                                 )
<                                 self._sl_streak = 0
<                                 self._sl_streak_symbols = []
<                             else:
<                                 self.in_shadow  = True
<                                 self._sl_streak = 0
<                                 self._sl_streak_symbols = []
<                                 self._vtp       = 0
<                                 self._vpnl      = 0.0
<                                 logger.info(
<                                     f'[CROSS-SHADOW] GLOBAL ON (Phantom SL streak={self.entry_n}, '
<                                     f'distinct symbols={distinct})'
<                                 )
<                                 if self.on_shadow_on:
<                                     try:
<                                         self.on_shadow_on()
<                                     except Exception as e:
<                                         logger.error(f'[PANIC-CLOSE] callback failed: {e}')
<                     else:
<                         # любой не-SL событие сбрасывает streak
<                         self._sl_streak = 0
<                         self._sl_streak_symbols = []
<                         # успешный TP снимает все локальные блоки
<                         if is_tp and self.local_shadow:
<                             logger.info(f'[CROSS-SHADOW] LOCAL clear ({len(self.local_shadow)} symbols) on TP')
<                             self.local_shadow.clear()
<                 else:
<                     self._vpnl += (pnl or 0)
<                     if is_tp:
<                         self._vtp += 1
<                     elif is_sl:
<                         self._vtp = 0
<                     if self._vtp >= self.exit_n or self._vpnl > 0:
<                         self.in_shadow  = False
<                         self._vtp       = 0
<                         self._vpnl      = 0.0
<                         self._sl_streak = 0
<                         self._sl_streak_symbols = []
<                         logger.info(f'[CROSS-SHADOW] GLOBAL OFF (vTP={self._vtp} vPnL={self._vpnl:+.2f}$)')
<         except Exception as e:
<             logger.warning(f'[CROSS-SHADOW] refresh error: {e}')
< 
<     def can_trade(self, symbol: str = '') -> bool:
<         with self._lock:
<             self._refresh()
<             if self.in_shadow:
<                 return False
<             if symbol and symbol in self.local_shadow:
<                 return False
<             return True
< 
<     def on_trade_closed(self, pnl: float, symbol: str = '', reason: str = ''):
<         pass  # CrossShadow игнорирует собственные сделки NEXUS
< 
<     def save(self, *args, **kwargs):
<         pass  # нет локального state-файла
< 
<     def get_state(self) -> dict:
<         with self._lock:
<             mode = 'GLOBAL' if self.in_shadow else ('LOCAL' if self.local_shadow else 'OFF')
<             return {
<                 'mode':           mode,
<                 'global_shadow':  self.in_shadow,
<                 'local_shadow':   sorted(self.local_shadow),
<                 'g_sl_streak':    self._sl_streak,
<                 'g_vtp':          self._vtp,
<                 'g_vpnl':         round(self._vpnl, 2),
<                 'entry_n':        self.entry_n,
<                 'exit_n':         self.exit_n,
<                 'source':         'phantom',
<                 'fake_sl_dropped': self._fake_sl_dropped,
<             }
< 
< 
443,447d232
<     # трейлинг стоп
<     trail_active: bool = False
<     trail_best_price: float = 0.0
<     trail_dist: float = 0.0
<     trail_activation_price: float = 0.0
477,478d261
<         self.trail_activation_frac = config.get('strategy', {}).get('trail_activation_frac', None)
<         self.trail_dist_frac       = config.get('strategy', {}).get('trail_dist_frac', 0.1)
483,485d265
<         self._cooldowns: dict = {}   # symbol → time.time() до которого в cooldown
<         COOLDOWN_SEC    = 15 * 60   # 15 минут
<         self._cooldown_sec = COOLDOWN_SEC
487,514c267,272
<         self.shadow_enabled = sf_cfg.get('enabled', True)
<         phantom_db = sf_cfg.get('phantom_db', '')
<         if phantom_db and Path(phantom_db).exists():
<             self.panic_close_enabled = sf_cfg.get('panic_close', False)
<             self.shadow = CrossShadowFilter(
<                 phantom_db  = phantom_db,
<                 entry_n     = sf_cfg.get('entry_sl_count', 1),
<                 exit_n      = sf_cfg.get('exit_tp_count',  1),
<                 on_shadow_on = (self._panic_close_all if self.panic_close_enabled else None),
<             )
<             self.ghost_saved = 0.0
<             self.ghost_skipped_loss = 0
<             self.ghost_skipped_win  = 0
<             mode_tag = ' + panic_close' if self.panic_close_enabled else ''
<             logger.info(f'[SHADOW] CrossShadow mode{mode_tag} — Phantom DB: {phantom_db}')
<         else:
<             self.shadow, self.ghost_saved, self.ghost_skipped_loss, self.ghost_skipped_win = \
<                 ShadowFilter.load(sf_cfg.get('entry_sl_count', 3), sf_cfg.get('exit_tp_count', 3))
< 
<     def is_cooldown(self, symbol: str) -> bool:
<         """Проверяем cooldown после блокировки >50% к TP."""
<         until = self._cooldowns.get(symbol)
<         if until is None:
<             return False
<         if time.time() > until:
<             del self._cooldowns[symbol]
<             return False
<         return True
---
>         self.shadow, self.ghost_saved, self.ghost_skipped_loss, self.ghost_skipped_win = \
>             ShadowFilter.load(sf_cfg.get('entry_n', 1), sf_cfg.get('exit_m', 4))
>         # Если shadow_filter отключён — сразу переводим в режим PAPER
>         if not sf_cfg.get('enabled', True):
>             self.shadow.mode = 'PAPER'
>             self.shadow.consec_wins = self.shadow.entry_n
527c285
<         """Открываем реальную позицию MARKET ордером.
---
>         """Открываем реальную позицию на бирже.
552,583c310
<             # 2. Проверяем текущую цену — не входим если сделка уже потеряла смысл
<             live_price = _get_live_price(pos.symbol)
<             if live_price:
<                 if pos.side == 'LONG':
<                     # LONG: цена не должна быть выше TP и не ниже SL
<                     if live_price >= pos.take_profit:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} LONG price={live_price} >= TP={pos.take_profit} — skip')
<                         return 0
<                     if live_price <= pos.stop_loss:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} LONG price={live_price} <= SL={pos.stop_loss} — skip')
<                         return 0
<                     # Не входим если >50% профита уже упущено
<                     tp_dist = pos.take_profit - pos.entry_price
<                     if tp_dist > 0 and (live_price - pos.entry_price) > tp_dist * 0.5:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} LONG price={live_price} >50% to TP — skip, cooldown {self._cooldown_sec//60}min')
<                         self._cooldowns[pos.symbol] = time.time() + self._cooldown_sec
<                         return 0
<                 else:
<                     # SHORT: цена не должна быть ниже TP и не выше SL
<                     if live_price <= pos.take_profit:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} SHORT price={live_price} <= TP={pos.take_profit} — skip')
<                         return 0
<                     if live_price >= pos.stop_loss:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} SHORT price={live_price} >= SL={pos.stop_loss} — skip')
<                         return 0
<                     tp_dist = pos.entry_price - pos.take_profit
<                     if tp_dist > 0 and (pos.entry_price - live_price) > tp_dist * 0.5:
<                         logger.warning(f'[LIVE_OPEN] {sym_b} SHORT price={live_price} >50% to TP — skip, cooldown {self._cooldown_sec//60}min')
<                         self._cooldowns[pos.symbol] = time.time() + self._cooldown_sec
<                         return 0
< 
<             # 3. MARKET ордер
---
>             # 2. Открываем MARKET ордер
591c318
<             # 4. Верификация
---
>             # 3. Верификация: позиция реально открылась?
602a330
>             # Берём fill из userTrades (testnet avgPrice=0)
604,654d331
<             logger.info(f'[LIVE_OPEN] {sym_b} {side_str} MARKET fill={fill} '
<                         f'(signal={pos.entry_price}) qty={real_amt} leverage={real_lev}x ✓')
<             return fill if fill > 0 else pos.entry_price
<         except Exception as e:
<             logger.error(f'[LIVE_OPEN] {sym_b}: {e}')
<         return 0
< 
<     def _live_open_wait_fill(self, pos: 'Position'):
<         """Ждём fill LIMIT ордера (до 30 сек).
<         Если не исполнился — проверяем текущую цену:
<           - цена ОК (между entry и TP, не за SL) → MARKET fallback
<           - цена плохая (за TP или за SL) → отменяем
<         """
<         sym_b = _ccxt_sym_to_binance(pos.symbol)
<         side_str = 'BUY' if pos.side == 'LONG' else 'SELL'
<         try:
<             filled = False
<             for _ in range(6):  # 6 x 5s = 30 сек
<                 time.sleep(5)
<                 pr = _testnet_request('GET', '/fapi/v2/positionRisk', self.api_key, self.secret,
<                                       {'symbol': sym_b})
<                 real_amt = abs(float(pr[0].get('positionAmt', 0))) if pr else 0
<                 if real_amt > 0:
<                     filled = True
<                     break
< 
<             if not filled:
<                 # Отменяем LIMIT
<                 try:
<                     _testnet_request('DELETE', '/fapi/v1/allOpenOrders', self.api_key, self.secret,
<                                      {'symbol': sym_b})
<                 except Exception:
<                     pass
< 
<                 # Проверяем текущую цену — стоит ли входить MARKET
<                 live_price = _get_live_price(pos.symbol)
<                 if not live_price:
<                     logger.warning(f'[LIVE_OPEN] {sym_b} LIMIT not filled 30s, no live price — cancelling')
<                     with self.lock:
<                         if pos.id in self.positions:
<                             del self.positions[pos.id]
<                     return
< 
<                 # Проверка: цена должна быть между SL и TP (сделка ещё имеет смысл)
<                 price_ok = False
<                 if pos.side == 'LONG':
<                     # LONG: цена должна быть выше SL и ниже TP
<                     price_ok = pos.stop_loss < live_price < pos.take_profit
<                 else:
<                     # SHORT: цена должна быть ниже SL и выше TP
<                     price_ok = pos.take_profit < live_price < pos.stop_loss
656,721c333,334
<                 if not price_ok:
<                     logger.warning(f'[LIVE_OPEN] {sym_b} LIMIT not filled 30s, price={live_price} '
<                                    f'outside SL={pos.stop_loss}/TP={pos.take_profit} — cancelling')
<                     with self.lock:
<                         if pos.id in self.positions:
<                             del self.positions[pos.id]
<                     return
< 
<                 # Дополнительно: цена не должна быть хуже entry более чем на 50% от дистанции до TP
<                 # (не хотим входить когда половина профита уже упущена)
<                 if pos.side == 'LONG':
<                     tp_dist = pos.take_profit - pos.entry_price
<                     slippage = live_price - pos.entry_price  # положительный = дороже
<                 else:
<                     tp_dist = pos.entry_price - pos.take_profit
<                     slippage = pos.entry_price - live_price  # положительный = дешевле
< 
<                 if tp_dist > 0 and slippage > tp_dist * 0.5:
<                     logger.warning(f'[LIVE_OPEN] {sym_b} LIMIT not filled 30s, price={live_price} '
<                                    f'moved >50% to TP (slip={slippage:.6f}, tp_dist={tp_dist:.6f}) — cancelling')
<                     with self.lock:
<                         if pos.id in self.positions:
<                             del self.positions[pos.id]
<                     return
< 
<                 # Цена ОК — MARKET fallback
<                 logger.info(f'[LIVE_OPEN] {sym_b} LIMIT not filled 30s, price={live_price} OK — MARKET fallback')
<                 step = _get_step_size(sym_b)
<                 qty = _round_qty(pos.size_usdt * self.leverage / live_price, step)
<                 try:
<                     _testnet_request('POST', '/fapi/v1/order', self.api_key, self.secret, {
<                         'symbol':   sym_b,
<                         'side':     side_str,
<                         'type':     'MARKET',
<                         'quantity': str(qty),
<                     })
<                 except Exception as e:
<                     logger.error(f'[LIVE_OPEN] {sym_b} MARKET fallback failed: {e}')
<                     with self.lock:
<                         if pos.id in self.positions:
<                             del self.positions[pos.id]
<                     return
< 
<                 # Ждём 1 сек и верифицируем
<                 time.sleep(1)
<                 pr = _testnet_request('GET', '/fapi/v2/positionRisk', self.api_key, self.secret,
<                                       {'symbol': sym_b})
<                 real_amt = abs(float(pr[0].get('positionAmt', 0))) if pr else 0
<                 if real_amt <= 0:
<                     logger.error(f'[LIVE_OPEN] {sym_b} MARKET fallback — position not opened!')
<                     with self.lock:
<                         if pos.id in self.positions:
<                             del self.positions[pos.id]
<                     return
<                 filled = True
< 
<             # Верификация + fill price
<             pr = _testnet_request('GET', '/fapi/v2/positionRisk', self.api_key, self.secret,
<                                   {'symbol': sym_b})
<             real_entry = float(pr[0].get('entryPrice', 0)) if pr else 0
<             real_lev = int(pr[0].get('leverage', 0)) if pr else 0
<             real_amt = abs(float(pr[0].get('positionAmt', 0))) if pr else 0
<             fill = _get_last_fill_price(sym_b, self.api_key, self.secret) or real_entry
< 
<             logger.info(f'[LIVE_OPEN] {sym_b} {side_str} LIMIT filled @ {fill} '
<                         f'(signal={pos.entry_price}) qty={real_amt} leverage={real_lev}x ✓')
---
>             logger.info(f'[LIVE_OPEN] {sym_b} {side_str} qty={real_amt} fill={fill} '
>                         f'leverage={real_lev}x entry(exchange)={real_entry} ✓ verified')
726,758c339
<             actual_entry = fill if fill > 0 else pos.entry_price
< 
<             # Обновляем позицию: PENDING_OPEN → OPEN, пересчитываем SL/TP от fill
<             with self.lock:
<                 if pos.id not in self.positions:
<                     logger.warning(f'[LIVE_OPEN] {sym_b} position disappeared while waiting fill')
<                     return
<                 old_entry = pos.entry_price
<                 pos.entry_price   = actual_entry
<                 pos.current_price = actual_entry
<                 pos.status = 'OPEN'
< 
<                 if pos.sl_pct and pos.tp_pct:
<                     if pos.side == 'LONG':
<                         pos.stop_loss   = actual_entry * (1 - pos.sl_pct / 100)
<                         pos.take_profit = actual_entry * (1 + pos.tp_pct / 100)
<                     else:
<                         pos.stop_loss   = actual_entry * (1 + pos.sl_pct / 100)
<                         pos.take_profit = actual_entry * (1 - pos.tp_pct / 100)
< 
<                     max_sl_pct = 0.8 / self.leverage if self.leverage > 0 else 0.08
<                     if pos.side == 'LONG':
<                         min_sl = actual_entry * (1 - max_sl_pct)
<                         if pos.stop_loss < min_sl:
<                             pos.stop_loss = min_sl
<                     else:
<                         max_sl = actual_entry * (1 + max_sl_pct)
<                         if pos.stop_loss > max_sl:
<                             pos.stop_loss = max_sl
< 
<                 logger.info(f"[OPEN] {pos.symbol} fill={actual_entry} signal={old_entry} "
<                             f"SL={pos.stop_loss:.6f} TP={pos.take_profit:.6f} (recalc from fill)")
<                 db.save_position_open(pos)
---
>             return fill if fill > 0 else pos.entry_price
760,763c341,342
<             logger.error(f'[LIVE_OPEN] {sym_b} fill-wait thread: {e}')
<             with self.lock:
<                 if pos.id in self.positions and pos.status == 'PENDING_OPEN':
<                     del self.positions[pos.id]
---
>             logger.error(f'[LIVE_OPEN] {sym_b}: {e}')
>         return 0
809c388
<                     # Ждём заполнения до 300 сек (5 мин)
---
>                     # Ждём заполнения до 60 сек
811c390
<                     for _ in range(60):
---
>                     for _ in range(12):
824,825c403,404
<                         # LIMIT не заполнился за 300 сек — отменяем, fallback на MARKET
<                         logger.warning(f'[LIVE_CLOSE] {sym_b} LIMIT not filled in 300s, switching to MARKET')
---
>                         # LIMIT не заполнился за 60 сек — отменяем, fallback на MARKET
>                         logger.warning(f'[LIVE_CLOSE] {sym_b} LIMIT not filled in 60s, switching to MARKET')
880a460,474
>     def _tp_already_breached(self, signal: dict) -> bool:
>         """Проверяет: цена уже за TP до открытия — входить бессмысленно."""
>         live = _get_live_price(signal.get('symbol', ''))
>         if not live:
>             return False
>         direction = signal.get('direction')
>         tp        = signal.get('tp', 0)
>         if direction == 'LONG'  and live >= tp:
>             logger.warning(f"[SKIP] {signal.get('symbol')} LONG — цена уже >= TP: price={live} >= tp={tp}")
>             return True
>         if direction == 'SHORT' and live <= tp:
>             logger.warning(f"[SKIP] {signal.get('symbol')} SHORT — цена уже <= TP: price={live} <= tp={tp}")
>             return True
>         return False
> 
885c479,481
<         if self.shadow_enabled and not self.shadow.can_trade(symbol):
---
>         if self._tp_already_breached(signal):
>             return None
>         if not self.shadow.can_trade():
926c522
<                 if p.symbol == symbol and p.status in ('OPEN', 'PENDING_CLOSE'):
---
>                 if p.symbol == symbol and p.status == 'OPEN':
955d550
<             rr = self.config.get('strategy', {}).get('rr', 2.5)
960,961c555
<                     pos.stop_loss   = min_sl
<                     pos.take_profit = pos.entry_price + rr * (pos.entry_price - pos.stop_loss)
---
>                     pos.stop_loss = min_sl
966,978c560
<                     pos.stop_loss   = max_sl
<                     pos.take_profit = pos.entry_price - rr * (pos.stop_loss - pos.entry_price)
< 
<             # Трейлинг стоп: инициализация параметров для позиции
<             if self.trail_activation_frac is not None:
<                 sl_dist = abs(pos.entry_price - pos.stop_loss)
<                 tp_dist = abs(pos.take_profit - pos.entry_price)
<                 pos.trail_dist = sl_dist * self.trail_dist_frac
<                 pos.trail_best_price = pos.entry_price
<                 if pos.side == 'LONG':
<                     pos.trail_activation_price = pos.entry_price + self.trail_activation_frac * tp_dist
<                 else:
<                     pos.trail_activation_price = pos.entry_price - self.trail_activation_frac * tp_dist
---
>                     pos.stop_loss = max_sl
982a565
>                     # Биржа не открыла позицию — не добавляем в бота
984a568
>                 # Entry price от fill (для честного PnL), но SL/TP от сигнала (уровни стратегии)
988,1033c572,580
<                 # Пересчитываем SL/TP от реального fill
<                 # Вариант 2: если fill ушёл от сигнала в сторону TP — расширяем SL на drift
<                 if pos.sl_pct and pos.tp_pct:
<                     signal_price = signal['price']
<                     if signal_price > 0:
<                         if pos.side == 'LONG':
<                             drift_pct = max(0.0, (actual_entry - signal_price) / signal_price * 100)
<                         else:
<                             drift_pct = max(0.0, (signal_price - actual_entry) / signal_price * 100)
<                     else:
<                         drift_pct = 0.0
<                     sl_pct_eff = pos.sl_pct + drift_pct  # расширенный SL
<                     if drift_pct > 0:
<                         logger.info(f"[OPEN] {symbol} drift={drift_pct:.2f}% → SL расширен: {pos.sl_pct:.2f}% → {sl_pct_eff:.2f}%")
<                     if pos.side == 'LONG':
<                         pos.stop_loss   = actual_entry * (1 - sl_pct_eff / 100)
<                         pos.take_profit = actual_entry * (1 + pos.tp_pct / 100)
<                     else:
<                         pos.stop_loss   = actual_entry * (1 + sl_pct_eff / 100)
<                         pos.take_profit = actual_entry * (1 - pos.tp_pct / 100)
<                     # SL cap от fill
<                     max_sl_pct = 0.8 / self.leverage if self.leverage > 0 else 0.08
<                     if pos.side == 'LONG':
<                         min_sl = actual_entry * (1 - max_sl_pct)
<                         if pos.stop_loss < min_sl:
<                             pos.stop_loss = min_sl
<                     else:
<                         max_sl = actual_entry * (1 + max_sl_pct)
<                         if pos.stop_loss > max_sl:
<                             pos.stop_loss = max_sl
< 
<                 logger.info(f"[OPEN] {symbol} {pos.side} fill={actual_entry} signal={signal['price']} "
<                             f"SL={pos.stop_loss:.6f} TP={pos.take_profit:.6f} (recalc from fill)")
< 
<             # Пересчитываем trail-параметры от финального entry/SL/TP
<             if self.trail_activation_frac is not None:
<                 sl_dist = abs(pos.entry_price - pos.stop_loss)
<                 tp_dist = abs(pos.take_profit - pos.entry_price)
<                 pos.trail_dist         = sl_dist * self.trail_dist_frac
<                 pos.trail_best_price   = pos.entry_price
<                 pos.trail_active       = False
<                 if pos.side == 'LONG':
<                     pos.trail_activation_price = pos.entry_price + self.trail_activation_frac * tp_dist
<                 else:
<                     pos.trail_activation_price = pos.entry_price - self.trail_activation_frac * tp_dist
<                 logger.info(f"[TRAIL] {symbol} init: dist={pos.trail_dist:.6f} activation={pos.trail_activation_price:.6f}")
---
>                 # Проверка: TP должен быть лучше entry, иначе сделка бессмысленна
>                 if pos.side == 'LONG' and pos.take_profit <= actual_entry:
>                     logger.warning(f"[OPEN] {symbol} LONG fill={actual_entry} >= TP={pos.take_profit} — закрываем, сделка невалидна")
>                     self._live_close_with_price(pos, actual_entry, reason='SL')
>                     return None
>                 if pos.side == 'SHORT' and pos.take_profit >= actual_entry:
>                     logger.warning(f"[OPEN] {symbol} SHORT fill={actual_entry} <= TP={pos.take_profit} — закрываем, сделка невалидна")
>                     self._live_close_with_price(pos, actual_entry, reason='SL')
>                     return None
1034a582,583
>                 logger.info(f"[OPEN] {symbol} fill={actual_entry} signal={signal['price']} "
>                             f"SL={pos.stop_loss:.6f} TP={pos.take_profit:.6f} (kept from signal)")
1039,1051d587
<             # ── Audit trail (детальный) ──────────────────────────────────────
<             try:
<                 import audit_log
<                 audit_log.log_open(
<                     symbol=pos.symbol, side=pos.side,
<                     entry_price=pos.entry_price, size_usdt=pos.size_usdt,
<                     leverage=pos.leverage,
<                     stop_loss=pos.stop_loss, take_profit=pos.take_profit,
<                     rsi=signal.get('rsi', 0.0), trend=signal.get('trend', '?'),
<                     sl_pct=pos.sl_pct, tp_pct=pos.tp_pct,
<                 )
<             except Exception as _e:
<                 logger.warning(f"[audit log_open] {_e}")
1061,1063d596
<                 # Не трогаем позиции ожидающие закрытие
<                 if pos.status == 'PENDING_CLOSE':
<                     continue
1081,1113d613
<                 # Трейлинг стоп
<                 if pos.trail_dist > 0 and pos.trail_activation_price > 0:
<                     if pos.side == 'LONG':
<                         if not pos.trail_active and price >= pos.trail_activation_price:
<                             pos.trail_active = True
<                             logger.info(f'[TRAIL] {pos.symbol} LONG activated @ {price:.6f}')
<                             try:
<                                 import audit_log
<                                 audit_log.log_trail_activated(pos.symbol, 'LONG',
<                                     pos.entry_price, price, pos.trail_activation_price, pos.trail_dist)
<                             except Exception: pass
<                         if pos.trail_active:
<                             if price > pos.trail_best_price:
<                                 pos.trail_best_price = price
<                             new_sl = pos.trail_best_price - pos.trail_dist
<                             if new_sl > pos.stop_loss:
<                                 pos.stop_loss = new_sl
<                     else:
<                         if not pos.trail_active and price <= pos.trail_activation_price:
<                             pos.trail_active = True
<                             logger.info(f'[TRAIL] {pos.symbol} SHORT activated @ {price:.6f}')
<                             try:
<                                 import audit_log
<                                 audit_log.log_trail_activated(pos.symbol, 'SHORT',
<                                     pos.entry_price, price, pos.trail_activation_price, pos.trail_dist)
<                             except Exception: pass
<                         if pos.trail_active:
<                             if price < pos.trail_best_price or pos.trail_best_price == 0:
<                                 pos.trail_best_price = price
<                             new_sl = pos.trail_best_price + pos.trail_dist
<                             if new_sl < pos.stop_loss:
<                                 pos.stop_loss = new_sl
< 
1117c617
<                         reason = 'TRAIL' if pos.trail_active else 'SL'
---
>                         reason = 'SL'
1122c622
<                         reason = 'TRAIL' if pos.trail_active else 'SL'
---
>                         reason = 'SL'
1126,1127c626
<                 max_hold = self.config.get('strategy', {}).get('max_hold_hours', self.max_hold_h)
<                 if reason is None and age_h >= max_hold:
---
>                 if reason is None and age_h >= self.max_hold_h:
1157,1158c656
<                 max_hold = self.config.get('strategy', {}).get('max_hold_hours', self.max_hold_h)
<                 if reason is None and age_h >= max_hold:
---
>                 if reason is None and age_h >= self.max_hold_h:
1175c673
<                 self.shadow.on_trade_closed(pnl, symbol=g['symbol'], reason=reason)
---
>                 self.shadow.on_trade_closed(pnl)
1178,1179c676,691
<     def _finalize_close(self, pos: 'Position', reason: str, price: float):
<         """Финализирует закрытие позиции (обновляет статус, пишет в DB)."""
---
>     def _close(self, pid: str, reason: str, price: float):
>         pos = self.positions.pop(pid, None)
>         if not pos:
>             return
> 
>         if self.trade_mode == 'LIVE':
>             # TP → LIMIT (без проскальзывания), SL/TIME → MARKET
>             close_price = self._live_close_with_price(pos, price, reason=reason)
>             if close_price <= 0:
>                 # Биржа не закрыла — возвращаем позицию, orphan_loop подхватит
>                 pos.status = 'OPEN'
>                 self.positions[pid] = pos
>                 logger.error(f"[CLOSE] {pos.symbol} — не удалось закрыть, возвращаем в трекинг")
>                 return
>             price = close_price
> 
1186,1187d697
<         if len(self.closed) > 500:
<             self.closed = self.closed[-500:]
1191,1214c701
< 
<         # ── Audit trail (детальный) ──────────────────────────────────────────
<         try:
<             import audit_log
<             from datetime import datetime as _dt
<             held_min = 0.0
<             try:
<                 t1 = _dt.strptime(pos.opened_at, '%Y-%m-%d %H:%M:%S')
<                 t2 = _dt.strptime(pos.closed_at, '%Y-%m-%d %H:%M:%S')
<                 held_min = (t2 - t1).total_seconds() / 60
<             except Exception:
<                 pass
<             audit_log.log_close(
<                 symbol=pos.symbol, side=pos.side,
<                 entry_price=pos.entry_price, exit_price=price,
<                 pnl_usdt=pos.pnl_usdt, pnl_pct=pos.pnl_pct,
<                 reason=reason, held_minutes=held_min,
<                 trail_active=getattr(pos, 'trail_active', False),
<                 opened_at=pos.opened_at, closed_at=pos.closed_at,
<             )
<         except Exception as _e:
<             logger.warning(f"[audit log_close] {_e}")
< 
<         self.shadow.on_trade_closed(pos.pnl_usdt, symbol=pos.symbol, reason=reason)
---
>         self.shadow.on_trade_closed(pos.pnl_usdt)
1216,1293d702
< 
<     def _live_close_async(self, pos: 'Position', pid: str, reason: str, fallback_price: float):
<         """Закрывает позицию на бирже в ОТДЕЛЬНОМ потоке (для TP LIMIT)."""
<         try:
<             close_price = self._live_close_with_price(pos, fallback_price, reason=reason)
<             with self.lock:
<                 if close_price <= 0:
<                     pos.status = 'OPEN'
<                     self.positions[pid] = pos
<                     logger.error(f"[CLOSE] {pos.symbol} — не удалось закрыть, возвращаем в трекинг")
<                     return
<                 self._finalize_close(pos, reason, close_price)
<         except Exception as e:
<             logger.error(f'[CLOSE] {pos.symbol} async close thread: {e}')
<             with self.lock:
<                 pos.status = 'OPEN'
<                 self.positions[pid] = pos
< 
<     def _close(self, pid: str, reason: str, price: float):
<         pos = self.positions.pop(pid, None)
<         if not pos:
<             return
< 
<         if self.trade_mode == 'LIVE':
<             use_limit = (reason == 'TP')
<             if use_limit:
<                 # TP LIMIT — асинхронно (до 300 сек), не блокируем
<                 pos.status = 'PENDING_CLOSE'
<                 logger.info(f"[CLOSE] {pos.symbol} {pos.side} TP → LIMIT async")
<                 t = threading.Thread(target=self._live_close_async,
<                                      args=(pos, pid, reason, price),
<                                      daemon=True, name=f'close-{pos.symbol}')
<                 t.start()
<                 return
<             else:
<                 # SL/TIME → MARKET (быстро, ~1 сек)
<                 close_price = self._live_close_with_price(pos, price, reason=reason)
<                 if close_price <= 0:
<                     pos.status = 'OPEN'
<                     self.positions[pid] = pos
<                     logger.error(f"[CLOSE] {pos.symbol} — не удалось закрыть, возвращаем в трекинг")
<                     return
<                 price = close_price
< 
<         self._finalize_close(pos, reason, price)
< 
<     # ── PANIC-CLOSE: закрывает ВСЕ открытые позиции (triggered CrossShadow OFF→ON)
<     def _panic_close_all(self):
<         """Вызывается CrossShadowFilter когда shadow включился.
<         Закрывает все открытые NEXUS-позиции по рыночной цене (reason='SHADOW_PANIC').
< 
<         Использует current_price позиции (обновляется в живую _refresh циклом).
<         В LIVE режиме _close() → _live_close_with_price() делает market-order.
<         """
<         with self.lock:
<             pids = list(self.positions.keys())
<         if not pids:
<             logger.info('[PANIC-CLOSE] shadow ON → открытых позиций нет, nothing to do')
<             try:
<                 import audit_log
<                 audit_log.log_shadow_event('PANIC_ON', 'no open positions to close')
<             except Exception: pass
<             return
<         logger.warning(f'[PANIC-CLOSE] shadow ON → закрываю {len(pids)} открытых позиций по рынку')
<         try:
<             import audit_log
<             audit_log.log_shadow_event('PANIC_ON', f'closing {len(pids)} positions: {",".join(pids)}')
<         except Exception: pass
<         for pid in pids:
<             pos = self.positions.get(pid)
<             if not pos:
<                 continue
<             price = pos.current_price or pos.entry_price
<             try:
<                 self._close(pid, 'SHADOW_PANIC', price)
<                 logger.info(f'[PANIC-CLOSE] {pos.symbol} {pos.side} @ {price} closed')
<             except Exception as e:
<                 logger.error(f'[PANIC-CLOSE] {pos.symbol}: {e}')
