This commit is contained in:
parent
3f649fca21
commit
7a2dfd95e7
2 changed files with 192 additions and 47 deletions
|
|
@ -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
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