From 2338c44e6e2166ecb624fb5aaad6ab1a94b1cf20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:08:06 +0000 Subject: [PATCH 1/2] Backtest: fetch Polymarket price history in 14-day windows The CLOB now answers 400 ("'startTs' and 'endTs' interval is too long") to /prices-history windows longer than about 15 days, and the backtest asked for up to 32 days (30-day horizon + 2), so every case failed. The history is now fetched in windows of at most 14 days; resolved markets without hourly points are asked again every 12 hours; a 4xx for one market means no history (case skipped) instead of an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JLWZrfy12imc6dQRsEtFjj --- backend/markets/polymarket.py | 45 ++++++++++++++++++++++--------- docs/it/verifica.md | 3 ++- docs/verification.md | 3 ++- tests/test_polymarket.py | 50 +++++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 14 deletions(-) diff --git a/backend/markets/polymarket.py b/backend/markets/polymarket.py index 48e1dc4..4553b55 100644 --- a/backend/markets/polymarket.py +++ b/backend/markets/polymarket.py @@ -6,7 +6,7 @@ import json import logging from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Optional import httpx from backend.config import settings @@ -377,22 +377,43 @@ async def fetch_resolved_markets( return markets[:limit] +# The CLOB answers 400 ("'startTs' and 'endTs' interval is too long") to windows longer than +# about 15 days, and for resolved markets it often has no points finer than 12 hours. +HISTORY_MAX_WINDOW = timedelta(days=14) +HISTORY_FIDELITIES = (60, 720) # minutes: hourly first, then every 12 hours + + async def fetch_price_history(token_id: str, start: datetime, end: datetime, client: Optional[httpx.AsyncClient] = None) -> list[tuple[datetime, float]]: - """Price points (time, price) of a share between two dates, from the CLOB (hourly).""" + """Price points (time, price) of a share between two dates, from the CLOB. + + Long periods are fetched in windows of at most 14 days. If there are no hourly points + (common for resolved markets) it asks again every 12 hours. A market the CLOB does not + know (4xx) has no history: an empty list, not an error.""" own_client = client is None client = client or httpx.AsyncClient(base_url=settings.POLYMARKET_CLOB_URL, timeout=20.0) try: - res = await client.get("/prices-history", params={ - "market": token_id, "startTs": int(start.timestamp()), "endTs": int(end.timestamp()), "fidelity": 60, - }) - res.raise_for_status() - points = [] - for point in (res.json() or {}).get("history") or []: - t, p = point.get("t"), _float(point.get("p"), -1.0) - if isinstance(t, (int, float)) and 0.0 <= p <= 1.0: - points.append((datetime.fromtimestamp(t, tz=timezone.utc), p)) - return sorted(points) + for fidelity in HISTORY_FIDELITIES: + points: dict[int, float] = {} + lo = start + while lo < end: + hi = min(end, lo + HISTORY_MAX_WINDOW) + res = await client.get("/prices-history", params={ + "market": token_id, "startTs": int(lo.timestamp()), "endTs": int(hi.timestamp()), + "fidelity": fidelity, + }) + if 400 <= res.status_code < 500 and res.status_code != 429: + logger.info(f"No price history for token {token_id[:12]}…: {res.status_code} {res.text[:200]}") + return [] + res.raise_for_status() + for point in (res.json() or {}).get("history") or []: + t, p = point.get("t"), _float(point.get("p"), -1.0) + if isinstance(t, (int, float)) and 0.0 <= p <= 1.0 and lo.timestamp() <= t <= hi.timestamp(): + points[int(t)] = p + lo = hi + if points: + return sorted((datetime.fromtimestamp(t, tz=timezone.utc), p) for t, p in points.items()) + return [] finally: if own_client: await client.aclose() diff --git a/docs/it/verifica.md b/docs/it/verifica.md index adb365d..e2982e8 100644 --- a/docs/it/verifica.md +++ b/docs/it/verifica.md @@ -15,7 +15,8 @@ previsto Jev i mercati già risolti? (Gamma API, `closed=true`). 2. **Momenti.** Per ogni mercato e ogni orizzonte (1, 7 o 30 giorni prima della chiusura) ricostruisce la situazione di quel momento: - - il **prezzo di allora**, dallo storico della CLOB (`/prices-history`); + - il **prezzo di allora**, dallo storico della CLOB (`/prices-history`, chiesto a finestre di + al massimo 14 giorni, orario e, se un mercato risolto non ha punti orari, ogni 12 ore); - le **notizie dei 7 giorni precedenti**, da una di due fonti (scelta nel modulo): - **archivio**: gli articoli che questa app aveva già salvato a quella data (`fetched_at ≤ momento`). Nessuna notizia successiva può entrare: è la misura onesta, diff --git a/docs/verification.md b/docs/verification.md index de9f0ab..a3ac351 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -15,7 +15,8 @@ markets already resolved? (Gamma API, `closed=true`). 2. **Moments.** For each market and each horizon (1, 7 or 30 days before the close) it rebuilds the situation at that moment: - - the **price of the time**, from the CLOB history (`/prices-history`); + - the **price of the time**, from the CLOB history (`/prices-history`, asked in windows of at + most 14 days, hourly and, when a resolved market has no hourly points, every 12 hours); - the **news of the 7 previous days**, from one of two sources (chosen in the form): - **archive**: the articles this app had already saved at that date (`fetched_at ≤ moment`). No later news can get in: it is the honest measure, but it only diff --git a/tests/test_polymarket.py b/tests/test_polymarket.py index f759237..0893c91 100644 --- a/tests/test_polymarket.py +++ b/tests/test_polymarket.py @@ -78,3 +78,53 @@ def handler(request: httpx.Request): async with httpx.AsyncClient(base_url="https://clob.test", transport=httpx.MockTransport(handler)) as client: book = await polymarket.fetch_order_book("111", client=client) assert book.best_ask == 0.5 + + +def clob_history_like_polymarket(calls, hourly=True): + """Mimics the CLOB: 400 for windows longer than 15 days; with hourly=False no points + finer than 12 hours (as for many resolved markets).""" + def handler(request: httpx.Request): + q = request.url.params + start, end, fidelity = int(q["startTs"]), int(q["endTs"]), int(q["fidelity"]) + calls.append((start, end, fidelity)) + if end - start > 15 * 86400: + return httpx.Response(400, json={"error": "invalid filters: 'startTs' and 'endTs' interval is too long"}) + if not hourly and fidelity < 720: + return httpx.Response(200, json={"history": []}) + step = fidelity * 60 + # One point past endTs, as the CLOB appends the current price: it must be dropped + return httpx.Response(200, json={"history": [{"t": t, "p": 0.4} for t in range(start, end + 1, step)] + + [{"t": end + 10 * 86400, "p": 0.9}]}) + return handler + + +async def test_price_history_splits_long_periods_into_short_windows(): + from datetime import datetime, timedelta, timezone + end = datetime(2026, 9, 1, tzinfo=timezone.utc) + start = end - timedelta(days=32) + calls = [] + async with httpx.AsyncClient(base_url="https://clob.test", transport=httpx.MockTransport(clob_history_like_polymarket(calls))) as client: + points = await polymarket.fetch_price_history("111", start, end, client=client) + assert len(calls) == 3 and all(e - s <= 14 * 86400 for s, e, _ in calls) + assert points[0][0] == start and points[-1][0] == end and all(p == 0.4 for _, p in points) + assert len({t for t, _ in points}) == len(points) == 32 * 24 + 1 # hourly, no duplicates at the joins + assert polymarket.price_at(points, end - timedelta(days=30)) == 0.4 + + +async def test_price_history_falls_back_to_12_hours_for_resolved_markets(): + from datetime import datetime, timedelta, timezone + end = datetime(2026, 9, 1, tzinfo=timezone.utc) + calls = [] + async with httpx.AsyncClient(base_url="https://clob.test", transport=httpx.MockTransport(clob_history_like_polymarket(calls, hourly=False))) as client: + points = await polymarket.fetch_price_history("111", end - timedelta(days=9), end, client=client) + assert [c[2] for c in calls] == [60, 720] and len(points) == 9 * 2 + 1 + assert polymarket.price_at(points, end - timedelta(days=7, hours=5)) == 0.4 + + +async def test_price_history_unknown_market_is_empty_not_an_error(): + from datetime import datetime, timedelta, timezone + def handler(request): + return httpx.Response(400, json={"error": "invalid market"}) + end = datetime(2026, 9, 1, tzinfo=timezone.utc) + async with httpx.AsyncClient(base_url="https://clob.test", transport=httpx.MockTransport(handler)) as client: + assert await polymarket.fetch_price_history("bad", end - timedelta(days=3), end, client=client) == [] From d2ae17e524c51792a21bf48f3e20c18467fa1b5c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:28:07 +0000 Subject: [PATCH 2/2] Portfolio: keep history on exclude/readmit, plans for old outcome bets, bid value - Excluding a bet only changes its status; readmitting restores it as it was: a sold bet comes back sold, a closed one keeps its result and closing time (settle_bets no longer overwrites settled_at). Sold bets excluded by the old code get their result rebuilt from the sale. - latest_prediction falls back to the market's own forecast when its multi-outcome event has none, so positions opened before multi-outcome support get an exit plan and are reviewed by automatic selling. - Open positions are valued at the best bid minus the sale fee (what selling now would fetch); the value at the market price stays available. - "Sell now" asks for confirmation and offers to exclude the market from automatic buys, so the next forecast does not buy it back. - Opportunities flag stored exposure/cash reasons that no longer hold. - The simplified Kelly stake is labelled as indicative and points to the real stake of the economic assessment. - .table-wrap is position: relative, so the hidden "Actions" header no longer widens the Portfolio page. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JLWZrfy12imc6dQRsEtFjj --- backend/api/routes/markets.py | 14 +++- backend/api/routes/portfolio.py | 27 +++++-- backend/betting/plans.py | 5 ++ backend/betting/portfolio.py | 62 +++++++++++++-- docs/it/strategia.md | 12 ++- docs/strategy.md | 9 ++- frontend/app.js | 5 +- frontend/explain.js | 6 +- frontend/i18n-en.js | 17 ++++ frontend/strategy.js | 8 +- frontend/styles.css | 4 +- frontend/ui.js | 2 +- frontend/views/portfolio.js | 41 ++++++++-- tests/test_portfolio.py | 132 +++++++++++++++++++++++++++++++- 14 files changed, 306 insertions(+), 38 deletions(-) diff --git a/backend/api/routes/markets.py b/backend/api/routes/markets.py index 3ab3393..2ccf1bb 100644 --- a/backend/api/routes/markets.py +++ b/backend/api/routes/markets.py @@ -14,6 +14,7 @@ from backend.db.database import get_db, SessionLocal from backend.db.models import Market, MarketArticleLink, MarketPrediction from backend.config import settings +from backend.betting import portfolio from backend.markets import bulk, polymarket, targeted from backend.markets.forecast import brier_score from backend.markets.service import get_market_evidence, predict_market, refresh_links, run_market_pipeline @@ -283,10 +284,15 @@ async def list_opportunities( stmt = stmt.where(MarketPrediction.signal != "HOLD") rows = (await db.execute(stmt.order_by(func.abs(MarketPrediction.edge).desc()).limit(limit))).all() counts = await _link_counts(db, [m.id for m, _ in rows]) - return [ - {"market": _market_dict(m, counts.get(m.id, 0), p), "prediction": PredictionResponse.model_validate(p)} - for m, p in rows - ] + out = [] + for m, p in rows: + prediction = PredictionResponse.model_validate(p) + # The evaluation is the one of the forecast; exposure and cash may have changed since (a sale) + stale = await portfolio.stale_portfolio_reasons(db, m, p.economics) + if stale: + prediction.economics = {**prediction.economics, "stale": stale} + out.append({"market": _market_dict(m, counts.get(m.id, 0), p), "prediction": prediction}) + return out @predictions_router.get("/calibration", response_model=CalibrationResponse) diff --git a/backend/api/routes/portfolio.py b/backend/api/routes/portfolio.py index 28d200b..d890b4e 100644 --- a/backend/api/routes/portfolio.py +++ b/backend/api/routes/portfolio.py @@ -44,6 +44,7 @@ def _market_url(m: Market) -> Optional[str]: def _bet_dict(bet: PaperBet, market: Market) -> dict: value = portfolio.mark_value(bet, market) if bet.status == "open" else None + mid = portfolio.mid_value(bet, market) if bet.status == "open" else None return { "id": bet.id, "market_id": market.id, "question": market.question, "url": _market_url(market), "event_slug": market.event_slug, "category": market.category, "end_date": market.end_date, @@ -53,8 +54,11 @@ def _bet_dict(bet: PaperBet, market: Market) -> dict: "expected_profit": bet.expected_profit, "preset": bet.preset, "status": bet.status, "placed_by": bet.placed_by, "created_at": bet.created_at, "settled_at": bet.settled_at, "payout": bet.payout, "pnl": bet.pnl, "current_price": (market.yes_price if bet.side == "YES" else 1 - market.yes_price) if market.yes_price is not None else None, - "current_value": value, + "current_value": value, # selling now at the best bid, sale fee included "unrealized_pnl": (value - bet.stake - bet.fee) if value is not None else None, + "mid_value": mid, + "unrealized_pnl_mid": (mid - bet.stake - bet.fee) if mid is not None else None, + "bid_price": plans.known_bid(market, bet.side) if bet.status == "open" else None, "clv": portfolio.bet_clv(bet, market), "exit_price": bet.exit_price, "exit_reason": bet.exit_reason, } @@ -132,9 +136,11 @@ async def _bet(db: AsyncSession, bet_id: uuid.UUID) -> PaperBet: @router.post("/bets/{bet_id}/exclude", dependencies=admin) async def exclude_bet(bet_id: uuid.UUID, db: AsyncSession = Depends(get_db)): - """Removes a bet from the simulation (kept in the list, not counted in capital and results).""" + """Removes a bet from the simulation (kept in the list, not counted in capital and results). + Only the status changes: payout, profit and closing time stay, so readmitting it restores + the bet as it was (ledger and summary count bets by status).""" bet = await _bet(db, bet_id) - bet.status, bet.payout, bet.pnl, bet.settled_at = "excluded", None, None, None + bet.status = "excluded" await db.commit() return {"status": bet.status} @@ -146,11 +152,18 @@ async def include_bet(bet_id: uuid.UUID, db: AsyncSession = Depends(get_db)): return {"status": bet.status} other_open = (await db.execute(select(PaperBet.id).where( PaperBet.market_id == bet.market_id, PaperBet.status == "open", PaperBet.id != bet.id))).first() - if other_open: + if other_open and bet.exit_price is None: raise HTTPException(status_code=409, detail=tr("C'è già una scommessa aperta su questo mercato", "There is already an open bet on this market")) - bet.status = "open" - await db.commit() - await portfolio.settle_bets(db) # settles it right away if the market has resolved + if bet.exit_price is not None: + bet.status = "sold" # sold before being excluded: the shares are no longer held + if bet.pnl is None: # excluded before exclusions kept the result: rebuild it from the sale + bet.pnl = bet.exit_price * bet.shares - bet.stake - bet.fee + bet.settled_at = bet.settled_at or portfolio._now() + await db.commit() + else: + bet.status = "open" + await db.commit() + await portfolio.settle_bets(db) # settles it right away if the market has resolved await db.refresh(bet) return {"status": bet.status} diff --git a/backend/betting/plans.py b/backend/betting/plans.py index 9e1c700..ed3170c 100644 --- a/backend/betting/plans.py +++ b/backend/betting/plans.py @@ -46,6 +46,11 @@ async def best_bid(market: Market, side: str) -> Optional[float]: return book.bids[0][0] except Exception as e: logger.info(f"Order book unavailable for {market.id} ({side}): {e}") + return known_bid(market, side) + + +def known_bid(market: Market, side: str) -> Optional[float]: + """Best bid of `side` from the last sync (no network): Gamma's top of book, else mid − half spread.""" if side == "YES" and market.best_bid is not None: return market.best_bid if side == "NO" and market.best_ask is not None: diff --git a/backend/betting/portfolio.py b/backend/betting/portfolio.py index 05136ae..28c018c 100644 --- a/backend/betting/portfolio.py +++ b/backend/betting/portfolio.py @@ -188,6 +188,29 @@ async def evaluate_prediction(db: AsyncSession, market: Market, prediction: Mark ) +PORTFOLIO_REASONS = ("exposure_cap", "no_cash") # reasons that depend on the portfolio, not on the market + + +async def stale_portfolio_reasons(db: AsyncSession, market: Market, economics: Optional[dict]) -> list[str]: + """Blocking reasons of a stored evaluation that depend on the portfolio (exposure caps full, no + cash) and no longer hold now, e.g. after a sale. Prices are not re-read: only the portfolio.""" + codes = {r.get("code") for r in (economics or {}).get("reasons", []) if r.get("blocking")} & set(PORTFOLIO_REASONS) + if not codes: + return [] + profile = get_profile((await get_settings(db)).preset) + led, exp = await ledger(db), await exposure_for(db, market) + equity = led["equity"] + full = any(frac * equity - used <= 0 for frac, used in ( + (profile.max_market_frac, exp.market), (profile.max_event_frac, exp.event), + (profile.max_category_frac, exp.category), (profile.max_total_frac, exp.total))) + stale = [] + if "exposure_cap" in codes and not full: + stale.append("exposure_cap") + if "no_cash" in codes and led["cash"] > 0: + stale.append("no_cash") + return stale + + # ---------- Exclusions ---------- async def excluded_reason(db: AsyncSession, market: Market) -> Optional[str]: @@ -269,7 +292,7 @@ async def settle_bets(db: AsyncSession) -> int: bet.payout = bet.shares if won else 0.0 bet.status = "won" if won else "lost" bet.pnl = bet.payout - bet.stake - bet.fee - bet.settled_at = _now() + bet.settled_at = bet.settled_at or _now() # a readmitted bet keeps its original closing time await db.commit() return len(rows) @@ -291,7 +314,7 @@ async def sell_bet(db: AsyncSession, bet: PaperBet, market: Market, reason: str, if not bids: price = await best_bid(market, bet.side) bids = [(price, bet.shares)] if price else [] - fee_bps = market.taker_fee_bps if market.taker_fee_bps is not None else fees.category_rate(market.category) * 10_000 + fee_bps = fee_bps_of(market) left, proceeds, fee = bet.shares, 0.0, 0.0 for price, size in bids: if price < min_price or left <= 1e-9: @@ -345,13 +368,17 @@ async def review_open_bets(db: AsyncSession, market_ids: Optional[list] = None, async def latest_prediction(db: AsyncSession, market: Market): - """Latest forecast for a market (for an outcome of a multi-outcome event, its share of the distribution).""" + """Latest forecast for a market (for an outcome of a multi-outcome event, its share of the distribution). + An outcome without a forecast of its event falls back to a forecast of the market itself: + bets placed before multi-outcome support were forecast as Yes/No markets.""" if market.multi_event_id: from backend.db.models import MultiPrediction from backend.multi.service import outcome_prediction latest = (await db.execute(select(MultiPrediction).where(MultiPrediction.event_id == market.multi_event_id) .order_by(MultiPrediction.created_at.desc()).limit(1))).scalar_one_or_none() - return outcome_prediction(latest, market.id) if latest else None + outcome = outcome_prediction(latest, market.id) if latest else None + if outcome is not None: + return outcome return (await db.execute(select(MarketPrediction).where(MarketPrediction.market_id == market.id) .order_by(MarketPrediction.created_at.desc()).limit(1))).scalar_one_or_none() @@ -363,20 +390,35 @@ def bet_clv(bet: PaperBet, market: Market) -> Optional[float]: return clv.clv(bet.avg_price, market.last_trading_price, bet.side) -def mark_value(bet: PaperBet, market: Market) -> Optional[float]: - """Current value of an open bet at the market price (what the shares would be worth now).""" +def fee_bps_of(market: Market) -> float: + return market.taker_fee_bps if market.taker_fee_bps is not None else fees.category_rate(market.category) * 10_000 + + +def mid_value(bet: PaperBet, market: Market) -> Optional[float]: + """Value of an open bet at the market (mid) price.""" if market.yes_price is None: return None price = market.yes_price if bet.side == "YES" else 1.0 - market.yes_price return bet.shares * price +def mark_value(bet: PaperBet, market: Market) -> Optional[float]: + """What an open bet would fetch if sold now: the shares at the best bid known from the last + sync, minus the sale fee. Lower than the value at the mid price by half the spread.""" + from backend.betting.fees import fee_per_share + from backend.betting.plans import known_bid + bid = known_bid(market, bet.side) + if bid is None: + return None + return bet.shares * (bid - fee_per_share(bid, fee_bps_of(market))) + + async def summary(db: AsyncSession) -> dict: s = await get_settings(db) led = await ledger(db) bets = (await db.execute(select(PaperBet, Market).join(Market, Market.id == PaperBet.market_id) .order_by(PaperBet.created_at))).all() - open_value = unrealized = 0.0 + open_value = unrealized = unrealized_mid = 0.0 counts = Counter(bet.status for bet, _ in bets) expected_settled = 0.0 settled_rows = sorted((r for r in bets if r[0].status in SETTLED), key=lambda r: r[0].settled_at) @@ -393,6 +435,9 @@ async def summary(db: AsyncSession) -> dict: if value is not None: open_value += value unrealized += value - bet.stake - bet.fee + mid = mid_value(bet, market) + if mid is not None: + unrealized_mid += mid - bet.stake - bet.fee settled = counts["won"] + counts["lost"] sold_won = sum(1 for bet, _ in bets if bet.status == "sold" and (bet.pnl or 0) > 0) return { @@ -403,7 +448,8 @@ async def summary(db: AsyncSession) -> dict: "invested": led["open_outlay"], "open_value": open_value, "realized_pnl": led["realized"], - "unrealized_pnl": unrealized, + "unrealized_pnl": unrealized, # selling now at the best bid, sale fee included + "unrealized_pnl_mid": unrealized_mid, # at the market (mid) price "total_value": led["cash"] + open_value, "roi": (led["cash"] + open_value - s.bankroll) / s.bankroll if s.bankroll else None, "counts": {"open": counts["open"], "won": counts["won"], "lost": counts["lost"], "void": counts["void"], "sold": counts["sold"], "excluded": counts["excluded"]}, diff --git a/docs/it/strategia.md b/docs/it/strategia.md index 7368d6b..3824abb 100644 --- a/docs/it/strategia.md +++ b/docs/it/strategia.md @@ -48,15 +48,21 @@ previsione e, dal vivo, nella scheda **Conviene?** del dettaglio mercato. - **Vendita:** dopo ogni aggiornamento dei mercati e ogni nuova previsione le posizioni aperte vengono riviste con la [strategia](#strategia-di-acquisto-e-vendita): se il piano dice «Vendi», le quote vengono vendute sul book (stato «venduta», con prezzo e motivo). Si può - disattivare («Vendi automaticamente») o vendere a mano. Le vendite in guadagno contano come - vinte. + disattivare («Vendi automaticamente») o vendere a mano: «Vendi ora» chiede conferma e propone + di escludere il mercato dagli acquisti automatici (attivo di base), altrimenti la previsione + successiva che dice ancora «conviene» lo ricomprerebbe a un prezzo più alto. Le vendite in + guadagno contano come vinte. - **Chiusura:** avviene quando il mercato si risolve in modo definitivo (prezzo a 1/0 e, se Gamma lo riporta, `umaResolutionStatus` = «resolved»: un esito solo proposto o contestato può ancora cambiare). Una quota vincente vale 1 $; se il mercato si chiude 50-50 ogni quota vale 0,50 $ e la scommessa risulta «annullata» (fuori dalla percentuale di vittorie). - **Cosa mostra:** valore attuale, profitti realizzati e latenti, percentuale di vittorie, curva del capitale e confronto tra profitto atteso e reale. + Le posizioni aperte valgono quanto si incasserebbe vendendole ora: il miglior prezzo di + acquisto dell'ultimo aggiornamento, meno la commissione di vendita (il valore al prezzo di + mercato compare al passaggio del mouse). - **Esclusioni:** - - singole scommesse, anche già chiuse (non contano nei risultati e si possono riammettere); + - singole scommesse, anche già chiuse (non contano nei risultati e si possono riammettere + com'erano: stesso risultato e stessa data di chiusura; una scommessa venduta torna venduta); - mercati, eventi o categorie, che le scommesse automatiche saltano. - **Impostazioni:** si possono scegliere il preset, attivare o disattivare le scommesse e le vendite automatiche, oppure ricominciare con un nuovo capitale. diff --git a/docs/strategy.md b/docs/strategy.md index 5264476..6bb459d 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -47,14 +47,19 @@ every forecast and, live, in the **Worth it?** card of the market detail page. - **Selling:** after every market update and every new forecast, open positions are reviewed with the [strategy](#buy-and-sell-strategy): if the plan says «Sell», the shares are sold on the book (status «sold», with price and reason). It can be turned off («Sell automatically») - or done by hand. Sales at a profit count as won. + or done by hand: «Sell now» asks for confirmation and offers to exclude the market from + automatic buys (on by default), otherwise the next forecast that still says «worth it» would + buy it back at a higher price. Sales at a profit count as won. - **Closing:** happens when the market resolves for good (price at 1/0 and, if Gamma reports it, `umaResolutionStatus` = «resolved»: an outcome that is only proposed or disputed can still change). A winning share is worth $1; if the market closes 50-50 each share is worth $0.50 and the bet counts as «voided» (outside the win rate). - **What it shows:** current value, realised and unrealised profits, win rate, capital curve and a comparison between expected and actual profit. + Open positions are valued at what selling them now would fetch: the best bid from the last + sync, minus the sale fee (the value at the market price is shown on hover). - **Exclusions:** - - single bets, even already closed ones (they do not count in the results and can be readmitted); + - single bets, even already closed ones (they do not count in the results and can be readmitted + as they were: same result and closing date; a sold bet comes back sold); - markets, events or categories, which automatic bets skip. - **Settings:** you can choose the preset, turn automatic bets and sales on or off, or start over with a new capital. diff --git a/frontend/app.js b/frontend/app.js index 027bc39..da9ada4 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -471,8 +471,9 @@ function opportunityCard({ market, prediction: p }) { p.economics ? h("div", {}, h("div", { class: "fig-label" }, t("Conviene?"), infoTip(t("Valutazione economica al momento della previsione: prezzo reale dal book, costi, incertezza, tempo e limiti del preset."))), verdictBadge(p.economics.verdict), + p.economics.stale?.length ? h("div", { class: "fig-label", style: { marginTop: "4px" } }, t("da ricalcolare")) : null, p.economics.verdict !== "NO" ? h("div", { class: "fig-label", style: { marginTop: "4px" } }, t("Puntata {0}", fmt.money(p.economics.outlay))) : null) - : h("div", {}, h("div", { class: "fig-label" }, t("Puntata suggerita"), infoTip(GLOSSARY.kelly)), h("div", { class: "fig-value" }, p.kelly_fraction > 0 ? `${fmt.pct(p.kelly_fraction)}` : "–"), + : h("div", {}, h("div", { class: "fig-label" }, t("Kelly semplice"), infoTip(GLOSSARY.kelly)), h("div", { class: "fig-value" }, p.kelly_fraction > 0 ? `${fmt.pct(p.kelly_fraction)}` : "–"), p.kelly_fraction > 0 ? h("div", { class: "fig-label" }, t("del bankroll")) : null), ), h("div", {}, h("div", { class: "fig-label" }, t("Forza delle evidenze"), infoTip(GLOSSARY.evidence)), meter(p.evidence_strength, t("Forza delle evidenze"))), @@ -657,7 +658,7 @@ async function viewMarketDetail(id) { probLegend({ market: latest.market_probability, blended: latest.blended_probability, jev: latest.model_probability }), h("div", { class: "opp-figures", style: { marginTop: "16px", gridTemplateColumns: "repeat(3, minmax(0, 1fr))" } }, h("div", {}, h("div", { class: "fig-label" }, t("Edge")), h("div", { class: `fig-value ${latest.edge > 0 ? "pos" : latest.edge < 0 ? "neg" : ""}` }, fmt.pts(latest.edge))), - h("div", {}, h("div", { class: "fig-label" }, t("Puntata")), h("div", { class: "fig-value" }, latest.kelly_fraction > 0 ? fmt.pct(latest.kelly_fraction) : "–")), + h("div", {}, h("div", { class: "fig-label" }, t("Kelly semplice"), infoTip(GLOSSARY.kelly)), h("div", { class: "fig-value" }, latest.kelly_fraction > 0 ? fmt.pct(latest.kelly_fraction) : "–")), h("div", {}, h("div", { class: "fig-label" }, t("Notizie")), h("div", { class: "fig-value" }, fmt.int(latest.article_count))), ), h("div", { style: { marginTop: "12px" } }, h("div", { class: "fig-label" }, t("Forza delle evidenze")), meter(latest.evidence_strength, t("Forza delle evidenze"))), diff --git a/frontend/explain.js b/frontend/explain.js index b6c22c5..2f34694 100644 --- a/frontend/explain.js +++ b/frontend/explain.js @@ -115,7 +115,7 @@ export function explainCard(p, market, evidence, status) { h("p", {}, t("Risultato: "), h("b", {}, p.signal === "BUY_YES" ? t("Compra SÌ") : p.signal === "BUY_NO" ? t("Compra NO") : t("Attendi")), p.signal === "HOLD" ? t(" (almeno un controllo non è superato).") : "."), ), - p.signal !== "HOLD" ? step(6, t("Puntata suggerita"), GLOSSARY.kelly, + p.signal !== "HOLD" ? step(6, t("Kelly semplice (indicativo)"), GLOSSARY.kelly, buyYes ? formula(t("Kelly = ({0} − {1}) / (100% − {2}) = {3}", pct(b), pct(q), pct(q), pct(fullKelly))) : formula(t("Kelly = ({0} − {1}) / {2} = {3}", pct(q), pct(b), pct(q), pct(fullKelly))), @@ -123,6 +123,10 @@ export function explainCard(p, market, evidence, status) { h("p", { class: "muted small" }, buyYes ? t("Compri quote SÌ a {0}: se il mercato si risolve SÌ ogni quota vale 1 $.", fmt.cents(q)) : t("Compri quote NO a {0}: se il mercato si risolve NO ogni quota vale 1 $.", fmt.cents(1 - q))), + h("p", { class: "muted small" }, + p.economics && p.economics.verdict !== "NO" && p.economics.outlay + ? t("È un'indicazione sul prezzo di mercato. Con prezzo reale del book, commissioni, incertezza e limiti del preset la puntata era {0} al momento della previsione: è quella che conta (scheda «Conviene?»).", fmt.money(p.economics.outlay)) + : t("È un'indicazione sul prezzo di mercato: la puntata effettiva, con prezzo reale del book, commissioni, incertezza e limiti del preset, è nella scheda «Conviene?».")), ) : null, ), h("p", { class: "note", style: { marginTop: "14px" } }, icon("alert"), diff --git a/frontend/i18n-en.js b/frontend/i18n-en.js index f404e29..36ea298 100644 --- a/frontend/i18n-en.js +++ b/frontend/i18n-en.js @@ -1168,4 +1168,21 @@ export default { "Le notizie arrivano al prossimo aggiornamento.": "The news arrives at the next update.", "{0} attivata": "{0} activated", "{0} aggiornata": "{0} updated", + "Al prezzo di mercato: {0}": "At the market price: {0}", + "Prezzo di mercato {0}": "Market price {0}", + "Conferma la vendita": "Confirm the sale", + "Dopo la previsione si è liberato spazio nel portafoglio: la valutazione di allora non vale più. Apri il mercato per i conti aggiornati.": "Room has freed up in the portfolio since the forecast: the assessment of that time no longer holds. Open the market for up-to-date numbers.", + "Escludi il mercato dagli acquisti automatici": "Exclude the market from automatic buys", + "Kelly semplice": "Simple Kelly", + "Kelly semplice (indicativo)": "Simple Kelly (indicative)", + "da ricalcolare": "to recompute", + "Ricomincia…": "Start over…", + "Prezzo di vendita": "Sale price", + "Scommessa venduta; il mercato è escluso dagli acquisti automatici": "Bet sold; the market is excluded from automatic buys", + "Vendere {0} quote al miglior prezzo del book, circa {1}?": "Sell {0} shares at the best book price, about {1}?", + "Miglior prezzo di acquisto offerto nel book all'ultimo aggiornamento: quanto si incasserebbe vendendo ora. Valore e profitto latente sono calcolati a questo prezzo, al netto della commissione di vendita; al passaggio del mouse, il prezzo di mercato.": "Best bid in the book at the last update: what selling now would fetch. Value and unrealised profit are computed at this price, net of the sale fee; hover for the market price.", + "Vendendo ora le posizioni aperte al miglior prezzo offerto, commissione compresa. Al prezzo di mercato sarebbero {0}.": "Selling the open positions now at the best bid, fee included. At the market price they would be {0}.", + "Quota del capitale secondo il criterio di Kelly sulla probabilità blended e sul prezzo, ridotta per prudenza (Kelly frazionario). È indicativa: non tiene conto di book, commissioni, incertezza e limiti del preset. La puntata effettiva è quella della scheda «Conviene?».": "Share of capital according to the Kelly criterion on the blended probability and the price, reduced for prudence (fractional Kelly). It is indicative: it ignores the book, fees, uncertainty and the preset limits. The actual stake is the one in the «Worth it?» card.", + "È un'indicazione sul prezzo di mercato. Con prezzo reale del book, commissioni, incertezza e limiti del preset la puntata era {0} al momento della previsione: è quella che conta (scheda «Conviene?»).": "It is an indication on the market price. With the real book price, fees, uncertainty and the preset limits the stake was {0} at the time of the forecast: that is the one that counts («Worth it?» card).", + "È un'indicazione sul prezzo di mercato: la puntata effettiva, con prezzo reale del book, commissioni, incertezza e limiti del preset, è nella scheda «Conviene?».": "It is an indication on the market price: the actual stake, with the real book price, fees, uncertainty and the preset limits, is in the «Worth it?» card.", }; diff --git a/frontend/strategy.js b/frontend/strategy.js index f6b1c01..a2b142e 100644 --- a/frontend/strategy.js +++ b/frontend/strategy.js @@ -102,8 +102,14 @@ const STRUCTURAL = new Set(["illiquid", "too_far", "exposure_cap", "no_cash", "n export function planLine(ev) { if (!ev) return null; const side = SIDE[ev.side]; - const blocking = (ev.reasons || []).filter((r) => r.blocking); + // Reasons about the portfolio (exposure, cash) that no longer hold, e.g. after a sale + const stale = new Set(ev.stale || []); + const blocking = (ev.reasons || []).filter((r) => r.blocking && !stale.has(r.code)); let badge, text; + if (stale.size && !blocking.length) { + return h("p", { class: "opp-plan" }, icon("refresh"), h("span", { class: "small" }, + t("Dopo la previsione si è liberato spazio nel portafoglio: la valutazione di allora non vale più. Apri il mercato per i conti aggiornati."))); + } if (ev.verdict === "GO" || ev.verdict === "SMALL") { badge = actionBadge({ action: "BUY", side: ev.side }); text = t("{0} a non più di {1} · circa {2}", side, fmt.cents(ev.limit_price), fmt.money(ev.outlay)); diff --git a/frontend/styles.css b/frontend/styles.css index 7a16c3f..9b6bc4e 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -104,7 +104,7 @@ button, input, select { font: inherit; color: inherit; } .small { font-size: 0.85rem; } .mono, .num { font-family: var(--mono); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; } .container { max-width: 1200px; margin: 0 auto; padding-inline: 16px; } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } .skip-link { position: absolute; left: -999px; } .skip-link:focus { left: 16px; top: 8px; z-index: 100; background: var(--surface); padding: 8px 12px; border-radius: 8px; } .icon-svg { width: 18px; height: 18px; flex: none; } @@ -384,7 +384,7 @@ main { padding-block: 24px; outline: none; } .meter-value { font-family: var(--mono); font-size: 0.78rem; color: var(--text-secondary); min-width: 3em; text-align: right; } /* ---------- Tables ---------- */ -.table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } +.table-wrap { position: relative; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } table { width: 100%; border-collapse: collapse; font-size: 0.92rem; } th, td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--grid); vertical-align: middle; } th { font-weight: 500; font-size: 0.78rem; color: var(--text-muted); background: var(--surface); white-space: nowrap; } diff --git a/frontend/ui.js b/frontend/ui.js index 7270ed4..8af2787 100644 --- a/frontend/ui.js +++ b/frontend/ui.js @@ -331,7 +331,7 @@ export const GLOSSARY = { weight: t("Peso della stima di Jev nella probabilità finale: peso massimo × forza delle evidenze. Il resto del peso va al prezzo di mercato."), blended: t("Probabilità finale usata per il segnale: una media pesata tra la stima di Jev e il prezzo di mercato."), edge: t("Differenza tra la probabilità blended e il prezzo, in punti percentuali. Positivo: il SÌ sembra sottovalutato. Negativo: il NO sembra sottovalutato."), - kelly: t("Quota del capitale da puntare secondo il criterio di Kelly, ridotta per prudenza (Kelly frazionario). È un'indicazione, non un obbligo."), + kelly: t("Quota del capitale secondo il criterio di Kelly sulla probabilità blended e sul prezzo, ridotta per prudenza (Kelly frazionario). È indicativa: non tiene conto di book, commissioni, incertezza e limiti del preset. La puntata effettiva è quella della scheda «Conviene?»."), brier: t("Errore quadratico medio tra probabilità prevista ed esito reale (1 se SÌ, 0 se NO). Più basso è meglio; dire sempre 50% vale 0,25."), relevance: t("Quanto la notizia può cambiare la probabilità di un evento futuro verificabile su cui si scommette (elezioni, tassi, conflitti, sentenze, prezzi, partite)."), }; diff --git a/frontend/views/portfolio.js b/frontend/views/portfolio.js index b8ca0c6..b47746d 100644 --- a/frontend/views/portfolio.js +++ b/frontend/views/portfolio.js @@ -55,24 +55,52 @@ export async function viewPortfolio(ctx) { }); return btn; }; + // Manual sale: asks first, and offers to keep the automatic buys off this market, otherwise + // the next forecast that still says "worth it" would buy it back at a higher price + const sellNow = (bet) => { + const btn = h("button", { class: "btn btn-ghost btn-sm", type: "button" }, t("Vendi ora")); + btn.addEventListener("click", () => { + const cell = btn.closest("td"); + const previous = [...cell.childNodes]; + const box = h("input", { type: "checkbox", id: `sell-excl-${bet.id}`, checked: true }); + const go = h("button", { class: "btn btn-danger-solid btn-sm", type: "button" }, t("Vendi")); + const cancel = h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: () => cell.replaceChildren(...previous) } }, t("Annulla")); + go.addEventListener("click", async () => { + go.disabled = cancel.disabled = true; + try { + await api(`/portfolio/bets/${bet.id}/sell`, { method: "POST" }); + if (box.checked) await api("/portfolio/exclusions", { method: "POST", body: { kind: "market", value: bet.market_id, label: bet.question } }); + toast(box.checked ? t("Scommessa venduta; il mercato è escluso dagli acquisti automatici") : t("Scommessa venduta al prezzo del book")); + refresh(); + } catch (e) { toast(e.message, { error: true }); go.disabled = cancel.disabled = false; } + }); + cell.replaceChildren(h("div", { class: "confirm", role: "group", "aria-label": t("Conferma la vendita") }, + h("p", { class: "small" }, t("Vendere {0} quote al miglior prezzo del book, circa {1}?", fmt.shares(bet.shares), money(bet.current_value))), + h("label", { class: "small", for: `sell-excl-${bet.id}` }, box, " ", t("Escludi il mercato dagli acquisti automatici")), + h("div", { class: "confirm-actions" }, go, cancel))); + go.focus(); + }); + return btn; + }; const marketLink = (b) => h("a", { href: b.multi_event_id ? `#/multi/${encodeURIComponent(b.multi_event_id)}` : `#/mercati/${encodeURIComponent(b.market_id)}` }, b.question); const openTable = open.length ? h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, t("Mercato")), h("th", {}, t("Lato")), h("th", { class: "num" }, t("Quote")), h("th", { class: "num" }, t("Prezzo medio")), - h("th", { class: "num" }, t("Costo")), h("th", { class: "num" }, t("Prezzo attuale")), h("th", { class: "num" }, t("Valore")), + h("th", { class: "num" }, t("Costo")), h("th", { class: "num" }, t("Prezzo di vendita"), infoTip(t("Miglior prezzo di acquisto offerto nel book all'ultimo aggiornamento: quanto si incasserebbe vendendo ora. Valore e profitto latente sono calcolati a questo prezzo, al netto della commissione di vendita; al passaggio del mouse, il prezzo di mercato."))), + h("th", { class: "num" }, t("Valore")), h("th", { class: "num" }, t("Profitto latente")), h("th", {}, t("Piano")), h("th", {}, t("Scade")), admin ? h("th", {}, h("span", { class: "sr-only" }, t("Azioni"))) : null, )), h("tbody", {}, open.map((b) => h("tr", {}, h("td", { class: "q-cell" }, marketLink(b)), h("td", {}, sideBadge(b.side)), h("td", { class: "num" }, fmt.shares(b.shares)), h("td", { class: "num" }, fmt.cents(b.avg_price)), - h("td", { class: "num" }, money(b.outlay)), h("td", { class: "num" }, fmt.cents(b.current_price)), - h("td", { class: "num" }, money(b.current_value)), + h("td", { class: "num" }, money(b.outlay)), h("td", { class: "num", title: b.current_price != null ? t("Prezzo di mercato {0}", fmt.cents(b.current_price)) : "" }, fmt.cents(b.bid_price)), + h("td", { class: "num", title: b.mid_value != null ? t("Al prezzo di mercato: {0}", money(b.mid_value)) : "" }, money(b.current_value)), h("td", { class: `num ${pnlCls(b.unrealized_pnl)}` }, fmt.signedMoney(b.unrealized_pnl)), h("td", { class: "nowrap" }, planCell(b)), h("td", { class: "nowrap" }, fmt.date(b.end_date)), admin ? h("td", { class: "row-actions" }, - betAction(b, `/portfolio/bets/${b.id}/sell`, t("Vendi ora"), t("Scommessa venduta al prezzo del book")), + sellNow(b), betAction(b, `/portfolio/bets/${b.id}/exclude`, t("Escludi"), t("Scommessa esclusa dalla simulazione")), excludeMarket(b)) : null, ))), )) : h("p", { class: "secondary" }, t("Nessuna posizione aperta.")); @@ -119,7 +147,8 @@ export async function viewPortfolio(ctx) { h("div", { class: "kpis" }, statTile(t("Valore attuale"), money(data.total_value), data.roi != null ? t("{0} da capitale {1}", fmt.pts(data.roi).replace(` ${t("pt")}`, "%"), money(s.bankroll)) : ""), statTile(t("Profitti realizzati"), fmt.signedMoney(data.realized_pnl), t("{0} vinte · {1} perse{2}{3}", data.counts.won, data.counts.lost, data.counts.sold ? t(" · {0} vendute", data.counts.sold) : "", data.counts.void ? t(" · {0} annullate", data.counts.void) : "")), - statTile(t("Profitti latenti"), fmt.signedMoney(data.unrealized_pnl), t("{0} aperte · {1} investiti", data.counts.open, money(data.invested))), + statTile(h("span", {}, t("Profitti latenti"), infoTip(t("Vendendo ora le posizioni aperte al miglior prezzo offerto, commissione compresa. Al prezzo di mercato sarebbero {0}.", fmt.signedMoney(data.unrealized_pnl_mid)))), + fmt.signedMoney(data.unrealized_pnl), t("{0} aperte · {1} investiti", data.counts.open, money(data.invested))), statTile(t("Scommesse vinte"), data.hit_rate != null ? fmt.pct(data.hit_rate) : "–", t("liquidità {0}", money(data.cash))), statTile(t("Prezzo di chiusura"), data.clv?.n ? fmt.pts(data.clv.avg) : "–", data.clv?.n ? t("{0} comprate sotto la chiusura · {1}", fmt.pct(data.clv.share_positive), fmt.count(data.clv.n, t("mercato chiuso"), t("mercati chiusi"))) @@ -173,7 +202,7 @@ function settingsCard(ctx, data, refresh) { const paintReset = () => resetArea.replaceChildren( h("div", { class: "reset-row" }, h("span", {}, t("Capitale iniziale "), h("b", { class: "mono" }, money(s.bankroll))), - admin ? h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: askReset } }, "Ricomincia…") : null, + admin ? h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: askReset } }, t("Ricomincia…")) : null, )); function askReset() { const amount = h("input", { id: "reset-amount", class: "input", type: "number", min: "10", step: "10", value: String(s.bankroll), style: { maxWidth: "160px" } }); diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index 43b04ab..60cd0ad 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -118,7 +118,12 @@ async def test_settlement_pnl_curve_and_exclusion(db, book): await s.commit() summ = await portfolio.summary(s) bet = (await s.execute(select(PaperBet))).scalar_one() - assert summ["unrealized_pnl"] == pytest.approx(bet.shares * 0.5 - 40) + assert summ["unrealized_pnl_mid"] == pytest.approx(bet.shares * 0.5 - 40) + # Selling now: at the bid (no book synced: mid − half the default spread), minus the sale fee + from backend.betting.fees import fee_per_share + from backend.config import settings + bid = 0.5 - settings.DEFAULT_SPREAD / 2 + assert summ["unrealized_pnl"] == pytest.approx(bet.shares * (bid - fee_per_share(bid, portfolio.fee_bps_of(m))) - 40) m.resolved_yes, m.closed = True, True await s.commit() assert await portfolio.settle_bets(s) == 1 @@ -315,3 +320,128 @@ async def test_auto_sell_off_and_manual_sell(db, book): assert r.status_code == 200 and r.json()["status"] == "sold" and r.json()["exit_reason"] == "Venduta a mano" assert (await api.post(f"/portfolio/bets/{bet.id}/sell")).status_code == 409 assert (await api.put("/portfolio/settings", json={"auto_sell": True})).json()["auto_sell"] is True + + +async def test_excluding_and_readmitting_a_closed_bet_keeps_its_history(db, book): + """A lost bet excluded and readmitted keeps its closing time and result (equity curve).""" + closed_at = NOW - timedelta(days=2) + async with SessionLocal() as s: + m = await make_market(s) + await portfolio.apply_economics(s, m, await make_prediction(s, m)) + m.resolved_yes, m.closed = False, True + await s.commit() + await portfolio.settle_bets(s) + bet = (await s.execute(select(PaperBet))).scalar_one() + bet.settled_at = closed_at + await s.commit() + pnl = bet.pnl + assert bet.status == "lost" and pnl < 0 + async with login_client("admin") as api: + assert (await api.post(f"/portfolio/bets/{bet.id}/exclude")).json()["status"] == "excluded" + summ = (await api.get("/portfolio")).json() + assert summ["equity"] == pytest.approx(1000) and summ["realized_pnl"] == pytest.approx(0) + assert summ["counts"]["lost"] == 0 and len(summ["equity_curve"]) == 1 + assert (await api.post(f"/portfolio/bets/{bet.id}/include")).json()["status"] == "lost" + summ = (await api.get("/portfolio")).json() + async with SessionLocal() as s: + bet = await s.get(PaperBet, bet.id) + assert bet.settled_at == closed_at and bet.pnl == pytest.approx(pnl) + assert summ["equity"] == pytest.approx(1000 + pnl) + assert datetime.fromisoformat(summ["equity_curve"][-1]["t"]) == closed_at + + +async def test_excluding_and_readmitting_a_sold_bet_keeps_it_sold(db, book): + """A bet sold before resolution is readmitted as sold, not as an open position.""" + async with SessionLocal() as s: + m = await make_market(s) + await portfolio.apply_economics(s, m, await make_prediction(s, m)) + bet = (await s.execute(select(PaperBet))).scalar_one() + assert await portfolio.sell_bet(s, bet, m, "Venduta a mano") + await s.refresh(bet) + sold = (bet.exit_price, bet.fee, bet.pnl, bet.settled_at) + async with login_client("admin") as api: + before = (await api.get("/portfolio")).json() + await api.post(f"/portfolio/bets/{bet.id}/exclude") + assert (await api.post(f"/portfolio/bets/{bet.id}/include")).json()["status"] == "sold" + after = (await api.get("/portfolio")).json() + assert (await api.get("/portfolio/bets", params={"status": "open"})).json() == [] + async with SessionLocal() as s: + bet = await s.get(PaperBet, bet.id) + assert (bet.exit_price, bet.fee, bet.pnl, bet.settled_at) == sold + assert after["equity"] == pytest.approx(before["equity"]) and after["invested"] == pytest.approx(0) + assert after["counts"]["sold"] == 1 and after["counts"]["open"] == 0 + + +async def test_outcome_without_event_forecast_uses_its_own_forecast(db, book): + """A position on an outcome of a multi-outcome event with no event forecast (placed before + multi-outcome support) falls back to the market's own forecast: it gets an exit plan.""" + async with SessionLocal() as s: + m = await make_market(s, mid="m-outcome") + m.multi_event_id = "ev-1" + await s.commit() + p = await make_prediction(s, m) + s.add(PaperBet(market_id=m.id, prediction_id=p.id, side="YES", shares=100, avg_price=0.36, stake=36.0, fee=0.5, + p_side=0.5, p_conservative=0.45, expected_profit=5.0, preset="bilanciato", status="open", placed_by="auto")) + await s.commit() + assert (await portfolio.latest_prediction(s, m)).id == p.id + async with login_client("admin") as api: + bets = (await api.get("/portfolio/bets", params={"status": "open"})).json() + assert bets[0]["plan"]["action"] in ("HOLD", "SELL") and bets[0]["plan"]["sell_above"] is not None + + +async def test_open_bet_is_valued_at_the_bid_it_would_sell_at(db, book): + async with SessionLocal() as s: + m = await make_market(s) + await portfolio.apply_economics(s, m, await make_prediction(s, m)) + m.yes_price, m.best_bid, m.best_ask = 0.5, 0.47, 0.53 + await s.commit() + bet = (await s.execute(select(PaperBet))).scalar_one() + from backend.betting.fees import fee_per_share + assert portfolio.mark_value(bet, m) == pytest.approx(bet.shares * (0.47 - fee_per_share(0.47, portfolio.fee_bps_of(m)))) + assert portfolio.mid_value(bet, m) == pytest.approx(bet.shares * 0.5) + async with login_client("viewer") as api: + row = (await api.get("/portfolio/bets", params={"status": "open"})).json()[0] + assert row["bid_price"] == 0.47 and row["current_price"] == 0.5 + assert row["unrealized_pnl"] < row["unrealized_pnl_mid"] + + +async def test_opportunities_flag_exposure_reasons_that_no_longer_hold(db, book): + """An opportunity blocked because the market's exposure was full: after the position is sold + the stored reason no longer holds, and the API says so.""" + async with SessionLocal() as s: + m = await make_market(s) + p = await make_prediction(s, m) + await portfolio.apply_economics(s, m, p) # opens the position + p2 = await make_prediction(s, m) + await portfolio.apply_economics(s, m, p2) # market cap now full: exposure_cap + await s.refresh(p2) + assert any(r["code"] == "exposure_cap" and r["blocking"] for r in p2.economics["reasons"]) + bet = (await s.execute(select(PaperBet))).scalar_one() + async with login_client("viewer") as api: + opp = (await api.get("/predictions/opportunities")).json()[0] + assert "stale" not in (opp["prediction"]["economics"] or {}) + async with SessionLocal() as s: + m = await s.get(Market, "m-fed") + bet = await s.get(PaperBet, bet.id) + assert await portfolio.sell_bet(s, bet, m, "Venduta a mano") + async with login_client("viewer") as api: + opp = (await api.get("/predictions/opportunities")).json()[0] + assert opp["prediction"]["economics"]["stale"] == ["exposure_cap"] + + +async def test_readmitting_a_sold_bet_excluded_by_the_old_code(db, book): + """Bets excluded before the fix lost pnl and closing time: readmitted sold, the result is + rebuilt from the sale (fee already includes the sale fee) and the summary still works.""" + async with SessionLocal() as s: + m = await make_market(s) + await portfolio.apply_economics(s, m, await make_prediction(s, m)) + bet = (await s.execute(select(PaperBet))).scalar_one() + assert await portfolio.sell_bet(s, bet, m, "Venduta a mano") + await s.refresh(bet) + pnl = bet.pnl + bet.status, bet.payout, bet.pnl, bet.settled_at = "excluded", None, None, None # the old exclusion + await s.commit() + async with login_client("admin") as api: + assert (await api.post(f"/portfolio/bets/{bet.id}/include")).json()["status"] == "sold" + summ = (await api.get("/portfolio")).json() + assert summ["realized_pnl"] == pytest.approx(pnl) and len(summ["equity_curve"]) == 2