BotDeepSeek/state.py

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)