Files
polymarket-bot/tests/test_paper_close.py
chemavxandClaude Fable 5 5aa54eb423
CI/CD / build-and-push (push) Successful in 8s
fix(resolution): cast $3 explicitly in close_paper_position and persist before mutating portfolio
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>
2026-06-11 14:15:05 +00:00

131 lines
4.6 KiB
Python

"""
Tests for PaperExecutor.close_position() settlement payout.
Regression: the old code computed cash += position_cost * resolution, which
ignores direction — a winning BUY_NO (resolution = 0.0) paid out $0.
Correct settlement:
BUY_YES: payout = shares * resolution
BUY_NO: payout = shares * (1 - resolution)
pnl = payout - net_cost
"""
import asyncio
import pytest
from bot.executor import paper
from bot.executor.paper import PaperExecutor
class FakeDB:
"""Minimal Database stub for close_position()."""
def __init__(self, trades_by_market: dict[str, list[dict]]):
self._trades = trades_by_market
self.closed: list[tuple] = []
async def get_open_trades_for_market(self, market_id: str) -> list[dict]:
return self._trades.get(market_id, [])
async def close_paper_position(self, market_id, reason="", resolution=None):
self.closed.append((market_id, reason, resolution))
def _close(direction: str, resolution: float):
"""Open one paper trade (size $100 @ 0.5 → 200 shares, net_cost $102)
and settle it at `resolution`. Returns (pnl, executor, notifications)."""
notifications: list[tuple] = []
async def fake_trade_closed(question, pnl):
notifications.append((question, pnl))
async def run():
db = FakeDB({
"mkt1": [{"direction": direction, "shares": 200.0, "net_cost": 102.0}],
})
ex = PaperExecutor(db=db, bankroll=1000.0)
ex._portfolio.cash = 898.0 # 1000 - net_cost spent at entry
ex._portfolio.positions["mkt1"] = 100.0 # size_usdc, as execute() stores it
original = paper.telegram.trade_closed
paper.telegram.trade_closed = fake_trade_closed
try:
pnl = await ex.close_position("mkt1", resolution, question="Test market?")
await asyncio.sleep(0) # let the notification task run
finally:
paper.telegram.trade_closed = original
return pnl, ex, db
pnl, ex, db = asyncio.run(run())
return pnl, ex, db, notifications
def test_buy_yes_wins():
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
assert pnl == pytest.approx(200.0 - 102.0) # payout = 200 * 1.0
assert pnl > 0
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
assert notif[0][1] > 0 # Telegram reports a win
def test_buy_yes_loses():
pnl, ex, db, notif = _close("BUY_YES", resolution=0.0)
assert pnl == pytest.approx(-102.0) # payout = 0
assert pnl < 0
assert ex._portfolio.cash == pytest.approx(898.0)
assert notif[0][1] < 0 # Telegram reports a loss
def test_buy_no_wins():
pnl, ex, db, notif = _close("BUY_NO", resolution=0.0)
assert pnl == pytest.approx(200.0 - 102.0) # payout = 200 * (1 - 0.0)
assert pnl > 0
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
assert notif[0][1] > 0 # win despite resolution = 0.0
def test_buy_no_loses():
pnl, ex, db, notif = _close("BUY_NO", resolution=1.0)
assert pnl == pytest.approx(-102.0) # payout = 200 * (1 - 1.0) = 0
assert pnl < 0
assert ex._portfolio.cash == pytest.approx(898.0)
assert notif[0][1] < 0 # loss despite resolution = 1.0
def test_position_is_removed_and_persisted():
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
assert "mkt1" not in ex._portfolio.positions
assert db.closed == [("mkt1", "resolved", 1.0)]
def test_unknown_market_returns_none():
async def run():
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