commit b2e4a8b46a35958fdff9e13ce4a48b13c688ddb0 Author: IvanGizer Date: Fri May 22 12:27:27 2026 +0200 initial release: GizerBridge v1.0 diff --git a/README.de.md b/README.de.md new file mode 100644 index 0000000..f513bcb --- /dev/null +++ b/README.de.md @@ -0,0 +1,118 @@ +# GizerBridge – Sierra Chart → Tralgo Analytics + +Überträgt abgeschlossene Trades automatisch von Sierra Chart (Wine/Bottles/Lutris) +an Tralgo Analytics. + +--- + +## Installation + +### Schritt 1 – Terminal öffnen + +Rechtsklick auf den Desktop → **"Terminal öffnen"** +(oder im App-Menü nach "Terminal" suchen) + +### Schritt 2 – In den App-Ordner wechseln + +``` +cd ~/Downloads/gizerbridge +``` + +### Schritt 3 – Installations-Script ausführen + +``` +bash install.sh +``` + +Das Script installiert alle benötigten Komponenten automatisch. + +### Schritt 4 – App starten + +Im App-Menü (z. B. Activities) nach **"GizerBridge"** suchen und starten. + +--- + +## Erster Start – Einrichtung + +Beim ersten Start öffnet sich automatisch das **Einstellungs-Fenster**: + +| Feld | Was hier einzutragen ist | +|------|--------------------------| +| **API Key** | Der `transfer_token` aus deinem Tralgo-Konto | +| **Tralgo E-Mail** | Deine Login-E-Mail (nur zur Anzeige) | +| **SC DTC Host** | Normalerweise `localhost` | +| **SC DTC Port** | Normalerweise `11099` | +| **Konten** | Kontonamen genau wie in Sierra Chart (z. B. `4PRO-RQTPUT`, `Sim1.simulated`) | +| **Instrumente** | Gewünschte Instrumente aktivieren, Tick-Werte prüfen | + +Nach dem Speichern startet die App sofort mit der Überwachung. + +--- + +## Tralgo-Login (einmalig pro 30 Tage) + +Damit die App Zugriff auf dein Konto hat, muss Firefox bei tralgo.app eingeloggt sein: + +1. **Firefox öffnen** und `tralgo.app` aufrufen +2. Mit **E-Mail und Passwort** einloggen +3. Den **2FA-Code** aus der E-Mail eingeben +4. **"Für 30 Tage merken" aktivieren** ← Das ist wichtig! + +Die App liest den Login-Cookie aus Firefox – dein Passwort wird nirgends gespeichert oder übertragen. + +--- + +## System-Tray Icon + +Nach dem Start erscheint ein farbiger Status-Dot auf dem Tray-Icon: + +- **Grün** – App läuft, wartet auf neue Trades +- **Gelb** – Initialisierung +- **Rot** – Fehler (Cookie abgelaufen, Verbindungsproblem, o.ä.) + +Rechtsklick auf das Icon → Öffnen · Manueller Import · Einstellungen · Log · Beenden + +### Kein Tray-Icon sichtbar? (GNOME) + +GNOME zeigt Tray-Icons nur mit der **AppIndicator Extension**: + +1. Im Browser `extensions.gnome.org` öffnen +2. Nach **"AppIndicator and KStatusNotifierItem Support"** suchen +3. Extension installieren und aktivieren +4. GizerBridge neu starten + +--- + +## Was passiert im Hintergrund? + +- Die App verbindet sich via DTC-Protokoll direkt mit Sierra Chart (localhost:11099) +- Beim **ersten Start** werden bereits vorhandene Trades als "bekannt" markiert + und **nicht** übertragen (verhindert Duplikate) +- Nur **neue** Trades nach dem Start werden an Tralgo gesendet +- Bereits gesendete Trade-IDs werden in `~/.gizerbridge_sent.json` gespeichert +- Die Konfiguration liegt in `~/.gizerbridge_config.json` (nur für dich lesbar) + +--- + +## Sicherheit + +- **Kein Passwort** wird gespeichert oder übertragen +- Die Firefox-Cookie-Datenbank wird nur als temporäre Kopie gelesen (nie verändert) +- Die Config-Datei mit dem API Key hat Zugriffsrechte `600` (nur dein Benutzer) + +--- + +## Fehlerbehebung + +| Problem | Lösung | +|---------|--------| +| "Cookie fehlt" Popup | In Firefox bei tralgo.app neu einloggen, "Für 30 Tage merken" aktivieren | +| Rotes Icon, Übertragungsfehler | Internetverbindung prüfen; Status-Fenster für Details öffnen | +| Keine Trades erkannt | Kontonamen in den Einstellungen mit SC-Kontonamen abgleichen | +| DTC-Verbindungsfehler | Sierra Chart öffnen, DTC-Server auf Port 11099 aktivieren | + +Log der letzten 50 Aktionen: Rechtsklick auf Tray-Icon → **"Log"** + +--- + +*Ein Projekt von [GizerLabs](https://gizerlabs.com)* diff --git a/README.md b/README.md new file mode 100644 index 0000000..960308a --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +# GizerBridge – Sierra Chart → Tralgo Analytics + +**Automatically transfers completed trades from Sierra Chart to Tralgo Analytics — on Linux.** + +Sierra Chart runs on Linux via Wine/Bottles/Lutris. Tralgo Analytics has no official importer for this setup. GizerBridge fills that gap. + +> Built and used daily in live ES/NQ futures trading. + +--- + +## How it works + +GizerBridge connects directly to Sierra Chart via the **DTC Protocol** (TCP on `localhost:11099`). +No log file polling. No manual export. Trades are detected and transferred in real time as they close. + +``` +Sierra Chart (DTC Server) + │ + │ TCP · localhost:11099 + ▼ + GizerBridge + │ + │ HTTPS · Tralgo Analytics API + ▼ + Tralgo Analytics +``` + +- Already-sent trade IDs are stored locally → no duplicate uploads +- Manual import available for historical trades +- Runs as a system tray app (AppImage, no installation required) + +--- + +## Requirements + +- Linux (any distribution) +- Sierra Chart running via Wine / Bottles / Lutris +- DTC Protocol Server enabled in Sierra Chart +- Active Tralgo Analytics account +- Firefox (for session authentication) + +### Enable DTC in Sierra Chart + +`Global Settings → Data/Trade Service Settings → DTC Protocol Server` +→ Enable, Port: `11099` + +--- + +## Installation + +**Option A – AppImage (recommended, no install needed)** + +1. Download the latest `GizerBridge.AppImage` from [Releases](../../releases) +2. Make it executable: + ```bash + chmod +x GizerBridge.AppImage + ``` +3. Run it: + ```bash + ./GizerBridge.AppImage + ``` + +**Option B – From source** + +```bash +git clone https://github.com/gizerlabs/gizerbridge.git +cd gizerbridge +bash install.sh +``` + +--- + +## First Start – Setup + +On first launch, the settings window opens automatically: + +| Field | What to enter | +|---|---| +| **API Key** | Your `transfer_token` from Tralgo Analytics | +| **Tralgo E-Mail** | Your login e-mail (display only) | +| **SC DTC Host** | `localhost` (default) | +| **SC DTC Port** | `11099` (default) | +| **Accounts** | Account names exactly as shown in Sierra Chart | +| **Instruments** | Enable instruments, verify tick sizes and values | + +Save → the app connects immediately and starts monitoring. + +--- + +## Tralgo Login (once every 30 days) + +The app reads the session cookie from Firefox — your password is never stored or transmitted. + +1. Open Firefox and go to `tralgo.app` +2. Log in with your e-mail and password +3. Enter the 2FA code from your e-mail +4. **Enable "Remember for 30 days"** ← required + +--- + +## System Tray + +After start, a colored status dot appears on the tray icon: + +| Color | Status | +|---|---| +| 🟢 Green | Running, monitoring for new trades | +| 🟡 Yellow | Initializing | +| 🔴 Red | Error (session expired, connection issue) | + +Right-click the tray icon → Open · Manual Import · Settings · Log · Quit + +**No tray icon on GNOME?** +Install the [AppIndicator Extension](https://extensions.gnome.org/extension/615/appindicator-support/) and restart the app. + +--- + +## Security + +- No password is stored or transmitted +- Firefox cookie database is read as a temporary copy — never modified +- Config file (`~/.gizerbridge_config.json`) is stored with permissions `600` +- API key is stored locally, never logged or shared + +--- + +## Troubleshooting + +| Problem | Solution | +|---|---| +| "Cookie missing" popup | Log in to tralgo.app in Firefox, enable "Remember for 30 days" | +| Red icon / transfer error | Check internet connection; open Status window for details | +| No trades detected | Verify account names in settings match Sierra Chart exactly | +| DTC connection failed | Confirm DTC Protocol Server is enabled in Sierra Chart on port 11099 | + +Last 50 events: Right-click tray icon → **Log** + +--- + +## Built with + +- Python 3.11 +- [DTC Protocol](https://www.sierrachart.com/index.php?page=doc/DTCProtocol.html) (Sierra Chart) +- [Tralgo Analytics API](https://tralgo.app) +- `requests`, `Pillow`, `pystray` + +--- + +## Status + +Active development. Used daily in live trading on ES/NQ futures. +Feedback and issues welcome → open a [GitHub Issue](../../issues). + +--- + +*Not affiliated with Sierra Chart or Tralgo Analytics.* +*A [GizerLabs](https://gizerlabs.com) project.* diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000..d5db180 Binary files /dev/null and b/__pycache__/config.cpython-314.pyc differ diff --git a/__pycache__/cookie_reader.cpython-314.pyc b/__pycache__/cookie_reader.cpython-314.pyc new file mode 100644 index 0000000..0d1ba84 Binary files /dev/null and b/__pycache__/cookie_reader.cpython-314.pyc differ diff --git a/__pycache__/debug_cookies.cpython-314.pyc b/__pycache__/debug_cookies.cpython-314.pyc new file mode 100644 index 0000000..0e233bd Binary files /dev/null and b/__pycache__/debug_cookies.cpython-314.pyc differ diff --git a/__pycache__/dtc_client.cpython-314.pyc b/__pycache__/dtc_client.cpython-314.pyc new file mode 100644 index 0000000..39828f6 Binary files /dev/null and b/__pycache__/dtc_client.cpython-314.pyc differ diff --git a/__pycache__/main.cpython-314.pyc b/__pycache__/main.cpython-314.pyc new file mode 100644 index 0000000..17487df Binary files /dev/null and b/__pycache__/main.cpython-314.pyc differ diff --git a/__pycache__/test_dtc_diag.cpython-314.pyc b/__pycache__/test_dtc_diag.cpython-314.pyc new file mode 100644 index 0000000..851030e Binary files /dev/null and b/__pycache__/test_dtc_diag.cpython-314.pyc differ diff --git a/__pycache__/trade_parser.cpython-314.pyc b/__pycache__/trade_parser.cpython-314.pyc new file mode 100644 index 0000000..bc9c836 Binary files /dev/null and b/__pycache__/trade_parser.cpython-314.pyc differ diff --git a/__pycache__/tralgo_api.cpython-314.pyc b/__pycache__/tralgo_api.cpython-314.pyc new file mode 100644 index 0000000..fa961cc Binary files /dev/null and b/__pycache__/tralgo_api.cpython-314.pyc differ diff --git a/__pycache__/tray_app.cpython-314.pyc b/__pycache__/tray_app.cpython-314.pyc new file mode 100644 index 0000000..48a2f95 Binary files /dev/null and b/__pycache__/tray_app.cpython-314.pyc differ diff --git a/config.py b/config.py new file mode 100644 index 0000000..1b5b335 --- /dev/null +++ b/config.py @@ -0,0 +1,51 @@ +import json +import os +import stat + +CONFIG_PATH = os.path.expanduser("~/.gizerbridge_config.json") +SENT_PATH = os.path.expanduser("~/.gizerbridge_sent.json") + +DEFAULT_INSTRUMENTS = { + "MNQ": {"tick_size": 0.25, "tick_value": 0.50, "enabled": True}, + "NQ": {"tick_size": 0.25, "tick_value": 5.00, "enabled": False}, + "ES": {"tick_size": 0.25, "tick_value": 12.50, "enabled": False}, + "MES": {"tick_size": 0.25, "tick_value": 1.25, "enabled": False}, +} + +DEFAULT_CONFIG = { + "api_key": "", + "email": "", + "dtc_host": "localhost", + "dtc_port": 11099, + "accounts": [], + "instruments": DEFAULT_INSTRUMENTS, +} + + +def load_config(): + if not os.path.exists(CONFIG_PATH): + return dict(DEFAULT_CONFIG) + with open(CONFIG_PATH, encoding="utf-8") as f: + cfg = json.load(f) + # Merge missing instrument defaults + for name, defaults in DEFAULT_INSTRUMENTS.items(): + cfg.setdefault("instruments", {}).setdefault(name, defaults) + return cfg + + +def save_config(cfg): + with open(CONFIG_PATH, "w", encoding="utf-8") as f: + json.dump(cfg, f, indent=2, ensure_ascii=False) + os.chmod(CONFIG_PATH, stat.S_IRUSR | stat.S_IWUSR) + + +def load_sent(): + if not os.path.exists(SENT_PATH): + return set() + with open(SENT_PATH, encoding="utf-8") as f: + return set(json.load(f)) + + +def save_sent(sent_set): + with open(SENT_PATH, "w", encoding="utf-8") as f: + json.dump(list(sent_set), f) diff --git a/cookie_reader.py b/cookie_reader.py new file mode 100644 index 0000000..ba00d6b --- /dev/null +++ b/cookie_reader.py @@ -0,0 +1,84 @@ +import glob +import os +import shutil +import sqlite3 +import tempfile +import time + +FIREFOX_COOKIE_PATHS = [ + "~/.mozilla/firefox/*/cookies.sqlite", + "~/snap/firefox/common/.mozilla/firefox/*/cookies.sqlite", + "~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/cookies.sqlite", + "~/.var/app/org.mozilla.firefox/config/mozilla/firefox/*/cookies.sqlite", +] + +NO_COOKIE_MSG = ( + "Bitte in Firefox bei tralgo.app einloggen und " + "'Für 30 Tage merken' aktivieren." +) +EXPIRED_MSG = ( + "Deine tralgo.app Sitzung ist abgelaufen. " + "Bitte erneut in Firefox einloggen und 'Für 30 Tage merken' aktivieren." +) + + +def find_cookies(): + """Return (dict of cookie name→value, error_str). + On success error_str is None; on failure dict is None.""" + all_dbs = [] + for pattern in FIREFOX_COOKIE_PATHS: + all_dbs.extend(glob.glob(os.path.expanduser(pattern))) + + if not all_dbs: + return None, NO_COOKIE_MSG + + best_expiry = -1 + best_cookies = None + + for db_path in all_dbs: + tmp = None + try: + tmp = tempfile.mktemp(suffix="_tralgo_cookies.sqlite") + shutil.copy2(db_path, tmp) + cookies = _read_tralgo_cookies(tmp) + if not cookies: + continue + max_expiry = max(v["expiry"] for v in cookies.values()) + if max_expiry > best_expiry: + best_expiry = max_expiry + best_cookies = cookies + except Exception: + pass + finally: + if tmp and os.path.exists(tmp): + try: + os.unlink(tmp) + except OSError: + pass + + if best_cookies is None: + return None, NO_COOKIE_MSG + + now = int(time.time()) + if best_expiry < now: + return None, EXPIRED_MSG + + return {name: data["value"] for name, data in best_cookies.items()}, None + + +def _read_tralgo_cookies(db_path): + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + try: + cur = conn.cursor() + cur.execute( + "SELECT name, value, expiry FROM moz_cookies " + "WHERE host LIKE '%tralgo.app%' " + "AND (name LIKE 'wordpress_logged_in%' OR name LIKE 'wp2fa_device%')" + ) + rows = cur.fetchall() + finally: + conn.close() + + if not rows: + return {} + return {row[0]: {"value": row[1], "expiry": row[2]} for row in rows} diff --git a/debug_cookies.py b/debug_cookies.py new file mode 100644 index 0000000..2283c35 --- /dev/null +++ b/debug_cookies.py @@ -0,0 +1,123 @@ +"""Cookie debug tool — run with: python3 debug_cookies.py""" + +import glob +import os +import shutil +import sqlite3 +import tempfile +import time + +FIREFOX_COOKIE_PATHS = [ + "~/.mozilla/firefox/*/cookies.sqlite", + "~/snap/firefox/common/.mozilla/firefox/*/cookies.sqlite", + "~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/cookies.sqlite", + "~/.var/app/org.mozilla.firefox/config/mozilla/firefox/*/cookies.sqlite", +] + +SEPARATOR = "-" * 70 + + +def main(): + print(SEPARATOR) + print("Firefox Cookie Debug") + print(SEPARATOR) + + # ── 1. Find all DB files ────────────────────────────────────────────────── + all_dbs = [] + for pattern in FIREFOX_COOKIE_PATHS: + expanded = os.path.expanduser(pattern) + matches = glob.glob(expanded) + print(f"\nPattern: {pattern}") + if matches: + for m in matches: + print(f" FOUND: {m}") + all_dbs.append(m) + else: + print(f" (keine Treffer)") + + print(f"\n{SEPARATOR}") + print(f"Gesamt: {len(all_dbs)} cookies.sqlite Datei(en)") + print(SEPARATOR) + + if not all_dbs: + print("\nKeine Firefox-Profile gefunden.") + print("Ist Firefox installiert? (Standard, Snap, oder Flatpak?)") + return + + # ── 2. Read tralgo.app cookies from each DB ─────────────────────────────── + now = int(time.time()) + + for db_path in all_dbs: + print(f"\nDB: {db_path}") + tmp = None + try: + tmp = tempfile.mktemp(suffix="_debug.sqlite") + shutil.copy2(db_path, tmp) + conn = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True) + try: + cur = conn.cursor() + + # All tralgo.app cookies + cur.execute( + "SELECT name, value, expiry, host FROM moz_cookies " + "WHERE host LIKE '%tralgo%' ORDER BY name" + ) + all_rows = cur.fetchall() + + # Only auth cookies the app needs + cur.execute( + "SELECT name, value, expiry FROM moz_cookies " + "WHERE host LIKE '%tralgo.app%' " + "AND (name LIKE 'wordpress_logged_in%' " + " OR name LIKE 'wp2fa_device%')" + ) + auth_rows = cur.fetchall() + finally: + conn.close() + + if not all_rows: + print(" Keine tralgo.app Cookies gefunden.") + else: + print(f" Alle tralgo.app Cookies ({len(all_rows)} Stück):") + for name, value, expiry, host in all_rows: + expired = expiry < now + exp_str = (f"abgelaufen seit {now - expiry}s" + if expired else f"gültig noch {expiry - now}s") + print(f" [{exp_str:>30}] {name[:60]}") + + if auth_rows: + print(f"\n Auth-Cookies für GizerBridge ({len(auth_rows)} Stück):") + for name, value, expiry in auth_rows: + expired = expiry < now + status = "ABGELAUFEN" if expired else "OK" + print(f" [{status}] {name[:60]}") + print(f" Wert: {value[:60]}...") + else: + print("\n FEHLER: Keine Auth-Cookies (wordpress_logged_in / wp2fa_device).") + print(" Lösung: Firefox öffnen → tralgo.app → einloggen →") + print(" 'Für 30 Tage merken' aktivieren.") + + except Exception as e: + print(f" Fehler beim Lesen: {e}") + finally: + if tmp and os.path.exists(tmp): + try: + os.unlink(tmp) + except OSError: + pass + + # ── 3. Final verdict ────────────────────────────────────────────────────── + print(f"\n{SEPARATOR}") + from cookie_reader import find_cookies + cookies, err = find_cookies() + if err: + print(f"Ergebnis: FEHLER — {err}") + else: + print(f"Ergebnis: OK — {len(cookies)} Auth-Cookie(s) bereit") + for name in cookies: + print(f" {name[:60]}") + print(SEPARATOR) + + +if __name__ == "__main__": + main() diff --git a/dtc_client.py b/dtc_client.py new file mode 100644 index 0000000..e4aa041 --- /dev/null +++ b/dtc_client.py @@ -0,0 +1,520 @@ +"""DTC Binary protocol client for Sierra Chart (default: localhost:11099). + +Binary wire format (no pragma pack → default C alignment): + Every message: [uint16 Size][uint16 Type][payload...] + Size = total byte count including the header. + +Struct offsets used here match MSVC/GCC x64 default alignment: + - int64/double are 8-byte aligned (padding inserted before them when needed) + +Supported messages: + Send: LOGON_REQUEST, HEARTBEAT, HISTORICAL_ORDER_FILLS_REQUEST, + OPEN_ORDERS_REQUEST + Receive: LOGON_RESPONSE, HEARTBEAT, HISTORICAL_ORDER_FILL_RESPONSE, + ORDER_UPDATE +""" + +import re +import socket +import struct +import threading +import time +from datetime import datetime + +# ── DTC message type constants ──────────────────────────────────────────────── + +_LOGON_REQUEST = 1 +_LOGON_RESPONSE = 2 +_HEARTBEAT = 3 +_OPEN_ORDERS_REQ = 300 +_ORDER_UPDATE = 301 +_HIST_FILLS_REQ = 303 +_HIST_FILL_RESP = 304 + +_PROTOCOL_VER = 8 +_LOGON_SUCCESS = 1 +_BUY = 1 +_SELL = 2 + + +# ── Encoding helpers ────────────────────────────────────────────────────────── + +def _s(text, n): + """Return exactly n bytes: ASCII-encoded, null-padded.""" + b = (text or "").encode("ascii", errors="replace")[:n] + return b.ljust(n, b"\x00") + + +def _unpack_str(data, offset, n): + return data[offset:offset + n].rstrip(b"\x00").decode("ascii", errors="replace") + + +def _unpack_ts(data, offset): + """Read int64 Unix timestamp at offset → datetime or None.""" + raw = struct.unpack_from(" 0) + ) + + +def _build_open_orders_request(): + size = struct.calcsize(_FMT_OPEN_ORD) + return struct.pack(_FMT_OPEN_ORD, size, _OPEN_ORDERS_REQ, 1, 0) + + +# ── Message parsers ─────────────────────────────────────────────────────────── + +# HISTORICAL_ORDER_FILL_RESPONSE – empirisch ermittelte Offsets (480-Byte-Pakete): +# 0 uint16 Size +# 2 uint16 Type +# 4 int32 RequestID +# 8 int32 TotalNumberMessages +# 12 int32 MessageNumber +# 16 char[64] Symbol +# 80 char[16] Exchange +# 96 char[32] ??? (ASCII-Feld, Inhalt noch unbekannt) +# 128 uint32 BuySell (1=BUY, 2=SELL) +# 132 uint32 ??? (4 Bytes Padding/unbekannt) +# 136 double Price (136 % 8 == 0, 8-Byte-aligned) +# 144 int64 DateTime (Unix-Sekunden) +# 152 double Volume/Qty +# 160 char[32] FillIdentifier/TradeID +# 192 char[32] ??? +# 224 char[32] TradeAccount +# 256 uint8 NoOrderFills +# 257 char[96] InfoText +# 353 char[64] UniqueExecutionID +# 417 char[32] ExchangeOrderID + +def _parse_fill_resp(data): + if len(data) < 260: + return None + try: + no_fills = bool(struct.unpack_from(" 256 else False + price = struct.unpack_from("= 417 else "", + } + except struct.error: + return None + + +# ORDER_UPDATE field offsets (C struct, default alignment): +# 0 uint16 Size +# 2 uint16 Type +# 4 int32 RequestID +# 8 int32 TotalNumMessages +# 12 int32 MessageNumber +# 16 char[64] Symbol +# 80 char[16] Exchange +# 96 char[32] PreviousServerOrderID +# 128 char[32] ServerOrderID +# 160 char[32] ClientOrderID +# 192 char[32] ExchangeOrderID +# 224 uint32 OrderStatus +# 228 uint32 OrderUpdateReason +# 232 uint32 OrderType +# 236 uint32 BuySell +# 240 double Price1 (240 % 8 == 0 ✓) +# 248 double Price2 +# 256 uint32 TimeInForce +# 260 [4 bytes padding] +# 264 int64 GoodTillDateTime (264 % 8 == 0 ✓) +# 272 double OrderQuantity +# 280 double FilledQuantity +# 288 double RemainingQuantity +# 296 double AverageFillPrice +# 304 double LastFillPrice +# 312 int64 LastFillDateTime +# 320 double LastFillQuantity +# 328 char[64] UniqueExecutionID +# 392 char[32] TradeAccount + +def _parse_order_update(data): + if len(data) < 328: + return None + try: + qty = struct.unpack_from("= 392 else "" + return { + "symbol": _unpack_str(data, 16, 64), + "buysell": struct.unpack_from("= 424 else "", + "unique_id": uid, + } + except struct.error: + return None + + +# ── DTCClient ───────────────────────────────────────────────────────────────── + +class DTCClient: + """Low-level DTC Binary protocol client (not thread-safe for concurrent sends).""" + + def __init__(self, host="localhost", port=11099): + self._host = host + self._port = port + self._sock = None + self._buf = b"" + self._lock = threading.Lock() + + def connect(self): + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((self._host, self._port)) + sock.settimeout(None) + self._sock = sock + + def disconnect(self): + if self._sock: + try: + self._sock.close() + except OSError: + pass + self._sock = None + + def logon(self, heartbeat_secs=10): + """Send LOGON_REQUEST and block until LOGON_RESPONSE received.""" + self._send(_build_logon(heartbeat_secs)) + for msg_type, data in self._read_iter(timeout=10): + if msg_type == _LOGON_RESPONSE and len(data) >= 16: + result = struct.unpack_from(" 0 and msgno >= total): + break + # Gültige Fills: BuySell muss 1 (BUY) oder 2 (SELL) sein, Qty > 0 + if f["buysell"] in (_BUY, _SELL) and f["qty"] > 0: + fills.append(f) + return fills + + def subscribe_order_updates(self): + """Send OPEN_ORDERS_REQUEST so SC starts pushing ORDER_UPDATE messages.""" + self._send(_build_open_orders_request()) + + def send_heartbeat(self): + self._send(_build_heartbeat()) + + def read_messages(self, timeout=5): + """Non-blocking read: return all complete messages buffered within timeout.""" + self._sock.settimeout(timeout) + msgs = [] + try: + chunk = self._sock.recv(65536) + if not chunk: + raise ConnectionError("DTC-Verbindung vom Server geschlossen") + self._buf += chunk + except socket.timeout: + pass + while len(self._buf) >= 4: + size = struct.unpack_from("= 4: + size = struct.unpack_from("= 10: + try: + client.send_heartbeat() + except Exception: + pass + last_hb = time.time() + + for msg_type, data in msgs: + if msg_type == _HEARTBEAT: + try: + client.send_heartbeat() + except Exception: + pass + + elif msg_type == _ORDER_UPDATE: + f = _parse_order_update(data) + if not f: + continue + uid = f["unique_id"] + if uid and uid in seen_uids: + continue + if uid: + seen_uids.add(uid) + + base = _base_symbol(f["symbol"], instruments) + if not base or not f["account"]: + continue + + key = (f["account"], base) + rt_position.setdefault(key, 0.0) + rt_pending.setdefault(key, []) + + rt_position[key] += f["qty"] if f["buysell"] == _BUY else -f["qty"] + rt_pending[key].append(f) + + if abs(rt_position[key]) < 0.01: + group = rt_pending.pop(key) + rt_position[key] = 0.0 + t = _pending_to_trade(group, f["account"], base, instruments) + if t and t["trade_id"] not in existing_ids: + existing_ids.add(t["trade_id"]) + on_trade(t) + + client.disconnect() + return existing_ids diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..2a2a967 Binary files /dev/null and b/icon.png differ diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..9ca80a1 --- /dev/null +++ b/install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -e + +APP_DIR="$(cd "$(dirname "$0")" && pwd)" +EXEC="python3 $APP_DIR/main.py" +ICON_DIR="$HOME/.local/share/icons" +DESKTOP_DIR="$HOME/.local/share/applications" +ICON_PATH="$ICON_DIR/gizerbridge.png" +DESKTOP_FILE="$DESKTOP_DIR/gizerbridge.desktop" + +export APP_DIR ICON_PATH + +echo "=== GizerBridge – Installation ===" +echo "" + +# ── Python dependencies ──────────────────────────────────────────────────── +echo "[1/5] Installiere Python-Abhängigkeiten..." +pip install -r "$APP_DIR/requirements.txt" --user --quiet +echo " Fertig." + +# ── App icon ─────────────────────────────────────────────────────────────── +echo "[2/5] Kopiere App-Icon..." +mkdir -p "$ICON_DIR" +cp "$APP_DIR/icon.png" "$ICON_PATH" +echo " Icon gespeichert: $ICON_PATH" + +# ── .desktop file ────────────────────────────────────────────────────────── +echo "[3/5] Erstelle Desktop-Eintrag..." +mkdir -p "$DESKTOP_DIR" +cat > "$DESKTOP_FILE" </dev/null || true +echo " Fertig." + +# ── Update desktop database ──────────────────────────────────────────────── +echo "[5/5] Aktualisiere App-Datenbank..." +if command -v update-desktop-database &>/dev/null; then + update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true +fi +if command -v gtk-update-icon-cache &>/dev/null; then + gtk-update-icon-cache -f "$ICON_DIR" 2>/dev/null || true +fi + +echo "" +echo "✓ GizerBridge wurde installiert." +echo " Suche ihn im App-Menü unter 'GizerBridge'." +echo " Oder direkt starten: python3 $APP_DIR/main.py" diff --git a/main.py b/main.py new file mode 100644 index 0000000..def1b3b --- /dev/null +++ b/main.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +import sys +import os + +# Ensure the app directory is on the path regardless of how it's launched +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from tray_app import App + + +def main(): + app = App() + app.run() + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bf43d27 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +requests>=2.28.0 +Pillow>=9.0.0 +pystray>=0.19.0 diff --git a/test_dtc_diag.py b/test_dtc_diag.py new file mode 100644 index 0000000..88efaff --- /dev/null +++ b/test_dtc_diag.py @@ -0,0 +1,284 @@ +"""Diagnose-Script für DTC HISTORICAL_ORDER_FILL_RESPONSE Paket-Struktur. + +Startet SC, verbindet via DTC, empfängt Fill-Pakete und analysiert +die Byte-Struktur um den Preis-Offset zu finden. + +Aufruf: python3 test_dtc_diag.py [account] [num_days] +""" + +import socket +import struct +import sys +import time +from datetime import datetime + +# ── DTC Konstanten ────────────────────────────────────────────────────────── +_LOGON_REQUEST = 1 +_LOGON_RESPONSE = 2 +_HEARTBEAT = 3 +_HIST_FILLS_REQ = 303 +_HIST_FILL_RESP = 304 +_PROTOCOL_VER = 8 + +def _s(text, n): + b = (text or "").encode("ascii", errors="replace")[:n] + return b.ljust(n, b"\x00") + +def _build_logon(): + fmt = "= 4: + size = struct.unpack_from("= 4 and len(buf) >= size: + msg = bytes(buf[:size]) + return msg, buf[size:] + try: + chunk = sock.recv(65536) + if not chunk: + raise ConnectionError("Verbindung geschlossen") + buf += chunk + except socket.timeout: + return None, buf + +def _unpack_str(data, offset, n): + return data[offset:offset+n].rstrip(b"\x00").decode("ascii", errors="replace") + +def _ts(raw): + try: + return datetime.fromtimestamp(raw).strftime("%Y-%m-%d %H:%M:%S") if 0 < raw < 4_102_444_800 else f"invalid({raw})" + except Exception: + return f"error({raw})" + +def hex_dump(data, label=""): + """Formatierter Hex-Dump mit ASCII-Darstellung.""" + print(f"\n{'='*60}") + if label: + print(f" {label}") + print(f" Länge: {len(data)} Bytes") + print(f"{'='*60}") + for i in range(0, len(data), 16): + chunk = data[i:i+16] + hex_part = " ".join(f"{b:02X}" for b in chunk) + ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) + print(f" {i:4d} ({i:04X}): {hex_part:<48} |{ascii_part}|") + +def analyze_fill_packet(data, fill_num): + """Umfassende Analyse eines HIST_FILL_RESP Pakets.""" + n = len(data) + print(f"\n{'#'*60}") + print(f" FILL-PAKET #{fill_num} ({n} Bytes)") + print(f"{'#'*60}") + + # Bekannte Felder ausgeben + print("\n--- Bestätigte Felder ---") + print(f" [0] Size (uint16) = {struct.unpack_from('= 16: + print(f" [8] TotalMsg(int32) = {struct.unpack_from('= 80: + sym = _unpack_str(data, 16, 64) + print(f" [16] Symbol (ch[64]) = '{sym}'") + if n >= 96: + exch = _unpack_str(data, 80, 16) + print(f" [80] Exchange(ch[16]) = '{exch}'") + if n >= 132: + bs = struct.unpack_from("= 152: + dt_raw = struct.unpack_from("= 160: + qty = struct.unpack_from("= 192: + tid = _unpack_str(data, 160, 32) + print(f" [160] TradeID (ch[32]) = '{tid}'") + if n >= 256: + acc = _unpack_str(data, 224, 32) + print(f" [224] Account (ch[32]) = '{acc}'") + + # Unbekannte Zone 96-128 analysieren + print("\n--- Zone 96-128 (UNBEKANNT, möglicherweise Preis) ---") + for off in range(96, min(128, n-3), 4): + if off + 4 <= n: + as_uint32 = struct.unpack_from(" 1 else "" + num_days = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + print(f"DTC Diagnose-Tool") + print(f"Account: '{account}', Tage: {num_days}") + print(f"Verbinde zu localhost:11099 ...") + + try: + sock = socket.socket() + sock.settimeout(10) + sock.connect(("localhost", 11099)) + sock.settimeout(5) + print("Verbunden!") + except Exception as e: + print(f"FEHLER: {e}") + return + + buf = b"" + + # Logon + sock.sendall(_build_logon()) + for _ in range(20): + msg, buf = _recv_msg(sock, buf) + if msg is None: + continue + mtype = struct.unpack_from(" check_off: + val = struct.unpack_from(">> NoOrderFills=1 gefunden bei Offset {check_off}! (fill_count={fill_count})") + + # Letztes Paket immer anzeigen (NoOrderFills-Paket) + if fill_count == 1: + print(f"\n=== ERSTES PAKET (hex dump) ===") + hex_dump(msg) + + print(f"\n\nGesamt empfangene Fill-Pakete: {fill_count}") + + if raw_packets: + print("\n=== LETZTES PAKET (hex dump) ===") + hex_dump(raw_packets[-1], f"Paket #{fill_count}") + + if len(raw_packets) >= 2: + print("\n=== VERGLEICH: Preis-relevante Bytes in allen Paketen ===") + print(f" {'Offset':<8} ", end="") + for i in range(min(len(raw_packets), 12)): + print(f"{'Paket'+str(i+1):>12}", end="") + print() + + # Zeige uint32-Werte für Offsets 96-200 + for off in range(88, 200, 4): + row = f" [{off:3d}] " + interesting = False + vals = [] + for pkt in raw_packets[:12]: + if len(pkt) > off + 3: + v = struct.unpack_from("12}" + else: + row += f"{'N/A':>12}" + + # Interessant wenn Werte variieren (könnten Preise sein) + if vals and max(vals) != min(vals): + interesting = True + if off in (128, 144, 152, 160): + interesting = True + + if interesting: + print(row + " ← variiert!" if max(vals) != min(vals) else row) + + sock.close() + print("\nFertig.") + +if __name__ == "__main__": + main() diff --git a/trade_parser.py b/trade_parser.py new file mode 100644 index 0000000..114c0e5 --- /dev/null +++ b/trade_parser.py @@ -0,0 +1,456 @@ +"""Sierra Chart TradeActivityLog parser. + +Binary .data files are decoded field-by-field (TLV format): + [uint32 LE: field_id] [uint32 LE: length] [bytes * length] + + Field 0x66 → SCDateTime (int64 LE, microseconds since 1899-12-30) ← timestamp + Field 0x67 → symbol string + Field 0x68 → log message text + +parse_binary_log() returns [(datetime_or_None, str)] for each log entry. +All internal parsers work on this list; timestamps propagate to _make_trade. + +Sim accounts (contain 'sim'/'simulated' in account name): + - Entry: User order entry ... AOE=true (not a Flatten) + - Entry price: first 'Trade simulation fill ... Last: X' after signal + - Direction: 'Modifying Attached Order from parent fill. Parent base price: B. + New price: P' → Long if P > B + - Exit type 1 (TP/SL): next simulation fill at price ≠ entry price + - Exit type 2 (Flatten): 'Flatten&CancelAllOrders | Last: X. Current Position + quantity: N' → exit = X, lots = |N| + +Live accounts: + - Entry: Updated Internal Position Quantity to N. Previous: 0 + - Entry price: first 'Modifying Attached Order from parent fill. + Parent base price: X' after entry update + - Exit: Updated Internal Position Quantity to 0. Previous: N + - Exit price (priority order): + 1. 'Flatten&CancelAllOrders | Last: X' + 2. 'Rithmic Direct - DTC (Filled)' + 'Limit' nearby → tp_price + 3. 'Rithmic Direct - DTC (Filled)' + 'Stop' nearby → sl_price + 4. 'User order entry | Last: X | AOE=true' (Reverse entry) +""" + +import glob +import os +import re +import struct +from datetime import datetime, timedelta + +_LOG_GLOB = "TradeActivityLog_*_UTC.{account}.data" + +# SCDateTime: int64 microseconds since 1899-12-30 +_SC_EPOCH = datetime(1899, 12, 30) +_SC_TS_MIN = int((datetime(2000, 1, 1) - _SC_EPOCH).total_seconds() * 1_000_000) +_SC_TS_MAX = int((datetime(2060, 1, 1) - _SC_EPOCH).total_seconds() * 1_000_000) + +_FIELD_DATETIME = 0x66 # SCDateTime +_FIELD_MESSAGE = 0x68 # log message text + + +# ── File discovery ───────────────────────────────────────────────────────────── + +def find_sc_log_dir(): + """Return first detected SC TradeActivityLogs directory, or None.""" + patterns = [ + "~/.local/share/bottles/bottles/*/drive_c/SierraChart*/TradeActivityLogs/", + "~/Games/SierraChart*/TradeActivityLogs/", + "~/Games/sierra-chart*/TradeActivityLogs/", + "~/.wine/drive_c/SierraChart*/TradeActivityLogs/", + "~/.PlayOnLinux/wineprefix/*/drive_c/SierraChart*/TradeActivityLogs/", + ] + for pattern in patterns: + matches = glob.glob(os.path.expanduser(pattern)) + if matches: + return matches[0] + return None + + +def find_log_files(log_dir, accounts): + """Return [(filepath, account_name)] for all matching log files.""" + if not log_dir or not os.path.isdir(log_dir): + return [] + result = [] + for account in accounts: + pattern = os.path.join(log_dir, _LOG_GLOB.format(account=account)) + for path in glob.glob(pattern): + result.append((path, account)) + return result + + +# ── Binary log parser ────────────────────────────────────────────────────────── + +def parse_binary_log(filepath): + """Read binary SC .data file. + + Returns [(datetime_or_None, message_str)] — one entry per log line, + timestamped with the SCDateTime field that preceded it in the file. + """ + try: + with open(filepath, "rb") as f: + data = f.read() + except OSError: + return [] + + entries = [] + i = 0 + n = len(data) + cur_ts = None + + while i + 8 <= n: + try: + field_id, field_len = struct.unpack_from(" n: + break + + value = data[i : i + field_len] + i += field_len + + if field_id == _FIELD_DATETIME and field_len == 8: + raw, = struct.unpack(" 0: + text = value.rstrip(b"\x00").decode("ascii", errors="replace") + if text.strip(): + entries.append((cur_ts, text)) + + return entries + + +extract_strings = parse_binary_log # alias used by tray_app + + +# ── Public entry point ───────────────────────────────────────────────────────── + +def parse_trades(entries, account, instruments, trade_date=None): + """Parse all complete trades from [(datetime_or_None, str)] entry list.""" + if _is_sim(account): + return _parse_sim(entries, account, instruments, trade_date) + return _parse_live(entries, account, instruments, trade_date) + + +# ── Shared helpers ───────────────────────────────────────────────────────────── + +def _is_sim(account): + low = account.lower() + return "sim" in low or "simulated" in low + + +def _text(entry): + """Extract the string from a (ts, str) entry.""" + return entry[1] + + +def _ts(entry): + """Extract the datetime (or None) from a (ts, str) entry.""" + return entry[0] + + +def _extract_instrument(s, known): + m = re.search(r'\b([A-Z]{2,4})[A-Z]\d', s) + if m and m.group(1) in known: + return m.group(1) + return None + + +_PRICE = r"(\d+(?:\.\d+)?)" # matches 29744.25 but not a trailing bare period + + +def _ts_str(ts, trade_date=None): + """Format a datetime for the API: actual time → HH:MM:SS; fallback → 00:00:00.""" + if ts: + return ts.strftime("%Y-%m-%d %H:%M:%S") + if trade_date: + return f"{trade_date} 00:00:00" + return datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def _find_direction(entries, start, n, window=40): + """Long if first Modifying new_price > base, Short otherwise.""" + for j in range(start, min(start + window, n)): + s = _text(entries[j]) + if "Order modified internally" in s: + continue + m = re.search( + r"Modifying Attached Order from parent fill\." + r" Parent base price: " + _PRICE + r"\. New price: " + _PRICE, s) + if m: + return "Long" if float(m.group(2)) > float(m.group(1)) else "Short" + return None + + +def _nearby_order_type(entries, dtc_idx, n, window=4): + """After DTC (Filled) at dtc_idx, look at nearby strings for order type.""" + for k in range(dtc_idx + 1, min(dtc_idx + window, n)): + s = _text(entries[k]) + if "Market" in s: + return "Market" + if "Limit" in s: + return "Limit" + if "Stop" in s: + return "Stop" + return None + + +def _make_trade(instrument, direction, lots, entry_price, exit_price, + account, instruments, entry_ts=None, exit_ts=None, trade_date=None): + cfg = instruments[instrument] + tick_size = cfg["tick_size"] + tick_value = cfg["tick_value"] + + if direction == "Long": + pnl_ticks = round((exit_price - entry_price) / tick_size) * lots + else: + pnl_ticks = round((entry_price - exit_price) / tick_size) * lots + + pnl = round(pnl_ticks * tick_value, 2) + + entry_str = _ts_str(entry_ts, trade_date) + exit_str = _ts_str(exit_ts, trade_date) + + if direction == "Long": + exec_str = (f"Buy {lots} @ {entry_price} / {entry_str}," + f"Sell {lots} @ {exit_price} / {exit_str}") + else: + exec_str = (f"Sell {lots} @ {entry_price} / {entry_str}," + f"Buy {lots} @ {exit_price} / {exit_str}") + + return { + "trade_id": f"{account}_{instrument}_{entry_price}_{exit_price}", + "instrument": instrument, + "direction": direction, + "lots": lots, + "entry_price": entry_price, + "exit_price": exit_price, + "entry_datetime": entry_str, + "exit_datetime": exit_str, + "pnl": pnl, + "pnl_ticks": pnl_ticks, + "executions": exec_str, + "account": account, + } + + +# ── Sim parser ───────────────────────────────────────────────────────────────── + +def _parse_sim(entries, account, instruments, trade_date=None): + trades = [] + current_inst = None + i = 0 + n = len(entries) + + while i < n: + s = _text(entries[i]) + + inst = _extract_instrument(s, instruments) + if inst: + current_inst = inst + + if (re.search(r"User order entry.+AOE=true", s) + and "Flatten" not in s + and current_inst): + + entry_idx, entry_price = _sim_next_fill(entries, i + 1, n) + if entry_idx is None: + i += 1 + continue + + direction = _find_direction(entries, i + 1, n) + + exit_idx, exit_price, lots, exit_direction = _sim_find_exit( + entries, entry_idx + 1, n, entry_price) + if exit_idx is None: + i += 1 + continue + + if direction is None: + direction = exit_direction + if direction is None: + i += 1 + continue + + if lots is None: + lots = 1 + + entry_ts = _ts(entries[entry_idx]) + exit_ts = _ts(entries[exit_idx]) + + trades.append(_make_trade( + current_inst, direction, lots, + entry_price, exit_price, account, instruments, + entry_ts=entry_ts, exit_ts=exit_ts, trade_date=trade_date)) + i = exit_idx + 1 + continue + + i += 1 + return trades + + +def _sim_next_fill(entries, start, n, window=60): + """Return (idx, last_price) of next simulation fill.""" + for j in range(start, min(start + window, n)): + m = re.search( + r"simulation fill\. Bid: \S+ Ask: \S+ Last: " + _PRICE, + _text(entries[j])) + if m: + return j, float(m.group(1)) + return None, None + + +def _sim_find_exit(entries, start, n, entry_price, window=400): + """Find Sim trade exit. Returns (exit_idx, exit_price, lots, direction_or_None).""" + for j in range(start, min(start + window, n)): + s = _text(entries[j]) + + m_flat = re.search( + r"Flatten&CancelAllOrders \| Last: " + _PRICE + + r".*?Current Position quantity: (-?\d+)", s) + if m_flat: + qty = int(m_flat.group(2)) + direction = "Long" if qty > 0 else "Short" + return j, float(m_flat.group(1)), abs(qty), direction + + m_fill = re.search( + r"simulation fill\. Bid: \S+ Ask: \S+ Last: " + _PRICE, s) + if m_fill: + price = float(m_fill.group(1)) + if abs(price - entry_price) > 0.1: + fill_type = _nearby_order_type(entries, j, n) + if fill_type == "Limit": + direction = "Long" if price > entry_price else "Short" + elif fill_type == "Stop": + direction = "Long" if price < entry_price else "Short" + else: + direction = None + return j, price, None, direction + + return None, None, None, None + + +# ── Live parser ──────────────────────────────────────────────────────────────── + +def _parse_live(entries, account, instruments, trade_date=None): + trades = [] + current_inst = None + last_signal_price = None + i = 0 + n = len(entries) + + while i < n: + s = _text(entries[i]) + + inst = _extract_instrument(s, instruments) + if inst: + current_inst = inst + + m_sig = re.search( + r"User order entry \| Last: " + _PRICE + r" \| AOE=true", s) + if m_sig and "Flatten" not in s: + last_signal_price = float(m_sig.group(1)) + + m_entry = re.search( + r"Updated Internal Position Quantity to (-?\d+)\. Previous: 0", s) + if m_entry and current_inst: + new_qty = int(m_entry.group(1)) + if new_qty == 0: + i += 1 + continue + + direction = "Long" if new_qty > 0 else "Short" + lots = abs(new_qty) + entry_ts = _ts(entries[i]) + + entry_price, tp_price, sl_price = _live_bracket_prices(entries, i + 1, n) + + if entry_price is None: + entry_price = last_signal_price + if entry_price is None: + i += 1 + continue + + exit_idx = _live_exit_idx(entries, i + 1, n) + if exit_idx is None: + i += 1 + continue + + exit_price = _live_exit_price(entries, i + 1, exit_idx, tp_price, sl_price, n) + if exit_price is None: + i += 1 + continue + + exit_ts = _ts(entries[exit_idx]) + last_signal_price = None + + trades.append(_make_trade( + current_inst, direction, lots, + entry_price, exit_price, account, instruments, + entry_ts=entry_ts, exit_ts=exit_ts, trade_date=trade_date)) + i = exit_idx + 1 + continue + + i += 1 + return trades + + +def _live_bracket_prices(entries, start, n, window=40): + """Return (entry_price, tp_price, sl_price) from bracket modification strings.""" + entry_price = None + bracket_new_prices = [] + + for j in range(start, min(start + window, n)): + s = _text(entries[j]) + if "Order modified internally" in s: + continue + m = re.search( + r"Modifying Attached Order from parent fill\." + r" Parent base price: " + _PRICE + r"\. New price: " + _PRICE, s) + if m: + if entry_price is None: + entry_price = float(m.group(1)) + bracket_new_prices.append(float(m.group(2))) + if len(bracket_new_prices) >= 2: + break + + tp_price = bracket_new_prices[0] if len(bracket_new_prices) >= 1 else None + sl_price = bracket_new_prices[1] if len(bracket_new_prices) >= 2 else None + return entry_price, tp_price, sl_price + + +def _live_exit_idx(entries, start, n): + """Find index of position returning to 0.""" + for j in range(start, n): + if re.search( + r"Updated Internal Position Quantity to 0\. Previous: -?\d+", + _text(entries[j])): + return j + return None + + +def _live_exit_price(entries, start, exit_idx, tp_price, sl_price, n): + """Determine exit price from events between entry and exit position updates.""" + for j in range(start, exit_idx): + m = re.search(r"Flatten&CancelAllOrders \| Last: " + _PRICE, + _text(entries[j])) + if m: + return float(m.group(1)) + + for j in range(start, exit_idx): + if "Rithmic Direct - DTC (Filled)" in _text(entries[j]): + otype = _nearby_order_type(entries, j, exit_idx) + if otype == "Limit" and tp_price is not None: + return tp_price + if otype == "Stop" and sl_price is not None: + return sl_price + + for j in range(start, exit_idx): + s = _text(entries[j]) + m = re.search(r"User order entry \| Last: " + _PRICE + r" \| AOE=true", s) + if m and "Flatten" not in s: + return float(m.group(1)) + + return None diff --git a/tralgo_api.py b/tralgo_api.py new file mode 100644 index 0000000..fa4b5ad --- /dev/null +++ b/tralgo_api.py @@ -0,0 +1,57 @@ +import requests + +TRALGO_URL = "https://tralgo.app/wp-admin/admin-ajax.php" + +_HEADERS = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Origin": "https://tralgo.app", + "Referer": "https://tralgo.app/analytics-logbuch/", +} + + +def build_session(cookies): + """Create a requests.Session with tralgo.app cookies set.""" + session = requests.Session() + for name, value in cookies.items(): + session.cookies.set(name, value, domain="tralgo.app") + return session + + +def send_trade(session, trade, api_key): + """POST trade to Tralgo. Raises on HTTP error. Returns parsed JSON.""" + payload = { + "action": "insert_new_trade", + "tradingaccount": trade["account"], + "instrument": trade["instrument"], + "type": trade["direction"], + "lots": trade["lots"], + "entry_price": trade["entry_price"], + "exit_price": trade["exit_price"], + "entry_datetime": trade["entry_datetime"], + "exit_datetime": trade["exit_datetime"], + "fees": 0, + "pnl": trade["pnl"], + "net_pnl": trade["pnl"], + "pnl_ticks": trade["pnl_ticks"], + "tp_price": 0, "sl_price": 0, + "tp_ticks": 0, "tp_usd": 0, + "sl_ticks": 0, "sl_usd": 0, + "setup": "", + "entry_specifications": "", + "exit_specifications": "", + "mistakes": "", + "market_conditions": "", + "path": "", + "trade_rating": 0, + "review_status": "Unreviewed", + "journal": "", + "executions": trade["executions"], + "transfer_token": api_key, + } + resp = session.post(TRALGO_URL, data=payload, headers=_HEADERS, timeout=15) + resp.raise_for_status() + try: + return resp.json() + except Exception: + return resp.text diff --git a/tray_app.py b/tray_app.py new file mode 100644 index 0000000..2eae163 --- /dev/null +++ b/tray_app.py @@ -0,0 +1,842 @@ +import os +import queue +import threading +import tkinter as tk +from datetime import datetime +from tkinter import messagebox, ttk + +from config import (DEFAULT_INSTRUMENTS, load_config, load_sent, save_config, + save_sent) +from cookie_reader import find_cookies +from dtc_client import DTCClient, fills_to_trades, monitor_realtime +from tralgo_api import build_session, send_trade + +try: + from PIL import Image, ImageDraw, ImageTk + _PIL_AVAILABLE = True +except ImportError: + _PIL_AVAILABLE = False + +try: + import pystray + _PYSTRAY_AVAILABLE = True +except ImportError: + _PYSTRAY_AVAILABLE = False + +_ICON_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "icon.png") + + +# ── Config window ───────────────────────────────────────────────────────────── + +class ConfigWindow: + _PRESETS = list(DEFAULT_INSTRUMENTS.keys()) # MNQ, NQ, ES, MES + + def __init__(self, parent, config, on_save): + self.win = tk.Toplevel(parent) + self.win.title("Einstellungen – GizerBridge") + self.win.resizable(False, False) + self.win.grab_set() + self._config = config + self._on_save = on_save + self._inst_vars = {} + self._inst_frame = None + self._build() + self.win.protocol("WM_DELETE_WINDOW", self.win.destroy) + + def _build(self): + cfg = self._config + pad = {"padx": 10, "pady": 3} + + self._api_key_var = self._labeled_entry("API Key *", cfg.get("api_key", ""), **pad) + self._email_var = self._labeled_entry("Tralgo E-Mail", cfg.get("email", ""), **pad) + self._dtc_host_var = self._labeled_entry( + "SC DTC Host", cfg.get("dtc_host", "localhost"), **pad) + + row = tk.Frame(self.win) + row.pack(fill=tk.X, **pad) + tk.Label(row, text="SC DTC Port", width=20, anchor="w").pack(side=tk.LEFT) + self._dtc_port_var = tk.StringVar(value=str(cfg.get("dtc_port", 11099))) + tk.Entry(row, textvariable=self._dtc_port_var, width=8).pack(side=tk.LEFT) + tk.Label(row, text="(Sierra Chart → DTC Protocol Server aktivieren)", + fg="gray", font=("", 8)).pack(side=tk.LEFT, padx=(8, 0)) + + tk.Label(self.win, text="Konten:", anchor="w", + font=("", 9, "bold")).pack(fill=tk.X, padx=10, pady=(10, 2)) + acc_outer = tk.Frame(self.win) + acc_outer.pack(fill=tk.X, padx=10) + self._acc_listbox = tk.Listbox(acc_outer, height=4, width=48, selectmode=tk.SINGLE) + self._acc_listbox.pack(side=tk.LEFT) + for acc in cfg.get("accounts", []): + self._acc_listbox.insert(tk.END, acc) + + add_row = tk.Frame(self.win) + add_row.pack(fill=tk.X, padx=10, pady=2) + self._acc_entry = tk.Entry(add_row, width=35) + self._acc_entry.pack(side=tk.LEFT) + self._acc_entry.bind("", lambda _: self._add_account()) + tk.Button(add_row, text="Hinzufügen", command=self._add_account).pack(side=tk.LEFT, padx=4) + tk.Button(add_row, text="Entfernen", command=self._remove_account).pack(side=tk.LEFT) + + tk.Label(self.win, text="Instrumente:", anchor="w", + font=("", 9, "bold")).pack(fill=tk.X, padx=10, pady=(10, 2)) + outer = tk.LabelFrame(self.win, text="") + outer.pack(fill=tk.X, padx=10, pady=2) + + hdr = tk.Frame(outer) + hdr.pack(fill=tk.X, padx=4, pady=(4, 0)) + tk.Label(hdr, text="", width=3).pack(side=tk.LEFT) + tk.Label(hdr, text="Symbol", width=10, anchor="w").pack(side=tk.LEFT) + tk.Label(hdr, text="Tick-Größe", width=11, anchor="w").pack(side=tk.LEFT) + tk.Label(hdr, text="Tick-Wert $",width=11, anchor="w").pack(side=tk.LEFT) + + self._inst_frame = tk.Frame(outer) + self._inst_frame.pack(fill=tk.X) + + existing = cfg.get("instruments", {}) + rendered = set() + for name in self._PRESETS: + self._add_inst_row(name, existing.get(name, DEFAULT_INSTRUMENTS.get(name, {}))) + rendered.add(name) + for name, data in existing.items(): + if name not in rendered: + self._add_inst_row(name, data) + + custom = tk.Frame(outer) + custom.pack(fill=tk.X, padx=4, pady=6) + tk.Label(custom, text="Weiteres:").pack(side=tk.LEFT) + self._custom_name = tk.Entry(custom, width=7) + self._custom_name.pack(side=tk.LEFT, padx=(3, 6)) + tk.Label(custom, text="Tick-Größe:").pack(side=tk.LEFT) + self._custom_tick_size = tk.Entry(custom, width=6) + self._custom_tick_size.insert(0, "0.25") + self._custom_tick_size.pack(side=tk.LEFT, padx=(2, 6)) + tk.Label(custom, text="Tick-Wert $:").pack(side=tk.LEFT) + self._custom_tick_value = tk.Entry(custom, width=6) + self._custom_tick_value.pack(side=tk.LEFT, padx=2) + tk.Button(custom, text="+", width=3, command=self._add_custom_inst).pack(side=tk.LEFT, padx=4) + + btn = tk.Frame(self.win) + btn.pack(pady=10) + tk.Button(btn, text="Speichern", width=12, command=self._save).pack(side=tk.LEFT, padx=8) + tk.Button(btn, text="Abbrechen", width=12, command=self.win.destroy).pack(side=tk.LEFT) + + def _labeled_entry(self, label, value, **pack_kwargs): + row = tk.Frame(self.win) + row.pack(fill=tk.X, **pack_kwargs) + tk.Label(row, text=label, width=20, anchor="w").pack(side=tk.LEFT) + var = tk.StringVar(value=value) + tk.Entry(row, textvariable=var, width=40).pack(side=tk.LEFT) + return var + + def _add_inst_row(self, name, data): + if name in self._inst_vars: + return + row = tk.Frame(self._inst_frame) + row.pack(fill=tk.X, padx=4, pady=1) + preset_defaults = DEFAULT_INSTRUMENTS.get(name, {}) + enabled_var = tk.BooleanVar(value=data.get("enabled", name in self._PRESETS)) + tick_size_var = tk.StringVar(value=str(data.get("tick_size", preset_defaults.get("tick_size", 0.25)))) + tick_value_var = tk.StringVar(value=str(data.get("tick_value", preset_defaults.get("tick_value", 0.0)))) + tk.Checkbutton(row, variable=enabled_var).pack(side=tk.LEFT) + tk.Label(row, text=name, width=10, anchor="w").pack(side=tk.LEFT) + tk.Entry(row, textvariable=tick_size_var, width=9).pack(side=tk.LEFT, padx=2) + tk.Entry(row, textvariable=tick_value_var, width=9).pack(side=tk.LEFT, padx=2) + self._inst_vars[name] = { + "enabled": enabled_var, + "tick_size": tick_size_var, + "tick_value": tick_value_var, + } + + def _add_custom_inst(self): + name = self._custom_name.get().strip().upper() + if not name: + return + if name in self._inst_vars: + messagebox.showinfo("Hinweis", f"{name} ist bereits vorhanden.", parent=self.win) + return + try: + ts = float(self._custom_tick_size.get()) + tv = float(self._custom_tick_value.get()) + except ValueError: + messagebox.showerror("Fehler", "Ungültige Zahlenwerte.", parent=self.win) + return + self._add_inst_row(name, {"enabled": True, "tick_size": ts, "tick_value": tv}) + self._custom_name.delete(0, tk.END) + self._custom_tick_value.delete(0, tk.END) + + def _add_account(self): + name = self._acc_entry.get().strip() + if name and name not in self._acc_listbox.get(0, tk.END): + self._acc_listbox.insert(tk.END, name) + self._acc_entry.delete(0, tk.END) + + def _remove_account(self): + sel = self._acc_listbox.curselection() + if sel: + self._acc_listbox.delete(sel[0]) + + def _save(self): + api_key = self._api_key_var.get().strip() + if not api_key: + messagebox.showerror("Fehler", "API Key ist ein Pflichtfeld.", parent=self.win) + return + try: + dtc_port = int(self._dtc_port_var.get().strip()) + except ValueError: + messagebox.showerror("Fehler", "DTC Port muss eine Zahl sein.", parent=self.win) + return + instruments = {} + for name, v in self._inst_vars.items(): + try: + ts = float(v["tick_size"].get()) + tv = float(v["tick_value"].get()) + except ValueError: + messagebox.showerror("Fehler", f"Ungültige Tick-Werte für {name}.", parent=self.win) + return + instruments[name] = { + "enabled": v["enabled"].get(), + "tick_size": ts, + "tick_value": tv, + } + self._on_save({ + "api_key": api_key, + "email": self._email_var.get().strip(), + "dtc_host": self._dtc_host_var.get().strip() or "localhost", + "dtc_port": dtc_port, + "accounts": list(self._acc_listbox.get(0, tk.END)), + "instruments": instruments, + }) + self.win.destroy() + + +# ── Manual import window ────────────────────────────────────────────────────── + +class ManualImportWindow: + _CHK_ON = "☑" + _CHK_OFF = "☐" + + _COLS = ("chk", "date", "account", "instrument", "direction", + "entry", "exit", "pnl", "ticks", "status") + _HDRS = ("", "Datum", "Konto", "Symbol", "Richtung", + "Entry", "Exit", "PnL $", "Ticks", "Status") + _WIDTHS = (30, 100, 140, 65, 70, 80, 80, 70, 55, 70) + + def __init__(self, parent, config, sent, session, api_key, on_imported): + self.win = tk.Toplevel(parent) + self.win.title("Manueller Import – GizerBridge") + self.win.geometry("870x520") + self.win.resizable(True, True) + self._config = config + self._sent = sent # set of trade IDs already sent + self._session = session # requests.Session or None + self._api_key = api_key + self._on_imported = on_imported # callback(trade_id) → updates App.sent + self._rows = [] # [(iid, trade_dict, is_sent)] + self._build() + + def _build(self): + # ── Filter bar ──────────────────────────────────────────────────────── + ctrl = tk.Frame(self.win) + ctrl.pack(fill=tk.X, padx=10, pady=(10, 4)) + + tk.Label(ctrl, text="Konto:").pack(side=tk.LEFT) + accounts = ["Alle"] + list(self._config.get("accounts", [])) + self._account_var = tk.StringVar(value=accounts[0] if accounts else "") + ttk.Combobox(ctrl, textvariable=self._account_var, values=accounts, + width=22, state="readonly").pack(side=tk.LEFT, padx=(4, 14)) + + tk.Label(ctrl, text="Von:").pack(side=tk.LEFT) + self._date_from_var = tk.StringVar(value="2026-01-01") + tk.Entry(ctrl, textvariable=self._date_from_var, width=11).pack(side=tk.LEFT, padx=(4, 14)) + + tk.Label(ctrl, text="Bis:").pack(side=tk.LEFT) + self._date_to_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d")) + tk.Entry(ctrl, textvariable=self._date_to_var, width=11).pack(side=tk.LEFT, padx=(4, 14)) + + self._search_btn = tk.Button(ctrl, text="Suchen", command=self._search) + self._search_btn.pack(side=tk.LEFT) + + self._count_lbl = tk.Label(ctrl, text="", fg="gray") + self._count_lbl.pack(side=tk.LEFT, padx=10) + + tk.Label(self.win, + text="ℹ Timestamps direkt aus SC via DTC — echte Füll-Zeiten.", + fg="#888888", font=("", 8), anchor="w", + ).pack(fill=tk.X, padx=11, pady=(0, 2)) + + # ── Treeview ────────────────────────────────────────────────────────── + tree_outer = tk.Frame(self.win) + tree_outer.pack(fill=tk.BOTH, expand=True, padx=10, pady=4) + + vsb = ttk.Scrollbar(tree_outer, orient="vertical") + hsb = ttk.Scrollbar(tree_outer, orient="horizontal") + vsb.pack(side=tk.RIGHT, fill=tk.Y) + hsb.pack(side=tk.BOTTOM, fill=tk.X) + + self._tree = ttk.Treeview( + tree_outer, columns=self._COLS, show="headings", + yscrollcommand=vsb.set, xscrollcommand=hsb.set, + selectmode="none", + ) + vsb.config(command=self._tree.yview) + hsb.config(command=self._tree.xview) + + for col, hdr, w in zip(self._COLS, self._HDRS, self._WIDTHS): + anchor = "e" if col in ("entry", "exit", "pnl", "ticks") else "w" + self._tree.heading(col, text=hdr, anchor=anchor) + self._tree.column(col, width=w, minwidth=w, + stretch=(col == "account"), anchor=anchor) + + self._tree.tag_configure("sent", foreground="#aaaaaa") + self._tree.tag_configure("sel", foreground="#000000", background="#e8f4e8") + self._tree.tag_configure("desel", foreground="#333333") + self._tree.tag_configure("ok", foreground="darkgreen", background="#e8f4e8") + self._tree.tag_configure("err", foreground="#cc0000") + + self._tree.bind("", self._on_click) + self._tree.pack(fill=tk.BOTH, expand=True) + + # ── Bottom bar ──────────────────────────────────────────────────────── + bot = tk.Frame(self.win) + bot.pack(fill=tk.X, padx=10, pady=(4, 10)) + + tk.Button(bot, text="Alle auswählen", command=self._select_all).pack(side=tk.LEFT, padx=(0, 4)) + tk.Button(bot, text="Keine", command=self._select_none).pack(side=tk.LEFT, padx=4) + + self._status_lbl = tk.Label(bot, text="", fg="gray") + self._status_lbl.pack(side=tk.LEFT, padx=12) + + self._import_btn = tk.Button( + bot, text="Ausgewählte importieren", + command=self._import_selected, state=tk.DISABLED, + ) + self._import_btn.pack(side=tk.RIGHT) + + # ── Search ──────────────────────────────────────────────────────────────── + + def _search(self): + self._tree.delete(*self._tree.get_children()) + self._rows.clear() + self._import_btn.config(state=tk.DISABLED) + self._count_lbl.config(text="Verbinde mit SC DTC…") + self._status_lbl.config(text="") + self._search_btn.config(state=tk.DISABLED) + self.win.update_idletasks() + + account_sel = self._account_var.get() + date_from = self._date_from_var.get().strip() + date_to = self._date_to_var.get().strip() + cfg_accounts = self._config.get("accounts", []) + accounts = cfg_accounts if account_sel == "Alle" else [account_sel] + instruments = {k: v for k, v in self._config.get("instruments", {}).items() + if v.get("enabled", True)} + host = self._config.get("dtc_host", "localhost") + port = self._config.get("dtc_port", 11099) + + def _run(): + all_fills = [] + error_msg = None + try: + client = DTCClient(host, port) + client.connect() + client.logon() + for account in accounts: + fills = client.request_fills(account, num_days=90) + all_fills.extend(fills) + client.disconnect() + except Exception as e: + error_msg = str(e) + + if error_msg: + self.win.after(0, lambda: self._search_done([], error_msg)) + return + + # Filter by date range + filtered = [] + for f in all_fills: + if not f.get("datetime"): + filtered.append(f) + continue + d = f["datetime"].strftime("%Y-%m-%d") + if date_from and d < date_from: + continue + if date_to and d > date_to: + continue + filtered.append(f) + + trades = fills_to_trades(filtered, instruments) + self.win.after(0, lambda: self._search_done(trades, None)) + + threading.Thread(target=_run, daemon=True).start() + + def _search_done(self, trades, error_msg): + self._search_btn.config(state=tk.NORMAL) + if error_msg: + self._count_lbl.config(text=f"Verbindungsfehler: {error_msg[:60]}") + return + + total = 0 + for trade in trades: + is_sent = trade["trade_id"] in self._sent + tag = "sent" if is_sent else "desel" + pnl_fmt = f"{trade['pnl']:+.2f}" if trade["pnl"] != 0 else "0.00" + date_str = trade["entry_datetime"][:10] if trade["entry_datetime"] else "" + iid = self._tree.insert( + "", tk.END, + values=( + self._CHK_OFF, + date_str, + trade["account"], + trade["instrument"], + trade["direction"], + trade["entry_price"], + trade["exit_price"], + pnl_fmt, + trade["pnl_ticks"], + "bereits importiert" if is_sent else "", + ), + tags=(tag,), + ) + self._rows.append((iid, trade, is_sent)) + total += 1 + + already = sum(1 for _, _, s in self._rows if s) + parts = [f"{total} Trade(s) gefunden"] + if already: + parts.append(f"{already} bereits importiert") + self._count_lbl.config(text=" – ".join(parts)) + + # ── Checkbox interaction ────────────────────────────────────────────────── + + def _on_click(self, event): + iid = self._tree.identify_row(event.y) + if not iid or self._tree.identify_region(event.x, event.y) != "cell": + return + for row_iid, trade, is_sent in self._rows: + if row_iid != iid: + continue + vals = list(self._tree.item(iid, "values")) + if vals[0] == self._CHK_OFF: + vals[0] = self._CHK_ON + self._tree.item(iid, values=vals, tags=("sel",)) + else: + vals[0] = self._CHK_OFF + self._tree.item(iid, values=vals, tags=("sent" if is_sent else "desel",)) + break + self._refresh_import_btn() + + def _select_all(self): + for iid, _, is_sent in self._rows: + vals = list(self._tree.item(iid, "values")) + vals[0] = self._CHK_ON + self._tree.item(iid, values=vals, tags=("sel",)) + self._refresh_import_btn() + + def _select_none(self): + for iid, _, is_sent in self._rows: + vals = list(self._tree.item(iid, "values")) + vals[0] = self._CHK_OFF + self._tree.item(iid, values=vals, tags=("sent" if is_sent else "desel",)) + self._refresh_import_btn() + + def _refresh_import_btn(self): + any_checked = any( + self._tree.item(iid, "values")[0] == self._CHK_ON + for iid, _, _ in self._rows + ) + self._import_btn.config(state=tk.NORMAL if any_checked else tk.DISABLED) + + # ── Import ──────────────────────────────────────────────────────────────── + + def _import_selected(self): + if self._session is None: + messagebox.showerror( + "Keine Verbindung", + "Keine aktive Tralgo-Sitzung.\n" + "Bitte Firefox-Cookie prüfen und App neu starten.", + parent=self.win, + ) + return + + to_send = [ + (iid, trade) + for iid, trade, _ in self._rows + if self._tree.item(iid, "values")[0] == self._CHK_ON + ] + if not to_send: + messagebox.showinfo("Hinweis", "Keine Trades ausgewählt.", parent=self.win) + return + + self._import_btn.config(state=tk.DISABLED) + self._search_btn.config(state=tk.DISABLED) + self._status_lbl.config(text=f"Importiere {len(to_send)} Trade(s)…") + + def _run(): + ok = err = 0 + for iid, trade in to_send: + try: + send_trade(self._session, trade, self._api_key) + self._on_imported(trade["trade_id"]) + ok += 1 + self.win.after(0, lambda i=iid, t=trade: self._mark_ok(i, t)) + except Exception as e: + err += 1 + self.win.after(0, lambda i=iid, msg=str(e): self._mark_err(i, msg)) + summary = f"Fertig: {ok} importiert" + if err: + summary += f", {err} Fehler" + self.win.after(0, lambda: self._status_lbl.config(text=summary)) + self.win.after(0, lambda: self._search_btn.config(state=tk.NORMAL)) + # Re-enable import for any rows that still failed + remaining = any( + not is_sent and self._tree.item(r_iid, "values")[0] == self._CHK_ON + for r_iid, _, is_sent in self._rows + ) + if remaining: + self.win.after(0, lambda: self._import_btn.config(state=tk.NORMAL)) + + threading.Thread(target=_run, daemon=True).start() + + def _mark_ok(self, iid, trade): + vals = list(self._tree.item(iid, "values")) + vals[0] = self._CHK_ON + vals[9] = "✅" + self._tree.item(iid, values=vals, tags=("ok",)) + + def _mark_err(self, iid, msg): + vals = list(self._tree.item(iid, "values")) + vals[9] = f"❌ {msg[:30]}" + self._tree.item(iid, values=vals, tags=("err",)) + + +# ── Main App ────────────────────────────────────────────────────────────────── + +class App: + def __init__(self): + self.root = tk.Tk() + self.root.withdraw() + if _PIL_AVAILABLE: + try: + _base = Image.open(_ICON_PATH).convert("RGBA") + # multiple sizes → WM picks best for taskbar / alt-tab + self._wm_icons = [ + ImageTk.PhotoImage(_base.resize((sz, sz), Image.LANCZOS)) + for sz in (256, 128, 64, 48, 32) + ] + self.root.iconphoto(True, *self._wm_icons) + except Exception: + pass + self.config = load_config() + self.sent = load_sent() + self.session = None + self._status_color = "yellow" + self._status_msg = "Initialisierung..." + self._log_entries = [] + self._log_lock = threading.Lock() + self._stop = threading.Event() + self._queue = queue.Queue() + self._monitor_thread = None + + # ── Public entry point ──────────────────────────────────────────────────── + + def run(self): + self.root.after(200, self._process_queue) + self._setup_tray() + + if not self.config.get("api_key"): + self.root.after(100, lambda: ConfigWindow( + self.root, self.config, self._on_config_saved)) + else: + self._start_monitor() + + self._show_main_window() + self.root.mainloop() + + def _set_icon_color(self, color): + self._status_color = color + if hasattr(self, "_tray") and _PIL_AVAILABLE: + try: + self._tray.icon = self._make_tray_image(color) + except Exception: + pass + + # ── Pystray helpers ─────────────────────────────────────────────────────── + + def _make_tray_image(self, color="yellow"): + """256×256 logo with a small colored status dot in the bottom-right.""" + img = Image.open(_ICON_PATH).convert("RGBA").resize((256, 256), Image.LANCZOS) + dot_colors = { + "green": (0, 200, 0, 255), + "red": (220, 0, 0, 255), + "yellow": (230, 190, 0, 255), + } + c = dot_colors.get(color, (160, 160, 160, 255)) + draw = ImageDraw.Draw(img) + r = 30 + x0, y0 = 256 - 2*r - 8, 256 - 2*r - 8 + draw.ellipse([x0, y0, x0 + 2*r, y0 + 2*r], + fill=c, outline=(0, 0, 0, 160), width=3) + return img + + def _setup_tray(self): + if not _PYSTRAY_AVAILABLE or not _PIL_AVAILABLE: + return + menu = pystray.Menu( + pystray.MenuItem("GizerBridge", self._tray_show, default=True), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Manueller Import", + lambda: self._queue.put(self._open_manual_import)), + pystray.MenuItem("Einstellungen", + lambda: self._queue.put(self._open_config)), + pystray.MenuItem("Log", + lambda: self._queue.put(self._open_log)), + pystray.Menu.SEPARATOR, + pystray.MenuItem("Beenden", + lambda: self._queue.put(self._quit)), + ) + self._tray = pystray.Icon( + "gizerbridge", + self._make_tray_image("yellow"), + "GizerBridge", + menu, + ) + self._tray.run_detached() + + def _tray_show(self, *_): + self._queue.put(lambda: (self.root.deiconify(), self.root.lift())) + + # ── Main window ─────────────────────────────────────────────────────────── + + def _show_main_window(self): + if hasattr(self, "_fallback_status"): + return + + self.root.deiconify() + self.root.title("GizerBridge") + self.root.resizable(True, True) + self.root.minsize(420, 140) + + # ── Header ── + hdr = tk.Frame(self.root, padx=14, pady=10) + hdr.pack(fill=tk.X) + if _PIL_AVAILABLE: + try: + _img = Image.open(_ICON_PATH).convert("RGBA") + _img = _img.resize((128, 128), Image.LANCZOS) + self._hdr_photo = ImageTk.PhotoImage(_img) + tk.Label(hdr, image=self._hdr_photo).pack(side=tk.LEFT, padx=(0, 8)) + except Exception: + pass + tk.Label(hdr, text="GizerBridge", + font=("", 13, "bold")).pack(side=tk.LEFT) + + # ── Status row ── + sf = tk.Frame(self.root, padx=14) + sf.pack(fill=tk.X) + self._fallback_dot = tk.Label(sf, text="●", fg="goldenrod", + font=("", 11)) + self._fallback_dot.pack(side=tk.LEFT) + self._fallback_status = tk.Label(sf, text=self._status_msg, + anchor="w", wraplength=360) + self._fallback_status.pack(side=tk.LEFT, padx=(5, 0)) + + # ── Buttons ── + bf = tk.Frame(self.root, padx=14, pady=12) + bf.pack(fill=tk.X) + for text, cmd in [ + ("Manueller Import", self._open_manual_import), + ("Einstellungen", self._open_config), + ("Log", self._open_log), + ("Beenden", self._quit), + ]: + tk.Button(bf, text=text, width=15, + command=cmd).pack(side=tk.LEFT, padx=(0, 6)) + + close_action = self.root.withdraw if _PYSTRAY_AVAILABLE else self._quit + self.root.protocol("WM_DELETE_WINDOW", close_action) + + # ── Monitor thread ──────────────────────────────────────────────────────── + + def _start_monitor(self): + self._stop.clear() + self._monitor_thread = threading.Thread( + target=self._monitor_loop, daemon=True) + self._monitor_thread.start() + + def _monitor_loop(self): + self._queue.put(lambda: self._set_icon_color("yellow")) + + cfg = self.config + if not cfg.get("api_key"): + self._log("Konfiguration unvollständig — bitte Einstellungen öffnen.") + self._queue.put(lambda: self._set_icon_color("red")) + return + + cookies, err = find_cookies() + if err: + self._log(f"Cookie-Fehler: {err}") + self._queue.put(lambda: self._set_icon_color("red")) + self._queue.put(lambda: messagebox.showwarning( + "Tralgo – Cookie fehlt", err, parent=self.root)) + return + + self.session = build_session(cookies) + + host = cfg.get("dtc_host", "localhost") + port = cfg.get("dtc_port", 11099) + accounts = cfg.get("accounts", []) + instruments = self._active_instruments(cfg) + + self._log(f"Verbinde mit Sierra Chart DTC {host}:{port}…") + + def _on_trade(trade): + if trade["trade_id"] in self.sent: + return + try: + send_trade(self.session, trade, cfg["api_key"]) + self.sent.add(trade["trade_id"]) + save_sent(self.sent) + msg = (f"Übertragen: {trade['instrument']} {trade['direction']} " + f"Entry {trade['entry_price']} → Exit {trade['exit_price']} " + f"| PnL {trade['pnl']} USD ({trade['pnl_ticks']} Ticks)") + self._log(msg) + self._update_status_msg(msg) + self._queue.put(lambda: self._set_icon_color("green")) + except Exception as e: + err_msg = f"Übertragungsfehler ({trade['instrument']}): {e}" + self._log(err_msg) + self._update_status_msg(err_msg) + self._queue.put(lambda: self._set_icon_color("red")) + + def _on_ready(existing_ids): + self.sent.update(existing_ids) + save_sent(self.sent) + self._log("Bereit. Überwache SC via DTC auf neue Trades…") + self._queue.put(lambda: self._set_icon_color("green")) + self._update_status_msg("Läuft – warte auf neue Trades (DTC)") + + result = monitor_realtime( + host, port, accounts, instruments, + on_trade=_on_trade, + stop_event=self._stop, + log_fn=self._log, + on_ready=_on_ready, + ) + + if result is None: + self._queue.put(lambda: self._set_icon_color("red")) + self._update_status_msg("DTC-Verbindung fehlgeschlagen – Sierra Chart läuft?") + else: + self._log("DTC-Monitor beendet.") + + @staticmethod + def _active_instruments(cfg): + return {k: v for k, v in cfg.get("instruments", {}).items() + if v.get("enabled", True)} + + # ── Queue / status helpers ──────────────────────────────────────────────── + + def _process_queue(self): + try: + while True: + self._queue.get_nowait()() + except queue.Empty: + pass + if hasattr(self, "_fallback_status"): + lbl_fg = {"green": "darkgreen", "red": "#cc0000", "yellow": "darkorange"} + dot_fg = {"green": "#00b300", "red": "#cc0000", "yellow": "goldenrod"} + self._fallback_status.config( + text=self._status_msg, + fg=lbl_fg.get(self._status_color, "gray")) + self._fallback_dot.config( + fg=dot_fg.get(self._status_color, "gray")) + self.root.after(300, self._process_queue) + + def _log(self, msg): + entry = f"[{datetime.now().strftime('%H:%M:%S')}] {msg}" + with self._log_lock: + self._log_entries.append(entry) + if len(self._log_entries) > 200: + self._log_entries.pop(0) + + def _update_status_msg(self, msg): + self._status_msg = msg + + # ── GUI windows ─────────────────────────────────────────────────────────── + + def _open_status(self): + win = tk.Toplevel(self.root) + win.title("Status") + win.resizable(False, False) + colors = {"green": "darkgreen", "red": "red", "yellow": "darkorange"} + labels = {"green": "Läuft", "red": "Fehler", "yellow": "Initialisierung"} + tk.Label(win, text=f"● {labels.get(self._status_color, '')}", + fg=colors.get(self._status_color, "gray"), + font=("", 12, "bold")).pack(padx=20, pady=(14, 4)) + tk.Label(win, text=self._status_msg, wraplength=380).pack(padx=20, pady=4) + tk.Label(win, text=f"Übertragene Trades gesamt: {len(self.sent)}", + fg="gray").pack(padx=20, pady=4) + with self._log_lock: + last = self._log_entries[-1] if self._log_entries else "—" + tk.Label(win, text=f"Letzter Log-Eintrag:\n{last}", + wraplength=380, fg="gray", justify=tk.LEFT).pack(padx=20, pady=(4, 14)) + tk.Button(win, text="Schließen", width=10, command=win.destroy).pack(pady=(0, 12)) + + def _open_log(self): + win = tk.Toplevel(self.root) + win.title("Log – letzte 50 Einträge") + win.geometry("620x400") + frame = tk.Frame(win) + frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=8) + sb = tk.Scrollbar(frame) + sb.pack(side=tk.RIGHT, fill=tk.Y) + txt = tk.Text(frame, yscrollcommand=sb.set, state=tk.DISABLED, + font=("Monospace", 8), wrap=tk.NONE) + txt.pack(fill=tk.BOTH, expand=True) + sb.config(command=txt.yview) + with self._log_lock: + entries = list(self._log_entries[-50:]) + txt.config(state=tk.NORMAL) + txt.insert(tk.END, "\n".join(entries)) + txt.config(state=tk.DISABLED) + txt.see(tk.END) + tk.Button(win, text="Schließen", command=win.destroy).pack(pady=6) + + def _open_config(self): + ConfigWindow(self.root, self.config, self._on_config_saved) + + def _open_manual_import(self): + ManualImportWindow( + self.root, + self.config, + self.sent, + self.session, + self.config.get("api_key", ""), + self._on_trade_manually_imported, + ) + + def _on_trade_manually_imported(self, trade_id): + self.sent.add(trade_id) + save_sent(self.sent) + + def _on_config_saved(self, new_cfg): + self.config = new_cfg + save_config(new_cfg) + self._stop.set() + if self._monitor_thread and self._monitor_thread.is_alive(): + self._monitor_thread.join(timeout=5) + self._start_monitor() + + def _quit(self): + self._stop.set() + save_sent(self.sent) + if hasattr(self, "_tray"): + try: + self._tray.stop() + except Exception: + pass + try: + self.root.quit() + self.root.destroy() + except tk.TclError: + pass