fix(resolution): cast $3 explicitly in close_paper_position and persist before mutating portfolio
CI/CD / build-and-push (push) Successful in 8s

Cycle-10 resolution check found market 562186 resolved (Paxton YES) but the
close failed with asyncpg AmbiguousParameterError: Postgres cannot infer the
type of a bare '$3 IS NOT NULL' in the close_pnl CASE. Reproduced via PREPARE
in the postgres pod; fixed by casting every $3 use to double precision.

The failed DB write also left memory/DB diverged: close_position() popped the
position and credited cash before persisting, so the retry at cycle 20 skipped
the market (pnl=n/a) while the DB row stayed open. Now the DB write happens
first and memory mutates only on success; check_resolutions() also isolates
per-market close failures so one error doesn't abort the cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-06-11 14:15:05 +00:00
co-authored by Claude Fable 5
parent e137116e7f
commit 5aa54eb423
4 changed files with 45 additions and 9 deletions
+6 -3
View File
@@ -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
+7 -3
View File
@@ -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,
+7 -3
View File
@@ -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",
+25
View File
@@ -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