From 82fb1e2251a60f28e73431a4ec5b458569f4a2b6 Mon Sep 17 00:00:00 2001 From: bast Date: Tue, 10 Nov 2026 20:53:16 +0100 Subject: [PATCH] update logging --- bots.env | 2 +- bots/live_signal_trader.py | 221 +++++++++++++++++++++++++++++-------- check_channel_ids.sh | 18 +++ 3 files changed, 193 insertions(+), 48 deletions(-) create mode 100755 check_channel_ids.sh diff --git a/bots.env b/bots.env index 7210c975..02fb9b3c 100644 --- a/bots.env +++ b/bots.env @@ -6,4 +6,4 @@ MT5_PATH=C:\Program Files\PU Prime MT5 Terminal\terminal64.exe API_ID="28487993" API_HASH="b14b06cec21a72b5cb2773ea782c7057" PHONE_NUMBER=+41796600666 -CHANNEL2_ID=-3908994316 +CHANNEL2_ID=-1003908994316 diff --git a/bots/live_signal_trader.py b/bots/live_signal_trader.py index fa6cec87..0949f1b8 100644 --- a/bots/live_signal_trader.py +++ b/bots/live_signal_trader.py @@ -73,10 +73,7 @@ from traderanalyser import ( TradeSignalAnalyzer, RISK_TIERS, RISK_PCT_TIERS, CONTRACT_SIZE, DEFAULT_CONTRACT_SIZE, PIP_SIZE, DEFAULT_PIP_SIZE, risk_based_lot, ) -try: - from hm_signal_parser import HMTradeSignalAnalyzer -except ImportError: # nur für --channel2-id nötig - HMTradeSignalAnalyzer = None +from hm_signal_parser import HMTradeSignalAnalyzer logging.basicConfig(level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s') 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)") 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_.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_.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): """Link two traders that share the same MT5 account so the concurrent-position guard counts open trades from both channels.""" @@ -243,6 +389,9 @@ class LiveSignalTrader: # Management / update messages (TP done, SL hit, partial close) self._diag_record(date, text, None, 'no_signal', '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 if signal == 'CANCEL': @@ -632,10 +781,13 @@ class LiveSignalTrader: signal_snapshot = _copy.copy(signal) 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 - self._diag_record(signal['date'], '', signal_snapshot, 'trade', - f"lot={lot:.4f} mode={mode} balance={self.capital:.2f} " - f"parsed_tps={len(_parsed_tp_levels)} used_tps={len(signal['tp_levels'])} " - f"{'DRY' if self.dry_run else 'LIVE'}") + detail_str = (f"lot={lot:.4f} mode={mode} balance={self.capital:.2f} " + f"parsed_tps={len(_parsed_tp_levels)} used_tps={len(signal['tp_levels'])} " + 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: position = { @@ -664,6 +816,9 @@ class LiveSignalTrader: 'be_done': False, '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): """Enforce the broker's minimum tradable lot (default 0.01). @@ -689,37 +844,12 @@ class LiveSignalTrader: n_tranches = min(n_desired_tranches, max_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): """Same default-fill logic as the backtester's _resolve_informal_signals, just resolved against a live price - 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 + instead of historical bars.""" 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: return False signal['entry'] = price @@ -728,9 +858,6 @@ class LiveSignalTrader: if signal['sl'] is None: dist = self.analyzer.default_sl_pips * pip 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']: levels = [signal['entry'] + (d * pip if signal['action'] == 'BUY' else -d * pip) for d in self.analyzer.default_tp_ladder_pips] @@ -841,9 +968,8 @@ class LiveSignalTrader: resp.raise_for_status() positions = resp.json().get('positions', []) ticket_set = set(int(t) for t in tickets) - fills = [float(p.get('open_price') or p.get('price_open')) for p in positions - if int(p.get('ticket', -1)) in ticket_set - and (p.get('open_price') or p.get('price_open'))] + fills = [float(p['price_open']) for p in positions + if int(p.get('ticket', -1)) in ticket_set and p.get('price_open')] if fills: avg = sum(fills) / len(fills) 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.") 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") sys.exit(1) 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)") # 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) - 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: 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"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 -------------------------------------------------------- if trader is not None: @@ -1173,6 +1296,10 @@ def main(): if t is not None: 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 import time now = time.monotonic() diff --git a/check_channel_ids.sh b/check_channel_ids.sh new file mode 100755 index 00000000..077b916e --- /dev/null +++ b/check_channel_ids.sh @@ -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()) +"