From 8a56bf77d117415b28831795036d204341e81d0c Mon Sep 17 00:00:00 2001 From: chemavx Date: Wed, 22 Apr 2026 11:05:47 +0000 Subject: [PATCH] fix(accounting): use size_usdc (not net_cost) for positions in initialize() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the phantom 0.4% exposure overage after pod restarts. During live trading execute() stores size_usdc in portfolio.positions and deducts net_cost from cash — so total_value = bankroll − fees and exposure_pct = sum(size_usdc) / (bankroll − fees). Old initialize() stored net_cost in positions, making total_value = bankroll and inflating exposure_pct (observed: 30.085% vs runtime 29.670%). Fix: new get_open_position_data() returns both {market_id: size_usdc} and total_net_cost in one query; initialize() uses size_usdc for positions and total_net_cost for cash — identical model to execute(). Co-Authored-By: Claude Sonnet 4.6 --- bot/data/db.py | 21 +++++++++++++++++++++ bot/executor/paper.py | 30 ++++++++++++++++++++++-------- 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/bot/data/db.py b/bot/data/db.py index a23a4b5..6cb6410 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -91,6 +91,27 @@ class Database: ) return {r["market_id"]: float(r["total"]) for r in rows} + async def get_open_position_data(self) -> tuple[dict[str, float], float]: + """Return (positions_by_size_usdc, total_net_cost) for all open trades. + + positions_by_size_usdc — {market_id: size_usdc} mirrors what live trading + stores in portfolio.positions (no fee included). + total_net_cost — SUM(net_cost) across all open trades, used to + reconstruct cash = bankroll − total_net_cost. + + Together these let initialize() replicate the exact same accounting model + that execute() uses at runtime, eliminating the phantom exposure overage + caused by the old net_cost-in-positions approach. + """ + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT market_id, SUM(size_usdc) AS sz, SUM(net_cost) AS nc " + "FROM trades WHERE closed_at IS NULL GROUP BY market_id" + ) + positions = {r["market_id"]: float(r["sz"]) for r in rows} + total_net_cost = sum(float(r["nc"]) for r in rows) + return positions, total_net_cost + async def get_open_families(self) -> set[str]: """Return the set of family_key values from all open positions. diff --git a/bot/executor/paper.py b/bot/executor/paper.py index 7f80767..73145da 100644 --- a/bot/executor/paper.py +++ b/bot/executor/paper.py @@ -82,20 +82,34 @@ class PaperExecutor: Called once after __init__ so the executor reflects any trades that survived a pod restart. After a TRUNCATE the DB is empty and the portfolio resets to a full bankroll automatically. + + Accounting model (must match execute() exactly): + positions[market_id] = size_usdc (fee excluded — same as runtime) + cash = bankroll - sum(net_cost) (fees already spent) + total_value = cash + sum(size_usdc) = bankroll - sum(fees) + exposure_pct = sum(size_usdc) / total_value """ - positions = await self._db.get_open_positions() - if not positions: + positions_size, total_net_cost = await self._db.get_open_position_data() + if not positions_size: log.info("No open positions in DB — starting with full bankroll") return - total_deployed = sum(positions.values()) - self._portfolio.positions = positions - self._portfolio.cash = max(0.0, self._portfolio.cash - total_deployed) + positions_value = sum(positions_size.values()) + self._portfolio.positions = positions_size + self._portfolio.cash = max(0.0, self._portfolio.cash - total_net_cost) + + total_value = self._portfolio.cash + positions_value + exposure_pct = positions_value / total_value if total_value > 0 else 0.0 log.info( - "Restored %d open position(s) from DB — deployed $%.2f, cash $%.2f", - len(positions), - total_deployed, + "Restored %d open position(s) from DB — " + "positions_value=$%.2f net_cost_spent=$%.2f cash=$%.2f " + "total_value=$%.2f exposure=%.2f%%", + len(positions_size), + positions_value, + total_net_cost, self._portfolio.cash, + total_value, + exposure_pct * 100, ) def get_portfolio(self) -> Portfolio: