Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions backend/api/routes/markets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 20 additions & 7 deletions backend/api/routes/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
Expand Down Expand Up @@ -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}

Expand All @@ -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}

Expand Down
5 changes: 5 additions & 0 deletions backend/betting/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 54 additions & 8 deletions backend/betting/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down Expand Up @@ -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()

Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -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"]},
Expand Down
45 changes: 33 additions & 12 deletions backend/markets/polymarket.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
12 changes: 9 additions & 3 deletions docs/it/strategia.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion docs/it/verifica.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions docs/strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading