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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ FORECAST_MAX_PRICE_MOVE=0.5
EXCLUDE_PRICE_MARKETS=true
MIN_EDGE=0.05
MIN_EVIDENCE=0.5
# Evidence from the facts: weighted news total worth 0.5; the lower of this and Jev's rating is used (0 = Jev only)
EVIDENCE_OBJECTIVE_HALF=1.5
KELLY_FRACTION=0.25

# Betting economics & simulated portfolio
Expand All @@ -113,6 +115,15 @@ DEFAULT_SPREAD=0.02
MODEL_PSEUDO_COUNT=20
PAPER_BANKROLL=1000
PAPER_PRESET=bilanciato
# No buys of shares under this price (long shots win less often than their price says)
LONGSHOT_MIN_PRICE=0.10
# Closing-line guard: pause automatic bets (or exclude a category) when the price keeps moving against them
CLV_GUARD_ENABLED=true
CLV_GUARD_WINDOW=15
CLV_GUARD_MIN_BETS=8
CLV_GUARD_CATEGORY_MIN_BETS=5
CLV_GUARD_MIN_AVG=-0.02
CLV_GUARD_MIN_AGE_HOURS=1

# Client-side rate limits for AI providers: set them to your plan's limits
GROQ_RPM=20
Expand Down
10 changes: 9 additions & 1 deletion backend/api/routes/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from backend.auth.deps import require_admin
from backend.betting import export, plans, portfolio
from backend.betting import export, guard, plans, portfolio
from backend.betting.profiles import PROFILES, get_profile
from backend.markets.calibration import summary as calibration_summary
from backend.config import settings
Expand Down Expand Up @@ -70,9 +70,17 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)):
data["presets"] = [p.as_dict() for p in PROFILES.values()]
data["risk_free_rate"] = settings.RISK_FREE_RATE
data["calibration_factor"] = await portfolio.calibration_factor(db)
data["guard"] = await guard.status(db)
return data


@router.post("/guard/resume", dependencies=admin)
async def resume_guard(db: AsyncSession = Depends(get_db)):
"""Resumes automatic bets after the closing-line guard paused them."""
await guard.resume(db)
return await guard.status(db)


@router.get("/export")
async def export_portfolio(format: Literal["xlsx", "csv"] = Query("xlsx"), db: AsyncSession = Depends(get_db)):
"""The whole simulated portfolio as it is now: an Excel workbook (summary, bets, equity curve,
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 @@ -30,6 +30,10 @@ async def count(stmt):
"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,
"longshot_min_price": settings.LONGSHOT_MIN_PRICE,
"evidence_objective_half": settings.EVIDENCE_OBJECTIVE_HALF,
"clv_guard": {"enabled": settings.CLV_GUARD_ENABLED, "min_bets": settings.CLV_GUARD_MIN_BETS,
"window": settings.CLV_GUARD_WINDOW, "min_avg": settings.CLV_GUARD_MIN_AVG},
"blend_method": settings.BLEND_METHOD,
"jev_calib_a": settings.JEV_CALIB_A,
"jev_calib_b": settings.JEV_CALIB_B,
Expand Down
3 changes: 3 additions & 0 deletions backend/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ class PredictionResponse(BaseModel):
blend_method: Optional[str] = None
model_samples: Optional[int] = None
evidence_strength: float
jev_evidence_strength: Optional[float] = None
objective_evidence: Optional[float] = None
base_rate: Optional[float] = None
blended_probability: float
model_weight: Optional[float] = None
edge: float
Expand Down
5 changes: 4 additions & 1 deletion backend/backtest/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
from backend.ingestor.fetcher import fetch_feed
from backend.markets import polymarket
from backend.markets.forecast import compute_signal
from backend.markets.matching import extract_terms, match_score, rank_evidence, term_overlap
from backend.markets.matching import extract_terms, match_score, objective_evidence, rank_evidence, term_overlap
from backend.markets.service import EVIDENCE_CRITERIA, build_jev_request, parse_forecast
from backend.markets.targeted import build_query

Expand Down Expand Up @@ -405,6 +405,9 @@ async def _eval_binary(case, market, as_of, params, histories, preset) -> bool:
state, questions = build_jev_request(ns_market, evidence, now=as_of)
response = await _jev_with_retries(state, questions)
model_p, strength = parse_forecast(response)
objective = objective_evidence(evidence, now=as_of) # as live: the lower of Jev's rating and the facts
if objective is not None:
strength = min(strength, objective)
signal = compute_signal(model_p, price, strength)
bet = _simulate_bet(signal, price, signal.blended_probability, model_p, strength, signal.model_weight,
(market.end_date - as_of).total_seconds() / 86400, market.liquidity or market.volume * 0.02, preset,
Expand Down
8 changes: 8 additions & 0 deletions backend/betting/economics.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import Optional
from backend.betting.fees import fee_per_share
from backend.betting.profiles import RiskProfile
from backend.config import settings
from backend.i18n import lang, tr

MIN_ORDER_USD = 1.0
Expand Down Expand Up @@ -273,6 +274,13 @@ def evaluate(
if days > profile.max_days:
reasons.append(Reason("too_far", tr(f"Si risolve tra {days:.0f} giorni: il preset accetta al massimo {profile.max_days} giorni.",
f"It resolves in {days:.0f} days: the preset accepts at most {profile.max_days} days.")))
if best is not None and best < settings.LONGSHOT_MIN_PRICE:
# Favourite-longshot bias: cheap shares win less often than their price says
reasons.append(Reason("longshot", tr(
f"Quota a {_num(best * 100, 1)}¢: sotto i {_num(settings.LONGSHOT_MIN_PRICE * 100)}¢ le quote improbabili sono in media "
"sopravvalutate su Polymarket (chi compra paga troppo le vincite grandi).",
f"Share at {_num(best * 100, 1)}¢: below {_num(settings.LONGSHOT_MIN_PRICE * 100)}¢ long shots are overpriced on average "
"on Polymarket (buyers overpay for big wins).")))
if best is None:
reasons.append(Reason("no_book", tr("Nessuna offerta di vendita disponibile per questo lato.", "No sell offer available for this side.")))
elif net_edge < profile.min_net_edge:
Expand Down
10 changes: 9 additions & 1 deletion backend/betting/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@
("jev_probability", "price", "Stima di Jev della probabilità del SÌ", "Jev's estimate of the probability of YES"),
("calibrated_probability", "price", "Stima di Jev dopo la calibrazione", "Jev's estimate after calibration"),
("blended_probability", "price", "Probabilità finale (Jev unito al prezzo)", "Final probability (Jev pooled with the price)"),
("evidence_strength", "price", "Forza delle evidenze (0–1)", "Evidence strength (0–1)"),
("evidence_strength", "price", "Forza delle evidenze usata (0–1): la più bassa tra Jev e i fatti", "Evidence strength used (0–1): the lower of Jev's and the facts'"),
("jev_evidence_strength", "price", "Forza delle evidenze secondo Jev", "Evidence strength as Jev rated it"),
("objective_evidence", "price", "Forza delle evidenze dai fatti (fonti, età, conferme)", "Evidence strength from the facts (sources, age, confirmations)"),
("base_rate", "price", "Caso tipico secondo Jev: quanto spesso accadono eventi simili", "Jev's base rate: how often similar events happen"),
("model_weight", "price", "Peso di Jev nella probabilità finale", "Jev's weight in the final probability"),
("edge", "price", "Probabilità finale − prezzo", "Final probability − price"),
("signal", "text", "Segnale: BUY_YES, BUY_NO, HOLD", "Signal: BUY_YES, BUY_NO, HOLD"),
Expand Down Expand Up @@ -155,6 +158,9 @@ async def build(db: AsyncSession) -> dict:
"calibrated_probability": p.calibrated_probability if p is not None else None,
"blended_probability": p.blended_probability if p is not None else None,
"evidence_strength": p.evidence_strength if p is not None else None,
"jev_evidence_strength": p.jev_evidence_strength if p is not None else None,
"objective_evidence": p.objective_evidence if p is not None else None,
"base_rate": p.base_rate if p is not None else None,
"model_weight": p.model_weight if p is not None else None,
"edge": p.edge if p is not None else None,
"signal": p.signal if p is not None else None,
Expand Down Expand Up @@ -200,6 +206,8 @@ async def build(db: AsyncSession) -> dict:
("clv_share_positive", "pct", tr("Quota comprata sotto la chiusura", "Share bought below the close"), clv_all.get("share_positive")),
("clv_n", "int", tr("Scommesse con CLV", "Bets with CLV"), clv_all.get("n")),
("clv_open_avg", "price", tr("Movimento medio finora sulle aperte", "Average move so far on the open ones"), clv_open.get("avg")),
("guard_paused", "bool", tr("Scommesse automatiche in pausa per il CLV", "Automatic bets paused by the CLV guard"), s.paused_at is not None),
("guard_reason", "text", tr("Motivo della pausa", "Reason for the pause"), s.paused_reason),
("risk_free_rate", "pct", tr("Tasso senza rischio", "Risk-free rate"), settings.RISK_FREE_RATE),
("calibration_factor", "num", tr("Correzione dell'incertezza dai risultati passati", "Uncertainty correction from past results"), await portfolio.calibration_factor(db)),
("preset_kelly_scale", "num", tr("Preset: frazione di Kelly", "Preset: Kelly fraction"), profile.kelly_scale),
Expand Down
127 changes: 127 additions & 0 deletions backend/betting/guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Closing-line guard: stops automatic bets when the market keeps moving against them.

The result of a bet takes weeks to arrive; the price moves within hours. If, over the latest
bets, the price of the side bought has on average gone down since the purchase (negative
closing line value), the signals are not ahead of the market and betting on is just paying
the spread. Then:
- per category (at least CLV_GUARD_CATEGORY_MIN_BETS bets): the category is excluded from
automatic bets, visibly, in the exclusions;
- overall (at least CLV_GUARD_MIN_BETS bets): automatic bets pause, with a Telegram message,
until someone resumes them. Bets placed before a resume are not counted again.
Manual bets are never blocked, but they count.
"""
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional

from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from backend.betting import clv
from backend.config import settings
from backend.db.models import Market, PaperBet, PaperExclusion
from backend.i18n import tr

logger = logging.getLogger(__name__)


def _now() -> datetime:
return datetime.now(timezone.utc)


def bet_move(bet: PaperBet, market: Market) -> Optional[float]:
"""How much the price of the side bought has moved since the purchase (points, > 0 = in favour):
to the closing price once the market has closed, to the current price before."""
price = market.last_trading_price if market.closed and market.last_trading_price is not None else market.yes_price
if price is None:
return None
return clv.clv(bet.avg_price, price, bet.side)


async def recent_moves(db: AsyncSession, since: datetime, category: Optional[str] = None) -> list[float]:
"""Moves of the latest CLV_GUARD_WINDOW bets placed after `since` and old enough to have moved."""
stmt = (select(PaperBet, Market).join(Market, Market.id == PaperBet.market_id)
.where(PaperBet.status != "excluded", PaperBet.created_at >= since,
PaperBet.created_at <= _now() - timedelta(hours=settings.CLV_GUARD_MIN_AGE_HOURS))
.order_by(PaperBet.created_at.desc()).limit(settings.CLV_GUARD_WINDOW))
if category is not None:
stmt = stmt.where(Market.category == category)
moves = [bet_move(b, m) for b, m in (await db.execute(stmt)).all()]
return [m for m in moves if m is not None]


def _pts(x: float) -> str:
from backend.i18n import dec
return dec(x * 100, 1)


async def status(db: AsyncSession) -> dict:
"""What the guard sees now (for the portfolio page)."""
from backend.betting.portfolio import get_settings
s = await get_settings(db)
moves = await recent_moves(db, s.guard_since or s.started_at)
return {
"enabled": settings.CLV_GUARD_ENABLED, "paused": s.paused_at is not None, "paused_at": s.paused_at,
"reason": s.paused_reason, "n": len(moves), "min_bets": settings.CLV_GUARD_MIN_BETS,
"avg_move": sum(moves) / len(moves) if moves else None, "threshold": settings.CLV_GUARD_MIN_AVG,
}


async def allows_auto_bet(db: AsyncSession, market: Market) -> bool:
"""Checks the guard before an automatic bet; pauses or excludes the category when it trips."""
if not settings.CLV_GUARD_ENABLED:
return True
from backend.betting.portfolio import get_settings
s = await get_settings(db)
if s.paused_at is not None:
return False
since = s.guard_since or s.started_at

if market.category:
moves = await recent_moves(db, since, market.category)
if len(moves) >= settings.CLV_GUARD_CATEGORY_MIN_BETS and sum(moves) / len(moves) < settings.CLV_GUARD_MIN_AVG:
avg = sum(moves) / len(moves)
label = tr(f"Pausa automatica: dopo le ultime {len(moves)} scommesse il prezzo si è mosso contro di {_pts(-avg)} punti in media",
f"Automatic pause: after the latest {len(moves)} bets the price moved against them by {_pts(-avg)} points on average")
exists = (await db.execute(select(PaperExclusion).where(
PaperExclusion.kind == "category", PaperExclusion.value == market.category))).scalar_one_or_none()
if exists is None:
db.add(PaperExclusion(kind="category", value=market.category, label=label))
await db.commit()
logger.warning(f"CLV guard: category {market.category} excluded ({avg:+.3f} over {len(moves)} bets)")
await _notify(tr(f"categoria {market.category} esclusa dalle scommesse automatiche. {label}.",
f"category {market.category} excluded from automatic bets. {label}."))
return False

moves = await recent_moves(db, since)
if len(moves) >= settings.CLV_GUARD_MIN_BETS:
avg = sum(moves) / len(moves)
if avg < settings.CLV_GUARD_MIN_AVG:
s.paused_at = _now()
s.paused_reason = tr(
f"Dopo le ultime {len(moves)} scommesse il prezzo si è mosso contro di {_pts(-avg)} punti in media: "
"i segnali non anticipano il mercato.",
f"After the latest {len(moves)} bets the price moved against them by {_pts(-avg)} points on average: "
"the signals are not ahead of the market.")
await db.commit()
logger.warning(f"CLV guard: automatic bets paused ({avg:+.3f} over {len(moves)} bets)")
await _notify(tr("scommesse automatiche in pausa. ", "automatic bets paused. ") + s.paused_reason)
return False
return True


async def resume(db: AsyncSession) -> None:
"""Resumes automatic bets; the bets so far are not counted again."""
from backend.betting.portfolio import get_settings
s = await get_settings(db)
s.paused_at, s.paused_reason, s.guard_since = None, None, _now()
await db.commit()


async def _notify(text: str) -> None:
try:
from backend.alerts import telegram
if telegram.is_configured():
await telegram.send_message(f"⏸ <b>News × Markets</b>: {telegram.escape(text)}")
except Exception as e: # a failed message must not break the betting flow
logger.info(f"CLV guard notification failed: {e}")
7 changes: 7 additions & 0 deletions backend/betting/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async def reset_portfolio(db: AsyncSession, bankroll: float, preset: Optional[st
if preset is not None:
row.preset = get_profile(preset).key
row.started_at = row.updated_at = _now()
row.paused_at = row.paused_reason = row.guard_since = None
await db.commit()
return row

Expand Down Expand Up @@ -306,6 +307,12 @@ async def maybe_place_bet(db: AsyncSession, market: Market, prediction: MarketPr
return None
if placed_by == "auto" and await excluded_reason(db, market):
return None
already_open = (await db.execute(select(PaperBet.id).where(PaperBet.market_id == market.id, PaperBet.status == "open"))).first()
if already_open:
return None
from backend.betting import guard
if placed_by == "auto" and not await guard.allows_auto_bet(db, market):
return None # paused: the price has been moving against the recent bets (betting/guard.py)
already = (await db.execute(select(PaperBet.id).where(PaperBet.market_id == market.id, PaperBet.status == "open"))).first()
if already:
return None
Expand Down
3 changes: 3 additions & 0 deletions backend/betting/strategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from backend.betting.economics import annualize
from backend.betting.fees import fee_per_share
from backend.betting.profiles import RiskProfile
from backend.config import settings
from backend.i18n import dec, dollars, side as side_label, tr
from backend.markets.forecast import pool

Expand Down Expand Up @@ -84,6 +85,8 @@ def _buy_ok(fc: Forecast, q: float, side: str, profile: RiskProfile, fee_bps: fl
if p_side - mid < min_edge: # the signal itself (measured on the mid, like the forecast)
return False
price = min(0.99, mid + half_spread)
if price < settings.LONGSHOT_MIN_PRICE: # long shots are overpriced on average
return False
cost = price + fee_per_share(price, fee_bps)
p_cons = max(0.0, p_side - profile.z * fc.sigma)
if p_cons - cost < profile.min_net_edge: # margin after costs and uncertainty
Expand Down
13 changes: 13 additions & 0 deletions backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,19 @@ class Settings(BaseSettings):
# Markets decided by an asset price at a date ("Bitcoin above $84,000 on September 24"): Jev reads
# news, not the live price, and the market price already knows it. No bets, no paid forecasts.
EXCLUDE_PRICE_MARKETS: bool = True
# Long shots: shares under this price are overpriced on average on prediction markets
# (favourite-longshot bias). No buys below it, on either side; 0 turns it off.
LONGSHOT_MIN_PRICE: float = 0.10
# Objective evidence strength (sources, age, confirmations): the value that gives 50%;
# the forecast uses the lower of this and Jev's own rating. 0 = Jev's rating only.
EVIDENCE_OBJECTIVE_HALF: float = 1.5
# Closing-line guard: automatic bets pause when the price has moved against the recent bets
CLV_GUARD_ENABLED: bool = True
CLV_GUARD_WINDOW: int = 15 # latest bets looked at
CLV_GUARD_MIN_BETS: int = 8 # fewer than this: too early to judge
CLV_GUARD_MIN_AVG: float = -0.02 # pause when the average move is below this (points of the side bought)
CLV_GUARD_CATEGORY_MIN_BETS: int = 5 # same rule per category, which gets excluded
CLV_GUARD_MIN_AGE_HOURS: float = 1.0 # bets younger than this have not had time to move

# Betting economics (simulated portfolio)
RISK_FREE_RATE: float = 0.04 # annual return of the risk-free alternative (e.g. T-bills)
Expand Down
7 changes: 7 additions & 0 deletions backend/db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS calibrated_probability DOUBLE PRECISION",
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS blend_method TEXT",
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS model_samples INTEGER",
# Outside view, objective evidence strength, closing-line guard
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS jev_evidence_strength DOUBLE PRECISION",
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS objective_evidence DOUBLE PRECISION",
"ALTER TABLE market_predictions ADD COLUMN IF NOT EXISTS base_rate DOUBLE PRECISION",
"ALTER TABLE betting_settings ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ",
"ALTER TABLE betting_settings ADD COLUMN IF NOT EXISTS paused_reason TEXT",
"ALTER TABLE betting_settings ADD COLUMN IF NOT EXISTS guard_since TIMESTAMPTZ",
"""UPDATE markets SET resolution = CASE WHEN resolved_yes THEN 'yes' ELSE 'no' END
WHERE resolution IS NULL AND resolved_yes IS NOT NULL""",
]
Expand Down
Loading
Loading