trading_database.py:
- migrate_from_json: validate exit_time > entry_time before applying exit update
Trades with exit before entry are logged as open (no invalid exit applied)
This prevents the timestamp inversion bug that corrupted the DB with 625 bad trades
position_monitor.py:
- Replace fragile datetime.strptime('%Y-%m-%d %H:%M:%S') with fromisoformat()
Handles both space-separated and ISO 8601 T-separated formats, strips microseconds
trading_bot_gui.py:
- Call infra.log_bot_status('running') on bot start -> bot_status table now populated
- Call infra.log_bot_status('stopped') on bot stop
Previously bot_status table remained empty (0 rows), making monitoring impossible
telegram_bot_commands_old.py:
- Remove superseded file (replaced by telegram_bot_commands.py)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
223 lines
7.5 KiB
Python
223 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
📊 Position Monitor - Trade Exit Detection
|
||
Überwacht offene Positionen und updated die Datenbank wenn sie geschlossen werden
|
||
"""
|
||
|
||
import MetaTrader5 as mt5
|
||
from datetime import datetime, timedelta, timezone
|
||
from trading_database import TradingDatabase
|
||
from telegram_notifier import TelegramNotifier
|
||
import logging
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
class PositionMonitor:
|
||
"""
|
||
Überwacht offene Positionen und erkennt wenn sie geschlossen werden
|
||
"""
|
||
|
||
def __init__(self, database: TradingDatabase, telegram: TelegramNotifier = None):
|
||
self.db = database
|
||
self.telegram = telegram
|
||
|
||
def check_open_positions(self):
|
||
"""
|
||
Prüft alle offenen Positionen in der Datenbank
|
||
und vergleicht mit MT5 um geschlossene zu finden
|
||
"""
|
||
try:
|
||
if not mt5.terminal_info():
|
||
logger.error("MT5 not initialized – skipping position check")
|
||
return
|
||
|
||
open_trades = self.db.get_open_trades()
|
||
if not open_trades:
|
||
return
|
||
|
||
mt5_positions = mt5.positions_get()
|
||
mt5_tickets = {pos.ticket for pos in mt5_positions} if mt5_positions else set()
|
||
|
||
for trade in open_trades:
|
||
ticket = trade['ticket']
|
||
if ticket not in mt5_tickets:
|
||
self._handle_closed_position(ticket, trade)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error checking positions: {e}")
|
||
|
||
def _handle_closed_position(self, ticket, trade_data):
|
||
"""
|
||
Behandelt eine geschlossene Position
|
||
"""
|
||
try:
|
||
now = datetime.now(tz=timezone.utc)
|
||
days_ago = now - timedelta(days=30)
|
||
deals = mt5.history_deals_get(days_ago, now, ticket=ticket)
|
||
|
||
if not deals:
|
||
logger.warning(f"No history found for ticket {ticket}")
|
||
return
|
||
|
||
# Collect all close deals (entry==1); partial closes produce multiple
|
||
close_deals = [d for d in deals if d.entry == 1]
|
||
if not close_deals:
|
||
logger.warning(f"No close deal found for ticket {ticket}")
|
||
return
|
||
|
||
# Use the most recent close deal as the authoritative exit
|
||
close_deal = max(close_deals, key=lambda d: d.time)
|
||
|
||
# Convert MT5 UTC timestamp to naive UTC datetime for DB consistency
|
||
exit_time = datetime.fromtimestamp(close_deal.time, tz=timezone.utc).replace(tzinfo=None)
|
||
|
||
# Sum profit/commission/swap across all close deals (handles partial closes)
|
||
profit = sum(d.profit for d in close_deals)
|
||
commission = sum(d.commission for d in close_deals)
|
||
swap = sum(d.swap for d in close_deals)
|
||
|
||
entry_time = datetime.fromisoformat(trade_data['entry_time'].replace('T', ' ').split('.')[0])
|
||
duration = (exit_time - entry_time).total_seconds() / 3600 # hours
|
||
|
||
# commission and swap are already negative in MT5 history
|
||
net_profit = profit + commission + swap
|
||
|
||
# RR Ratio berechnen (falls SL/TP bekannt)
|
||
rr_ratio = None
|
||
if trade_data.get('sl_price') and trade_data.get('tp_price'):
|
||
entry_price = trade_data['entry_price']
|
||
sl_price = trade_data['sl_price']
|
||
tp_price = trade_data['tp_price']
|
||
|
||
risk = abs(entry_price - sl_price)
|
||
reward = abs(tp_price - entry_price)
|
||
if risk > 0:
|
||
rr_ratio = reward / risk
|
||
|
||
exit_reason = self._determine_exit_reason(
|
||
exit_price,
|
||
trade_data.get('sl_price'),
|
||
trade_data.get('tp_price'),
|
||
trade_data['type'],
|
||
trade_data.get('symbol', 'XAUUSD')
|
||
)
|
||
|
||
# Update Database
|
||
exit_data = {
|
||
'exit_price': exit_price,
|
||
'exit_time': exit_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||
'duration_hours': duration,
|
||
'profit': profit,
|
||
'commission': commission,
|
||
'swap': swap,
|
||
'net_profit': net_profit,
|
||
'rr_ratio': rr_ratio,
|
||
'status': 'closed',
|
||
'exit_reason': exit_reason
|
||
}
|
||
|
||
self.db.update_trade_exit(ticket, exit_data)
|
||
|
||
logger.info(f"✅ Updated closed position {ticket}: {exit_reason}, Profit: {net_profit:.2f}")
|
||
|
||
# Sende Telegram Notification
|
||
if self.telegram:
|
||
self._send_exit_notification(ticket, trade_data, exit_data)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling closed position {ticket}: {e}")
|
||
|
||
def _get_price_tolerance(self, symbol: str) -> float:
|
||
"""Returns 1-pip tolerance for the given symbol via MT5, with fallback"""
|
||
try:
|
||
info = mt5.symbol_info(symbol)
|
||
if info:
|
||
return info.point * 10 # 1 pip = 10 points on 5-digit brokers
|
||
except Exception:
|
||
pass
|
||
return 0.5 # fallback for XAUUSD
|
||
|
||
def _determine_exit_reason(self, exit_price, sl_price, tp_price, trade_type, symbol: str = 'XAUUSD'):
|
||
"""
|
||
Bestimmt warum der Trade geschlossen wurde
|
||
"""
|
||
if not sl_price or not tp_price:
|
||
return "manual_close"
|
||
|
||
tolerance = self._get_price_tolerance(symbol)
|
||
|
||
if abs(exit_price - tp_price) <= tolerance:
|
||
return "take_profit"
|
||
if abs(exit_price - sl_price) <= tolerance:
|
||
return "stop_loss"
|
||
|
||
return "manual_close"
|
||
|
||
def _send_exit_notification(self, ticket, trade_data, exit_data):
|
||
"""
|
||
Sendet Telegram Notification für geschlossenen Trade
|
||
"""
|
||
try:
|
||
notification_data = {
|
||
'ticket': ticket,
|
||
'symbol': trade_data['symbol'],
|
||
'type': trade_data['type'],
|
||
'entry_price': trade_data['entry_price'],
|
||
'exit_price': exit_data['exit_price'],
|
||
'exit_time': exit_data['exit_time'],
|
||
'exit_reason': exit_data['exit_reason'],
|
||
'net_profit': exit_data['net_profit'],
|
||
'duration_hours': exit_data['duration_hours'],
|
||
'session': trade_data.get('session', 'unknown')
|
||
}
|
||
|
||
self.telegram.notify_trade_exit(notification_data)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error sending exit notification: {e}")
|
||
|
||
|
||
def create_position_monitor_job(database, telegram=None):
|
||
"""
|
||
Factory-Funktion die einen Position Monitor Job erstellt
|
||
|
||
Usage:
|
||
monitor = PositionMonitor(infra.db, infra.telegram)
|
||
|
||
# Im Scheduler hinzufügen:
|
||
scheduler.add_job(
|
||
func=monitor.check_open_positions,
|
||
trigger='interval',
|
||
minutes=1, # Prüfe jede Minute
|
||
id='position_monitor'
|
||
)
|
||
"""
|
||
monitor = PositionMonitor(database, telegram)
|
||
return monitor
|
||
|
||
|
||
# ==========================================
|
||
# USAGE EXAMPLE (für Notebook)
|
||
# ==========================================
|
||
|
||
"""
|
||
Im Trading Bot Notebook hinzufügen:
|
||
|
||
# Nach Infrastructure Setup (Cell 8):
|
||
|
||
from position_monitor import PositionMonitor
|
||
|
||
# Position Monitor erstellen
|
||
position_monitor = PositionMonitor(infra.db, infra.telegram)
|
||
|
||
# Im Scheduler hinzufügen (Cell 33):
|
||
scheduler.add_job(
|
||
func=position_monitor.check_open_positions,
|
||
trigger='interval',
|
||
minutes=1,
|
||
id='position_monitor'
|
||
)
|
||
|
||
print("✅ Position Monitor aktiviert - prüft jede Minute nach geschlossenen Trades")
|
||
"""
|