This commit is contained in:
parent
9db594767f
commit
82fb1e2251
3 changed files with 193 additions and 48 deletions
2
bots.env
2
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
|
||||
|
|
|
|||
|
|
@ -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_<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):
|
||||
"""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} "
|
||||
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()
|
||||
|
|
|
|||
18
check_channel_ids.sh
Executable file
18
check_channel_ids.sh
Executable 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())
|
||||
"
|
||||
Loading…
Reference in a new issue