β ΠΠ°Π·Π°Π΄"""
Gerchik Position Manager β Position lifecycle with limit orders.
Manages Gerchik Level strategy positions:
- Limit order entry
- Stop-Limit SL with market fallback
- Limit TP1/TP2/TP3 with partial closes
- SL to BE after 2x SL in profit
- Trail SL behind new levels
"""
import asyncio
import logging
import os
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from typing import Optional, Callable
from trader import BinanceFuturesTrader
from trade_log import log_event
from order_placer import ExchangeOrderManager
from gerchik_config import (
GERCHIK_SIZE_USDT,
GERCHIK_LEVERAGE,
GERCHIK_MAX_POSITIONS,
GERCHIK_CHECK_INTERVAL,
GERCHIK_SCAN_INTERVAL,
GERCHIK_TP1_CLOSE_PCT,
GERCHIK_TP2_CLOSE_PCT,
GERCHIK_BE_TRIGGER_STOPLOSS_MULT,
GERCHIK_USE_LIMIT_ORDERS,
GERCHIK_LIMIT_FALLBACK_SEC,
GERCHIK_USE_EXCHANGE_ORDERS,
TAKER_FEE_PCT,
MAKER_FEE_PCT,
MODEL_LABELS,
)
from gerchik_models import GerchikSignal
logger = logging.getLogger(__name__)
VANCOUVER_TZ = timezone(timedelta(hours=-7))
def now_van() -> datetime:
return datetime.now(VANCOUVER_TZ)
def safe_pnl_pct(entry: float, exit_p: float, side: str) -> float:
if entry == 0:
return 0
if side == "BUY":
return ((exit_p - entry) / entry) * 100
else:
return ((entry - exit_p) / entry) * 100
@dataclass
class GerchikPosition:
"""A Gerchik level trade."""
symbol: str
side: str
model: str # A, B, C, D
entry_price: float
quantity: float
total_quantity: float
remaining_quantity: float
sl_price: float
tp1_price: float
tp2_price: float
tp3_price: float
level_price: float
level_strength: float
trade_id: str
opened_at: datetime
tp1_hit: bool = False
tp2_hit: bool = False
realized_pnl: float = 0.0 # Accumulated from partial closes
be_moved: bool = False # Whether SL was moved to BE
signal_data: dict = field(default_factory=dict)
# Exchange-side order IDs
sl_order_id: int | None = None
tp1_order_id: int | None = None
tp2_order_id: int | None = None
tp3_order_id: int | None = None
use_exchange_orders: bool = False
@property
def age_minutes(self) -> float:
return (now_van() - self.opened_at).total_seconds() / 60
class GerchikManager:
"""Manages Gerchik Level strategy positions."""
def __init__(self, trader: BinanceFuturesTrader, notify_fn: Callable, tmm=None):
self.trader = trader
self.notify = notify_fn
self.tmm = tmm
self.order_mgr = ExchangeOrderManager(trader)
self.positions: dict[str, GerchikPosition] = {}
self.stats = {"wins": 0, "losses": 0, "total_pnl": 0.0}
self._cooldowns: dict[str, datetime] = {} # symbol -> last close time
async def _open_with_limit(self, symbol: str, side: str, qty: float,
limit_price: float) -> dict | None:
"""
Place limit order, wait for fill, fallback to market if timeout.
Returns dict with fill_price, quantity, and is_maker flag.
"""
order = self.trader.open_limit_order(symbol, side, qty, limit_price)
if not order:
logger.warning(f"Limit order failed for {symbol}, trying market")
r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
if r:
r["is_maker"] = False
return r
order_id = order["orderId"]
status = order["status"]
# If filled immediately (unlikely but possible)
if status == "FILLED":
fp = order.get("fill_price") or limit_price
logger.info(f"Limit order filled immediately: {symbol} @ ${fp:.6f}")
return {"fill_price": fp, "quantity": order["quantity"], "is_maker": True}
# Poll for fill within timeout
elapsed = 0
poll_interval = 1 # 1 second
while elapsed < GERCHIK_LIMIT_FALLBACK_SEC:
await asyncio.sleep(poll_interval)
elapsed += poll_interval
check = self.trader.get_order_status(symbol, order_id)
if not check:
continue
if check["status"] == "FILLED":
fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
logger.info(f"Limit filled after {elapsed}s: {symbol} @ ${fp:.6f}")
return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}
if check["status"] in ("CANCELED", "EXPIRED", "REJECTED"):
logger.warning(f"Limit order {check['status']} for {symbol}, trying market")
r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
if r:
r["is_maker"] = False
return r
# Timeout β cancel limit, go market
logger.info(f"Limit timeout ({GERCHIK_LIMIT_FALLBACK_SEC}s) for {symbol}, cancel β market")
# Check one more time (might have filled during cancel)
check = self.trader.get_order_status(symbol, order_id)
if check and check["status"] == "FILLED":
fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}
# Partially filled?
if check and check["executedQty"] > 0:
# Cancel remaining, keep what we got
self.trader.cancel_order(symbol, order_id)
fp = check["fill_price"] if check["fill_price"] > 0 else limit_price
logger.info(f"Limit partial fill {check['executedQty']}/{check['origQty']} for {symbol}")
return {"fill_price": fp, "quantity": check["executedQty"], "is_maker": True}
# Nothing filled β cancel and market
self.trader.cancel_order(symbol, order_id)
r = self.trader.open_position(symbol, side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE)
if r:
r["is_maker"] = False
return r
async def open_trade(self, signal: GerchikSignal) -> bool:
"""Open a new Gerchik level trade (limit order with market fallback)."""
symbol = signal.symbol
if symbol in self.positions:
logger.debug(f"Already have position in {symbol}")
return False
# Double-check: verify no position exists on Binance (prevents duplicates on restart)
existing = self.trader.get_position(symbol)
if existing:
logger.warning(f"GR {symbol}: position exists on Binance ({existing['quantity']} qty) but not in manager β skip open")
return False
if len(self.positions) >= GERCHIK_MAX_POSITIONS:
logger.debug(f"Max Gerchik positions reached ({GERCHIK_MAX_POSITIONS})")
return False
# Cooldown: don't re-enter same symbol within 15 min
if symbol in self._cooldowns:
elapsed = (now_van() - self._cooldowns[symbol]).total_seconds()
if elapsed < 900: # 15 min
return False
# Set leverage and margin type first
if not self.trader.set_leverage(symbol, GERCHIK_LEVERAGE):
return False
self.trader.set_margin_type(symbol, "ISOLATED") # May fail if position exists, non-critical
# Get current price for qty calculation
price = self.trader.get_mark_price(symbol)
if not price:
return False
qty = self.trader.calculate_quantity(symbol, GERCHIK_SIZE_USDT, price, GERCHIK_LEVERAGE)
if qty <= 0:
logger.error(f"Invalid quantity for {symbol}")
return False
if GERCHIK_USE_LIMIT_ORDERS:
# Limit price: slightly better than current for sniper entry
# BUY β bid slightly below current | SELL β ask slightly above current
tick_improve = price * 0.0002 # 0.02% improvement
if signal.side == "BUY":
limit_price = price - tick_improve
else:
limit_price = price + tick_improve
result = await self._open_with_limit(symbol, signal.side, qty, limit_price)
else:
result = self.trader.open_position(
symbol, signal.side, GERCHIK_SIZE_USDT, GERCHIK_LEVERAGE
)
if not result:
logger.error(f"Failed to open Gerchik {signal.side} {symbol}")
return False
entry = result["fill_price"]
qty = result["quantity"]
# Recalculate SL/TP from actual entry (not from signal's current_price)
if signal.side == "BUY":
sl_dist = entry - signal.sl_price
tp1 = entry + sl_dist * (signal.tp1_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
tp2 = entry + sl_dist * (signal.tp2_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
tp3 = entry + sl_dist * (signal.tp3_price - signal.entry_price) / (signal.entry_price - signal.sl_price)
else:
sl_dist = signal.sl_price - entry
tp1 = entry - sl_dist * (signal.entry_price - signal.tp1_price) / (signal.sl_price - signal.entry_price)
tp2 = entry - sl_dist * (signal.entry_price - signal.tp2_price) / (signal.sl_price - signal.entry_price)
tp3 = entry - sl_dist * (signal.entry_price - signal.tp3_price) / (signal.sl_price - signal.entry_price)
# Open fee (maker if limit filled, taker if market fallback)
is_maker = result.get("is_maker", False)
entry_fee_pct = MAKER_FEE_PCT if is_maker else TAKER_FEE_PCT
open_fee = qty * entry * entry_fee_pct / 100
order_type = "LIMIT" if is_maker else "MARKET"
now = now_van()
trade_id = f"GR_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"
pos = GerchikPosition(
symbol=symbol,
side=signal.side,
model=signal.model,
entry_price=entry,
quantity=qty,
total_quantity=qty,
remaining_quantity=qty,
sl_price=signal.sl_price,
tp1_price=tp1,
tp2_price=tp2,
tp3_price=tp3,
level_price=signal.level.price,
level_strength=signal.level.strength,
trade_id=trade_id,
opened_at=now,
realized_pnl=-open_fee,
signal_data={
"model": signal.model,
"pattern": signal.candle_pattern,
"trend": signal.trend,
"volume_ratio": signal.volume_ratio,
"level_type": signal.level.level_type,
"level_touches": signal.level.touches,
},
)
# Place exchange-side TP/SL orders
if GERCHIK_USE_EXCHANGE_ORDERS:
self._place_initial_orders(pos)
self.positions[symbol] = pos
# Log
log_event("GR_ENTRY", {
"trade_id": trade_id,
"symbol": symbol,
"side": signal.side,
"model": signal.model,
"model_label": MODEL_LABELS.get(signal.model, "?"),
"entry_price": entry,
"quantity": qty,
"leverage": GERCHIK_LEVERAGE,
"margin_usdt": GERCHIK_SIZE_USDT,
"sl_price": signal.sl_price,
"tp1_price": tp1,
"tp2_price": tp2,
"tp3_price": tp3,
"level_price": signal.level.price,
"level_strength": signal.level.strength,
"level_type": signal.level.level_type,
"level_touches": signal.level.touches,
"trend": signal.trend,
"pattern": signal.candle_pattern,
"order_type": order_type,
"entry_fee_pct": entry_fee_pct,
})
direction = "LONG" if signal.side == "BUY" else "SHORT"
model_label = MODEL_LABELS.get(signal.model, "?")
level_type = signal.level.level_type
sl_pct = signal.sl_distance_pct
notional = qty * entry
msg = (
f"π GERCHIK ΠΠΎΠ΄Π΅Π»Ρ {signal.model} ({model_label}): {direction} {symbol}\n"
f"ββββββββββββββββββββ\n"
f"π Π£ΡΠΎΠ²Π΅Π½Ρ: ${signal.level.price:.6f} ({signal.level.touches} ΠΊΠ°Ρ., {level_type})\n"
f"πͺ Π‘ΠΈΠ»Π°: {signal.level.strength:.0f}/100\n"
f"π° Entry: ${entry:.6f} ({order_type})\n"
f"π Size: {qty} ({GERCHIK_LEVERAGE}x, ${notional:.2f})\n"
f"π SL: ${signal.sl_price:.6f} (-{sl_pct:.2f}%)\n"
f"π― TP1: ${tp1:.6f} (3:1) β 50%\n"
f"π― TP2: ${tp2:.6f} (4:1) β 25%\n"
f"π― TP3: ${tp3:.6f} (5:1) β 25%\n"
f"π Π’ΡΠ΅Π½Π΄: {signal.trend} | π {signal.candle_pattern}\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"Gerchik trade opened: {direction} {symbol} model={signal.model} level=${signal.level.price:.6f}")
# TMM journal: tag trade
if self.tmm:
model_name = MODEL_LABELS.get(signal.model, signal.model)
self.tmm.on_trade_opened(symbol, signal.side, "GERCHIK", model=signal.model,
signal_info=f"[GR-{signal.model}] {direction} {model_name} @ level ${signal.level.price:.6f}")
return True
def _place_initial_orders(self, pos: GerchikPosition):
"""Place TP1/TP2/TP3 + SL orders on Binance after position opens."""
tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)
orders = self.order_mgr.place_tp_sl_orders(
pos.symbol, pos.side,
sl_price=pos.sl_price,
sl_quantity=pos.total_quantity,
tp_levels=[
(pos.tp1_price, tp1_qty),
(pos.tp2_price, tp2_qty),
(pos.tp3_price, tp3_qty),
],
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
pos.tp1_order_id = ids[0] if len(ids) > 0 else None
pos.tp2_order_id = ids[1] if len(ids) > 1 else None
pos.tp3_order_id = ids[2] if len(ids) > 2 else None
pos.use_exchange_orders = bool(pos.sl_order_id and pos.tp1_order_id)
if pos.use_exchange_orders:
logger.info(f"GR {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id})")
else:
logger.warning(f"GR {pos.symbol}: exchange orders failed, using polling fallback")
def _place_recovery_orders(self, pos: GerchikPosition):
"""Place exchange orders for a recovered position (respects TP state)."""
remaining = pos.remaining_quantity
tp_levels = []
if pos.tp2_hit:
tp_levels.append((pos.tp3_price, remaining))
elif pos.tp1_hit:
tp2_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp2_qty)
if tp2_qty > 0:
tp_levels.append((pos.tp2_price, tp2_qty))
if tp3_qty > 0:
tp_levels.append((pos.tp3_price, tp3_qty))
else:
tp1_qty = self.trader.round_quantity(pos.symbol, remaining * GERCHIK_TP1_CLOSE_PCT)
tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, remaining - tp1_qty - tp2_qty)
if tp1_qty > 0:
tp_levels.append((pos.tp1_price, tp1_qty))
if tp2_qty > 0:
tp_levels.append((pos.tp2_price, tp2_qty))
if tp3_qty > 0:
tp_levels.append((pos.tp3_price, tp3_qty))
orders = self.order_mgr.place_tp_sl_orders(
pos.symbol, pos.side,
sl_price=pos.sl_price,
sl_quantity=remaining,
tp_levels=tp_levels,
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
if pos.tp2_hit:
pos.tp3_order_id = ids[0] if len(ids) > 0 else None
elif pos.tp1_hit:
pos.tp2_order_id = ids[0] if len(ids) > 0 else None
pos.tp3_order_id = ids[1] if len(ids) > 1 else None
else:
pos.tp1_order_id = ids[0] if len(ids) > 0 else None
pos.tp2_order_id = ids[1] if len(ids) > 1 else None
pos.tp3_order_id = ids[2] if len(ids) > 2 else None
pos.use_exchange_orders = bool(pos.sl_order_id and len(ids) > 0)
if pos.use_exchange_orders:
logger.info(f"GR {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})")
else:
logger.warning(f"GR {pos.symbol}: recovery orders failed, using polling fallback")
async def _handle_sl(self, pos: GerchikPosition, current_price: float):
"""Handle stop loss hit."""
result = self.trader.close_full(pos.symbol, pos.side)
fill_price = current_price
if result and result.get("fill_price"):
fill_price = result["fill_price"]
logger.info(f"GR SL {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
if pnl_pct < 0:
pnl_usdt = -pnl_usdt
close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
self.stats["losses"] += 1
self.stats["total_pnl"] += total_pnl
log_event("GR_SL_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"side": pos.side,
"model": pos.model,
"entry_price": pos.entry_price,
"exit_price": fill_price,
"mark_price": current_price,
"slippage_pct": round((fill_price - current_price) / current_price * 100, 3) if current_price else 0,
"pnl_pct": round(pnl_pct, 2),
"realized_pnl_usdt": round(pnl_usdt, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
"tp1_was_hit": pos.tp1_hit,
})
sl_type = "BE" if pos.tp1_hit else "SL"
emoji = "π‘" if pos.tp1_hit else "π΄"
slip_line = ""
if current_price and abs(fill_price - current_price) / current_price > 0.001:
slip_line = f"β οΈ Slip: {((fill_price - current_price) / current_price * 100):+.2f}%\n"
msg = (
f"{emoji} GR {sl_type}: {pos.symbol} (ΠΠΎΠ΄Π΅Π»Ρ {pos.model})\n"
f"ββββββββββββββββββββ\n"
f"Entry: ${pos.entry_price:.6f}\n"
f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
f"{slip_line}"
f"π΅ PnL: ${total_pnl:+.2f}\n"
f"π Π£ΡΠΎΠ²Π΅Π½Ρ: ${pos.level_price:.6f}\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
logger.info(f"GR SL: {pos.symbol} {pnl_pct:+.2f}% ${total_pnl:+.2f}")
async def _handle_tp1(self, pos: GerchikPosition, current_price: float):
"""Handle TP1: close 50%, SL β BE."""
close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
if close_qty <= 0:
close_qty = pos.remaining_quantity
result = self.trader.close_partial(pos.symbol, pos.side, close_qty)
if not result:
logger.error(f"Failed to close partial at GR TP1 for {pos.symbol}")
return
fill_price = result.get("fill_price") or current_price
pos.tp1_hit = True
pos.remaining_quantity -= close_qty
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# Move SL to breakeven
pos.sl_price = pos.entry_price
log_event("GR_TP1_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"model": pos.model,
"price": fill_price,
"closed_quantity": close_qty,
"remaining_quantity": pos.remaining_quantity,
"pnl_pct": round(pnl_pct, 2),
"realized_pnl_usdt": round(pnl_usdt, 2),
"new_sl_price": pos.sl_price,
})
msg = (
f"π― GR TP1: {pos.symbol} (ΠΠΎΠ΄Π΅Π»Ρ {pos.model})\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: 50% ({close_qty})\n"
f"π΅ +${pnl_usdt:.2f}\n"
f"π SL β BE (${pos.entry_price:.6f})\n"
f"ΠΡΡΠ°Π»ΠΎΡΡ: {pos.remaining_quantity} β TP2/TP3\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"GR TP1: {pos.symbol} closed {close_qty}, SLβBE")
async def _handle_tp2(self, pos: GerchikPosition, current_price: float):
"""Handle TP2: close 50% of remaining, SL β +1.5 SL."""
close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
if close_qty <= 0:
close_qty = pos.remaining_quantity
result = self.trader.close_partial(pos.symbol, pos.side, close_qty)
if not result:
logger.error(f"Failed to close partial at GR TP2 for {pos.symbol}")
return
fill_price = result.get("fill_price") or current_price
pos.tp2_hit = True
pos.remaining_quantity -= close_qty
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
close_fee = close_qty * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# Move SL to +1.5 SL distance (lock in profit)
sl_dist = abs(pos.entry_price - pos.level_price)
if pos.side == "BUY":
pos.sl_price = pos.entry_price + sl_dist * 1.5
else:
pos.sl_price = pos.entry_price - sl_dist * 1.5
log_event("GR_TP2_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"model": pos.model,
"price": fill_price,
"closed_quantity": close_qty,
"remaining_quantity": pos.remaining_quantity,
"pnl_pct": round(pnl_pct, 2),
"realized_pnl_usdt": round(pnl_usdt, 2),
"new_sl_price": pos.sl_price,
})
msg = (
f"π―π― GR TP2: {pos.symbol} (ΠΠΎΠ΄Π΅Π»Ρ {pos.model})\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: Π΅ΡΡ 25% ({close_qty})\n"
f"π΅ +${pnl_usdt:.2f}\n"
f"π SL β ${pos.sl_price:.6f} (Π»ΠΎΠΊ ΠΏΡΠΎΡΠΈΡΠ°)\n"
f"ΠΡΡΠ°Π»ΠΎΡΡ: {pos.remaining_quantity} β TP3\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"GR TP2: {pos.symbol} closed {close_qty}")
async def _handle_tp3(self, pos: GerchikPosition, current_price: float):
"""Handle TP3: close everything remaining."""
result = self.trader.close_full(pos.symbol, pos.side)
fill_price = current_price
if result and result.get("fill_price"):
fill_price = result["fill_price"]
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
self.stats["wins"] += 1
self.stats["total_pnl"] += total_pnl
log_event("GR_TP3_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"model": pos.model,
"entry_price": pos.entry_price,
"exit_price": fill_price,
"pnl_pct": round(pnl_pct, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
})
msg = (
f"π GR TP3 FULL: {pos.symbol} (ΠΠΎΠ΄Π΅Π»Ρ {pos.model})\n"
f"ββββββββββββββββββββ\n"
f"Entry: ${pos.entry_price:.6f}\n"
f"Exit: ${fill_price:.6f}\n"
f"π° ΠΡΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
logger.info(f"GR TP3: {pos.symbol} total PnL ${total_pnl:+.2f}")
def _build_current_tps(self, pos: GerchikPosition) -> list[tuple]:
"""Build list of (price, qty) for TPs that haven't been hit yet."""
tps = []
if not pos.tp1_hit:
tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)
if tp1_qty > 0:
tps.append((pos.tp1_price, tp1_qty))
if tp2_qty > 0:
tps.append((pos.tp2_price, tp2_qty))
if tp3_qty > 0:
tps.append((pos.tp3_price, tp3_qty))
elif not pos.tp2_hit:
tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty)
if tp2_qty > 0:
tps.append((pos.tp2_price, tp2_qty))
if tp3_qty > 0:
tps.append((pos.tp3_price, tp3_qty))
else:
if pos.remaining_quantity > 0:
tps.append((pos.tp3_price, pos.remaining_quantity))
return tps
async def _check_exchange_orders(self, pos: GerchikPosition, price: float):
"""Check exchange-side order fills for Gerchik position.
TP = regular LIMIT (queryable), SL = STOP_MARKET algo (NOT queryable).
Detection pattern:
- TP filled? β check LIMIT order status (queryable)
- SL fired? β position gone on Binance + TP not filled
After TP1/TP2: cancel_all + re-place SL + remaining TPs via replace_sl_and_tps().
"""
is_long = pos.side == "BUY"
# 0. Detect externally cancelled orders β re-place all
first_tp_id = pos.tp1_order_id or pos.tp2_order_id or pos.tp3_order_id
if first_tp_id:
st = self.trader.get_order_status(pos.symbol, first_tp_id)
if st and st["status"] in ("CANCELED", "CANCELLED", "EXPIRED", "REJECTED"):
logger.warning(f"GR {pos.symbol}: orders cancelled externally, re-placing")
remaining_tps = self._build_current_tps(pos)
orders = self.order_mgr.replace_sl_and_tps(
pos.symbol, pos.side, pos.sl_price, pos.remaining_quantity, remaining_tps
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
if not pos.tp1_hit:
pos.tp1_order_id = ids[0] if len(ids) > 0 else None
pos.tp2_order_id = ids[1] if len(ids) > 1 else None
pos.tp3_order_id = ids[2] if len(ids) > 2 else None
elif not pos.tp2_hit:
pos.tp2_order_id = ids[0] if len(ids) > 0 else None
pos.tp3_order_id = ids[1] if len(ids) > 1 else None
else:
pos.tp3_order_id = ids[0] if len(ids) > 0 else None
await self.notify(f"π GR {pos.symbol}: ΠΎΡΠ΄Π΅ΡΠ° ΠΏΠ΅ΡΠ΅ΡΡΠ°Π²Π»Π΅Π½Ρ (Π±ΡΠ»ΠΈ ΠΎΡΠΌΠ΅Π½Π΅Π½Ρ)")
logger.info(f"GR {pos.symbol}: re-placed SL + {len(ids)} TPs")
return
# 1. Check TP1 fill (regular LIMIT β queryable)
if not pos.tp1_hit and pos.tp1_order_id:
tp1_st = self.trader.get_order_status(pos.symbol, pos.tp1_order_id)
if tp1_st and tp1_st["status"] == "FILLED":
fill_price = tp1_st["fill_price"] if tp1_st["fill_price"] > 0 else pos.tp1_price
close_qty = tp1_st["executedQty"] or self.trader.round_quantity(
pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
pos.tp1_hit = True
pos.remaining_quantity -= close_qty
pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# SL β BE + re-place remaining TPs
new_sl = pos.entry_price
pos.sl_price = new_sl
remaining_tps = []
tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity - tp2_qty)
if tp2_qty > 0:
remaining_tps.append((pos.tp2_price, tp2_qty))
if tp3_qty > 0:
remaining_tps.append((pos.tp3_price, tp3_qty))
orders = self.order_mgr.replace_sl_and_tps(
pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
pos.tp1_order_id = None # already filled
pos.tp2_order_id = ids[0] if len(ids) > 0 else None
pos.tp3_order_id = ids[1] if len(ids) > 1 else None
log_event("GR_TP1_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"model": pos.model, "price": fill_price,
"closed_quantity": close_qty, "new_sl_price": new_sl,
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π― GR TP1: {pos.symbol} (Π{pos.model}) [LIMIT]\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: 50% ({close_qty})\nπ΅ +${pnl_usdt:.2f}\n"
f"π SL β BE (${new_sl:.6f})\nββββββββββββββββββββ"
)
await self.notify(msg)
return
# 2. Check TP2 fill
if pos.tp1_hit and not pos.tp2_hit and pos.tp2_order_id:
tp2_st = self.trader.get_order_status(pos.symbol, pos.tp2_order_id)
if tp2_st and tp2_st["status"] == "FILLED":
fill_price = tp2_st["fill_price"] if tp2_st["fill_price"] > 0 else pos.tp2_price
close_qty = tp2_st["executedQty"] or self.trader.round_quantity(
pos.symbol, pos.remaining_quantity * GERCHIK_TP2_CLOSE_PCT)
pos.tp2_hit = True
pos.remaining_quantity -= close_qty
pnl_usdt = close_qty * abs(fill_price - pos.entry_price)
close_fee = close_qty * fill_price * MAKER_FEE_PCT / 100
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# SL β entry + 1.5x SL distance + re-place remaining TP3
sl_dist = abs(pos.entry_price - pos.level_price)
if pos.side == "BUY":
new_sl = pos.entry_price + sl_dist * 1.5
else:
new_sl = pos.entry_price - sl_dist * 1.5
pos.sl_price = new_sl
remaining_tps = []
if pos.remaining_quantity > 0:
remaining_tps.append((pos.tp3_price, pos.remaining_quantity))
orders = self.order_mgr.replace_sl_and_tps(
pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
pos.tp2_order_id = None # already filled
pos.tp3_order_id = ids[0] if len(ids) > 0 else None
log_event("GR_TP2_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"model": pos.model, "price": fill_price,
"closed_quantity": close_qty, "new_sl_price": new_sl,
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π―π― GR TP2: {pos.symbol} (Π{pos.model}) [LIMIT]\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: 25% ({close_qty})\nπ΅ +${pnl_usdt:.2f}\n"
f"π SL β ${new_sl:.6f}\nββββββββββββββββββββ"
)
await self.notify(msg)
return
# 3. Check TP3 fill
if pos.tp2_hit and pos.tp3_order_id:
tp3_st = self.trader.get_order_status(pos.symbol, pos.tp3_order_id)
if tp3_st and tp3_st["status"] == "FILLED":
fill_price = tp3_st["fill_price"] if tp3_st["fill_price"] > 0 else pos.tp3_price
pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
close_fee = pos.remaining_quantity * fill_price * MAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
# Cancel SL algo order via cancel_all
self.order_mgr.cancel_all_for_symbol(pos.symbol)
self.stats["wins"] += 1
self.stats["total_pnl"] += total_pnl
log_event("GR_TP3_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"model": pos.model, "entry_price": pos.entry_price,
"exit_price": fill_price, "total_trade_pnl_usdt": round(total_pnl, 2),
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π GR TP3: {pos.symbol} (Π{pos.model}) [LIMIT]\n"
f"ββββββββββββββββββββ\n"
f"π° ΠΡΠΎΠ³ΠΎ PnL: ${total_pnl:+.2f}\nββββββββββββββββββββ"
)
await self.notify(msg)
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
return
# 4. BE move (polling-based trailing β acceptable, doesn't need order query)
if not pos.tp1_hit and not pos.be_moved:
sl_dist = abs(pos.entry_price - pos.level_price)
be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \
else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT
if (price >= be_trigger) if is_long else (price <= be_trigger):
new_sl = pos.entry_price
pos.sl_price = new_sl
pos.be_moved = True
# Build remaining TP levels for re-placement
remaining_tps = []
tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * GERCHIK_TP1_CLOSE_PCT)
tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * GERCHIK_TP2_CLOSE_PCT)
tp3_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity - tp1_qty - tp2_qty)
if pos.tp1_order_id and tp1_qty > 0:
remaining_tps.append((pos.tp1_price, tp1_qty))
if pos.tp2_order_id and tp2_qty > 0:
remaining_tps.append((pos.tp2_price, tp2_qty))
if pos.tp3_order_id and tp3_qty > 0:
remaining_tps.append((pos.tp3_price, tp3_qty))
orders = self.order_mgr.replace_sl_and_tps(
pos.symbol, pos.side, new_sl, pos.remaining_quantity, remaining_tps
)
pos.sl_order_id = orders["sl_order_id"]
ids = orders["tp_order_ids"]
idx = 0
if pos.tp1_order_id:
pos.tp1_order_id = ids[idx] if idx < len(ids) else None
idx += 1
if pos.tp2_order_id:
pos.tp2_order_id = ids[idx] if idx < len(ids) else None
idx += 1
if pos.tp3_order_id:
pos.tp3_order_id = ids[idx] if idx < len(ids) else None
logger.info(f"GR BE (exchange): {pos.symbol} SL moved to BE")
# 5. Position gone? Either SL fired (STOP_MARKET algo) or manual close
binance_pos = self.trader.get_position(pos.symbol)
if not binance_pos:
# Clean up all orders (both regular LIMIT and algo STOP_MARKET)
self.order_mgr.cancel_all_for_symbol(pos.symbol)
# Check if any TP was filled between checks (race condition)
any_tp_filled = False
for tp_id, label in [
(pos.tp1_order_id, "TP1"),
(pos.tp2_order_id, "TP2"),
(pos.tp3_order_id, "TP3"),
]:
if tp_id:
st = self.trader.get_order_status(pos.symbol, tp_id)
if st and st["status"] == "FILLED":
any_tp_filled = True
logger.info(f"GR {pos.symbol}: {label} was filled (detected via position-gone)")
if not any_tp_filled:
# SL fired via STOP_MARKET algo order
fill_price = pos.sl_price # best estimate
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
if pnl_pct < 0:
pnl_usdt = -pnl_usdt
close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
if pnl_pct >= 0:
self.stats["wins"] += 1
else:
self.stats["losses"] += 1
self.stats["total_pnl"] += total_pnl
sl_type = "BE" if pos.tp1_hit else "SL"
emoji = "π‘" if pos.tp1_hit else "π΄"
log_event("GR_SL_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"model": pos.model, "entry_price": pos.entry_price,
"exit_price": fill_price, "pnl_pct": round(pnl_pct, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
"order_type": "EXCHANGE_STOP",
})
msg = (
f"{emoji} GR {sl_type}: {pos.symbol} (Π{pos.model}) [STOP]\n"
f"ββββββββββββββββββββ\n"
f"Entry: ${pos.entry_price:.6f}\nExit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
f"π΅ PnL: ${total_pnl:+.2f}\nββββββββββββββββββββ"
)
await self.notify(msg)
else:
pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
log_event("GR_MANUAL_CLOSE", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"model": pos.model, "exit_price": price,
"pnl_pct": round(pnl_pct, 2),
"note": "tp_filled_race_condition",
})
await self.notify(
f"π GR {pos.symbol} Π·Π°ΠΊΡΡΡ (TP fill + position gone)")
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
async def check_position(self, pos: GerchikPosition):
"""Check a single position: exchange orders or polling fallback."""
price = self.trader.get_mark_price(pos.symbol)
if not price:
return
# === Exchange-side order mode ===
if pos.use_exchange_orders:
await self._check_exchange_orders(pos, price)
return
# === Polling fallback mode ===
# Safety: detect if position was closed externally
binance_pos = self.trader.get_position(pos.symbol)
if not binance_pos:
pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price)
if pnl_pct < 0:
pnl_usdt = -pnl_usdt
total_pnl = pos.realized_pnl + pnl_usdt
log_event("GR_MANUAL_CLOSE", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"model": pos.model,
"exit_price": price,
"pnl_pct": round(pnl_pct, 2),
"total_pnl_usdt": round(total_pnl, 2),
})
await self.notify(
f"π GR Manual: {pos.symbol} Π·Π°ΠΊΡΡΡ Π²ΡΡΡΠ½ΡΡ\n"
f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)"
)
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
return
is_long = pos.side == "BUY"
# 1. SL check
sl_hit = (price <= pos.sl_price) if is_long else (price >= pos.sl_price)
if sl_hit:
await self._handle_sl(pos, price)
return
# 2. TP3 (close all remaining)
if not pos.tp2_hit:
pass # Can't hit TP3 without TP2
else:
tp3_hit = (price >= pos.tp3_price) if is_long else (price <= pos.tp3_price)
if tp3_hit:
await self._handle_tp3(pos, price)
return
# 3. TP2
if not pos.tp1_hit:
pass # Can't hit TP2 without TP1
elif not pos.tp2_hit:
tp2_hit = (price >= pos.tp2_price) if is_long else (price <= pos.tp2_price)
if tp2_hit:
await self._handle_tp2(pos, price)
return
# 4. TP1
if not pos.tp1_hit:
tp1_hit = (price >= pos.tp1_price) if is_long else (price <= pos.tp1_price)
if tp1_hit:
await self._handle_tp1(pos, price)
return
# 5. Move SL to BE after 2x SL distance in profit (before TP1)
if not pos.tp1_hit and not pos.be_moved:
sl_dist = abs(pos.entry_price - pos.level_price)
be_trigger = pos.entry_price + sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT if is_long \
else pos.entry_price - sl_dist * GERCHIK_BE_TRIGGER_STOPLOSS_MULT
be_triggered = (price >= be_trigger) if is_long else (price <= be_trigger)
if be_triggered:
pos.sl_price = pos.entry_price
pos.be_moved = True
logger.info(f"GR BE: {pos.symbol} SL moved to BE @ ${pos.entry_price:.6f}")
async def monitor_loop(self):
"""Check all Gerchik positions every N seconds."""
logger.info(f"Gerchik position monitor started (interval: {GERCHIK_CHECK_INTERVAL}s)")
while True:
try:
symbols = list(self.positions.keys())
for symbol in symbols:
pos = self.positions.get(symbol)
if pos:
await self.check_position(pos)
except Exception as e:
logger.error(f"Gerchik monitor error: {e}", exc_info=True)
await asyncio.sleep(GERCHIK_CHECK_INTERVAL)
async def scan_loop(self, wt_positions: dict, scalp_positions: dict):
"""
Periodic scan for Gerchik level signals.
Args:
wt_positions: WT strategy positions
scalp_positions: Scalp strategy positions
"""
from gerchik_scanner import scan_for_gerchik_signals
logger.info(f"Gerchik scanner started (interval={GERCHIK_SCAN_INTERVAL}s)")
# Track previously alerted nearby levels (avoid spam)
self._alerted_nearby: set[str] = set()
scan_count = 0
# Wait for init
await asyncio.sleep(15)
while True:
try:
skip = (
set(self.positions.keys())
| set(wt_positions.keys())
| set(scalp_positions.keys())
)
async def on_signal(signal: GerchikSignal):
if len(self.positions) < GERCHIK_MAX_POSITIONS:
await self.open_trade(signal)
result = await scan_for_gerchik_signals(
self.trader.client,
on_signal,
skip,
)
scan_count += 1
nearby = result.get("nearby", [])
# Alert on new nearby levels (price approaching strong level)
new_nearby = []
for n in nearby:
key = f"{n.symbol}_{n.level_price:.6f}"
if key not in self._alerted_nearby:
self._alerted_nearby.add(key)
new_nearby.append(n)
if new_nearby:
lines = ["π Π¦Π΅Π½Π° Ρ ΡΠΈΠ»ΡΠ½ΡΡ
ΡΡΠΎΠ²Π½Π΅ΠΉ:\nββββββββββββββββββββ"]
for n in new_nearby[:5]:
direction = "β¬οΈ" if n.side == "BUY" else "β¬οΈ"
lines.append(
f"{direction} {n.symbol} ${n.current_price:.4f}\n"
f" π Π£ΡΠΎΠ²Π΅Π½Ρ ${n.level_price:.4f} ({n.level_type}, "
f"{n.level_touches}ΠΊΠ°Ρ, πͺ{n.level_strength:.0f})\n"
f" π Π Π°ΡΡΡΠΎΡΠ½ΠΈΠ΅: {n.distance_pct:.2f}%"
)
lines.append("ββββββββββββββββββββ")
await self.notify("\n".join(lines))
# Clean up stale alerts every 12 scans (~1 hour)
if scan_count % 12 == 0:
self._alerted_nearby.clear()
except Exception as e:
logger.error(f"Gerchik scan error: {e}", exc_info=True)
await asyncio.sleep(GERCHIK_SCAN_INTERVAL)
def recover_positions(self):
"""Recover Gerchik positions from trade log + Binance API on restart."""
from trade_log import load_trade_log
log = load_trade_log()
open_trades = {}
close_events = {"GR_SL_HIT", "GR_TP3_HIT", "GR_MANUAL_CLOSE"}
for event in log:
evt = event.get("event", "")
tid = event.get("trade_id", "")
if not tid or not tid.startswith("GR_"):
continue
if evt == "GR_ENTRY":
open_trades[tid] = event
elif evt in close_events and tid in open_trades:
del open_trades[tid]
for tid, entry in open_trades.items():
symbol = entry.get("symbol", "")
if not symbol:
continue
# Verify on Binance
binance_pos = self.trader.get_position(symbol)
if not binance_pos:
logger.info(f"GR recovery: {symbol} not found on Binance, skipping")
continue
# Get TP state from log
tp1_hit = False
tp2_hit = False
sl_price = entry.get("sl_price", 0)
realized_pnl = 0.0
for e in log:
if e.get("trade_id") != tid:
continue
evt = e.get("event", "")
if evt == "GR_TP1_HIT":
tp1_hit = True
sl_price = e.get("new_sl_price", sl_price)
realized_pnl += e.get("realized_pnl_usdt", 0)
elif evt == "GR_TP2_HIT":
tp2_hit = True
sl_price = e.get("new_sl_price", sl_price)
realized_pnl += e.get("realized_pnl_usdt", 0)
opened_at = datetime.fromisoformat(entry["timestamp"])
entry_price = entry.get("entry_price", binance_pos["entry_price"])
level_price = entry.get("level_price", 0)
# Detect if BE was already moved: SL at entry price or price past BE trigger
be_already = False
if sl_price and entry_price and abs(sl_price - entry_price) < entry_price * 0.001:
be_already = True
elif level_price and not tp1_hit:
# Check if current price is past BE trigger
sl_dist = abs(entry_price - level_price)
be_trigger_mult = GERCHIK_BE_TRIGGER_STOPLOSS_MULT
current_price = self.trader.get_mark_price(symbol)
if current_price:
side = entry.get("side", binance_pos["side"])
is_long = side == "BUY"
be_trigger = entry_price + sl_dist * be_trigger_mult if is_long else entry_price - sl_dist * be_trigger_mult
if (is_long and current_price >= be_trigger) or (not is_long and current_price <= be_trigger):
be_already = True
sl_price = entry_price # Set SL to BE
pos = GerchikPosition(
symbol=symbol,
side=entry.get("side", binance_pos["side"]),
model=entry.get("model", "?"),
entry_price=entry_price,
quantity=entry.get("quantity", binance_pos["quantity"]),
total_quantity=entry.get("quantity", binance_pos["quantity"]),
remaining_quantity=binance_pos["quantity"],
sl_price=sl_price,
tp1_price=entry.get("tp1_price", 0),
tp2_price=entry.get("tp2_price", 0),
tp3_price=entry.get("tp3_price", 0),
level_price=level_price,
level_strength=entry.get("level_strength", 0),
trade_id=tid,
opened_at=opened_at,
tp1_hit=tp1_hit,
tp2_hit=tp2_hit,
realized_pnl=realized_pnl,
)
pos.be_moved = be_already
if be_already:
logger.info(f"GR recovery: {symbol} BE already triggered, SL at entry")
self.positions[symbol] = pos
# Place exchange-side TP/SL orders for recovered position
if GERCHIK_USE_EXCHANGE_ORDERS:
try:
self.order_mgr.cancel_all_for_symbol(symbol)
time.sleep(2.0) # Let Binance fully process algo order cancellation
self._place_recovery_orders(pos)
logger.info(
f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, "
f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}, exchange_orders=YES"
)
except Exception as e:
logger.error(f"GR recovery orders failed for {symbol}: {e}", exc_info=True)
else:
logger.info(
f"Recovered GR position: {pos.side} {symbol} @ {pos.entry_price}, "
f"model={pos.model}, tp1={tp1_hit}, tp2={tp2_hit}"
)
def format_positions_message(self) -> str:
"""Format Gerchik positions for Telegram."""
if not self.positions:
return "π ΠΠ΅Ρ Gerchik-ΠΏΠΎΠ·ΠΈΡΠΈΠΉ"
lines = ["π Gerchik ΠΏΠΎΠ·ΠΈΡΠΈΠΈ:\nββββββββββββββββββββ"]
for symbol, pos in self.positions.items():
price = self.trader.get_mark_price(symbol) or pos.entry_price
pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
direction = "L" if pos.side == "BUY" else "S"
emoji = "π’" if pnl_pct >= 0 else "π΄"
model_lbl = MODEL_LABELS.get(pos.model, "?")
tp_status = ""
if pos.tp2_hit:
tp_status = " TP2β
"
elif pos.tp1_hit:
tp_status = " TP1β
"
lines.append(
f"{emoji} {direction} {symbol} | Π{pos.model} | {pnl_pct:+.2f}%{tp_status}\n"
f" Π£ΡΠΎΠ²Π΅Π½Ρ: ${pos.level_price:.4f} | {pos.age_minutes:.0f}ΠΌΠΈΠ½"
)
total = self.stats["wins"] + self.stats["losses"]
wr = (self.stats["wins"] / total * 100) if total > 0 else 0
lines.append(f"\nπ WR: {wr:.0f}% ({self.stats['wins']}W/{self.stats['losses']}L)")
lines.append(f"π° PnL: ${self.stats['total_pnl']:+.2f}")
lines.append("ββββββββββββββββββββ")
return "\n".join(lines)
async def close_manual(self, symbol: str) -> bool:
"""Manual close from command."""
if symbol not in self.positions:
return False
pos = self.positions[symbol]
price = self.trader.get_mark_price(symbol)
if not price:
return False
# Cancel exchange orders before market close
if pos.use_exchange_orders:
self.order_mgr.cancel_all_for_symbol(symbol)
result = self.trader.close_full(symbol, pos.side)
fill_price = price
if result and result.get("fill_price"):
fill_price = result["fill_price"]
pnl_pct = safe_pnl_pct(pos.entry_price, fill_price, pos.side)
pnl_usdt = pos.remaining_quantity * abs(fill_price - pos.entry_price)
if pnl_pct < 0:
pnl_usdt = -pnl_usdt
close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
if pnl_pct >= 0:
self.stats["wins"] += 1
else:
self.stats["losses"] += 1
self.stats["total_pnl"] += total_pnl
log_event("GR_MANUAL_CLOSE", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"model": pos.model,
"exit_price": fill_price,
"pnl_pct": round(pnl_pct, 2),
"total_pnl_usdt": round(total_pnl, 2),
})
await self.notify(
f"π GR Manual: {pos.symbol} (Π{pos.model})\n"
f"PnL: ${total_pnl:+.2f} ({pnl_pct:+.2f}%)"
)
self._cooldowns[pos.symbol] = now_van()
del self.positions[pos.symbol]
return True