CI/CD / build-and-push (push) Successful in 29s
- Legacy scan called ManifoldClient.get_probability(), removed in the v3
matcher migration, causing AttributeError when positions had changed
family keys. The block used Manifold to escalate positions to
CLOSE_RECOMMENDED (inversion detection) — a trading decision forbidden
under MANIFOLD_SIGNAL_ENABLED=false — so the dependency is removed
entirely; the scan keeps family re-keying and sibling-conflict logic.
- PaperExecutor.close_position() computed cash += position_cost * resolution,
ignoring direction: a winning BUY_NO (resolution=0.0) paid out $0 and
reported a loss. Now settles per trade:
BUY_YES: payout = shares * resolution
BUY_NO: payout = shares * (1 - resolution)
with pnl = payout - net_cost; Telegram win/loss keys off pnl > 0.
Adds read-only Database.get_open_trades_for_market().
- tests/test_paper_close.py covers the 4 deterministic payout cases;
tests/conftest.py shims datetime.UTC for local Python 3.10 (prod is 3.11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
106 lines
3.7 KiB
Python
106 lines
3.7 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", "market_resolved resolution=1.0", 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
|