From 340c8523cf475214c8bf83211392576b3f54863b Mon Sep 17 00:00:00 2001 From: chemavx Date: Thu, 11 Jun 2026 12:23:44 +0000 Subject: [PATCH] fix(critical): remove dead manifold.get_probability() from legacy scan and fix BUY_NO payout calculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- bot/data/db.py | 14 +++++ bot/executor/paper.py | 43 ++++++++++++---- bot/main.py | 43 ++++------------ tests/conftest.py | 10 ++++ tests/test_paper_close.py | 105 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 43 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_paper_close.py diff --git a/bot/data/db.py b/bot/data/db.py index fb72cb9..a4e4074 100644 --- a/bot/data/db.py +++ b/bot/data/db.py @@ -152,6 +152,20 @@ class Database: """) return [dict(r) for r in rows] + async def get_open_trades_for_market(self, market_id: str) -> list[dict]: + """Return direction, shares and net_cost for each open trade in a market. + + Used by PaperExecutor.close_position() to compute the settlement + payout per direction (BUY_NO pays out when resolution = 0.0). + """ + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT direction, shares, net_cost FROM trades " + "WHERE market_id = $1 AND closed_at IS NULL", + market_id, + ) + return [dict(r) for r in rows] + async def close_paper_position( self, market_id: str, reason: str = "", resolution: Optional[float] = None ) -> None: diff --git a/bot/executor/paper.py b/bot/executor/paper.py index 9e7fc36..9feb8aa 100644 --- a/bot/executor/paper.py +++ b/bot/executor/paper.py @@ -235,24 +235,49 @@ class PaperExecutor: """Close a paper position after market resolution. resolution: 1.0 if YES won, 0.0 if NO won. - Persists resolution and close_pnl to DB (computed via SQL from stored - entry_price and shares). Returns approximate P&L for logging. + Settlement payout per trade: + BUY_YES: shares * resolution + BUY_NO: shares * (1 - resolution) + pnl = payout - net_cost. + Persists resolution and close_pnl to DB. Returns realized P&L for + logging, or None if no position is open. """ if market_id not in self._portfolio.positions: return None position_cost = self._portfolio.positions.pop(market_id) - self._portfolio.cash += position_cost * resolution # pay out winnings + open_trades = await self._db.get_open_trades_for_market(market_id) + + if open_trades: + payout = sum( + float(t["shares"]) + * (resolution if t["direction"] == "BUY_YES" else 1.0 - resolution) + for t in open_trades + ) + net_cost = sum(float(t["net_cost"]) for t in open_trades) + pnl = payout - net_cost + else: + # In-memory position with no open DB trades: direction/shares are + # unknown, so settle at break-even instead of guessing the payout. + log.warning( + "close_position: no open DB trades for market %s — " + "settling at break-even", market_id, + ) + payout = position_cost + pnl = 0.0 + + self._portfolio.cash += payout await self._db.close_paper_position( market_id, reason=f"market_resolved resolution={resolution:.1f}", resolution=resolution, ) - approx_pnl = position_cost * resolution - position_cost - log.info("Closed position in %s, resolution=%.1f", market_id, resolution) - asyncio.create_task( - telegram.trade_closed(question or market_id, approx_pnl) + log.info( + "Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f", + market_id, resolution, payout, pnl, ) - # Approximate PnL: settlement value minus cost. Exact value is in close_pnl. - return approx_pnl + asyncio.create_task( + telegram.trade_closed(question or market_id, pnl) + ) + return pnl diff --git a/bot/main.py b/bot/main.py index 147517f..c1b0f7f 100644 --- a/bot/main.py +++ b/bot/main.py @@ -223,14 +223,17 @@ async def run_trading_loop( async def run_legacy_scan( db: Database, markets: list, - manifold: ManifoldClient, executor: PaperExecutor, paper_mode: bool, ) -> None: """ One-time startup scan: re-key all open DB positions with the current - market_family_key() logic, detect contradictions, re-validate Manifold - signals, and report KEEP / REVIEW / CLOSE_RECOMMENDED per position. + market_family_key() logic, detect family conflicts, and report + KEEP / REVIEW / CLOSE_RECOMMENDED per position. + + Manifold is intentionally not consulted here: with + MANIFOLD_SIGNAL_ENABLED=false it is observational-only and must not + drive position closures. In paper_mode: auto-closes all CLOSE_RECOMMENDED positions after logging. """ @@ -269,8 +272,6 @@ async def run_legacy_scan( "family_key_old": old_fk, "family_key_new": new_fk, "fk_changed": new_fk != old_fk, - "manifold_prob_new": None, - "manifold_inverted": False, "recommendation": "legacy_incomplete" if is_legacy_incomplete else "OK", "rec_reason": "edge_net and live market unavailable" if is_legacy_incomplete else "no family conflict", }) @@ -308,31 +309,7 @@ async def run_legacy_scan( p["market_id"], p["family_key_old"] or "none", p["family_key_new"], ) - # Step 3: Manifold re-query for positions whose family key changed - for p in enriched: - if p["live_market"] and p["fk_changed"]: - prob = await manifold.get_probability(p["question"]) - p["manifold_prob_new"] = prob - if prob is not None: - # Detect if original trade direction conflicts with corrected Manifold signal - if prob < 0.40 and p["direction"] == "BUY_YES": - p["manifold_inverted"] = True - note = f"Manifold:{prob:.3f} contradicts BUY_YES (inversion bug confirmed)" - if p["recommendation"] in ("OK", "REVIEW"): - p["recommendation"] = "CLOSE_RECOMMENDED" - p["rec_reason"] = note - else: - p["rec_reason"] += f" | {note}" - elif prob > 0.60 and p["direction"] == "BUY_NO": - p["manifold_inverted"] = True - note = f"Manifold:{prob:.3f} contradicts BUY_NO (inversion bug confirmed)" - if p["recommendation"] in ("OK", "REVIEW"): - p["recommendation"] = "CLOSE_RECOMMENDED" - p["rec_reason"] = note - else: - p["rec_reason"] += f" | {note}" - - # Step 4: log the full scan report (before any closures) + # Step 3: log the full scan report (before any closures) n_close = sum(1 for p in enriched if p["recommendation"] == "CLOSE_RECOMMENDED") n_keep = sum(1 for p in enriched if p["recommendation"] == "KEEP") n_ok = sum(1 for p in enriched if p["recommendation"] == "OK") @@ -348,7 +325,6 @@ async def run_legacy_scan( " [%-18s] market=%-8s | dir=%-8s | edge_net=%+.3f\n" " stored_family: %s\n" " new_family: %s%s\n" - " manifold_new: %s\n" " reason: %s", p["recommendation"], p["market_id"], p["direction"], @@ -356,12 +332,11 @@ async def run_legacy_scan( p["family_key_old"] or "none", p["family_key_new"], " [CHANGED]" if p["fk_changed"] else "", - f"{p['manifold_prob_new']:.3f}" if p["manifold_prob_new"] is not None else "n/a", p["rec_reason"], ) log.warning("━" * 70) - # Step 5: auto-close in paper mode + # Step 4: auto-close in paper mode if paper_mode and n_close > 0 and isinstance(executor, PaperExecutor): log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close) for p in enriched: @@ -414,7 +389,7 @@ async def main() -> None: except Exception as e: log.warning("Could not fetch markets for legacy scan: %s — scan skipped", e) scan_markets = [] - await run_legacy_scan(db, scan_markets, manifold, executor, PAPER_MODE) + await run_legacy_scan(db, scan_markets, executor, PAPER_MODE) try: await run_trading_loop(poly, external, strategy, risk, executor, metrics, db) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..86476ba --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Test environment shims. + +The bot runs on python:3.11-slim in production; local dev machines may have +3.10, which lacks datetime.UTC (added in 3.11). Alias it so modules using +`from datetime import UTC` import cleanly under 3.10. +""" +import datetime + +if not hasattr(datetime, "UTC"): + datetime.UTC = datetime.timezone.utc diff --git a/tests/test_paper_close.py b/tests/test_paper_close.py new file mode 100644 index 0000000..0c30c4a --- /dev/null +++ b/tests/test_paper_close.py @@ -0,0 +1,105 @@ +""" +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