diff --git a/bot/data/db.py b/bot/data/db.py index a4e4074..a364c2c 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -176,15 +176,18 @@ class Database: is computed in SQL so it matches the stored entry_price and shares exactly. """ async with self._pool.acquire() as conn: + # $3 is cast on every use: Postgres cannot infer the parameter type + # from a bare "$3 IS NOT NULL" and fails the prepare with + # AmbiguousParameterError otherwise. await conn.execute(""" UPDATE trades SET closed_at = NOW(), close_reason = $2, - resolution = $3, + resolution = $3::double precision, close_pnl = CASE - WHEN $3 IS NOT NULL AND direction = 'BUY_YES' + WHEN $3::double precision IS NOT NULL AND direction = 'BUY_YES' THEN ($3::double precision - entry_price) * shares - WHEN $3 IS NOT NULL AND direction = 'BUY_NO' + WHEN $3::double precision IS NOT NULL AND direction = 'BUY_NO' THEN ((1.0 - $3::double precision) - entry_price) * shares ELSE NULL END diff --git a/bot/executor/paper.py b/bot/executor/paper.py index bf2192f..2b75bbe 100644 --- a/bot/executor/paper.py +++ b/bot/executor/paper.py @@ -245,7 +245,7 @@ class PaperExecutor: if market_id not in self._portfolio.positions: return None - position_cost = self._portfolio.positions.pop(market_id) + position_cost = self._portfolio.positions[market_id] open_trades = await self._db.get_open_trades_for_market(market_id) if open_trades: @@ -266,13 +266,17 @@ class PaperExecutor: payout = position_cost pnl = 0.0 - self._portfolio.cash += payout - + # Persist first, mutate memory after: if the DB write fails, the + # in-memory portfolio must keep the position so the next resolution + # check can retry the close. await self._db.close_paper_position( market_id, reason="resolved", resolution=resolution, ) + + self._portfolio.positions.pop(market_id) + self._portfolio.cash += payout log.info( "Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f", market_id, resolution, payout, pnl, diff --git a/bot/main.py b/bot/main.py index e2bcf10..217e4d4 100644 --- a/bot/main.py +++ b/bot/main.py @@ -58,9 +58,13 @@ async def check_resolutions( checked += 1 if res is None or not res.resolved or res.resolution is None: continue - pnl = await executor.close_position( - market_id, res.resolution, question=pos.get("question") or "", - ) + try: + pnl = await executor.close_position( + market_id, res.resolution, question=pos.get("question") or "", + ) + except Exception as exc: + log.error("Failed to close resolved market %s: %s", market_id, exc) + continue resolved += 1 log.info( "MARKET_RESOLVED market_id=%s resolution=%.1f pnl=%s | %s", diff --git a/tests/test_paper_close.py b/tests/test_paper_close.py index b497ad5..b833711 100644 --- a/tests/test_paper_close.py +++ b/tests/test_paper_close.py @@ -103,3 +103,28 @@ def test_unknown_market_returns_none(): ex = PaperExecutor(db=FakeDB({}), bankroll=1000.0) return await ex.close_position("nope", 1.0) assert asyncio.run(run()) is None + + +def test_db_failure_keeps_position_for_retry(): + """Regression: a DB error during close must not mutate the in-memory + portfolio — otherwise the next resolution check skips the market + (not in positions) and the DB row stays open forever.""" + + class FailingDB(FakeDB): + async def close_paper_position(self, market_id, reason="", resolution=None): + raise RuntimeError("db down") + + async def run(): + db = FailingDB({ + "mkt1": [{"direction": "BUY_YES", "shares": 200.0, "net_cost": 102.0}], + }) + ex = PaperExecutor(db=db, bankroll=1000.0) + ex._portfolio.cash = 898.0 + ex._portfolio.positions["mkt1"] = 100.0 + with pytest.raises(RuntimeError): + await ex.close_position("mkt1", 1.0) + return ex + + ex = asyncio.run(run()) + assert ex._portfolio.positions == {"mkt1": 100.0} # still open in memory + assert ex._portfolio.cash == pytest.approx(898.0) # payout not credited