fix(critical): remove dead manifold.get_probability() from legacy scan and fix BUY_NO payout calculation
CI/CD / build-and-push (push) Successful in 29s
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3a353c7e5b
commit
340c8523cf
@@ -152,6 +152,20 @@ class Database:
|
|||||||
""")
|
""")
|
||||||
return [dict(r) for r in rows]
|
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(
|
async def close_paper_position(
|
||||||
self, market_id: str, reason: str = "", resolution: Optional[float] = None
|
self, market_id: str, reason: str = "", resolution: Optional[float] = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
+34
-9
@@ -235,24 +235,49 @@ class PaperExecutor:
|
|||||||
"""Close a paper position after market resolution.
|
"""Close a paper position after market resolution.
|
||||||
|
|
||||||
resolution: 1.0 if YES won, 0.0 if NO won.
|
resolution: 1.0 if YES won, 0.0 if NO won.
|
||||||
Persists resolution and close_pnl to DB (computed via SQL from stored
|
Settlement payout per trade:
|
||||||
entry_price and shares). Returns approximate P&L for logging.
|
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:
|
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.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(
|
await self._db.close_paper_position(
|
||||||
market_id,
|
market_id,
|
||||||
reason=f"market_resolved resolution={resolution:.1f}",
|
reason=f"market_resolved resolution={resolution:.1f}",
|
||||||
resolution=resolution,
|
resolution=resolution,
|
||||||
)
|
)
|
||||||
approx_pnl = position_cost * resolution - position_cost
|
log.info(
|
||||||
log.info("Closed position in %s, resolution=%.1f", market_id, resolution)
|
"Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
|
||||||
asyncio.create_task(
|
market_id, resolution, payout, pnl,
|
||||||
telegram.trade_closed(question or market_id, approx_pnl)
|
|
||||||
)
|
)
|
||||||
# Approximate PnL: settlement value minus cost. Exact value is in close_pnl.
|
asyncio.create_task(
|
||||||
return approx_pnl
|
telegram.trade_closed(question or market_id, pnl)
|
||||||
|
)
|
||||||
|
return pnl
|
||||||
|
|||||||
+9
-34
@@ -223,14 +223,17 @@ async def run_trading_loop(
|
|||||||
async def run_legacy_scan(
|
async def run_legacy_scan(
|
||||||
db: Database,
|
db: Database,
|
||||||
markets: list,
|
markets: list,
|
||||||
manifold: ManifoldClient,
|
|
||||||
executor: PaperExecutor,
|
executor: PaperExecutor,
|
||||||
paper_mode: bool,
|
paper_mode: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
One-time startup scan: re-key all open DB positions with the current
|
One-time startup scan: re-key all open DB positions with the current
|
||||||
market_family_key() logic, detect contradictions, re-validate Manifold
|
market_family_key() logic, detect family conflicts, and report
|
||||||
signals, and report KEEP / REVIEW / CLOSE_RECOMMENDED per position.
|
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.
|
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_old": old_fk,
|
||||||
"family_key_new": new_fk,
|
"family_key_new": new_fk,
|
||||||
"fk_changed": new_fk != old_fk,
|
"fk_changed": new_fk != old_fk,
|
||||||
"manifold_prob_new": None,
|
|
||||||
"manifold_inverted": False,
|
|
||||||
"recommendation": "legacy_incomplete" if is_legacy_incomplete else "OK",
|
"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",
|
"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"],
|
p["market_id"], p["family_key_old"] or "none", p["family_key_new"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 3: Manifold re-query for positions whose family key changed
|
# Step 3: log the full scan report (before any closures)
|
||||||
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)
|
|
||||||
n_close = sum(1 for p in enriched if p["recommendation"] == "CLOSE_RECOMMENDED")
|
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_keep = sum(1 for p in enriched if p["recommendation"] == "KEEP")
|
||||||
n_ok = sum(1 for p in enriched if p["recommendation"] == "OK")
|
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"
|
" [%-18s] market=%-8s | dir=%-8s | edge_net=%+.3f\n"
|
||||||
" stored_family: %s\n"
|
" stored_family: %s\n"
|
||||||
" new_family: %s%s\n"
|
" new_family: %s%s\n"
|
||||||
" manifold_new: %s\n"
|
|
||||||
" reason: %s",
|
" reason: %s",
|
||||||
p["recommendation"],
|
p["recommendation"],
|
||||||
p["market_id"], p["direction"],
|
p["market_id"], p["direction"],
|
||||||
@@ -356,12 +332,11 @@ async def run_legacy_scan(
|
|||||||
p["family_key_old"] or "none",
|
p["family_key_old"] or "none",
|
||||||
p["family_key_new"],
|
p["family_key_new"],
|
||||||
" [CHANGED]" if p["fk_changed"] else "",
|
" [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"],
|
p["rec_reason"],
|
||||||
)
|
)
|
||||||
log.warning("━" * 70)
|
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):
|
if paper_mode and n_close > 0 and isinstance(executor, PaperExecutor):
|
||||||
log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close)
|
log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close)
|
||||||
for p in enriched:
|
for p in enriched:
|
||||||
@@ -414,7 +389,7 @@ async def main() -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not fetch markets for legacy scan: %s — scan skipped", e)
|
log.warning("Could not fetch markets for legacy scan: %s — scan skipped", e)
|
||||||
scan_markets = []
|
scan_markets = []
|
||||||
await run_legacy_scan(db, scan_markets, manifold, executor, PAPER_MODE)
|
await run_legacy_scan(db, scan_markets, executor, PAPER_MODE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await run_trading_loop(poly, external, strategy, risk, executor, metrics, db)
|
await run_trading_loop(poly, external, strategy, risk, executor, metrics, db)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user