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.
|
is computed in SQL so it matches the stored entry_price and shares exactly.
|
||||||
"""
|
"""
|
||||||
async with self._pool.acquire() as conn:
|
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("""
|
await conn.execute("""
|
||||||
UPDATE trades
|
UPDATE trades
|
||||||
SET closed_at = NOW(),
|
SET closed_at = NOW(),
|
||||||
close_reason = $2,
|
close_reason = $2,
|
||||||
resolution = $3,
|
resolution = $3::double precision,
|
||||||
close_pnl = CASE
|
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
|
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
|
THEN ((1.0 - $3::double precision) - entry_price) * shares
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END
|
END
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ class PaperExecutor:
|
|||||||
if market_id not in self._portfolio.positions:
|
if market_id not in self._portfolio.positions:
|
||||||
return None
|
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)
|
open_trades = await self._db.get_open_trades_for_market(market_id)
|
||||||
|
|
||||||
if open_trades:
|
if open_trades:
|
||||||
@@ -266,13 +266,17 @@ class PaperExecutor:
|
|||||||
payout = position_cost
|
payout = position_cost
|
||||||
pnl = 0.0
|
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(
|
await self._db.close_paper_position(
|
||||||
market_id,
|
market_id,
|
||||||
reason="resolved",
|
reason="resolved",
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self._portfolio.positions.pop(market_id)
|
||||||
|
self._portfolio.cash += payout
|
||||||
log.info(
|
log.info(
|
||||||
"Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
|
"Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
|
||||||
market_id, resolution, payout, pnl,
|
market_id, resolution, payout, pnl,
|
||||||
|
|||||||
@@ -58,9 +58,13 @@ async def check_resolutions(
|
|||||||
checked += 1
|
checked += 1
|
||||||
if res is None or not res.resolved or res.resolution is None:
|
if res is None or not res.resolved or res.resolution is None:
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
pnl = await executor.close_position(
|
pnl = await executor.close_position(
|
||||||
market_id, res.resolution, question=pos.get("question") or "",
|
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
|
resolved += 1
|
||||||
log.info(
|
log.info(
|
||||||
"MARKET_RESOLVED market_id=%s resolution=%.1f pnl=%s | %s",
|
"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)
|
ex = PaperExecutor(db=FakeDB({}), bankroll=1000.0)
|
||||||
return await ex.close_position("nope", 1.0)
|
return await ex.close_position("nope", 1.0)
|
||||||
assert asyncio.run(run()) is None
|
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