321 lines
13 KiB
Python
321 lines
13 KiB
Python
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):
|
|
rec = {
|
|
"ts": datetime.now().isoformat(timespec="seconds"),
|
|
"type": type_, "strategy": strategy, "symbol": key,
|
|
"qty": qty, "price": price,
|
|
}
|
|
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):
|
|
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)
|
|
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):
|
|
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)
|
|
self._ledger_append("sell", strategy, key, quantity, price)
|
|
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)
|
|
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)
|