fix(resolution): cast $3 explicitly in close_paper_position and persist before mutating portfolio
CI/CD / build-and-push (push) Successful in 8s
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:
co-authored by
Claude Fable 5
parent
e137116e7f
commit
5aa54eb423
+6
-3
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -58,9 +58,13 @@ async def check_resolutions(
|
||||
checked += 1
|
||||
if res is None or not res.resolved or res.resolution is None:
|
||||
continue
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user