← Назад"""
WT Bot v3 — Trade Manager
============================
Управление позициями: вход, SL/TP лимитки, отслеживание, закрытие.
Пайплайн:
1. Скринер нашёл монету в WT зоне → вайтлист
2. Manager каждые 5 сек проверяет вайтлист на WT cross
3. Cross → вход по маркету + SL (STOP_MARKET) + TP (LIMIT)
4. Мониторинг: проверка что ордера на месте, детекция закрытия
5. Закрытие → лог, cooldown, убрать из позиций
"""
import json
import time
import logging
import tempfile
import os
from datetime import datetime, timezone
from src.config import (
SL_PCT, TP_PCT, TRADE_SIZE_USD, LEVERAGE, MAX_POSITIONS,
TIMEFRAME, POSITIONS_FILE, TRADE_LOG_FILE, COOLDOWN_MIN,
WT_ZONE_LOOKBACK, WT_OVERSOLD, WT_OVERBOUGHT,
TP1_PCT, TP1_CLOSE_RATIO, TRAIL_CALLBACK_PCT,
CIRCUIT_BREAKER_LOSSES, CIRCUIT_BREAKER_PAUSE_MIN,
)
from src.indicators import calc_wavetrend_series, detect_wt_cross, is_in_wt_zone, calc_ema
logger = logging.getLogger("manager")
class Position:
"""Одна открытая позиция."""
def __init__(self, symbol, side, entry_price, qty, sl_price, tp_price,
symbol_info, zone, wt1, wt2, opened_at=None):
self.symbol = symbol
self.side = side # "LONG" or "SHORT"
self.entry_price = entry_price
self.qty = qty # current qty (decreases after TP1)
self.original_qty = qty # original full qty
self.sl_price = sl_price
self.tp_price = tp_price # TP1 price (1.0%)
self.symbol_info = symbol_info
self.zone = zone
self.wt1 = wt1
self.wt2 = wt2
self.opened_at = opened_at or datetime.now(timezone.utc).isoformat()
self.sl_order_placed = False
self.tp_order_placed = False
# TP1 partial + trailing
self.tp1_hit = False # True after TP1 filled (50% closed)
self.trail_high = 0.0 # best price seen since TP1 (for trailing)
def to_dict(self):
return {
"symbol": self.symbol,
"side": self.side,
"entry_price": self.entry_price,
"qty": self.qty,
"original_qty": self.original_qty,
"sl_price": self.sl_price,
"tp_price": self.tp_price,
"zone": self.zone,
"wt1": self.wt1,
"wt2": self.wt2,
"opened_at": self.opened_at,
"sl_order_placed": self.sl_order_placed,
"tp_order_placed": self.tp_order_placed,
"tp1_hit": self.tp1_hit,
"trail_high": self.trail_high,
}
@classmethod
def from_dict(cls, d, symbol_info=None):
pos = cls(
symbol=d["symbol"], side=d["side"],
entry_price=d["entry_price"], qty=d["qty"],
sl_price=d["sl_price"], tp_price=d["tp_price"],
symbol_info=symbol_info,
zone=d.get("zone", 0), wt1=d.get("wt1", 0), wt2=d.get("wt2", 0),
opened_at=d.get("opened_at"),
)
pos.original_qty = d.get("original_qty", d["qty"])
pos.sl_order_placed = d.get("sl_order_placed", False)
pos.tp_order_placed = d.get("tp_order_placed", False)
pos.tp1_hit = d.get("tp1_hit", False)
pos.trail_high = d.get("trail_high", 0.0)
return pos
class TradeManager:
def __init__(self, exchange, screener, notifier=None, tmm=None):
self.exchange = exchange
self.screener = screener
self.notifier = notifier # Telegram notify function
self.tmm = tmm # TMMClient instance
self.positions = {} # symbol → Position
self._symbol_info_cache = {} # symbol → info
# Circuit breaker
self._consecutive_losses = 0
self._circuit_breaker_until = 0 # timestamp when pause ends
self._load_positions()
# ============================================================
# MAIN LOOP: check whitelist for crosses
# ============================================================
def check_whitelist_for_entries(self):
"""
Проверяет каждый символ в вайтлисте на WT cross.
Если cross совпадает с зоной → вход.
"""
# Circuit breaker: пауза после серии лоссов
now = time.time()
if now < self._circuit_breaker_until:
remaining = int((self._circuit_breaker_until - now) / 60)
logger.debug(f"Circuit breaker active, {remaining}min left")
return
whitelist = self.screener.get_whitelist()
if not whitelist:
return
if len(self.positions) >= MAX_POSITIONS:
return
for entry in whitelist:
symbol = entry["symbol"]
zone = entry["zone"]
# Уже в позиции?
if symbol in self.positions:
continue
# Макс позиций?
if len(self.positions) >= MAX_POSITIONS:
break
try:
# Получаем свежие klines для cross detection
klines = self.exchange.get_klines(symbol, TIMEFRAME, limit=250)
if len(klines) < 10:
continue
wt1_series, wt2_series = calc_wavetrend_series(klines)
if wt1_series is None:
continue
cross = detect_wt_cross(wt1_series, wt2_series)
# Нет cross — скип
if cross == 0:
continue
# Cross direction must match zone from screener
# zone=1 (oversold) → ждём cross_up (1) → LONG
# zone=-1 (overbought) → ждём cross_down (-1) → SHORT
if cross != zone:
continue
# ====================================================
# ZONE LOOKBACK: cross must happen IN or NEAR the zone
# Check if WT was in oversold/overbought within last
# WT_ZONE_LOOKBACK bars from the cross bar (-2 = last closed)
# ====================================================
in_zone_recently = False
for lb in range(0, WT_ZONE_LOOKBACK + 1):
idx = -2 - lb # -2 is the cross bar (last closed candle)
if abs(idx) > len(wt1_series):
break
w1 = float(wt1_series[idx])
w2 = float(wt2_series[idx])
if cross == 1 and (w1 < WT_OVERSOLD or w2 < WT_OVERSOLD):
in_zone_recently = True
break
if cross == -1 and (w1 > WT_OVERBOUGHT or w2 > WT_OVERBOUGHT):
in_zone_recently = True
break
if not in_zone_recently:
logger.info(f"Skip {symbol}: cross={cross} but WT not in zone within {WT_ZONE_LOOKBACK} bars")
continue
# Перепроверяем EMA200
ema200 = calc_ema(klines, 200) if len(klines) >= 200 else None
# Если не хватает данных для EMA200, берём из entry
last_close = float(klines[-2][4]) # Закрытая свеча
if ema200 is not None:
if cross == 1 and last_close <= ema200:
logger.info(f"Cross found but EMA200 blocks LONG {symbol} (price={last_close:.6f} < EMA={ema200:.6f})")
self.screener.remove_from_whitelist(symbol)
continue
if cross == -1 and last_close >= ema200:
logger.info(f"Cross found but EMA200 blocks SHORT {symbol} (price={last_close:.6f} > EMA={ema200:.6f})")
self.screener.remove_from_whitelist(symbol)
continue
# ВХОД!
self._open_position(
symbol=symbol,
side="LONG" if cross == 1 else "SHORT",
wt1=float(wt1_series[-2]),
wt2=float(wt2_series[-2]),
zone=zone,
atr_pct=entry.get("atr_pct", 0),
ema200=ema200 or entry.get("ema200", 0),
price=last_close,
)
# Убираем из вайтлиста после входа
self.screener.remove_from_whitelist(symbol)
time.sleep(0.2)
except Exception as e:
logger.error(f"Entry check error {symbol}: {e}")
continue
# ============================================================
# OPEN POSITION
# ============================================================
def _open_position(self, symbol, side, wt1, wt2, zone, atr_pct=0, ema200=0, price=0):
"""Открыть позицию: market order + SL + TP."""
try:
# Защита от повторного входа: проверяем биржу
exchange_positions = self.exchange.get_positions()
for ep in exchange_positions:
if ep["symbol"] == symbol and float(ep["positionAmt"]) != 0:
logger.warning(f"Already have position on {symbol} on exchange, skipping")
return
# Symbol info
sym_info = self._get_symbol_info(symbol)
if not sym_info:
logger.error(f"No symbol info for {symbol}")
return
# Set leverage
self.exchange.set_leverage(symbol)
self.exchange.set_margin_type(symbol)
# Рассчитываем qty
mark_price = self.exchange.get_mark_price(symbol)
position_size = TRADE_SIZE_USD * LEVERAGE # $5 * 10 = $50
qty = position_size / mark_price
qty = self.exchange.round_qty(sym_info, qty)
if sym_info["min_qty"] and qty < sym_info["min_qty"]:
logger.warning(f"Qty {qty} below min {sym_info['min_qty']} for {symbol}")
return
# Market order
order_side = "BUY" if side == "LONG" else "SELL"
order, fill_price = self.exchange.open_market(symbol, order_side, qty)
if fill_price == 0:
logger.error(f"Fill price 0 for {symbol}! Closing phantom position...")
try:
close_side = "SELL" if order_side == "BUY" else "BUY"
self.exchange.close_position(symbol, close_side, qty)
self._notify(f"⚠️ Phantom position closed: {symbol} (fill_price=0)")
except Exception as ce:
logger.error(f"Failed to close phantom {symbol}: {ce}")
return
# Рассчитываем SL и TP1 (partial)
if side == "LONG":
sl_price = fill_price * (1 - SL_PCT)
tp1_price = fill_price * (1 + TP1_PCT)
else:
sl_price = fill_price * (1 + SL_PCT)
tp1_price = fill_price * (1 - TP1_PCT)
# Создаём позицию (tp_price = TP1 level)
pos = Position(
symbol=symbol, side=side,
entry_price=fill_price, qty=qty,
sl_price=sl_price, tp_price=tp1_price,
symbol_info=sym_info, zone=zone,
wt1=wt1, wt2=wt2,
)
# TP1 qty = 50% of full position
tp1_qty = self.exchange.round_qty(sym_info, qty * TP1_CLOSE_RATIO)
# Ставим SL (full qty) + TP1 (partial qty) на бирже
self._place_sl_tp(pos, tp_qty=tp1_qty)
self.positions[symbol] = pos
self._save_positions()
msg = (
f"{'🟢' if side == 'LONG' else '🔴'} **{side} {symbol}**\n"
f"Entry: {fill_price}\n"
f"SL: {sl_price:.4f} (-{SL_PCT*100}%)\n"
f"TP1: {tp1_price:.4f} (+{TP1_PCT*100}%) → 50% close\n"
f"Trail: {TRAIL_CALLBACK_PCT*100}% callback after TP1\n"
f"Qty: {qty} (${TRADE_SIZE_USD}×{LEVERAGE}x)\n"
f"WT1: {wt1:.1f} | WT2: {wt2:.1f}"
)
logger.info(msg.replace("**", "").replace("\n", " | "))
self._notify(msg)
# TMM: tag trade with strategy + entry reason
if self.tmm:
try:
self.tmm.on_trade_opened(
symbol=symbol, side=side,
wt1=wt1, wt2=wt2, zone=zone,
atr_pct=atr_pct, ema200=ema200, price=price,
)
except Exception as e:
logger.warning(f"TMM tag error: {e}")
except Exception as e:
logger.error(f"Open position failed {symbol}: {e}")
self._notify(f"❌ Open failed {symbol}: {e}")
def _place_sl_tp(self, pos, tp_qty=None):
"""
Поставить SL и TP ордера на бирже.
tp_qty: количество для TP ордера (для TP1 partial = 50% qty).
Если None и tp1 уже hit — не ставим TP (trailing вместо этого).
"""
sym_info = pos.symbol_info or self._get_symbol_info(pos.symbol)
close_side = "SELL" if pos.side == "LONG" else "BUY"
# SL — всегда на полный текущий qty
try:
self.exchange.place_sl(pos.symbol, close_side, pos.qty, pos.sl_price, sym_info)
pos.sl_order_placed = True
except Exception as e:
logger.error(f"SL placement failed {pos.symbol}: {e}")
pos.sl_order_placed = False
# TP — только если не в trailing режиме
if not pos.tp1_hit:
actual_tp_qty = tp_qty or pos.qty
try:
self.exchange.place_tp(pos.symbol, close_side, actual_tp_qty, pos.tp_price, sym_info)
pos.tp_order_placed = True
except Exception as e:
logger.error(f"TP placement failed {pos.symbol}: {e}")
pos.tp_order_placed = False
else:
# After TP1: no TP order, trailing handled in check_positions
pos.tp_order_placed = True # suppress re-placement warnings
# ============================================================
# MONITOR POSITIONS
# ============================================================
def check_positions(self):
"""
Проверяет все открытые позиции:
1. Позиция закрыта? → лог
2. TP1 partial fill? → close 50%, SL→BE, start trailing
3. Trailing: update trail_high, check callback → close rest
4. Ордера на месте?
"""
if not self.positions:
return
exchange_positions = self.exchange.get_positions()
exchange_map = {p["symbol"]: p for p in exchange_positions}
closed = []
changed = False
for symbol, pos in self.positions.items():
ex_pos = exchange_map.get(symbol)
actual_qty = abs(float(ex_pos["positionAmt"])) if ex_pos else 0
# ── 1. Позиция полностью закрыта на бирже? ──
if actual_qty == 0:
result = self._determine_close_result(pos)
self._log_trade(pos, result)
closed.append(symbol)
continue
# ── 2. TP1 detection: qty decreased = partial TP filled ──
if not pos.tp1_hit:
# TP1 limit order was for 50% qty. If actual_qty dropped → TP1 hit
expected_after_tp1 = self.exchange.round_qty(
pos.symbol_info or self._get_symbol_info(symbol),
pos.original_qty * (1 - TP1_CLOSE_RATIO)
)
if actual_qty <= expected_after_tp1 + 0.0001 and actual_qty < pos.qty - 0.0001:
# TP1 filled!
pos.tp1_hit = True
pos.qty = actual_qty
# Move SL to breakeven
pos.sl_price = pos.entry_price
# Init trail_high to TP1 price
pos.trail_high = pos.tp_price if pos.side == "LONG" else pos.tp_price
# Cancel all old orders, place new SL at BE (no TP — trailing now)
self.exchange.cancel_all_orders(symbol)
self._place_sl_tp(pos) # SL only, tp1_hit=True skips TP
changed = True
tp1_pnl_pct = TP1_PCT * 100
tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_PCT * TP1_CLOSE_RATIO
msg = (
f"🎯 **TP1 {pos.side} {symbol}** +{tp1_pnl_pct:.1f}%\n"
f"Closed {TP1_CLOSE_RATIO*100:.0f}% (${tp1_pnl_usd:+.2f})\n"
f"SL → BE ({pos.entry_price})\n"
f"Trailing {TRAIL_CALLBACK_PCT*100}% on remaining {actual_qty}"
)
logger.info(f"TP1 {pos.side} {symbol} +{tp1_pnl_pct:.1f}% | SL→BE | trail rest")
self._notify(msg)
# ── 3. Trailing stop (after TP1) ──
if pos.tp1_hit:
try:
mark = self.exchange.get_mark_price(symbol)
if mark and mark > 0:
if pos.side == "LONG":
# Track highest price
if mark > pos.trail_high:
pos.trail_high = mark
changed = True
# Check callback: price dropped from high
trail_sl = pos.trail_high * (1 - TRAIL_CALLBACK_PCT)
if mark <= trail_sl and pos.trail_high > pos.entry_price:
# Trail triggered → close remaining
self._close_trailing(pos, mark)
closed.append(symbol)
continue
else: # SHORT
# Track lowest price
if pos.trail_high == 0 or mark < pos.trail_high:
pos.trail_high = mark
changed = True
# Check callback: price rose from low
trail_sl = pos.trail_high * (1 + TRAIL_CALLBACK_PCT)
if mark >= trail_sl and pos.trail_high < pos.entry_price:
# Trail triggered → close remaining
self._close_trailing(pos, mark)
closed.append(symbol)
continue
except Exception as e:
logger.debug(f"Trail check error {symbol}: {e}")
# ── 4. Order health check (only pre-TP1) ──
if not pos.tp1_hit:
if not pos.sl_order_placed or not pos.tp_order_placed:
logger.warning(f"Re-placing orders for {symbol}")
self.exchange.cancel_all_orders(symbol)
sym_info = pos.symbol_info or self._get_symbol_info(symbol)
tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO)
self._place_sl_tp(pos, tp_qty=tp1_qty)
changed = True
try:
open_orders = self.exchange.get_open_orders(symbol)
has_limit = any(o["type"] == "LIMIT" for o in open_orders)
if not has_limit and pos.tp_order_placed:
logger.warning(f"TP order missing for {symbol}, re-placing")
self.exchange.cancel_all_orders(symbol)
sym_info = pos.symbol_info or self._get_symbol_info(symbol)
tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO)
self._place_sl_tp(pos, tp_qty=tp1_qty)
changed = True
except Exception as e:
logger.debug(f"Order check error {symbol}: {e}")
# Cleanup closed
for symbol in closed:
del self.positions[symbol]
self.exchange.cancel_all_orders(symbol)
self.screener.add_cooldown(symbol)
if closed or changed:
self._save_positions()
def _close_trailing(self, pos, mark_price):
"""Close remaining position via trailing stop."""
try:
close_side = "SELL" if pos.side == "LONG" else "BUY"
fill_price = self.exchange.close_position(pos.symbol, close_side, pos.qty)
if pos.side == "LONG":
trail_pnl_pct = (fill_price - pos.entry_price) / pos.entry_price * 100
else:
trail_pnl_pct = (pos.entry_price - fill_price) / pos.entry_price * 100
remaining_ratio = 1 - TP1_CLOSE_RATIO
trail_pnl_usd = TRADE_SIZE_USD * LEVERAGE * remaining_ratio * (trail_pnl_pct / 100)
tp1_pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT
total_pnl_usd = tp1_pnl_usd + trail_pnl_usd
# Log combined trade
trade = {
"symbol": pos.symbol,
"side": pos.side,
"entry_price": pos.entry_price,
"sl_price": pos.sl_price,
"tp_price": pos.tp_price,
"qty": pos.original_qty,
"result": "TRAIL",
"pnl_pct": round(TP1_PCT * 100 * TP1_CLOSE_RATIO + trail_pnl_pct * remaining_ratio, 2),
"pnl_usd": round(total_pnl_usd, 2),
"trail_close_price": fill_price,
"trail_high": pos.trail_high,
"tp1_hit": True,
"wt1": pos.wt1,
"wt2": pos.wt2,
"opened_at": pos.opened_at,
"closed_at": datetime.now(timezone.utc).isoformat(),
}
self._append_trade_log(trade)
emoji = "🏃" if total_pnl_usd > 0 else "⚠️"
msg = (
f"{emoji} **TRAIL {pos.side} {pos.symbol}**\n"
f"TP1: +{TP1_PCT*100:.1f}% (${tp1_pnl_usd:+.2f})\n"
f"Trail: {trail_pnl_pct:+.1f}% (${trail_pnl_usd:+.2f})\n"
f"**Total: ${total_pnl_usd:+.2f}**\n"
f"Peak: {pos.trail_high:.6f} → Exit: {fill_price:.6f}"
)
logger.info(f"TRAIL {pos.side} {pos.symbol} total=${total_pnl_usd:+.2f}")
self._notify(msg)
except Exception as e:
logger.error(f"Trail close failed {pos.symbol}: {e}")
self._log_trade(pos, "TRAIL_ERROR")
def _determine_close_result(self, pos):
"""
Определяем как закрылась позиция: SL или TP.
1. Проверяем открытые ордера — если TP (LIMIT) ещё висит → SL сработал
2. Fallback: проверяем последнюю цену vs SL/TP уровни
"""
try:
open_orders = self.exchange.get_open_orders(pos.symbol)
has_tp = any(o["type"] == "LIMIT" for o in open_orders)
if has_tp:
return "SL"
# Fallback: проверяем через mark price
try:
mark = self.exchange.get_mark_price(pos.symbol)
if pos.side == "LONG":
# TP выше entry, SL ниже
dist_to_tp = abs(mark - pos.tp_price)
dist_to_sl = abs(mark - pos.sl_price)
else:
# SHORT: TP ниже entry, SL выше
dist_to_tp = abs(mark - pos.tp_price)
dist_to_sl = abs(mark - pos.sl_price)
if dist_to_tp < dist_to_sl:
return "TP"
else:
return "SL"
except Exception:
return "TP" # Default: optimistic
except Exception:
return "UNKNOWN"
# ============================================================
# TRADE LOG
# ============================================================
def _append_trade_log(self, trade):
"""Append trade dict to trade_log.json (atomic write)."""
try:
with open(TRADE_LOG_FILE, "r") as f:
log = json.load(f)
except Exception:
log = []
log.append(trade)
try:
dir_name = os.path.dirname(TRADE_LOG_FILE)
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(log, f, indent=2)
os.replace(tmp_path, TRADE_LOG_FILE)
except Exception as e:
logger.error(f"Error saving trade log: {e}")
# Circuit breaker: track consecutive losses
pnl = trade.get("pnl_usd", 0)
if pnl < 0:
self._consecutive_losses += 1
if self._consecutive_losses >= CIRCUIT_BREAKER_LOSSES:
self._circuit_breaker_until = time.time() + CIRCUIT_BREAKER_PAUSE_MIN * 60
logger.warning(
f"🛑 CIRCUIT BREAKER: {self._consecutive_losses} consecutive losses → "
f"pausing entries for {CIRCUIT_BREAKER_PAUSE_MIN}min"
)
self._notify(
f"🛑 **Circuit Breaker активирован**\n"
f"{self._consecutive_losses} лоссов подряд → пауза {CIRCUIT_BREAKER_PAUSE_MIN} мин"
)
self._consecutive_losses = 0 # reset after triggering
else:
self._consecutive_losses = 0 # reset on any win/BE
def _log_trade(self, pos, result):
"""Записать сделку в лог и отправить уведомление."""
if pos.tp1_hit:
# Position was partially closed at TP1, rest hit SL at BE
tp1_pnl = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT
# Remaining 50% hit SL (at breakeven if SL was moved to BE)
if result == "SL" and abs(pos.sl_price - pos.entry_price) < pos.entry_price * 0.001:
rest_pnl = 0 # BE stop
result = "TP1+BE"
else:
rest_pnl = -(TRADE_SIZE_USD * LEVERAGE * (1 - TP1_CLOSE_RATIO) * SL_PCT)
result = "TP1+SL"
pnl_usd = tp1_pnl + rest_pnl
pnl_pct = pnl_usd / (TRADE_SIZE_USD * LEVERAGE) * 100
elif result == "TP":
# TP1 LIMIT filled + SL closed rest so fast that check_positions
# saw qty=0 before detecting TP1. Estimate as TP1 only (conservative).
pnl_pct = TP1_PCT * 100 * TP1_CLOSE_RATIO # +0.5%
pnl_usd = TRADE_SIZE_USD * LEVERAGE * TP1_CLOSE_RATIO * TP1_PCT # +$0.25
result = "TP1+BE" # most likely: TP1 hit, rest stopped at BE
logger.info(f"Fast TP1 detected {pos.symbol}: tp1_hit was false, estimating TP1+BE")
elif result == "SL":
pnl_pct = -SL_PCT * 100
pnl_usd = -(TRADE_SIZE_USD * LEVERAGE * SL_PCT)
else:
pnl_pct = 0
pnl_usd = 0
trade = {
"symbol": pos.symbol,
"side": pos.side,
"entry_price": pos.entry_price,
"sl_price": pos.sl_price,
"tp_price": pos.tp_price,
"qty": pos.original_qty,
"result": result,
"pnl_pct": round(pnl_pct, 2),
"pnl_usd": round(pnl_usd, 2),
"tp1_hit": pos.tp1_hit,
"wt1": pos.wt1,
"wt2": pos.wt2,
"opened_at": pos.opened_at,
"closed_at": datetime.now(timezone.utc).isoformat(),
}
self._append_trade_log(trade)
# Notify
if "TP1+BE" in result:
emoji = "✅"
elif "TP1" in result:
emoji = "🟡"
elif result == "SL":
emoji = "❌"
else:
emoji = "⚠️"
msg = (
f"{emoji} **{result} {pos.side} {pos.symbol}**\n"
f"Entry: {pos.entry_price}\n"
f"PnL: {pnl_pct:+.1f}% (${pnl_usd:+.2f})\n"
f"TP1 hit: {'Yes' if pos.tp1_hit else 'No'}"
)
logger.info(f"{result} {pos.side} {pos.symbol} PnL={pnl_pct:+.1f}%")
self._notify(msg)
# ============================================================
# STATS
# ============================================================
def get_pnl_summary(self):
"""Сводка PnL из trade_log."""
try:
with open(TRADE_LOG_FILE, "r") as f:
log = json.load(f)
except Exception:
return "No trades yet."
if not log:
return "No trades yet."
total_pnl = sum(t.get("pnl_usd", 0) for t in log)
wins = [t for t in log if t.get("pnl_usd", 0) > 0]
losses = [t for t in log if t.get("pnl_usd", 0) <= 0]
wr = len(wins) / len(log) * 100 if log else 0
return (
f"📊 **PnL Summary**\n"
f"Trades: {len(log)} ({len(wins)}W / {len(losses)}L)\n"
f"Win Rate: {wr:.1f}%\n"
f"Total PnL: ${total_pnl:+.2f}\n"
f"Last: {log[-1]['symbol']} {log[-1]['result']} {log[-1].get('pnl_pct', 0):+.1f}%"
)
# ============================================================
# PERSISTENCE
# ============================================================
def _save_positions(self):
"""Atomic write — сначала в tmp файл, потом rename."""
try:
data = {s: p.to_dict() for s, p in self.positions.items()}
dir_name = os.path.dirname(POSITIONS_FILE)
fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix=".tmp")
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp_path, POSITIONS_FILE)
except Exception as e:
logger.error(f"Error saving positions: {e}")
def _load_positions(self):
try:
with open(POSITIONS_FILE, "r") as f:
data = json.load(f)
if isinstance(data, dict) and data:
for symbol, d in data.items():
sym_info = self._get_symbol_info(symbol)
self.positions[symbol] = Position.from_dict(d, sym_info)
logger.info(f"Loaded {len(self.positions)} positions from file")
except Exception:
self.positions = {}
def _get_symbol_info(self, symbol):
if symbol not in self._symbol_info_cache:
info = self.exchange.get_symbol_info(symbol)
if info:
self._symbol_info_cache[symbol] = info
return self._symbol_info_cache.get(symbol)
# ============================================================
# RECOVERY (after restart)
# ============================================================
def recovery(self):
"""
При старте бота: проверяем позиции из файла vs биржа.
Если позиция есть в файле но нет на бирже → закрылась пока бот был выключен.
Если позиция есть на бирже → переставляем ордера.
"""
if not self.positions:
return
logger.info(f"Recovery: checking {len(self.positions)} saved positions...")
exchange_positions = self.exchange.get_positions()
exchange_symbols = {p["symbol"] for p in exchange_positions
if float(p["positionAmt"]) != 0}
closed = []
for symbol, pos in self.positions.items():
if symbol not in exchange_symbols:
logger.info(f"Recovery: {symbol} closed while bot was down")
self._log_trade(pos, "UNKNOWN")
closed.append(symbol)
else:
# Проверяем qty — если частично закрыта (TP1 hit), обновляем
for ep in exchange_positions:
if ep["symbol"] == symbol:
actual_qty = abs(float(ep["positionAmt"]))
if actual_qty != pos.qty and actual_qty > 0:
logger.warning(f"Recovery: {symbol} qty mismatch: saved={pos.qty} actual={actual_qty}")
# Detect TP1 hit during downtime
expected_after_tp1 = self.exchange.round_qty(
pos.symbol_info or self._get_symbol_info(symbol),
pos.original_qty * (1 - TP1_CLOSE_RATIO)
)
if actual_qty <= expected_after_tp1 + 0.0001 and not pos.tp1_hit:
logger.info(f"Recovery: TP1 was hit for {symbol} while bot was down")
pos.tp1_hit = True
pos.sl_price = pos.entry_price # BE
pos.trail_high = pos.tp_price
pos.qty = actual_qty
break
# Переставляем ордера
logger.info(f"Recovery: re-placing orders for {symbol}")
self.exchange.cancel_all_orders(symbol)
if not pos.tp1_hit:
sym_info = pos.symbol_info or self._get_symbol_info(symbol)
tp1_qty = self.exchange.round_qty(sym_info, pos.original_qty * TP1_CLOSE_RATIO)
self._place_sl_tp(pos, tp_qty=tp1_qty)
else:
self._place_sl_tp(pos) # SL only at BE
for symbol in closed:
del self.positions[symbol]
self.exchange.cancel_all_orders(symbol)
if closed:
self._save_positions()
# ============================================================
# NOTIFY
# ============================================================
def _notify(self, msg):
if self.notifier:
try:
self.notifier(msg)
except Exception as e:
logger.debug(f"Notify error: {e}")