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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,14 @@ TARGETED_NEWS_REFRESH_HOURS=6
# Forecasting signals (Jev vs market price)
PREDICTION_AUTO=false
PREDICTION_MAX_PER_RUN=10
MODEL_WEIGHT_MAX=0.5
# Jev's maximum weight against the price, and how far from the price (log-odds) before it shrinks
MODEL_WEIGHT_MAX=0.25
MODEL_DISAGREEMENT_LOGIT=2.0
# A forecast is valid for buying for this many hours, and while the price moves less than this (log-odds)
FORECAST_MAX_AGE_HOURS=6
FORECAST_MAX_PRICE_MOVE=0.5
# Markets decided by an asset's price (e.g. "Bitcoin above $84,000 on September 24"): no bets, no paid forecasts
EXCLUDE_PRICE_MARKETS=true
MIN_EDGE=0.05
MIN_EVIDENCE=0.5
KELLY_FRACTION=0.25
Expand Down
3 changes: 3 additions & 0 deletions backend/alerts/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
from backend.betting.clv import summarize as clv_summary
from backend.markets.matching import source_quality
from backend.i18n import side as side_label, tr
from backend.markets.kinds import skip_paid_forecast

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -227,6 +228,8 @@ async def _run_alerts(db: AsyncSession) -> dict:
market = await db.get(Market, trig["market_id"], populate_existing=True)
if market is None or market.closed:
continue
if skip_paid_forecast(market.question): # decided by an asset's price: no bet can come of it
continue
await _refresh_price(market)
price = market.yes_price
try:
Expand Down
4 changes: 4 additions & 0 deletions backend/api/routes/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ async def count(stmt):
"min_edge": settings.MIN_EDGE,
"min_evidence": settings.MIN_EVIDENCE,
"model_weight_max": settings.MODEL_WEIGHT_MAX,
"model_disagreement_logit": settings.MODEL_DISAGREEMENT_LOGIT,
"forecast_max_age_hours": settings.FORECAST_MAX_AGE_HOURS,
"forecast_max_price_move": settings.FORECAST_MAX_PRICE_MOVE,
"exclude_price_markets": settings.EXCLUDE_PRICE_MARKETS,
"blend_method": settings.BLEND_METHOD,
"jev_calib_a": settings.JEV_CALIB_A,
"jev_calib_b": settings.JEV_CALIB_B,
Expand Down
20 changes: 20 additions & 0 deletions backend/betting/economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,10 @@ def evaluate(
exposure: Exposure,
liquidity: float,
risk_free_rate: float,
hours_to_end: Optional[float] = None,
extra_reasons: Optional[list] = None,
) -> Evaluation:
"""extra_reasons: blocking reasons found by the caller (stale forecast, price market)."""
side = "NO" if signal == "BUY_NO" else "YES"
p_side = p_yes if side == "YES" else 1.0 - p_yes
p_cons = max(0.0, p_side - profile.z * sigma)
Expand All @@ -254,6 +257,13 @@ def evaluate(
notes.append(tr("Book non disponibile: book stimato a 4 livelli, da prezzo medio + metà spread in su, con la profondità dalla liquidità dichiarata.",
"Book not available: book estimated at 4 levels, from mid price + half spread upwards, with depth from the declared liquidity."))

reasons.extend(extra_reasons or [])
if hours_to_end is not None and hours_to_end < profile.min_hours_to_end:
left = tr(f"{hours_to_end:.0f} ore", f"{hours_to_end:.0f} hours") if hours_to_end >= 1 else \
tr(f"{hours_to_end * 60:.0f} minuti", f"{hours_to_end * 60:.0f} minutes")
reasons.append(Reason("too_close", tr(
f"Si risolve tra {left}: il prezzo sa già quasi tutto, il preset chiede almeno {profile.min_hours_to_end:.0f} ore.",
f"It resolves in {left}: the price already knows almost everything, the preset asks for at least {profile.min_hours_to_end:.0f} hours.")))
if signal == "HOLD":
reasons.append(Reason("no_signal", tr("Il modello non vede una differenza sufficiente rispetto al prezzo.",
"The model does not see a large enough difference from the price.")))
Expand Down Expand Up @@ -317,6 +327,16 @@ def evaluate(
f"Prudent annualised return {_num(apr * 100)}%, below the {_num(hurdle * 100)}% threshold "
f"(risk-free rate {_num(risk_free_rate * 100)}% + preset premium).")))

# Absolute return: annualizing a short bet makes any small margin look huge, so the prudent
# return on the money spent must also clear a minimum per bet
roi_cons = (exp_profit_cons / outlay) if outlay > 0 else \
((p_cons / (best + fee_per_share(best, quote.fee_bps)) - 1) if best is not None else None)
if roi_cons is not None and roi_cons < profile.min_roi and signal != "HOLD" \
and not any(r.code in ("edge_after_costs", "return_too_low") for r in reasons):
reasons.append(Reason("roi_too_low", tr(
f"Rendimento prudente della scommessa {_num(roi_cons * 100, 1)}%: il preset ne chiede almeno {_num(profile.min_roi * 100)}%.",
f"Prudent return of the bet {_num(roi_cons * 100, 1)}%: the preset asks for at least {_num(profile.min_roi * 100)}%.")))

blocking = [r for r in reasons if r.blocking]
if blocking:
verdict = "NO"
Expand Down
37 changes: 29 additions & 8 deletions backend/betting/plans.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,37 @@ def half_spread(market: Market) -> float:
return settings.DEFAULT_SPREAD / 2


def is_outcome(prediction) -> bool:
"""A view of one outcome of a multi-outcome forecast (its blend comes from the whole distribution)."""
return getattr(prediction, "multi_prediction_id", None) is not None


def forecast_of(prediction, sigma: float) -> Forecast:
"""The forecast as it was computed: calibrated Jev, its weight, the pooling method.
Forecasts made before calibration and log-odds pooling were linear and uncalibrated."""
"""The forecast with today's parameters, ready to be pooled with any price: Jev calibrated
with the current calibration, the base weight (MODEL_WEIGHT_MAX × evidence; the reduction
when Jev is far from the price is applied by the pooling, at each price).
Forecasts made before log-odds pooling were linear and uncalibrated; outcomes of
multi-outcome events keep the probability given by the distribution."""
method = getattr(prediction, "blend_method", None) or "linear"
cal = getattr(prediction, "calibrated_probability", None)
if cal is None:
cal = prediction.model_probability if method == "linear" else calibrate(prediction.model_probability)
w = prediction.model_weight if prediction.model_weight is not None else model_weight(prediction.evidence_strength)
if method == "linear" or is_outcome(prediction):
cal = getattr(prediction, "calibrated_probability", None) or prediction.model_probability
else:
cal = calibrate(prediction.model_probability)
w = model_weight(prediction.evidence_strength)
return Forecast(model=cal, weight=w, evidence=prediction.evidence_strength, sigma=sigma, method=method)


def signal_at(prediction, price: Optional[float]) -> str:
"""The forecast's signal at `price`: the blend is recomputed there, as when the forecast was made.
Outcomes of multi-outcome events and markets without a price keep the stored signal."""
if price is None or is_outcome(prediction):
return prediction.signal
edge = forecast_of(prediction, 0).p_yes(price) - price
if prediction.evidence_strength < settings.MIN_EVIDENCE or abs(edge) < settings.MIN_EDGE:
return "HOLD"
return "BUY_YES" if edge > 0 else "BUY_NO"


async def best_bid(market: Market, side: str) -> Optional[float]:
"""What one share of `side` would fetch now: the book's best bid, else Gamma's top of book."""
token = market.yes_token_id if side == "YES" else market.no_token_id
Expand Down Expand Up @@ -104,7 +124,7 @@ async def plan_for(db: AsyncSession, market: Market, prediction, ev: Evaluation,
fee_bps = market.taker_fee_bps if market.taker_fee_bps is not None else fees.category_rate(market.category) * 10_000
sell_bid = await best_bid(market, position.side) if position else None
return build_plan(
ev=ev.as_dict(), fc=forecast_of(prediction, ev.sigma), signal=prediction.signal,
ev=ev.as_dict(), fc=forecast_of(prediction, ev.sigma), signal=signal_at(prediction, market.yes_price),
market_price=market.yes_price, profile=profile, fee_bps=fee_bps, days=ev.days,
min_edge=settings.MIN_EDGE, risk_free=settings.RISK_FREE_RATE,
position={"side": position.side, "shares": position.shares, "avg_price": position.avg_price} if position else None,
Expand All @@ -124,6 +144,7 @@ def exit_plan(prediction, market: Market, side: str, profile: RiskProfile, days:
bid = market.best_bid if market.best_bid is not None else market.yes_price
else:
bid = 1 - market.best_ask if market.best_ask is not None else (1 - market.yes_price if market.yes_price is not None else None)
flipped = (prediction.signal == "BUY_NO" and side == "YES") or (prediction.signal == "BUY_YES" and side == "NO")
signal = signal_at(prediction, market.yes_price)
flipped = (signal == "BUY_NO" and side == "YES") or (signal == "BUY_YES" and side == "NO")
action = "SELL" if flipped or (bid is not None and target is not None and bid >= target) else "HOLD"
return {"action": action, "sell_above": target, "bid": bid, "flipped": flipped}
75 changes: 67 additions & 8 deletions backend/betting/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,24 @@ async def calibration_factor(db: AsyncSession) -> float:
.subquery()
)
rows = (await db.execute(
select(Market.resolved_yes, latest.c.blended_probability)
select(Market.resolved_yes, latest.c.blended_probability, latest.c.market_probability)
.join(latest, latest.c.market_id == Market.id).where(Market.resolved_yes.is_not(None))
)).all()
if len(rows) < MIN_RESOLVED_FOR_CALIBRATION:
return 1.0
observed = sum((r.blended_probability - (1.0 if r.resolved_yes else 0.0)) ** 2 for r in rows) / len(rows)
outcome = [1.0 if r.resolved_yes else 0.0 for r in rows]
observed = sum((r.blended_probability - y) ** 2 for r, y in zip(rows, outcome)) / len(rows)
expected = sum(r.blended_probability * (1 - r.blended_probability) for r in rows) / len(rows)
return max(0.75, min(2.0, math.sqrt(observed / max(expected, 1e-4))))
factor = max(0.75, min(2.0, math.sqrt(observed / max(expected, 1e-4))))
# Narrowing the uncertainty makes the bets bigger: only when the blend has shown it beats
# the market price on the same markets (paired Brier gain, two standard errors above zero).
# Being consistent with itself is not enough.
gains = [(r.market_probability - y) ** 2 - (r.blended_probability - y) ** 2 for r, y in zip(rows, outcome)]
mean = sum(gains) / len(gains)
se = math.sqrt(sum((g - mean) ** 2 for g in gains) / (len(gains) - 1) / len(gains)) if len(gains) > 1 else float("inf")
if mean - 2 * se <= 0:
factor = max(1.0, factor)
return factor


async def update_market_category(db: AsyncSession, market: Market) -> Optional[str]:
Expand Down Expand Up @@ -164,18 +174,33 @@ def days_to_end(market: Market) -> float:

async def evaluate_prediction(db: AsyncSession, market: Market, prediction: MarketPrediction,
quote: Optional[Quote] = None, preset: Optional[str] = None) -> Evaluation:
"""Economic evaluation of a forecast at the market's current price.

The blend is recomputed at the current price (Jev pooled with the price of now, with today's
calibration and weights): the stored blend was pooled with the price of the forecast, and
using it against a price that has moved since makes up an edge that is not there. Past
FORECAST_MAX_AGE_HOURS or FORECAST_MAX_PRICE_MOVE the forecast needs a new one before buying."""
from backend.betting import plans
from backend.markets.forecast import disagreement_factor
s = await get_settings(db)
profile = get_profile(preset or s.preset)
side = "NO" if prediction.signal == "BUY_NO" else "YES"
weight = prediction.model_weight if prediction.model_weight is not None else \
min(1.0, settings.MODEL_WEIGHT_MAX * prediction.evidence_strength)
price = market.yes_price
fc = plans.forecast_of(prediction, 0)
if price is None or plans.is_outcome(prediction):
# Outcomes keep the probability of their distribution (pooled over all the outcomes)
p_yes, signal = prediction.blended_probability, prediction.signal
weight = prediction.model_weight if prediction.model_weight is not None else fc.weight
else:
p_yes, signal = fc.p_yes(price), plans.signal_at(prediction, price)
weight = fc.weight * disagreement_factor(fc.model, price)
side = "NO" if signal == "BUY_NO" else "YES"
sigma = model_sigma(prediction.model_probability, prediction.evidence_strength, weight,
settings.MODEL_PSEUDO_COUNT, await calibration_factor(db))
await update_market_category(db, market)
ledger_now = await ledger(db)
return evaluate(
signal=prediction.signal,
p_yes=prediction.blended_probability,
signal=signal,
p_yes=p_yes,
sigma=sigma,
quote=quote or await build_quote(market, side),
days=days_to_end(market),
Expand All @@ -185,9 +210,43 @@ async def evaluate_prediction(db: AsyncSession, market: Market, prediction: Mark
exposure=await exposure_for(db, market),
liquidity=market.liquidity or 0.0,
risk_free_rate=settings.RISK_FREE_RATE,
hours_to_end=hours_to_end(market),
extra_reasons=forecast_reasons(market, prediction),
)


def hours_to_end(market: Market) -> Optional[float]:
"""Hours left before the market resolves (None without an end date)."""
if market.end_date is None:
return None
return (market.end_date - _now()).total_seconds() / 3600


def forecast_reasons(market: Market, prediction) -> list:
"""Blocking reasons about the forecast itself: too old, the price moved too much since,
or a market decided by an asset's price (see markets/kinds.py)."""
from backend.betting.economics import Reason
from backend.markets.kinds import is_price_market
reasons = []
if settings.EXCLUDE_PRICE_MARKETS and is_price_market(market.question):
reasons.append(Reason("price_market", tr(
"Mercato sul prezzo di un asset: si decide sul prezzo del momento, che Jev non vede e il mercato sì.",
"Market on an asset's price: it is decided by the price of the moment, which Jev does not see and the market does.")))
created = getattr(prediction, "created_at", None)
age = (_now() - created).total_seconds() / 3600 if created else 0.0
from backend.markets.forecast import logit
has_prices = market.yes_price is not None and prediction.market_probability is not None
move = abs(market.yes_price - prediction.market_probability) if has_prices else 0.0
moved = has_prices and abs(logit(market.yes_price) - logit(prediction.market_probability)) > settings.FORECAST_MAX_PRICE_MOVE
if age > settings.FORECAST_MAX_AGE_HOURS or moved:
why = tr(f"ha {age:.0f} ore", f"is {age:.0f} hours old") if age > settings.FORECAST_MAX_AGE_HOURS else \
tr(f"il prezzo del SÌ si è mosso di {move * 100:.0f} punti da allora", f"the YES price has moved {move * 100:.0f} points since")
reasons.append(Reason("stale_forecast", tr(
f"La previsione {why}: prima di comprare serve una previsione nuova.",
f"The forecast {why}: a new forecast is needed before buying.")))
return reasons


PORTFOLIO_REASONS = ("exposure_cap", "no_cash") # reasons that depend on the portfolio, not on the market


Expand Down
8 changes: 5 additions & 3 deletions backend/betting/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ class RiskProfile:
max_book_share: float # max share of the visible order-book depth we would take
min_liquidity: float # skip markets with less liquidity (USD)
max_days: int # skip markets resolving further away than this
min_hours_to_end: float = 24.0 # skip markets resolving sooner: the price already knows the outcome
min_roi: float = 0.06 # minimum prudent expected return on the outlay, per bet (not annualized)

def as_dict(self) -> dict:
out = asdict(self)
Expand All @@ -42,21 +44,21 @@ def as_dict(self) -> dict:
description="Poche scommesse, piccole, solo con margine ampio e mercati liquidi che si chiudono entro 4 mesi.",
kelly_scale=0.15, z=1.64, min_net_edge=0.04, min_apr_premium=0.15,
max_market_frac=0.02, max_event_frac=0.05, max_category_frac=0.15, max_total_frac=0.40,
max_book_share=0.10, min_liquidity=25_000, max_days=120,
max_book_share=0.10, min_liquidity=25_000, max_days=120, min_hours_to_end=72, min_roi=0.1,
),
"bilanciato": RiskProfile(
key="bilanciato", label="Bilanciato",
description="Il compromesso di default: un quarto di Kelly, margine di 3 punti dopo i costi, massimo 4% del capitale per mercato.",
kelly_scale=0.25, z=1.0, min_net_edge=0.03, min_apr_premium=0.08,
max_market_frac=0.04, max_event_frac=0.08, max_category_frac=0.25, max_total_frac=0.60,
max_book_share=0.20, min_liquidity=10_000, max_days=365,
max_book_share=0.20, min_liquidity=10_000, max_days=365, min_hours_to_end=24, min_roi=0.06,
),
"aggressivo": RiskProfile(
key="aggressivo", label="Aggressivo",
description="Più scommesse e più grandi: mezzo Kelly, margini ridotti, anche mercati lontani. Oscillazioni del capitale ampie.",
kelly_scale=0.5, z=0.5, min_net_edge=0.02, min_apr_premium=0.03,
max_market_frac=0.08, max_event_frac=0.15, max_category_frac=0.40, max_total_frac=0.85,
max_book_share=0.30, min_liquidity=5_000, max_days=730,
max_book_share=0.30, min_liquidity=5_000, max_days=730, min_hours_to_end=12, min_roi=0.03,
),
}

Expand Down
17 changes: 16 additions & 1 deletion backend/betting/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

GRID = [round(0.01 + i * 0.005, 3) for i in range(197)] # YES prices from 1¢ to 99¢
# Reasons that no price can fix: the market itself does not suit the preset or the portfolio
STRUCTURAL = {"illiquid", "too_far", "exposure_cap", "no_cash", "no_book"}
STRUCTURAL = {"illiquid", "too_far", "too_close", "price_market", "exposure_cap", "no_cash", "no_book"}


def _cents(p: Optional[float]) -> str:
Expand Down Expand Up @@ -88,6 +88,8 @@ def _buy_ok(fc: Forecast, q: float, side: str, profile: RiskProfile, fee_bps: fl
p_cons = max(0.0, p_side - profile.z * fc.sigma)
if p_cons - cost < profile.min_net_edge: # margin after costs and uncertainty
return False
if p_cons / cost - 1 < profile.min_roi: # prudent return of the bet itself
return False
return annualize(p_cons / cost - 1, days) >= hurdle # worth the time the money is locked


Expand Down Expand Up @@ -270,6 +272,19 @@ def build_plan(*, ev: dict, fc: Forecast, signal: str, market_price: float, prof

# ----- No position -----
side = "NO" if signal == "BUY_NO" else "YES" if signal == "BUY_YES" else None
# Reasons that no price can fix (a market on an asset's price, too close to the end): no
# buy levels, they would suggest a trade the assessment will never allow
unfixable = [r for r in blocking if r["code"] in ("price_market", "too_close")]
if unfixable:
return Plan("AVOID" if side else "NONE", side, tr("Evita questo mercato", "Avoid this market"),
" ".join(r["text"] for r in unfixable), levels={}, pros=[], cons=[r["text"] for r in unfixable],
confidence=confidence, confidence_why=why)
stale = next((r for r in blocking if r["code"] == "stale_forecast"), None)
if stale:
return Plan("WAIT", side, tr("Serve una nuova previsione", "A new forecast is needed"),
stale["text"] + tr(" I conti di sotto sono al prezzo attuale, ma la stima di Jev è di allora.",
" The numbers below are at the current price, but Jev's estimate is from then."),
levels=levels, pros=[], cons=[stale["text"]] + track_cons, confidence=confidence, confidence_why=why)
if side is None:
# No signal: say at which prices there would be one
side_hint = "YES" if p_now >= market_price else "NO"
Expand Down
Loading
Loading