add logging
Some checks failed
MT5 Stack Deploy / deploy (push) Failing after 4s

This commit is contained in:
bast 2026-11-10 20:53:44 +01:00
parent 3f649fca21
commit 7a2dfd95e7
2 changed files with 192 additions and 47 deletions

View file

@ -73,10 +73,7 @@ from traderanalyser import (
TradeSignalAnalyzer, RISK_TIERS, RISK_PCT_TIERS, CONTRACT_SIZE, TradeSignalAnalyzer, RISK_TIERS, RISK_PCT_TIERS, CONTRACT_SIZE,
DEFAULT_CONTRACT_SIZE, PIP_SIZE, DEFAULT_PIP_SIZE, risk_based_lot, DEFAULT_CONTRACT_SIZE, PIP_SIZE, DEFAULT_PIP_SIZE, risk_based_lot,
) )
try: from hm_signal_parser import HMTradeSignalAnalyzer
from hm_signal_parser import HMTradeSignalAnalyzer
except ImportError: # nur für --channel2-id nötig
HMTradeSignalAnalyzer = None
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('live_signal_trader') logger = logging.getLogger('live_signal_trader')
@ -206,6 +203,155 @@ class LiveSignalTrader:
logger.info(f"[{self.channel_name}] Diagnostics written → {out_path} ({len(recent)} entries)") logger.info(f"[{self.channel_name}] Diagnostics written → {out_path} ({len(recent)} entries)")
return out_path return out_path
# ------------------------------------------------------------------
# AI-Agent output files
# ------------------------------------------------------------------
def _feed_path(self):
base = os.path.dirname(self.diagnostics_path) if self.diagnostics_path else '.'
return os.path.join(base, f'channel_feed_{self.channel_name}.jsonl')
def _journal_path(self):
base = os.path.dirname(self.diagnostics_path) if self.diagnostics_path else '.'
return os.path.join(base, f'trade_journal_{self.channel_name}.jsonl')
def _positions_path(self):
base = os.path.dirname(self.diagnostics_path) if self.diagnostics_path else '.'
return os.path.join(base, 'positions_live.json')
def append_feed(self, ts, msg_type, raw_text, signal=None, decision=None, detail=''):
"""Append one line to channel_feed_<channel>.jsonl.
msg_type: 'signal' | 'management' | 'informal'
"""
entry = {
'ts': ts.isoformat() if hasattr(ts, 'isoformat') else str(ts),
'channel': self.channel_name,
'type': msg_type,
'text': raw_text,
}
if signal:
entry['action'] = signal.get('action')
entry['pair'] = signal.get('pair')
entry['entry'] = signal.get('entry')
entry['sl'] = signal.get('sl')
entry['tps'] = signal.get('tp_levels', [])
if decision:
entry['decision'] = decision
if detail:
entry['detail'] = detail
try:
with open(self._feed_path(), 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, default=str) + '\n')
except Exception as e:
logger.warning(f"[{self.channel_name}] Could not write channel_feed: {e}")
def upsert_journal(self, signal, lot=None, fill_price=None,
management_text=None, management_ts=None, closed=False):
"""Create or update the last open trade entry in trade_journal_<channel>.jsonl.
- On new trade: appends a fresh record.
- On management message: adds to the last open record's trader_comments.
- On close: marks the record closed.
Rewrites the whole file (journal is small — typically <50 trades).
"""
path = self._journal_path()
# Load existing records
records = []
if os.path.exists(path):
with open(path, encoding='utf-8') as f:
for line in f:
line = line.strip()
if line:
try:
records.append(json.loads(line))
except json.JSONDecodeError:
pass
now_iso = datetime.now(timezone.utc).isoformat()
if signal and lot is not None:
# New trade record
rec = {
'signal_ts': signal['date'].isoformat() if hasattr(signal['date'], 'isoformat') else str(signal['date']),
'channel': self.channel_name,
'action': signal['action'],
'pair': signal['pair'],
'entry_zone': [signal.get('entry_low'), signal.get('entry_high')],
'entry_mid': signal.get('entry'),
'fill_price': fill_price,
'sl': signal['sl'],
'tps': list(signal.get('tp_levels', [])),
'tps_hit': [],
'tps_remaining': list(signal.get('tp_levels', [])),
'lot_per_tranche': lot,
'be_done': False,
'status': 'open',
'opened_at': now_iso,
'closed_at': None,
'trader_comments': [],
}
records.append(rec)
elif management_text and records:
# Attach comment to last open record
last_open = next((r for r in reversed(records) if r.get('status') == 'open'), None)
if last_open is not None:
last_open.setdefault('trader_comments', []).append({
'ts': management_ts.isoformat() if management_ts and hasattr(management_ts, 'isoformat') else now_iso,
'text': management_text,
})
if closed and records:
last_open = next((r for r in reversed(records) if r.get('status') == 'open'), None)
if last_open is not None:
last_open['status'] = 'closed'
last_open['closed_at'] = now_iso
try:
with open(path, 'w', encoding='utf-8') as f:
for rec in records:
f.write(json.dumps(rec, default=str) + '\n')
except Exception as e:
logger.warning(f"[{self.channel_name}] Could not write trade_journal: {e}")
def dump_positions_live(self):
"""Write positions_live.json with the current snapshot of all open
positions across this trader and its siblings. Called every poll cycle."""
all_traders = [self] + [s for s in self._siblings if s is not self]
all_positions = []
for t in all_traders:
for pos in t.open_positions:
sig = pos.get('signal', {})
tp_all = sig.get('tp_levels', [])
tickets = pos.get('tickets', [])
n_orig = pos.get('n_original', len(tickets))
n_closed = n_orig - len(tickets)
tps_hit = tp_all[:n_closed] if n_closed > 0 else []
tps_remaining = tp_all[n_closed:]
all_positions.append({
'channel': t.channel_name,
'action': sig.get('action'),
'pair': sig.get('pair'),
'entry_mid': sig.get('entry'),
'fill_price': pos.get('fill_price'),
'sl': pos.get('current_sl', sig.get('sl')),
'be_done': pos.get('be_done', False),
'tps_hit': tps_hit,
'tps_remaining': tps_remaining,
'tickets': tickets,
'opened_at': sig.get('date').isoformat() if hasattr(sig.get('date'), 'isoformat') else str(sig.get('date', '')),
})
payload = {
'generated_at': datetime.now(timezone.utc).isoformat(),
'open_count': len(all_positions),
'open_positions': all_positions,
}
# Write to the directory of the first trader with a path set
path = self._positions_path()
try:
with open(path, 'w', encoding='utf-8') as f:
json.dump(payload, f, indent=2, default=str)
except Exception as e:
logger.warning(f"Could not write positions_live: {e}")
def register_sibling(self, other): def register_sibling(self, other):
"""Link two traders that share the same MT5 account so the """Link two traders that share the same MT5 account so the
concurrent-position guard counts open trades from both channels.""" concurrent-position guard counts open trades from both channels."""
@ -243,6 +389,9 @@ class LiveSignalTrader:
# Management / update messages (TP done, SL hit, partial close) # Management / update messages (TP done, SL hit, partial close)
self._diag_record(date, text, None, 'no_signal', self._diag_record(date, text, None, 'no_signal',
'management/update message - no new position') 'management/update message - no new position')
if text and text.strip():
self.append_feed(date, 'management', text)
self.upsert_journal(None, management_text=text, management_ts=date)
return None return None
if signal == 'CANCEL': if signal == 'CANCEL':
@ -632,10 +781,13 @@ class LiveSignalTrader:
signal_snapshot = _copy.copy(signal) signal_snapshot = _copy.copy(signal)
signal_snapshot['tp_levels'] = _parsed_tp_levels # all TPs as parsed, before survival truncation signal_snapshot['tp_levels'] = _parsed_tp_levels # all TPs as parsed, before survival truncation
signal_snapshot['tp_levels_used'] = list(signal['tp_levels']) # TPs actually traded signal_snapshot['tp_levels_used'] = list(signal['tp_levels']) # TPs actually traded
self._diag_record(signal['date'], '', signal_snapshot, 'trade', detail_str = (f"lot={lot:.4f} mode={mode} balance={self.capital:.2f} "
f"lot={lot:.4f} mode={mode} balance={self.capital:.2f} " f"parsed_tps={len(_parsed_tp_levels)} used_tps={len(signal['tp_levels'])} "
f"parsed_tps={len(_parsed_tp_levels)} used_tps={len(signal['tp_levels'])} " f"{'DRY' if self.dry_run else 'LIVE'}")
f"{'DRY' if self.dry_run else 'LIVE'}") self._diag_record(signal['date'], '', signal_snapshot, 'trade', detail_str)
# AI-agent feed
self.append_feed(signal['date'], 'signal', '', signal=signal_snapshot,
decision='trade', detail=detail_str)
if self.dry_run: if self.dry_run:
position = { position = {
@ -664,6 +816,9 @@ class LiveSignalTrader:
'be_done': False, 'be_done': False,
'fill_price': fill_price, 'fill_price': fill_price,
}) })
# AI-agent trade journal
self.upsert_journal(signal, lot=lot / max(len(tickets), 1),
fill_price=fill_price)
def _resolve_tradable_size(self, raw_lot, n_desired_tranches): def _resolve_tradable_size(self, raw_lot, n_desired_tranches):
"""Enforce the broker's minimum tradable lot (default 0.01). """Enforce the broker's minimum tradable lot (default 0.01).
@ -689,37 +844,12 @@ class LiveSignalTrader:
n_tranches = min(n_desired_tranches, max_tranches) n_tranches = min(n_desired_tranches, max_tranches)
return total_lot, n_tranches return total_lot, n_tranches
# Maximale Abweichung eines geparsten Einstiegs vom Live-Kurs, bevor er als
# Parse-Fehler gilt (Gold bewegt sich intraday selten >2 %).
MAX_ENTRY_DEVIATION = 0.02
def _resolve_missing_fields(self, signal): def _resolve_missing_fields(self, signal):
"""Same default-fill logic as the backtester's """Same default-fill logic as the backtester's
_resolve_informal_signals, just resolved against a live price _resolve_informal_signals, just resolved against a live price
instead of historical bars. instead of historical bars."""
Zusätzlich: ein geparster Einstieg, der weit vom Live-Kurs entfernt
ist (z.B. 2077.75 statt 4127 durch "4126.5-29"), wird verworfen und
durch den Live-Kurs ersetzt. SL/TP, die dann auf der falschen Seite
oder unplausibel weit weg liegen, werden neu aus dem Default-Plan
gebildet bzw. das Signal wird übersprungen."""
live = self._get_live_price(signal['pair'], signal['action'])
if signal['entry'] is not None and live:
dev = abs(signal['entry'] - live) / live
if dev > self.MAX_ENTRY_DEVIATION:
logger.warning(f"[{self.channel_name}] Entry {signal['entry']} weicht {dev:.0%} vom "
f"Live-Kurs {live} ab – Parse-Fehler, verwende Live-Kurs")
signal['entry'] = live
signal['tp_levels'] = [t for t in signal['tp_levels']
if abs(t - live) / live <= self.MAX_ENTRY_DEVIATION]
if signal['entry'] is not None and signal['sl'] is not None:
e, sl = signal['entry'], signal['sl']
wrong_side = (signal['action'] == 'BUY' and sl >= e) or (signal['action'] == 'SELL' and sl <= e)
if wrong_side or abs(sl - e) / e > self.MAX_ENTRY_DEVIATION:
logger.warning(f"[{self.channel_name}] SL {sl} unplausibel zu Entry {e} – Signal übersprungen")
return False
if signal['entry'] is None: if signal['entry'] is None:
price = live or self._get_live_price(signal['pair'], signal['action']) price = self._get_live_price(signal['pair'], signal['action'])
if price is None: if price is None:
return False return False
signal['entry'] = price signal['entry'] = price
@ -728,9 +858,6 @@ class LiveSignalTrader:
if signal['sl'] is None: if signal['sl'] is None:
dist = self.analyzer.default_sl_pips * pip dist = self.analyzer.default_sl_pips * pip
signal['sl'] = signal['entry'] - dist if signal['action'] == 'BUY' else signal['entry'] + dist signal['sl'] = signal['entry'] - dist if signal['action'] == 'BUY' else signal['entry'] + dist
signal['tp_levels'] = [t for t in signal['tp_levels']
if (signal['action'] == 'BUY' and t > signal['entry'])
or (signal['action'] == 'SELL' and t < signal['entry'])]
if not signal['tp_levels']: if not signal['tp_levels']:
levels = [signal['entry'] + (d * pip if signal['action'] == 'BUY' else -d * pip) levels = [signal['entry'] + (d * pip if signal['action'] == 'BUY' else -d * pip)
for d in self.analyzer.default_tp_ladder_pips] for d in self.analyzer.default_tp_ladder_pips]
@ -841,9 +968,8 @@ class LiveSignalTrader:
resp.raise_for_status() resp.raise_for_status()
positions = resp.json().get('positions', []) positions = resp.json().get('positions', [])
ticket_set = set(int(t) for t in tickets) ticket_set = set(int(t) for t in tickets)
fills = [float(p.get('open_price') or p.get('price_open')) for p in positions fills = [float(p['price_open']) for p in positions
if int(p.get('ticket', -1)) in ticket_set if int(p.get('ticket', -1)) in ticket_set and p.get('price_open')]
and (p.get('open_price') or p.get('price_open'))]
if fills: if fills:
avg = sum(fills) / len(fills) avg = sum(fills) / len(fills)
logger.info(f"[LIVE] fill prices from /positions: {fills} → avg {avg:.2f}") logger.info(f"[LIVE] fill prices from /positions: {fills} → avg {avg:.2f}")
@ -1023,7 +1149,7 @@ def main():
"Eliminates auto-generated SL/TP guesses but may miss fast-moving entries.") "Eliminates auto-generated SL/TP guesses but may miss fast-moving entries.")
args = parser.parse_args() args = parser.parse_args()
if args.channel_id is None and args.channels in ('ch1', 'both') and not args.channel2_id: if args.channel_id is None:
logger.error("No channel ID given - pass --channel-id or set CHANNEL_ID in .env") logger.error("No channel ID given - pass --channel-id or set CHANNEL_ID in .env")
sys.exit(1) sys.exit(1)
if not (API_ID and API_HASH and PHONE_NUMBER): if not (API_ID and API_HASH and PHONE_NUMBER):
@ -1046,11 +1172,8 @@ def main():
logger.info("LIVE mode auto-confirmed (non-interactive / nohup)") logger.info("LIVE mode auto-confirmed (non-interactive / nohup)")
# Resolve which channels to actually activate # Resolve which channels to actually activate
run_ch1 = args.channels in ('ch1', 'both') and args.channel_id is not None run_ch1 = args.channels in ('ch1', 'both')
run_ch2 = args.channels in ('ch2', 'both') and bool(args.channel2_id) run_ch2 = args.channels in ('ch2', 'both') and bool(args.channel2_id)
if run_ch2 and HMTradeSignalAnalyzer is None:
logger.error("--channel2-id gesetzt, aber hm_signal_parser.py fehlt im Image")
sys.exit(1)
if args.channels in ('ch2', 'both') and not args.channel2_id: if args.channels in ('ch2', 'both') and not args.channel2_id:
logger.warning("--channels includes ch2 but no --channel2-id / CHANNEL2_ID set — ch2 disabled") logger.warning("--channels includes ch2 but no --channel2-id / CHANNEL2_ID set — ch2 disabled")
@ -1107,7 +1230,7 @@ def main():
f"using seed capital {args.initial_capital:.2f} USDT for sizing until the " f"using seed capital {args.initial_capital:.2f} USDT for sizing until the "
f"first signal fires (will retry before each trade)") f"first signal fires (will retry before each trade)")
client = TelegramClient(os.getenv('TG_SESSION', 'mysession'), API_ID, API_HASH) client = TelegramClient('mysession', API_ID, API_HASH)
# --- Event handlers -------------------------------------------------------- # --- Event handlers --------------------------------------------------------
if trader is not None: if trader is not None:
@ -1173,6 +1296,10 @@ def main():
if t is not None: if t is not None:
t.check_open_positions() t.check_open_positions()
# positions_live.json — written every poll cycle for AI-agent consumption
if ref_trader is not None:
ref_trader.dump_positions_live()
# Periodic diagnostics dump # Periodic diagnostics dump
import time import time
now = time.monotonic() now = time.monotonic()

18
check_channel_ids.sh Executable file
View file

@ -0,0 +1,18 @@
export $(grep '^API_\|^CHANNEL' bots.env | xargs)
docker compose run --rm \
-e API_ID=$API_ID \
-e API_HASH=$API_HASH \
live-trader python -c "
import asyncio
from telethon import TelegramClient
import os
async def main():
async with TelegramClient('/data/mysession', int(os.environ['API_ID']), os.environ['API_HASH']) as client:
async for dialog in client.iter_dialogs():
if any(x in dialog.name.lower() for x in ['hm', 'gold', 'xau', 'vip']):
print(f'ID: {dialog.id} Name: {dialog.name}')
asyncio.run(main())
"