β ΠΠ°Π·Π°Π΄"""
Position Manager β monitors open positions and executes TP/SL strategy.
Strategy: Variant C Hybrid
Entry β signal from bot
SL: -1.5%
TP1: +2% β close 50%, move SL to BE (0%)
TP2: +3% β close 25% (50% of remaining), move SL to +1.5%
TP3: +5% β close remaining 25%
"""
import asyncio
import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone, timedelta
from trader import BinanceFuturesTrader
from trade_log import log_event, get_open_trades, get_tp_state
from order_placer import ExchangeOrderManager, MAKER_FEE_PCT
logger = logging.getLogger(__name__)
VANCOUVER_TZ = timezone(timedelta(hours=-7))
# === Strategy Config (from env or defaults) ===
TRADE_SIZE_USDT = float(os.environ.get("TRADE_SIZE_USDT", "10"))
MAX_LEVERAGE = int(os.environ.get("MAX_LEVERAGE", "5"))
MAX_OPEN_POSITIONS = int(os.environ.get("MAX_OPEN_POSITIONS", "3"))
SL_PERCENT = float(os.environ.get("SL_PERCENT", "1.5"))
TP1_PERCENT = float(os.environ.get("TP1_PERCENT", "2.0"))
TP2_PERCENT = float(os.environ.get("TP2_PERCENT", "3.0"))
TP3_PERCENT = float(os.environ.get("TP3_PERCENT", "5.0"))
TP1_CLOSE_RATIO = 0.50 # Close 50% of total at TP1
TP2_CLOSE_RATIO = 0.50 # Close 50% of remaining at TP2 (= 25% of total)
# TP3 = close everything remaining
SL_AFTER_TP1_PCT = 0.0 # Move SL to breakeven after TP1
SL_AFTER_TP2_PCT = 1.5 # Move SL to +1.5% after TP2
PRICE_CHECK_INTERVAL = int(os.environ.get("PRICE_CHECK_INTERVAL", "3"))
# Exchange-side TP/SL
WT_USE_EXCHANGE_ORDERS = os.environ.get("WT_USE_EXCHANGE_ORDERS", "true").lower() == "true"
# Binance Futures taker fee: 0.04% per side (open + close = 0.08% total)
TAKER_FEE_PCT = 0.04
def safe_pnl_pct(entry_price: float, current_price: float, side: str) -> float:
"""Calculate PnL % safely (no division by zero)."""
if entry_price <= 0:
return 0.0
if side == "BUY":
return ((current_price - entry_price) / entry_price) * 100
else:
return ((entry_price - current_price) / entry_price) * 100
@dataclass
class Position:
"""Active trading position."""
symbol: str
side: str # "BUY" (long) or "SELL" (short)
entry_price: float
total_quantity: float
remaining_quantity: float
sl_price: float
tp1_price: float
tp2_price: float
tp3_price: float
tp1_hit: bool = False
tp2_hit: bool = False
opened_at: str = ""
signal_data: dict = field(default_factory=dict)
trade_id: str = ""
# Track realized PnL from partial closes
realized_pnl: float = 0.0
# 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
def calculate_levels(entry_price: float, side: str) -> dict:
"""
Calculate SL and TP price levels.
For LONG (BUY):
SL = entry * (1 - SL%)
TP = entry * (1 + TP%)
For SHORT (SELL):
SL = entry * (1 + SL%)
TP = entry * (1 - TP%)
"""
if side == "BUY":
sl = entry_price * (1 - SL_PERCENT / 100)
tp1 = entry_price * (1 + TP1_PERCENT / 100)
tp2 = entry_price * (1 + TP2_PERCENT / 100)
tp3 = entry_price * (1 + TP3_PERCENT / 100)
else: # SELL (short)
sl = entry_price * (1 + SL_PERCENT / 100)
tp1 = entry_price * (1 - TP1_PERCENT / 100)
tp2 = entry_price * (1 - TP2_PERCENT / 100)
tp3 = entry_price * (1 - TP3_PERCENT / 100)
return {"sl": sl, "tp1": tp1, "tp2": tp2, "tp3": tp3}
class PositionManager:
"""Manages all open positions and runs the monitor loop."""
def __init__(self, trader: BinanceFuturesTrader, notify_fn, tmm=None):
"""
Args:
trader: authenticated Binance client
notify_fn: async function to send Telegram alerts
tmm: optional TMMClient for journal integration
"""
self.trader = trader
self.notify = notify_fn
self.tmm = tmm
self.positions: dict[str, Position] = {} # symbol -> Position
self.order_mgr = ExchangeOrderManager(trader)
def can_open_position(self, symbol: str) -> tuple[bool, str]:
"""Check if we can open a new position."""
if symbol in self.positions:
return False, f"Already have position in {symbol}"
# Double-check Binance β prevents duplicates after restarts
existing = self.trader.get_position(symbol)
if existing:
logger.warning(f"WT {symbol}: position exists on Binance but not in manager β skip")
return False, f"Position exists on Binance (untracked)"
if len(self.positions) >= MAX_OPEN_POSITIONS:
return False, f"Max positions reached ({MAX_OPEN_POSITIONS})"
balance = self.trader.get_account_balance()
if balance < TRADE_SIZE_USDT:
return False, f"Insufficient balance: ${balance:.2f} < ${TRADE_SIZE_USDT}"
return True, "OK"
async def open_trade(self, symbol: str, side: str, signal_data: dict) -> bool:
"""
Open a new trade.
Returns True if position was opened successfully.
"""
can_open, reason = self.can_open_position(symbol)
if not can_open:
logger.info(f"Cannot open {symbol}: {reason}")
await self.notify(f"β Skip {symbol}: {reason}")
return False
# Execute on Binance
result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE)
if not result:
await self.notify(f"β Failed to open {side} {symbol}")
log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"})
return False
entry_price = result["fill_price"]
quantity = result["quantity"]
# Calculate levels
levels = calculate_levels(entry_price, side)
now = datetime.now(VANCOUVER_TZ)
trade_id = f"{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"
# Create position object
pos = Position(
symbol=symbol,
side=side,
entry_price=entry_price,
total_quantity=quantity,
remaining_quantity=quantity,
sl_price=levels["sl"],
tp1_price=levels["tp1"],
tp2_price=levels["tp2"],
tp3_price=levels["tp3"],
opened_at=now.isoformat(),
signal_data=signal_data,
trade_id=trade_id,
)
# Deduct open commission from realized PnL (so total PnL matches exchange)
open_fee = quantity * entry_price * TAKER_FEE_PCT / 100
pos.realized_pnl = -open_fee
self.positions[symbol] = pos
# Place exchange-side TP/SL orders
if WT_USE_EXCHANGE_ORDERS:
self._place_initial_orders(pos)
# Log entry (include WT signal data for post-analysis)
log_event("ENTRY", {
"trade_id": trade_id,
"symbol": symbol,
"side": side,
"entry_price": entry_price,
"quantity": quantity,
"leverage": MAX_LEVERAGE,
"margin_usdt": TRADE_SIZE_USDT,
"sl_price": levels["sl"],
"tp1_price": levels["tp1"],
"tp2_price": levels["tp2"],
"tp3_price": levels["tp3"],
"exchange_orders": pos.use_exchange_orders,
"wt_15m_signal": signal_data.get("wt_15m_signal", ""),
"wt1_15m": signal_data.get("wt1_15m", 0),
"wt1_1h": signal_data.get("wt1_1h", 0),
"wt_1h_signal": signal_data.get("wt_1h_signal", ""),
})
# Notify Rick
direction = "LONG" if side == "BUY" else "SHORT"
order_tag = " [EX]" if pos.use_exchange_orders else ""
notional = quantity * entry_price
msg = (
f"{'π’' if side == 'BUY' else 'π΄'} TRADE OPENED: {direction} {symbol}{order_tag}\n"
f"ββββββββββββββββββββ\n"
f"π° Entry: ${entry_price:.6f}\n"
f"π Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n"
f"π SL: ${levels['sl']:.6f} (-{SL_PERCENT}%)\n"
f"π― TP1: ${levels['tp1']:.6f} (+{TP1_PERCENT}%) β 50%\n"
f"π― TP2: ${levels['tp2']:.6f} (+{TP2_PERCENT}%) β 25%\n"
f"π― TP3: ${levels['tp3']:.6f} (+{TP3_PERCENT}%) β 25%\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"Trade opened: {direction} {symbol} @ {entry_price}, qty={quantity}")
# TMM journal: tag trade
if self.tmm:
wt_sig = signal_data.get("wt_15m_signal", "")
self.tmm.on_trade_opened(symbol, side, "WT",
signal_info=f"[WT] {direction} via {wt_sig}")
return True
async def open_trade_with_levels(
self,
symbol: str,
side: str,
sl_price: float,
tp1_price: float,
tp2_price: float,
tp3_price: float,
signal_data: dict,
) -> bool:
"""
Open a trade with custom SL/TP levels (for Digash formations).
Instead of calculating levels from fixed percentages, uses
specific price levels based on formation analysis.
"""
can_open, reason = self.can_open_position(symbol)
if not can_open:
logger.info(f"Cannot open {symbol}: {reason}")
await self.notify(f"β Skip {symbol}: {reason}")
return False
# Execute on Binance
result = self.trader.open_position(symbol, side, TRADE_SIZE_USDT, MAX_LEVERAGE)
if not result:
await self.notify(f"β Failed to open {side} {symbol}")
log_event("ERROR", {"symbol": symbol, "side": side, "error": "open_position failed"})
return False
entry_price = result["fill_price"]
quantity = result["quantity"]
now = datetime.now(VANCOUVER_TZ)
trade_id = f"DG_{symbol}_{now.strftime('%Y%m%d_%H%M%S')}"
pos = Position(
symbol=symbol,
side=side,
entry_price=entry_price,
total_quantity=quantity,
remaining_quantity=quantity,
sl_price=sl_price,
tp1_price=tp1_price,
tp2_price=tp2_price,
tp3_price=tp3_price,
opened_at=now.isoformat(),
signal_data=signal_data,
trade_id=trade_id,
)
# Deduct open commission
open_fee = quantity * entry_price * TAKER_FEE_PCT / 100
pos.realized_pnl = -open_fee
# Place exchange-side TP/SL orders
if WT_USE_EXCHANGE_ORDERS:
self._place_initial_orders(pos)
self.positions[symbol] = pos
# Calculate SL/TP distances for display
sl_dist = safe_pnl_pct(entry_price, sl_price, side)
tp1_dist = safe_pnl_pct(entry_price, tp1_price, "SELL" if side == "BUY" else "BUY")
tp3_dist = safe_pnl_pct(entry_price, tp3_price, "SELL" if side == "BUY" else "BUY")
log_event("ENTRY", {
"trade_id": trade_id,
"symbol": symbol,
"side": side,
"entry_price": entry_price,
"quantity": quantity,
"leverage": MAX_LEVERAGE,
"margin_usdt": TRADE_SIZE_USDT,
"sl_price": sl_price,
"tp1_price": tp1_price,
"tp2_price": tp2_price,
"tp3_price": tp3_price,
"source": "digash",
"formation": signal_data.get("formation", ""),
})
direction = "LONG" if side == "BUY" else "SHORT"
notional = quantity * entry_price
formation = signal_data.get("formation", "?")
msg = (
f"{'π’' if side == 'BUY' else 'π΄'} DIGASH {direction} {symbol}\n"
f"ββββββββββββββββββββ\n"
f"π Formation: {formation}\n"
f"π° Entry: ${entry_price:.6f}\n"
f"π Size: {quantity} ({MAX_LEVERAGE}x, ${notional:.2f})\n"
f"π SL: ${sl_price:.6f} ({sl_dist:+.2f}%)\n"
f"π― TP1: ${tp1_price:.6f} β 50%\n"
f"π― TP2: ${tp2_price:.6f} β 25%\n"
f"π― TP3: ${tp3_price:.6f} β 25%\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"Digash trade opened: {direction} {symbol} @ {entry_price}, formation={formation}")
return True
def _place_initial_orders(self, pos: Position):
"""Place TP1/TP2/TP3 + SL orders on Binance after position opens."""
tp1_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)
tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO)
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"WT {pos.symbol}: exchange orders placed (SL #{pos.sl_order_id}, TP1 #{pos.tp1_order_id}, TP2 #{pos.tp2_order_id}, TP3 #{pos.tp3_order_id})")
else:
logger.warning(f"WT {pos.symbol}: exchange orders failed, using polling fallback")
def _place_recovery_orders(self, pos: Position):
"""Place exchange orders for a recovered position (respects TP state)."""
remaining = pos.remaining_quantity
# Build TP levels based on what hasn't been hit yet
tp_levels = []
if pos.tp2_hit:
# Only TP3 remaining
tp_levels.append((pos.tp3_price, remaining))
elif pos.tp1_hit:
# TP2 + TP3 remaining
tp2_qty = self.trader.round_quantity(pos.symbol, remaining * TP2_CLOSE_RATIO)
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:
# All TPs remaining β use remaining_quantity (not total)
tp1_qty = self.trader.round_quantity(pos.symbol, remaining * TP1_CLOSE_RATIO)
tp2_qty = self.trader.round_quantity(pos.symbol, (remaining - tp1_qty) * TP2_CLOSE_RATIO)
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"]
# Assign TP order IDs based on state
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"WT {pos.symbol}: recovery orders placed (SL #{pos.sl_order_id}, TPs: {ids})")
else:
logger.warning(f"WT {pos.symbol}: recovery orders failed, using polling fallback")
async def _handle_sl(self, pos: Position, current_price: float):
"""Handle stop loss hit."""
# Close everything
result = self.trader.close_full(pos.symbol, pos.side)
# Use actual fill price from Binance
fill_price = current_price # fallback
if result and result.get("fill_price"):
fill_price = result["fill_price"]
logger.info(f"SL close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f} slip={((fill_price - current_price) / current_price * 100):+.3f}%")
# Calculate PnL (with commission) using actual 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
# Subtract close commission (open commission already paid)
close_fee = pos.remaining_quantity * fill_price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
log_event("SL_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"side": pos.side,
"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,
"closed_quantity": pos.remaining_quantity,
"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,
"tp2_was_hit": pos.tp2_hit,
})
sl_type = "BE" if pos.tp1_hit else "SL"
emoji = "π‘" if pos.tp1_hit else "π΄"
slip_line = ""
if 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} {sl_type} HIT: {pos.symbol}\n"
f"ββββββββββββββββββββ\n"
f"Entry: ${pos.entry_price:.6f}\n"
f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
f"{slip_line}"
f"π΅ PnL ΡΡΠΎΠΉ ΡΠ°ΡΡΠΈ: ${pnl_usdt:+.2f}\n"
f"π° ΠΡΠΎΠ³ΠΎ ΠΏΠΎ ΡΠ΄Π΅Π»ΠΊΠ΅: ${total_pnl:+.2f}\n"
f"{'β
TP1 Π±ΡΠ» Π²Π·ΡΡ' if pos.tp1_hit else ''}"
f"{'β
TP2 Π±ΡΠ» Π²Π·ΡΡ' if pos.tp2_hit else ''}\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
del self.positions[pos.symbol]
logger.info(f"SL hit for {pos.symbol}: {pnl_pct:+.2f}%, PnL ${total_pnl:+.2f}")
async def _handle_tp1(self, pos: Position, current_price: float):
"""Handle TP1: close 50%, move SL to BE."""
close_qty = self.trader.round_quantity(pos.symbol, pos.total_quantity * TP1_CLOSE_RATIO)
if close_qty <= 0:
close_qty = pos.remaining_quantity # fallback: close all
result = self.trader.close_partial(pos.symbol, pos.side, close_qty)
if not result:
logger.error(f"Failed to close partial at TP1 for {pos.symbol}")
return
# Use actual fill price from Binance
fill_price = result.get("fill_price") or current_price
if fill_price != current_price:
logger.info(f"TP1 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")
# Update position
pos.tp1_hit = True
pos.remaining_quantity -= close_qty
# Calculate partial PnL (with commission) using actual fill price
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 # BE = entry price
log_event("TP1_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"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,
"new_sl_pct": SL_AFTER_TP1_PCT,
})
msg = (
f"π― TP1 HIT: {pos.symbol} (+{TP1_PERCENT}%)\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"TP1 hit for {pos.symbol}: closed {close_qty}, SL β BE")
async def _handle_tp2(self, pos: Position, current_price: float):
"""Handle TP2: close 50% of remaining (25% of total), move SL to +1.5%."""
close_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)
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 TP2 for {pos.symbol}")
return
# Use actual fill price from Binance
fill_price = result.get("fill_price") or current_price
if fill_price != current_price:
logger.info(f"TP2 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")
pos.tp2_hit = True
pos.remaining_quantity -= close_qty
# Calculate partial PnL (with commission) using actual fill price
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%
if pos.side == "BUY":
pos.sl_price = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100)
else:
pos.sl_price = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100)
log_event("TP2_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"price": current_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,
"new_sl_pct": SL_AFTER_TP2_PCT,
})
msg = (
f"π―π― TP2 HIT: {pos.symbol} (+{TP2_PERCENT}%)\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: Π΅ΡΡ 25% ({close_qty})\n"
f"π΅ +${pnl_usdt:.2f}\n"
f"π SL β +{SL_AFTER_TP2_PCT}% (${pos.sl_price:.6f})\n"
f"ΠΡΡΠ°Π»ΠΎΡΡ: {pos.remaining_quantity} β TP3\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"TP2 hit for {pos.symbol}: closed {close_qty}, SL β +{SL_AFTER_TP2_PCT}%")
async def _handle_tp3(self, pos: Position, current_price: float):
"""Handle TP3: close everything remaining."""
result = self.trader.close_full(pos.symbol, pos.side)
# Use actual fill price from Binance
fill_price = current_price # fallback
if result and result.get("fill_price"):
fill_price = result["fill_price"]
logger.info(f"TP3 close {pos.symbol}: mark=${current_price:.6f} fill=${fill_price:.6f}")
# Calculate PnL (with commission) using actual 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
log_event("TP3_HIT", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"side": pos.side,
"entry_price": pos.entry_price,
"exit_price": fill_price,
"mark_price": current_price,
"closed_quantity": pos.remaining_quantity,
"pnl_pct": round(pnl_pct, 2),
"realized_pnl_usdt": round(pnl_usdt, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
})
msg = (
f"π TP3 FULL HIT: {pos.symbol} (+{TP3_PERCENT}%)\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)
del self.positions[pos.symbol]
logger.info(f"TP3 full hit for {pos.symbol}: total PnL ${total_pnl:+.2f}")
def _build_current_tps(self, pos: Position) -> 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 * TP1_CLOSE_RATIO)
tp2_qty = self.trader.round_quantity(pos.symbol, (pos.total_quantity - tp1_qty) * TP2_CLOSE_RATIO)
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 * TP2_CLOSE_RATIO)
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: Position, price: float):
"""Check exchange-side order fills and react.
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().
"""
# 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"WT {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"π WT {pos.symbol}: ΠΎΡΠ΄Π΅ΡΠ° ΠΏΠ΅ΡΠ΅ΡΡΠ°Π²Π»Π΅Π½Ρ (Π±ΡΠ»ΠΈ ΠΎΡΠΌΠ΅Π½Π΅Π½Ρ)")
logger.info(f"WT {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 * TP1_CLOSE_RATIO)
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 * MAKER_FEE_PCT / 100 # maker!
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# Move SL to BE + re-place remaining TPs
new_sl = pos.entry_price
pos.sl_price = new_sl
# Build remaining TP levels
remaining_tps = []
tp2_qty = self.trader.round_quantity(pos.symbol, pos.remaining_quantity * TP2_CLOSE_RATIO)
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("TP1_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"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": new_sl,
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π― TP1 HIT: {pos.symbol} [LIMIT]\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: 50% ({close_qty})\n"
f"π΅ +${pnl_usdt:.2f}\n"
f"π SL β BE (${new_sl:.6f})\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"WT TP1 (exchange): {pos.symbol} closed {close_qty}, SLβBE")
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 * TP2_CLOSE_RATIO)
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 * MAKER_FEE_PCT / 100
pnl_usdt -= close_fee
pos.realized_pnl += pnl_usdt
# Move SL to +1.5% + re-place remaining TP3
if pos.side == "BUY":
new_sl = pos.entry_price * (1 + SL_AFTER_TP2_PCT / 100)
else:
new_sl = pos.entry_price * (1 - SL_AFTER_TP2_PCT / 100)
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("TP2_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"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": new_sl,
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π―π― TP2 HIT: {pos.symbol} [LIMIT]\n"
f"ββββββββββββββββββββ\n"
f"ΠΠ°ΠΊΡΡΡΠΎ: Π΅ΡΡ 25% ({close_qty})\n"
f"π΅ +${pnl_usdt:.2f}\n"
f"π SL β +{SL_AFTER_TP2_PCT}% (${new_sl:.6f})\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
logger.info(f"WT TP2 (exchange): {pos.symbol} closed {close_qty}")
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_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 * 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)
log_event("TP3_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"entry_price": pos.entry_price, "exit_price": fill_price,
"total_trade_pnl_usdt": round(total_pnl, 2),
"order_type": "EXCHANGE_LIMIT",
})
msg = (
f"π TP3 FULL HIT: {pos.symbol} [LIMIT]\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)
del self.positions[pos.symbol]
logger.info(f"WT TP3 (exchange): {pos.symbol} total PnL ${total_pnl:+.2f}")
return
# 4. 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, tp_price, label in [
(pos.tp1_order_id, pos.tp1_price, "TP1"),
(pos.tp2_order_id, pos.tp2_price, "TP2"),
(pos.tp3_order_id, pos.tp3_price, "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"WT {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 (algo orders don't give fill info)
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
sl_type = "BE" if pos.tp1_hit else "SL"
emoji = "π‘" if pos.tp1_hit else "π΄"
log_event("SL_HIT", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"side": pos.side, "entry_price": pos.entry_price,
"exit_price": fill_price, "mark_price": price,
"pnl_pct": round(pnl_pct, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
"tp1_was_hit": pos.tp1_hit, "tp2_was_hit": pos.tp2_hit,
"order_type": "EXCHANGE_STOP",
})
msg = (
f"{emoji} {sl_type} HIT: {pos.symbol} [STOP]\n"
f"ββββββββββββββββββββ\n"
f"Entry: ${pos.entry_price:.6f}\n"
f"Exit: ${fill_price:.6f} ({pnl_pct:+.2f}%)\n"
f"π° ΠΡΠΎΠ³ΠΎ: ${total_pnl:+.2f}\n"
f"ββββββββββββββββββββ"
)
await self.notify(msg)
else:
# TP was filled but we missed it β log as manual/race
pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
log_event("MANUAL_CLOSE", {
"trade_id": pos.trade_id, "symbol": pos.symbol,
"exit_price": price, "pnl_pct": round(pnl_pct, 2),
"note": "tp_filled_race_condition",
})
await self.notify(
f"π {pos.symbol} Π·Π°ΠΊΡΡΡΠ° (TP fill + position gone)\n"
f"ΠΠΎΡΠ»Π΅Π΄Π½ΡΡ ΡΠ΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)"
)
del self.positions[pos.symbol]
async def check_position(self, pos: Position):
"""
Check a single position against current mark price.
Order of checks: SL β TP3 β TP2 β TP1 (safety first).
"""
price = self.trader.get_mark_price(pos.symbol)
if price is None:
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 (manually on exchange)
binance_pos = self.trader.get_position(pos.symbol)
if not binance_pos:
pnl_pct = safe_pnl_pct(pos.entry_price, price, pos.side)
logger.warning(f"Position {pos.symbol} no longer exists on Binance β closed externally")
log_event("MANUAL_CLOSE", {
"trade_id": pos.trade_id,
"symbol": pos.symbol,
"side": pos.side,
"exit_price": price,
"pnl_pct": round(pnl_pct, 2),
"note": "closed_externally_on_exchange",
"tp1_was_hit": pos.tp1_hit,
"tp2_was_hit": pos.tp2_hit,
})
await self.notify(
f"π {pos.symbol} Π·Π°ΠΊΡΡΡΠ° Π½Π° Π±ΠΈΡΠΆΠ΅ Π²ΡΡΡΠ½ΡΡ\n"
f"Π£Π±ΠΈΡΠ°Ρ ΠΈΠ· ΠΌΠΎΠ½ΠΈΡΠΎΡΠΈΠ½Π³Π°.\n"
f"ΠΠΎΡΠ»Π΅Π΄Π½ΡΡ ΡΠ΅Π½Π°: ${price:.6f} ({pnl_pct:+.2f}%)"
)
del self.positions[pos.symbol]
return
# Safety: if entry_price is 0, try to recover from Binance
if pos.entry_price <= 0:
binance_pos = self.trader.get_position(pos.symbol)
if binance_pos and binance_pos["entry_price"] > 0:
pos.entry_price = binance_pos["entry_price"]
levels = calculate_levels(pos.entry_price, pos.side)
pos.sl_price = levels["sl"]
pos.tp1_price = levels["tp1"]
pos.tp2_price = levels["tp2"]
pos.tp3_price = levels["tp3"]
logger.info(f"Recovered entry price for {pos.symbol}: ${pos.entry_price:.6f}")
await self.notify(
f"π§ Recovered {pos.symbol} entry: ${pos.entry_price:.6f}\n"
f"SL: ${pos.sl_price:.6f} | TP1: ${pos.tp1_price:.6f}"
)
else:
logger.warning(f"Cannot recover entry price for {pos.symbol}, skipping check")
return
is_long = pos.side == "BUY"
# 1. Check SL
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. Check TP3 (close all remaining)
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. Check TP2 (if TP1 already hit)
if pos.tp1_hit and 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. Check 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
async def close_trade_manual(self, symbol: str) -> bool:
"""Manually close a position (via /close 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)
if pos.side == "BUY":
pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100
else:
pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100
pnl_usdt = pos.remaining_quantity * abs(price - pos.entry_price)
if pnl_pct < 0:
pnl_usdt = -pnl_usdt
close_fee = pos.remaining_quantity * price * TAKER_FEE_PCT / 100
pnl_usdt -= close_fee
total_pnl = pos.realized_pnl + pnl_usdt
log_event("MANUAL_CLOSE", {
"trade_id": pos.trade_id,
"symbol": symbol,
"exit_price": price,
"pnl_pct": round(pnl_pct, 2),
"realized_pnl_usdt": round(pnl_usdt, 2),
"total_trade_pnl_usdt": round(total_pnl, 2),
})
await self.notify(
f"π Manual close: {symbol}\n"
f"Exit: ${price:.6f} ({pnl_pct:+.2f}%)\n"
f"π° PnL: ${total_pnl:+.2f}"
)
del self.positions[symbol]
return True
def recover_positions(self):
"""
On startup, check for open positions on Binance
and reconstruct Position objects from trade log.
"""
open_trades = get_open_trades()
if not open_trades:
logger.info("No open trades to recover")
return
for trade in open_trades:
symbol = trade.get("symbol", "")
if not symbol:
continue
# Check if position actually exists on Binance
binance_pos = self.trader.get_position(symbol)
if not binance_pos:
logger.info(f"Trade log has {symbol} but no Binance position β skipping")
continue
entry_price = trade.get("entry_price", binance_pos["entry_price"])
side = trade.get("side", binance_pos["side"])
levels = calculate_levels(entry_price, side)
# Check TP state from log
tp_state = get_tp_state(symbol)
pos = Position(
symbol=symbol,
side=side,
entry_price=entry_price,
total_quantity=float(trade.get("quantity", binance_pos["quantity"])),
remaining_quantity=binance_pos["quantity"],
sl_price=levels["sl"],
tp1_price=levels["tp1"],
tp2_price=levels["tp2"],
tp3_price=levels["tp3"],
tp1_hit=tp_state["tp1_hit"],
tp2_hit=tp_state["tp2_hit"],
opened_at=trade.get("timestamp", ""),
trade_id=trade.get("trade_id", symbol),
)
# Adjust SL based on TP state
if pos.tp2_hit:
if side == "BUY":
pos.sl_price = entry_price * (1 + SL_AFTER_TP2_PCT / 100)
else:
pos.sl_price = entry_price * (1 - SL_AFTER_TP2_PCT / 100)
elif pos.tp1_hit:
pos.sl_price = entry_price # BE
# Recover or re-place exchange orders
if WT_USE_EXCHANGE_ORDERS:
# Cancel any stale orders from before restart
self.order_mgr.cancel_all_for_symbol(symbol)
import time; time.sleep(2.0) # Let Binance fully process algo order cancellation
# Re-place orders based on current TP state
self._place_recovery_orders(pos)
logger.info(f"Re-placed exchange orders for recovered WT {symbol}")
self.positions[symbol] = pos
logger.info(
f"Recovered position: {side} {symbol} @ {entry_price}, "
f"remaining={pos.remaining_quantity}, tp1={pos.tp1_hit}, tp2={pos.tp2_hit}, "
f"exchange_orders={'YES' if pos.use_exchange_orders else 'NO'}"
)
async def monitor_loop(self):
"""Price monitor loop β check all positions every N seconds."""
logger.info(f"Position monitor started (interval: {PRICE_CHECK_INTERVAL}s)")
while True:
try:
# Copy keys to avoid dict-changed-during-iteration
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"Error in monitor loop: {e}", exc_info=True)
await asyncio.sleep(PRICE_CHECK_INTERVAL)
def format_positions_message(self) -> str:
"""Format open positions for Telegram /positions command."""
if not self.positions:
return "π ΠΠ΅Ρ ΠΎΡΠΊΡΡΡΡΡ
ΠΏΠΎΠ·ΠΈΡΠΈΠΉ"
lines = ["π ΠΡΠΊΡΡΡΡΠ΅ ΠΏΠΎΠ·ΠΈΡΠΈΠΈ:\nββββββββββββββββββββ"]
for symbol, pos in self.positions.items():
price = self.trader.get_mark_price(symbol)
if not price:
price = pos.entry_price
if pos.entry_price > 0:
if pos.side == "BUY":
pnl_pct = ((price - pos.entry_price) / pos.entry_price) * 100
else:
pnl_pct = ((pos.entry_price - price) / pos.entry_price) * 100
else:
pnl_pct = 0
direction = "LONG" if pos.side == "BUY" else "SHORT"
emoji = "π’" if pnl_pct >= 0 else "π΄"
tp_status = ""
if pos.tp2_hit:
tp_status = "β
β
TP1+TP2"
elif pos.tp1_hit:
tp_status = "β
TP1"
else:
tp_status = "β³ waiting"
lines.append(
f"\n{emoji} {direction} {symbol}\n"
f" Entry: ${pos.entry_price:.6f}\n"
f" Now: ${price:.6f} ({pnl_pct:+.2f}%)\n"
f" SL: ${pos.sl_price:.6f}\n"
f" Status: {tp_status}\n"
f" Remaining: {pos.remaining_quantity}"
)
lines.append("\nββββββββββββββββββββ")
return "\n".join(lines)