import json import logging import os import tempfile import time from datetime import date, datetime, timezone from pathlib import Path from typing import Optional import config as app_config logger = logging.getLogger(__name__) def _atomic_write_text(path: Path, text: str): """Write text atomically: temp file in the same dir, then os.replace. Prevents corruption of the state files if the bot crashes (or the box loses power) mid-write - os.replace is atomic on POSIX. """ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") try: with os.fdopen(fd, "w") as f: f.write(text) os.replace(tmp, path) except Exception: try: os.unlink(tmp) except OSError: pass raise class PositionTracker: """Tracks which strategy owns which positions, persisted to a JSON file. IB only reports account-level positions, so ownership is tracked locally: {strategy_name: {symbol_or_pair: {"quantity": float, "entry_price": float, "entry_date": "YYYY-MM-DD"}}} """ def __init__(self, state_file: str): self.state_file = Path(state_file) self.ledger_file = self.state_file.with_name("trades.jsonl") self._day_pnl_file = self.state_file.with_name("day_pnl.json") self._recent_sells_file = self.state_file.with_name("recent_sells.json") self._data: dict[str, dict[str, dict]] = {} self.load() self._seed_ledger_once() self._day_date, self._day_realized = self._load_day_pnl() self._recent_sells: dict[str, dict] = self._load_recent_sells() # ---------- daily realized P&L (for the max_daily_loss circuit breaker) ---------- def _load_day_pnl(self) -> tuple[str, float]: try: d = json.loads(self._day_pnl_file.read_text()) if d.get("date") == date.today().isoformat(): return d["date"], float(d.get("realized", 0.0)) except Exception: pass return date.today().isoformat(), 0.0 def _save_day_pnl(self): try: _atomic_write_text( self._day_pnl_file, json.dumps({"date": self._day_date, "realized": self._day_realized}), ) except Exception as e: logger.error("Failed to save day pnl: %s", e) def _add_day_pnl(self, pnl: float): today = date.today().isoformat() if self._day_date != today: self._day_date = today self._day_realized = 0.0 self._day_realized += pnl self._save_day_pnl() def day_realized_pnl(self) -> float: """Today's realized P&L in USD (resets at local midnight).""" if self._day_date != date.today().isoformat(): return 0.0 return self._day_realized # ---------- global sell registry (cross-strategy re-entry guard) ---------- # # Every sell of a symbol (any strategy, any reason, incl. backfilled # external STP fills) is stamped here so no strategy can flip-flop: # buy-sell-buy at the same price in a few minutes. def _load_recent_sells(self) -> dict[str, dict]: try: d = json.loads(self._recent_sells_file.read_text()) if isinstance(d, dict): return {k: v for k, v in d.items() if isinstance(v, dict) and v.get("ts") is not None} except Exception: pass return {} def _save_recent_sells(self): try: _atomic_write_text(self._recent_sells_file, json.dumps(self._recent_sells)) except Exception as e: logger.error("Failed to save recent sells: %s", e) def _record_recent_sell(self, key: str, price: float): self._recent_sells[key] = { "ts": datetime.now(timezone.utc).timestamp(), "price": price, } self._save_recent_sells() def recent_sell(self, key: str) -> Optional[dict]: """Latest sell record {ts (epoch), price} for the symbol, or None once older than the improvement window (stale entries are purged lazily).""" rec = self._recent_sells.get(key) if not rec: return None window = app_config.config.sell_improvement_window_minutes * 60 if time.time() - rec["ts"] > window: del self._recent_sells[key] self._save_recent_sells() return None return rec # ---------- trade ledger (append-only JSONL, used by daily_report.py) ---------- def _ledger_append(self, type_: str, strategy: str, key: str, qty: float, price: float, estimated: bool = False, reason: str = "", commission: float = 0.0): """Append one fill to the ledger. `reason` is the signal that caused it (entry signal on buys, exit reason on sells) and `commission` the IB commission for the fill when it was available at record time. Both are what analytics.py attributes P&L by, so they are worth recording even when only partly populated. """ rec = { "ts": datetime.now().isoformat(timespec="seconds"), "type": type_, "strategy": strategy, "symbol": key, "qty": qty, "price": price, } if reason: rec["reason"] = reason if commission: rec["commission"] = round(commission, 4) if estimated: rec["est"] = True try: with open(self.ledger_file, "a") as f: f.write(json.dumps(rec) + "\n") except Exception as e: logger.error("Failed to append trade ledger: %s", e) def _seed_ledger_once(self): """On first run after the ledger was introduced, record currently tracked positions as opening lots so future sells can compute realized P&L.""" if self.ledger_file.exists(): return for strategy, entries in self._data.items(): for key, e in entries.items(): self._ledger_append("seed", strategy, key, e["quantity"], e["entry_price"]) if self._data: logger.info("Trade ledger seeded with %d opening lots -> %s", sum(len(v) for v in self._data.values()), self.ledger_file) def load(self): if not self.state_file.exists(): self._data = {} return try: self._data = json.loads(self.state_file.read_text()) logger.info("Loaded state from %s", self.state_file) except Exception as e: logger.error("Failed to load state file %s: %s (starting empty)", self.state_file, e) self._data = {} def save(self): try: _atomic_write_text(self.state_file, json.dumps(self._data, indent=2)) except Exception as e: logger.error("Failed to save state file %s: %s", self.state_file, e) def get(self, strategy: str, key: str) -> Optional[dict]: """Return owned position dict or None.""" entry = self._data.get(strategy, {}).get(key) if entry and entry.get("quantity", 0) > 0: return entry return None def total_positions(self) -> int: """Number of open lots across all strategies (for the global position cap).""" return sum( 1 for entries in self._data.values() for e in entries.values() if e.get("quantity", 0) > 0 ) def symbol_value(self, key: str) -> float: """Total tracked position value (qty x entry price) for one symbol, summed across all strategies (for the per-symbol value cap).""" return sum( e["quantity"] * e["entry_price"] for entries in self._data.values() for k, e in entries.items() if k == key and e.get("quantity", 0) > 0 ) def record_buy(self, strategy: str, key: str, quantity: float, price: float, reason: str = "", commission: float = 0.0): entries = self._data.setdefault(strategy, {}) entry = entries.get(key) if entry: prev_qty = entry["quantity"] total_qty = prev_qty + quantity entry["entry_price"] = (entry["entry_price"] * prev_qty + price * quantity) / total_qty entry["quantity"] = total_qty else: entries[key] = { "quantity": quantity, "entry_price": price, "entry_date": date.today().isoformat(), "entry_ts": datetime.now(timezone.utc).isoformat(), } self.save() self._ledger_append("buy", strategy, key, quantity, price, reason=reason, commission=commission) logger.info("Tracker: %s owns %s x%g @ %.4f", strategy, key, entries[key]["quantity"], entries[key]["entry_price"]) def record_sell(self, strategy: str, key: str, quantity: float, price: float = None, reason: str = "", commission: float = 0.0): entries = self._data.get(strategy, {}) entry = entries.get(key) if not entry: return entry["quantity"] -= quantity if entry["quantity"] <= 0: del entries[key] self.save() if price is not None: self._add_day_pnl((price - entry["entry_price"]) * quantity - commission) self._ledger_append("sell", strategy, key, quantity, price, reason=reason, commission=commission) self._record_recent_sell(key, price) logger.info("Tracker: %s sold %s x%g, remaining=%s", strategy, key, quantity, entry.get("quantity", 0)) def _record_external_sell(self, strategy: str, key: str, qty: float, entry_price: float, external_fills: Optional[dict]): """A tracked position vanished without a bot-recorded sell (e.g. its STP order filled while the bot was disconnected). Backfill the ledger so the report and day-PnL stay accurate: use the real fill price when IB can provide it, otherwise estimate at the stop price and mark it.""" price = (external_fills or {}).get(f"{strategy}:{key}:STP") estimated = not price # None or 0.0 (IB may report 0.0 for cross-session fills) if estimated: price = round(entry_price * 0.98, 2) # rough STP fill estimate logger.warning( "Tracker: backfilling external sell %s %s x%g @ %.2f%s", strategy, key, qty, price, " (ESTIMATED)" if estimated else "", ) self._ledger_append("sell_external", strategy, key, qty, price, estimated=estimated, reason="HardStopOffline") self._add_day_pnl((price - entry_price) * qty) self._record_recent_sell(key, price) def reconcile(self, actual: dict[str, float], external_fills: Optional[dict] = None): """Reconcile tracked state against real account positions. actual: {symbol_or_pair: total quantity held in account}. external_fills: {orderRef: avgFillPrice} for STP fills retrieved from IB. - tracked entries with no real position are cleared (ledger backfilled) - tracked quantities exceeding the real position are clamped (ledger backfilled) - real positions with no owner are reported as UNMANAGED (never sold by the bot) """ changed = False for strategy in list(self._data): entries = self._data[strategy] for key in list(entries): if actual.get(key, 0) <= 0: entry = entries[key] logger.warning( "Tracker: %s owns %s x%g but account holds none - clearing stale state", strategy, key, entry["quantity"], ) self._record_external_sell(strategy, key, entry["quantity"], entry["entry_price"], external_fills) del entries[key] changed = True def tracked_totals(): totals: dict[str, float] = {} for entries in self._data.values(): for key, e in entries.items(): totals[key] = totals.get(key, 0) + e["quantity"] return totals for key, total in tracked_totals().items(): avail = actual.get(key, 0) if total > avail: logger.warning( "Tracker: tracked %s x%g exceeds account position %g - clamping", key, total, avail, ) remaining = avail for strategy, entries in self._data.items(): if key not in entries: continue entry = entries[key] keep = min(entry["quantity"], remaining) removed = entry["quantity"] - keep if removed > 1e-9: self._record_external_sell(strategy, key, removed, entry["entry_price"], external_fills) entry["quantity"] = keep changed = True remaining -= keep if entry["quantity"] <= 0: del entries[key] changed = True tracked = tracked_totals() for key, qty in actual.items(): if qty > tracked.get(key, 0): logger.warning( "Tracker: account holds %s x%g but only %g tracked - " "%g unit(s) UNMANAGED (bot will never sell them; " "manage manually or add them to %s)", key, qty, tracked.get(key, 0), qty - tracked.get(key, 0), self.state_file, ) if changed: self.save() logger.info("State reconciled with account: %s", self._data)