From a3b2a0a6f28865d0e9fda3ddca815306e95d491c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 22:34:16 +0000 Subject: [PATCH] Long shots, outside view, closing-line guard, objective evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - No buys of shares under LONGSHOT_MIN_PRICE (10¢) on either side, in the economic assessment and in the strategy's buy levels - Jev is asked for the base rate (outside view) before the forecast and told to move away from it only with strong news; the base rate is stored and shown - Evidence strength is the lower of Jev's rating and one computed from the facts (outlet reliability, freshness, relevance, confirmations, primary sources); both are stored, used in the backtest too - Closing-line guard: when the price keeps moving against the latest bets, the category is excluded or automatic bets pause (Telegram message); admins resume them from the portfolio page (POST /portfolio/guard/resume) - Settings, export, docs (EN/IT), .env.example and tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JLWZrfy12imc6dQRsEtFjj --- .env.example | 11 +++ backend/api/routes/portfolio.py | 10 ++- backend/api/routes/status.py | 4 + backend/api/schemas.py | 3 + backend/backtest/engine.py | 5 +- backend/betting/economics.py | 8 ++ backend/betting/export.py | 10 ++- backend/betting/guard.py | 127 ++++++++++++++++++++++++++ backend/betting/portfolio.py | 7 ++ backend/betting/strategy.py | 3 + backend/config.py | 13 +++ backend/db/migrations.py | 7 ++ backend/db/models.py | 9 +- backend/markets/matching.py | 42 +++++++++ backend/markets/service.py | 39 +++++++- docs/api.md | 3 +- docs/configuration.md | 8 ++ docs/development.md | 1 + docs/it/api.md | 3 +- docs/it/configurazione.md | 8 ++ docs/it/metodo.md | 19 +++- docs/it/strategia.md | 16 +++- docs/it/sviluppo.md | 1 + docs/method.md | 19 +++- docs/strategy.md | 16 +++- frontend/explain.js | 4 + frontend/i18n-en.js | 14 +++ frontend/views/portfolio.js | 10 +++ frontend/views/settings.js | 3 + tests/conftest.py | 3 + tests/test_evidence.py | 152 ++++++++++++++++++++++++++++++++ 31 files changed, 562 insertions(+), 16 deletions(-) create mode 100644 backend/betting/guard.py create mode 100644 tests/test_evidence.py diff --git a/.env.example b/.env.example index aaaf1db..4c94bc7 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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 diff --git a/backend/api/routes/portfolio.py b/backend/api/routes/portfolio.py index 99478dd..115efe7 100644 --- a/backend/api/routes/portfolio.py +++ b/backend/api/routes/portfolio.py @@ -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 @@ -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, diff --git a/backend/api/routes/status.py b/backend/api/routes/status.py index 63f7f3b..45a32a4 100644 --- a/backend/api/routes/status.py +++ b/backend/api/routes/status.py @@ -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, diff --git a/backend/api/schemas.py b/backend/api/schemas.py index 05a6f47..a4d299d 100644 --- a/backend/api/schemas.py +++ b/backend/api/schemas.py @@ -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 diff --git a/backend/backtest/engine.py b/backend/backtest/engine.py index 2917d99..3ae3a74 100644 --- a/backend/backtest/engine.py +++ b/backend/backtest/engine.py @@ -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 @@ -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, diff --git a/backend/betting/economics.py b/backend/betting/economics.py index abcc33a..342f293 100644 --- a/backend/betting/economics.py +++ b/backend/betting/economics.py @@ -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 @@ -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: diff --git a/backend/betting/export.py b/backend/betting/export.py index e4cb743..2e271a4 100644 --- a/backend/betting/export.py +++ b/backend/betting/export.py @@ -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"), @@ -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, @@ -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), diff --git a/backend/betting/guard.py b/backend/betting/guard.py new file mode 100644 index 0000000..237ac90 --- /dev/null +++ b/backend/betting/guard.py @@ -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"⏸ News × Markets: {telegram.escape(text)}") + except Exception as e: # a failed message must not break the betting flow + logger.info(f"CLV guard notification failed: {e}") diff --git a/backend/betting/portfolio.py b/backend/betting/portfolio.py index b69ff4a..467afa0 100644 --- a/backend/betting/portfolio.py +++ b/backend/betting/portfolio.py @@ -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 @@ -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 diff --git a/backend/betting/strategy.py b/backend/betting/strategy.py index 11e7aa6..12b0c76 100644 --- a/backend/betting/strategy.py +++ b/backend/betting/strategy.py @@ -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 @@ -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 diff --git a/backend/config.py b/backend/config.py index 5c5da8f..e694586 100644 --- a/backend/config.py +++ b/backend/config.py @@ -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) diff --git a/backend/db/migrations.py b/backend/db/migrations.py index 0190bb7..88e7acb 100644 --- a/backend/db/migrations.py +++ b/backend/db/migrations.py @@ -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""", ] diff --git a/backend/db/models.py b/backend/db/models.py index fb056fa..223e378 100644 --- a/backend/db/models.py +++ b/backend/db/models.py @@ -149,7 +149,10 @@ class MarketPrediction(Base): calibrated_probability: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # after Platt scaling blend_method: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # logodds / linear model_samples: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Jev calls averaged - evidence_strength: Mapped[float] = mapped_column(Float, nullable=False) # 0-1 + evidence_strength: Mapped[float] = mapped_column(Float, nullable=False) # 0-1, used: min(Jev's, objective) + jev_evidence_strength: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # as Jev rated it + objective_evidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # from sources, age, confirmations + base_rate: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # Jev's outside view: how often events like this happen blended_probability: Mapped[float] = mapped_column(Float, nullable=False) # shrunk toward market model_weight: Mapped[Optional[float]] = mapped_column(Float, nullable=True) # weight w of Jev in the blend edge: Mapped[float] = mapped_column(Float, nullable=False) # blended - market @@ -196,6 +199,10 @@ class BettingSettings(Base): preset: Mapped[str] = mapped_column(Text, nullable=False) auto_paper: Mapped[bool] = mapped_column(Boolean, default=True) # bet automatically on every GO/SMALL auto_sell: Mapped[bool] = mapped_column(Boolean, default=True) # sell open bets when the plan says so + # Automatic bets paused by the closing-line guard (betting/guard.py); bets before guard_since are not counted + paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + paused_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + guard_since: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) diff --git a/backend/markets/matching.py b/backend/markets/matching.py index 51ed0a2..9c13756 100644 --- a/backend/markets/matching.py +++ b/backend/markets/matching.py @@ -331,3 +331,45 @@ async def outlet_priors(session, rows) -> dict: .group_by(key).having(func.count(ProcessedArticle.id) >= OUTLET_MIN_ARTICLES) ) return {k: float(v) for k, v in result.all()} + + +# ---------- Objective evidence strength ---------- + +# Official sources: a statement from the body that decides or measures the outcome +PRIMARY_DOMAINS = ( + "federalreserve.gov", "bls.gov", "bea.gov", "sec.gov", "treasury.gov", "whitehouse.gov", "congress.gov", + "supremecourt.gov", "fec.gov", "cdc.gov", "ecb.europa.eu", "europa.eu", "bankofengland.co.uk", "un.org", + "who.int", "nasa.gov", "noaa.gov", "openai.com", "blog.google", +) + + +def is_primary_source(article, source) -> bool: + from urllib.parse import urlparse + for url in (getattr(article, "url", None), getattr(source, "url", None)): + host = (urlparse(url).hostname or "").lower() if url else "" + if any(host == d or host.endswith("." + d) for d in PRIMARY_DOMAINS): + return True + return False + + +def evidence_weight(item: EvidenceItem, now: Optional[datetime] = None) -> float: + """What one news item is worth as evidence: source reliability × freshness × Jev's relevance, + more if several outlets confirm it (up to 3) and if it comes from a primary source.""" + link, article, _processed, source = item + w = item.quality * recency_factor(article.published_at or article.fetched_at, now) * jev_relevance_factor(link.relevance) + w *= 1 + 0.5 * (min(item.corroboration, 3) - 1) + if is_primary_source(article, source): + w *= 1.5 + return w + + +def objective_evidence(items: list, now: Optional[datetime] = None, half: Optional[float] = None) -> Optional[float]: + """Evidence strength from verifiable facts, 0-1: total weight / (total weight + half). + With half = 1.5: one fresh, relevant item from a reliable outlet gives about 0.25, three + about 0.5, a confirmed statement from a primary source about 0.6; many strong items approach + 1. None when turned off (half = 0).""" + half = settings.EVIDENCE_OBJECTIVE_HALF if half is None else half + if half <= 0: + return None + total = sum(evidence_weight(i, now) for i in items) + return total / (total + half) diff --git a/backend/markets/service.py b/backend/markets/service.py index 3344d6c..f9dbc71 100644 --- a/backend/markets/service.py +++ b/backend/markets/service.py @@ -16,7 +16,9 @@ from backend.markets.forecast import compute_signal from backend.markets.targeted import run_targeted_search from backend.alerts.service import run_alerts -from backend.markets.matching import EvidenceItem, extract_terms, match_score, outlet_priors, rank_evidence, term_overlap +from backend.markets.matching import ( + EvidenceItem, extract_terms, match_score, objective_evidence, outlet_priors, rank_evidence, term_overlap, +) from backend.i18n import tr logger = logging.getLogger(__name__) @@ -254,11 +256,26 @@ def build_jev_request(market: Market, evidence: list, now: Optional[datetime] = } questions = { + # Outside view first: forecasters who start from how often similar events happen, and + # move away from it only on strong, specific evidence, are far less overconfident + "base_rate": Noul( + instructions=( + "Outside view, ignoring the news items: think of the reference class of similar " + "questions (same kind of event, same time left before the end date). How often does an " + "event of this kind happen within such a period? Answer with that base rate." + ), + criteria={ + "true": "Events of this kind usually happen within such a period", + "false": "Events of this kind usually do not happen within such a period", + }, + ), "resolves_yes": Noul( instructions=( - "Considering the market's resolution rules, its end date and the days left, today's date, " - "the news items (weighted as described in how_to_weigh_news), the base rate of similar " - "events and general world knowledge, will this market resolve YES?" + "Start from the base rate of similar events (the outside view, as in base_rate). Then " + "adjust for this specific case: the market's resolution rules, its end date and the days " + "left, today's date and the news items (weighted as described in how_to_weigh_news). Move " + "far from the base rate only with strong, specific and reliable evidence that meets the " + "resolution rules before the end date. Will this market resolve YES?" ), criteria={ "true": "The market resolves YES according to its resolution rules", @@ -288,6 +305,11 @@ def parse_forecast(response) -> tuple[float, float]: return model_p, evidence_strength +def parse_base_rate(response) -> Optional[float]: + base = response.nouls.get("base_rate") if getattr(response, "nouls", None) else None + return float(base.noul) if base is not None else None + + async def ask_jev(state, questions, max_wait: Optional[float] = None, samples: Optional[int] = None): """Jev forecast, averaged over `JEV_SAMPLES` calls in log-odds (an ensemble reduces the noise of a single answer; every call is paid). Returns (first response, P(YES), evidence strength).""" @@ -329,6 +351,12 @@ async def predict_market(session: AsyncSession, market: Market, max_wait: Option link.impact = impact.choice link.impact_confidence = round(float(impact.confidence), 4) + # Jev rates the evidence itself and tends to rate it high: the forecast uses the lower of its + # rating and one computed from verifiable facts (sources, age, confirmations, Jev's relevance) + jev_strength = evidence_strength + objective = objective_evidence(evidence) + if objective is not None: + evidence_strength = min(evidence_strength, objective) signal = compute_signal(model_p, market.yes_price, evidence_strength) prediction = MarketPrediction( market_id=market.id, @@ -339,6 +367,9 @@ async def predict_market(session: AsyncSession, market: Market, max_wait: Option blend_method=settings.BLEND_METHOD, model_samples=samples, evidence_strength=round(evidence_strength, 4), + jev_evidence_strength=round(jev_strength, 4), + objective_evidence=round(objective, 4) if objective is not None else None, + base_rate=parse_base_rate(response), blended_probability=signal.blended_probability, model_weight=signal.model_weight, edge=signal.edge, diff --git a/docs/api.md b/docs/api.md index e0bbd62..dd95c23 100644 --- a/docs/api.md +++ b/docs/api.md @@ -79,9 +79,10 @@ curl -b cookie.txt "localhost:8000/articles?q=fed%20rate%20cut&since_hours=72&mi | Method | Path | Description | |---|---|---| -| GET | `/portfolio` | Summary, capital curve, available presets | +| GET | `/portfolio` | Summary, capital curve, available presets, closing-line guard state (`guard`) | | GET | `/portfolio/export` | The whole portfolio now: `format=xlsx` (summary, bets, equity, exclusions, columns) or `format=csv` (bets); `lang=it/en` for the descriptions | | PUT | `/portfolio/settings` | `{preset, auto_paper, auto_sell}` (admin) | +| POST | `/portfolio/guard/resume` | Resumes automatic bets paused by the closing-line guard; earlier bets are not counted again (admin) | | POST | `/portfolio/reset` | `{bankroll, preset}`: deletes the simulated bets and starts over (admin) | | GET | `/portfolio/bets` | Bets: `status` = `open`, `settled`, `excluded`, `all`; open ones have their exit plan (`plan`) | | POST | `/portfolio/bets/{id}/exclude` · `/include` | Excludes or readmits a bet (admin) | diff --git a/docs/configuration.md b/docs/configuration.md index 5ae83e4..3db1f2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,6 +72,7 @@ All variables are set in `.env`. Placeholder values `your_...` count as "not con | `JEV_SAMPLES` | `1` | Jev calls per forecast, averaged (each one is paid) | | `MIN_EDGE` | `0.05` | Minimum edge to issue a signal | | `MIN_EVIDENCE` | `0.5` | Minimum evidence strength to issue a signal | +| `EVIDENCE_OBJECTIVE_HALF` | `1.5` | Evidence computed from the facts: weighted news total at which it is 0.5. The forecast uses the lower of this and Jev's rating (`0` = Jev's rating only) | | `KELLY_FRACTION` | `0.25` | Fraction of the Kelly criterion used for the stake | @@ -109,6 +110,13 @@ All variables are set in `.env`. Placeholder values `your_...` count as "not con | `MODEL_PSEUDO_COUNT` | `20` | How many "observations" a Jev estimate with full evidence is worth (sets the uncertainty) | | `PAPER_BANKROLL` | `1000` | Starting simulated capital (editable from the dashboard) | | `PAPER_PRESET` | `bilanciato` | Starting preset: `prudente` (prudent), `bilanciato` (balanced), `aggressivo` (aggressive) | +| `LONGSHOT_MIN_PRICE` | `0.10` | No buys of shares cheaper than this, on either side (`0` = off) | +| `CLV_GUARD_ENABLED` | `true` | Closing-line guard on automatic bets | +| `CLV_GUARD_WINDOW` | `15` | Latest bets it looks at | +| `CLV_GUARD_MIN_BETS` | `8` | Bets needed before it can pause all automatic bets | +| `CLV_GUARD_CATEGORY_MIN_BETS` | `5` | Bets of one category needed before it can exclude that category | +| `CLV_GUARD_MIN_AVG` | `-0.02` | Average price move (fraction, side bought) below which it trips | +| `CLV_GUARD_MIN_AGE_HOURS` | `1` | Bets younger than this are not counted yet | diff --git a/docs/development.md b/docs/development.md index ad4d07f..1af048a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -49,6 +49,7 @@ In CI an unreachable database fails the tests instead of skipping them. | `tests/test_backtest.py` | Planning, news without hindsight (archive too), metrics, bootstrap, parameters verified on recent markets | | `tests/test_bulk_predict.py` | «Assess all with Jev»: full flow, waiting on rate limits, stop after repeated errors | | `tests/test_usage.py` | Call log, estimated costs, daily limits and warnings | +| `tests/test_evidence.py` | No long shots, base rate asked to Jev, evidence from the facts (lower of the two), closing-line guard (category, pause, resume) | | `tests/test_i18n.py` | English and Italian texts: request language (`X-Lang`), `APP_LANGUAGE`, plans in English, every dashboard text translated | The tests run with `APP_LANGUAGE=it` (set in `tests/conftest.py`), so most of them check the diff --git a/docs/it/api.md b/docs/it/api.md index cc25269..e798d98 100644 --- a/docs/it/api.md +++ b/docs/it/api.md @@ -81,9 +81,10 @@ curl -b cookie.txt "localhost:8000/articles?q=fed%20rate%20cut&since_hours=72&mi | Metodo | Path | Descrizione | |---|---|---| -| GET | `/portfolio` | Riepilogo, curva del capitale, preset disponibili | +| GET | `/portfolio` | Riepilogo, curva del capitale, preset disponibili, stato della guardia sul prezzo di chiusura (`guard`) | | GET | `/portfolio/export` | Tutto il portafoglio adesso: `format=xlsx` (riepilogo, scommesse, capitale, esclusioni, colonne) o `format=csv` (scommesse); `lang=it/en` per le descrizioni | | PUT | `/portfolio/settings` | `{preset, auto_paper, auto_sell}` (admin) | +| POST | `/portfolio/guard/resume` | Riprende le scommesse automatiche messe in pausa dalla guardia sul prezzo di chiusura; le scommesse precedenti non vengono ricontate (admin) | | POST | `/portfolio/reset` | `{bankroll, preset}`: cancella le scommesse simulate e ricomincia (admin) | | GET | `/portfolio/bets` | Scommesse: `status` = `open`, `settled`, `excluded`, `all`; quelle aperte hanno il piano d'uscita (`plan`) | | POST | `/portfolio/bets/{id}/exclude` · `/include` | Esclude o riammette una scommessa (admin) | diff --git a/docs/it/configurazione.md b/docs/it/configurazione.md index e0345a1..1b19559 100644 --- a/docs/it/configurazione.md +++ b/docs/it/configurazione.md @@ -74,6 +74,7 @@ Tutte le variabili si impostano in `.env`. I valori segnaposto `your_...` contan | `JEV_SAMPLES` | `1` | Chiamate a Jev per previsione, mediate (ognuna si paga) | | `MIN_EDGE` | `0.05` | Edge minimo per emettere un segnale | | `MIN_EVIDENCE` | `0.5` | Forza minima delle evidenze per emettere un segnale | +| `EVIDENCE_OBJECTIVE_HALF` | `1.5` | Evidenze calcolate dai fatti: totale pesato delle notizie a cui valgono 0,5. La previsione usa la più bassa tra questa e la valutazione di Jev (`0` = solo Jev) | | `KELLY_FRACTION` | `0.25` | Frazione del criterio di Kelly usata per la puntata | @@ -111,6 +112,13 @@ Tutte le variabili si impostano in `.env`. I valori segnaposto `your_...` contan | `MODEL_PSEUDO_COUNT` | `20` | Quante "osservazioni" vale una stima Jev con evidenze piene (regola l'incertezza) | | `PAPER_BANKROLL` | `1000` | Capitale iniziale simulato (modificabile dalla dashboard) | | `PAPER_PRESET` | `bilanciato` | Preset iniziale: `prudente`, `bilanciato`, `aggressivo` | +| `LONGSHOT_MIN_PRICE` | `0.10` | Nessun acquisto di quote che costano meno di così, su nessuno dei due lati (`0` = disattivato) | +| `CLV_GUARD_ENABLED` | `true` | Guardia sul prezzo di chiusura per le scommesse automatiche | +| `CLV_GUARD_WINDOW` | `15` | Ultime scommesse che considera | +| `CLV_GUARD_MIN_BETS` | `8` | Scommesse necessarie prima di poter mettere in pausa tutte le automatiche | +| `CLV_GUARD_CATEGORY_MIN_BETS` | `5` | Scommesse di una categoria necessarie prima di poterla escludere | +| `CLV_GUARD_MIN_AVG` | `-0.02` | Movimento medio del prezzo (frazione, lato comprato) sotto il quale scatta | +| `CLV_GUARD_MIN_AGE_HOURS` | `1` | Le scommesse più giovani di così non contano ancora | diff --git a/docs/it/metodo.md b/docs/it/metodo.md index 8f188d6..069c6f4 100644 --- a/docs/it/metodo.md +++ b/docs/it/metodo.md @@ -73,11 +73,26 @@ indipendente e confrontabile con il prezzo. | Domanda | Primitiva | Uso | |---|---|---| -| `resolves_yes` | `Noul` | Probabilità che il mercato si risolva SÌ | +| `base_rate` | `Noul` | Visione esterna: quanto spesso eventi di questo tipo accadono in un periodo simile, prima di leggere le notizie | +| `resolves_yes` | `Noul` | Probabilità che il mercato si risolva SÌ, partendo dal tasso di base e spostandosi solo quanto le notizie giustificano | | `evidence_strength` | `Score` (0–4) | Quanto le notizie informano davvero l'esito | | `relevant_nX` | `Noul` | La notizia X è rilevante per l'esito? | | `impact_nX` | `Choice` | La notizia X alza, abbassa o non cambia la probabilità del SÌ | +**Prima la visione esterna.** Chi prevede partendo da quanto spesso accadono eventi simili +(il tasso di base) e poi corregge per il caso specifico è meglio calibrato di chi parte dalla +storia. A Jev si chiede prima il tasso di base; il tasso di base viene salvato con la +previsione e mostrato nella spiegazione. + +**Forza delle evidenze: la più bassa di due valutazioni.** La valutazione di Jev (0–4 → 0–1) +si confronta con una calcolata dai fatti: ogni notizia collegata pesa l'affidabilità della +testata × la freschezza × la pertinenza data da Jev, di più se altre testate confermano la +stessa storia (fino a 3) e 1,5× se viene da una fonte primaria (Fed, BCE, BLS, SEC, +tribunali, annunci ufficiali…). Il totale `T` diventa `T / (T + EVIDENCE_OBJECTIVE_HALF)`: col +valore di base 1,5 servono circa tre notizie fresche da testate affidabili, o una fonte +primaria confermata, per arrivare a 0,5. La previsione usa la **più bassa** delle due, così un +solo articolo non può valere come evidenza forte perché lo dice Jev. Si salvano entrambe. + ### 2. Dalla stima al segnale I mercati liquidi di solito sono già ben calibrati, quindi la stima di Jev non viene usata @@ -123,7 +138,7 @@ per il NO la formula simmetrica sul prezzo del NO. |---|---| | Prezzo di mercato (SÌ) | 0.35 | | Stima Jev | 0.80 | -| Forza evidenze | 3/4 → 0.75 | +| Forza evidenze | 3/4 → 0.75 (i fatti la valutano almeno altrettanto) | | Distanza `d` | \|logit 0,80 − logit 0,35\| = 2,005 → riduzione 2 / 2,005 = 0,997 | | Peso `w` | 0,25 × 0,75 × 0,997 ≈ 0,187 | | Probabilità blended | logit⁻¹(0,187 × logit 0,80 + 0,813 × logit 0,35) ≈ **0,439** | diff --git a/docs/it/strategia.md b/docs/it/strategia.md index 2c33ccf..55657be 100644 --- a/docs/it/strategia.md +++ b/docs/it/strategia.md @@ -17,7 +17,9 @@ previsione e, dal vivo, nella scheda **Conviene?** del dettaglio mercato. al 50 %, 4 punti intorno al 90 %), richiede una previsione nuova prima di comprare. Niente scommesse sui mercati decisi dal prezzo di un asset (vedi [il metodo](metodo.md#quali-mercati-restano-fuori)) né su quelli che si chiudono prima delle ore minime del preset: a quel punto il prezzo - conosce già l'esito. + conosce già l'esito. Niente quote sotto `LONGSHOT_MIN_PRICE` (10¢), su nessuno dei due + lati: gli esiti improbabili vincono meno spesso di quanto dica il prezzo (il *favourite–longshot + bias*), e un errore di pochi punti su una quota da 5¢ è una grossa parte della puntata. 1. **Prezzo reale.** Legge il book del lato da comprare dal CLOB di Polymarket (API pubblica, sola lettura) e calcola il prezzo medio che pagheresti per quella cifra. Commissione (solo per chi compra dal book, come qui): `tasso × prezzo × (1 − prezzo)` per @@ -56,6 +58,18 @@ previsione e, dal vivo, nella scheda **Conviene?** del dettaglio mercato. **Portafoglio simulato** (pagina *Portafoglio*): - **Scommesse automatiche:** ogni previsione con verdetto *Conviene* o *Conviene poco* diventa una scommessa virtuale al prezzo reale del momento, al massimo una aperta per mercato. +- **Guardia sul prezzo di chiusura:** il risultato di una scommessa arriva dopo settimane, il + prezzo si muove in poche ore. Sulle ultime `CLV_GUARD_WINDOW` (15) scommesse più vecchie di + un'ora la guardia misura quanto si è mosso il prezzo del lato comprato dall'acquisto (fino al + prezzo di chiusura, se il mercato è chiuso). Se la media è sotto `CLV_GUARD_MIN_AVG` (−2 punti): + - su almeno `CLV_GUARD_CATEGORY_MIN_BETS` (5) scommesse di una categoria → la categoria viene + esclusa dalle scommesse automatiche (compare tra le esclusioni, e lì si può togliere); + - su almeno `CLV_GUARD_MIN_BETS` (8) scommesse → le scommesse automatiche vanno in pausa, con + un messaggio Telegram. La pagina del portafoglio mostra il motivo; un admin le riprende con + «Riprendi le scommesse automatiche», e le scommesse precedenti non vengono ricontate. + + Le scommesse manuali non vengono mai bloccate. Un mercato che si muove continuamente contro i + segnali vuol dire che non lo anticipano: continuare a scommettere pagherebbe solo lo spread. - **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ò diff --git a/docs/it/sviluppo.md b/docs/it/sviluppo.md index 036332d..c303d3d 100644 --- a/docs/it/sviluppo.md +++ b/docs/it/sviluppo.md @@ -50,6 +50,7 @@ In CI un database non raggiungibile fa fallire i test invece di saltarli. | `tests/test_backtest.py` | Pianificazione, notizie senza senno di poi (anche d'archivio), metriche, bootstrap, parametri verificati sui mercati recenti | | `tests/test_bulk_predict.py` | «Valuta tutti con Jev»: flusso completo, attesa sui limiti di frequenza, stop dopo errori ripetuti | | `tests/test_usage.py` | Registro delle chiamate, costi stimati, limiti giornalieri e avvisi | +| `tests/test_evidence.py` | Niente quote improbabili, tasso di base chiesto a Jev, evidenze dai fatti (la più bassa delle due), guardia sul prezzo di chiusura (categoria, pausa, ripresa) | | `tests/test_i18n.py` | Testi in inglese e italiano: lingua della richiesta (`X-Lang`), `APP_LANGUAGE`, piani in inglese, ogni testo della dashboard tradotto | ## Struttura del progetto diff --git a/docs/method.md b/docs/method.md index 650524c..486cfa6 100644 --- a/docs/method.md +++ b/docs/method.md @@ -71,11 +71,26 @@ passed**, so Jev's estimate stays independent and comparable with the price. | Question | Primitive | Use | |---|---|---| -| `resolves_yes` | `Noul` | Probability that the market resolves YES | +| `base_rate` | `Noul` | Outside view: how often events of this kind happen in a similar time frame, before reading the news | +| `resolves_yes` | `Noul` | Probability that the market resolves YES, starting from the base rate and moving away from it only as far as the news justifies | | `evidence_strength` | `Score` (0–4) | How much the news really informs the outcome | | `relevant_nX` | `Noul` | Is news item X relevant to the outcome? | | `impact_nX` | `Choice` | News item X raises, lowers or does not change the probability of YES | +**Outside view first.** Forecasters who start from how often similar events happen (the base +rate) and then adjust for the specific case are better calibrated than those who start from +the story. Jev is asked for the base rate first; the base rate is saved with the forecast and +shown in the explanation. + +**Evidence strength: the lower of two ratings.** Jev's rating (0–4 → 0–1) is compared with +one computed from the facts: every linked news item weighs its outlet's reliability × its +freshness × the relevance Jev gave it, more if other outlets confirm the same story (up to 3) +and 1.5× if it comes from a primary source (Fed, ECB, BLS, SEC, courts, official +announcements…). The total `T` becomes `T / (T + EVIDENCE_OBJECTIVE_HALF)`: with the default +1.5 about three fresh items from reliable outlets, or one confirmed primary source, are +needed to reach 0.5. The forecast uses the **lower** of the two ratings, so a single article +cannot count as strong evidence because Jev says so. Both ratings are saved. + ### 2. From estimate to signal Liquid markets are usually already well calibrated, so Jev's estimate is not used as it is: @@ -120,7 +135,7 @@ for NO the symmetric formula on the NO price. |---|---| | Market price (YES) | 0.35 | | Jev estimate | 0.80 | -| Evidence strength | 3/4 → 0.75 | +| Evidence strength | 3/4 → 0.75 (the facts rate it at least as high) | | Distance `d` | \|logit 0.80 − logit 0.35\| = 2.005 → reduction 2 / 2.005 = 0.997 | | Weight `w` | 0.25 × 0.75 × 0.997 ≈ 0.187 | | Blended probability | logit⁻¹(0.187 × logit 0.80 + 0.813 × logit 0.35) ≈ **0.439** | diff --git a/docs/strategy.md b/docs/strategy.md index 9a59cad..e98994a 100644 --- a/docs/strategy.md +++ b/docs/strategy.md @@ -17,7 +17,9 @@ every forecast and, live, in the **Worth it?** card of the market detail page. 4 points around 90 %), needs a new one before buying. No bets on markets decided by an asset's price (see [the method](method.md#which-markets-are-left-out)), nor on markets ending in less than the preset's minimum hours: at that point the price already knows the - outcome. + outcome. No shares under `LONGSHOT_MIN_PRICE` (10¢), on either side: long shots win less + often than their price says (the *favourite–longshot bias*), and an error of a few points + on a 5¢ share is a large share of the stake. 1. **Real price.** It reads the book of the side to buy from Polymarket's CLOB (public API, read only) and computes the average price you would pay for that amount. Fee (only for those who take from the book, as here): `rate × price × (1 − price)` per @@ -55,6 +57,18 @@ every forecast and, live, in the **Worth it?** card of the market detail page. **Simulated portfolio** (*Portfolio* page): - **Automatic bets:** every forecast with a *Worth it* or *Barely worth it* verdict becomes a virtual bet at the real price of the moment, at most one open per market. +- **Closing-line guard:** the result of a bet takes weeks, the price moves within hours. For + the latest `CLV_GUARD_WINDOW` (15) bets older than an hour, the guard measures how much the + price of the side bought has moved since the purchase (to the closing price once the market + has closed). If the average is below `CLV_GUARD_MIN_AVG` (−2 points): + - over at least `CLV_GUARD_CATEGORY_MIN_BETS` (5) bets of one category → the category is + excluded from automatic bets (it shows up in the exclusions, and can be removed there); + - over at least `CLV_GUARD_MIN_BETS` (8) bets → automatic bets pause, with a Telegram + message. The portfolio page shows why; an admin resumes them with «Resume automatic + bets», and the bets before the resume are not counted again. + + Manual bets are never blocked. A market that keeps moving against the signals means they are + not ahead of it: betting on would only pay the spread. - **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») diff --git a/frontend/explain.js b/frontend/explain.js index 1b94de2..786f3d3 100644 --- a/frontend/explain.js +++ b/frontend/explain.js @@ -86,10 +86,14 @@ export function explainCard(p, market, evidence, status) { step(1, t("Stima indipendente di Jev"), GLOSSARY.jev, h("p", {}, t("Jev ha letto le regole del mercato e {0} notizie collegate, senza vedere il prezzo: ", p.article_count), h("b", { class: "mono" }, pct(p.model_probability)), t(" di probabilità che si risolva SÌ.")), + p.base_rate != null ? h("p", { class: "muted small" }, + t("Prima di leggere le notizie, il caso tipico: eventi simili accadono circa nel {0} dei casi. Jev parte da lì e se ne allontana solo con notizie forti.", pct(p.base_rate))) : null, judged ? h("p", { class: "muted small" }, t("Delle notizie valutate: {0} favoriscono il SÌ, {1} il NO, {2} sono neutre.", tally.raises_yes, tally.lowers_yes, tally.neutral)) : null, ), step(2, t("Quanto contano le notizie"), GLOSSARY.weight, + p.objective_evidence != null && p.jev_evidence_strength != null ? h("p", { class: "muted small" }, + t("Jev valuta le notizie al {0}; i fatti (fonti, età, conferme da più testate) dicono {1}. Si usa la più bassa.", pct(p.jev_evidence_strength), pct(p.objective_evidence))) : null, h("p", {}, t("Forza delle evidenze "), h("b", { class: "mono" }, pct(p.evidence_strength)), t(". Il peso di Jev è il peso massimo per la forza delle evidenze:")), (() => { diff --git a/frontend/i18n-en.js b/frontend/i18n-en.js index 7e2933f..a0b7db4 100644 --- a/frontend/i18n-en.js +++ b/frontend/i18n-en.js @@ -1205,4 +1205,18 @@ export default { "w = ": "w = ", "Calcolato con i parametri di allora.": "Computed with the parameters of the time.", "Se Jev è molto lontano dal prezzo (più di {0} in log-odds) il suo peso si riduce in proporzione: su un mercato liquido le distanze più grandi sono più spesso errori di Jev che informazioni, e altrimenti diventerebbero gli edge più grandi.": "If Jev is very far from the price (more than {0} in log-odds) its weight shrinks in proportion: on a liquid market the biggest gaps are more often Jev's errors than information, and they would otherwise become the biggest edges.", + "Prima di leggere le notizie, il caso tipico: eventi simili accadono circa nel {0} dei casi. Jev parte da lì e se ne allontana solo con notizie forti.": "Before reading the news, the base rate: similar events happen in about {0} of cases. Jev starts from there and moves away only with strong news.", + "Jev valuta le notizie al {0}; i fatti (fonti, età, conferme da più testate) dicono {1}. Si usa la più bassa.": "Jev rates the news at {0}; the facts (sources, age, confirmations from several outlets) say {1}. The lower one is used.", + "Scommesse automatiche in pausa. ": "Automatic bets paused. ", + "Il prezzo si muove contro le scommesse recenti: i segnali non stanno anticipando il mercato. Riprendi quando hai cambiato qualcosa (calibrazione, preset, categorie): le scommesse fatte finora non verranno ricontate.": "The price is moving against the recent bets: the signals are not getting ahead of the market. Resume once you have changed something (calibration, preset, categories): the bets so far will not be counted again.", + "Scommesse automatiche riprese": "Automatic bets resumed", + "Riprendi le scommesse automatiche": "Resume automatic bets", + "Controllo del prezzo di chiusura: sulle ultime {0} scommesse il prezzo si è mosso in media di {1}; sotto {2} le scommesse automatiche si fermano (servono almeno {3} scommesse). ": "Closing-price check: over the latest {0} bets the price moved {1} on average; below {2} automatic bets stop (at least {3} bets needed). ", + "Prezzo minimo di una quota": "Minimum share price", + "nessuno": "none", + "Forza delle evidenze dai fatti": "Evidence strength from the facts", + "attiva (50% a {0})": "on (50% at {0})", + "spenta": "off", + "Pausa se il prezzo va contro": "Pause if the price goes against", + "sotto {0} di media su {1} scommesse": "below {0} on average over {1} bets", }; diff --git a/frontend/views/portfolio.js b/frontend/views/portfolio.js index 3e7cc61..fb38fa5 100644 --- a/frontend/views/portfolio.js +++ b/frontend/views/portfolio.js @@ -155,6 +155,14 @@ export async function viewPortfolio(ctx) { 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"))) : data.clv_open?.n ? t("finora {0} sulle aperte", fmt.pts(data.clv_open.avg)) : t("nessun mercato chiuso ancora")), ), + data.guard?.paused ? h("div", { class: "notice", role: "alert", style: { marginBottom: "16px" } }, icon("alert"), + h("div", {}, h("p", {}, h("b", {}, t("Scommesse automatiche in pausa. ")), data.guard.reason || ""), + h("p", { class: "small" }, t("Il prezzo si muove contro le scommesse recenti: i segnali non stanno anticipando il mercato. Riprendi quando hai cambiato qualcosa (calibrazione, preset, categorie): le scommesse fatte finora non verranno ricontate.")), + ctx.isAdmin() ? h("button", { class: "btn btn-ghost btn-sm", type: "button", on: { click: async (e) => { + e.currentTarget.disabled = true; + try { await api("/portfolio/guard/resume", { method: "POST" }); toast(t("Scommesse automatiche riprese")); ctx.rerender(); } + catch (err) { toast(err.message, { error: true }); e.currentTarget.disabled = false; } + } } }, t("Riprendi le scommesse automatiche")) : null)) : null, !bets.length ? h("p", { class: "note", style: { marginBottom: "16px" } }, icon("alert"), s.auto_paper ? t("Ancora nessuna scommessa: la prima arriva con la prossima previsione che supera i controlli economici del preset.") : t("Le scommesse automatiche sono disattivate: attivale qui sotto o aggiungile dal dettaglio di un mercato.")) : null, @@ -233,6 +241,8 @@ function settingsCard(ctx, data, refresh) { resetArea, h("p", { class: "muted small", style: { marginTop: "10px" } }, t("Tasso senza rischio {0}. ", fmt.pct(data.risk_free_rate)), + data.guard?.enabled ? t("Controllo del prezzo di chiusura: sulle ultime {0} scommesse il prezzo si è mosso in media di {1}; sotto {2} le scommesse automatiche si fermano (servono almeno {3} scommesse). ", + data.guard.n, data.guard.avg_move != null ? fmt.pts(data.guard.avg_move) : "–", fmt.pts(data.guard.threshold), data.guard.min_bets) : "", data.calibration_factor !== 1 ? t("Incertezza corretta ×{0} in base ai risultati passati.", fmt.dec(data.calibration_factor, 2)) : t("L'incertezza verrà corretta con i risultati reali dopo 30 mercati risolti."), infoTip(t("Se le previsioni passate sono state peggiori del prezzo di mercato, l'incertezza della stima viene allargata e le puntate si riducono (e viceversa).")), diff --git a/frontend/views/settings.js b/frontend/views/settings.js index be8be66..5cf0b4b 100644 --- a/frontend/views/settings.js +++ b/frontend/views/settings.js @@ -361,6 +361,9 @@ function parametersCard(ctx) { row(t("Peso ridotto se Jev dista dal prezzo più di"), st.model_disagreement_logit ? t("{0} in log-odds", fmt.dec(st.model_disagreement_logit)) : t("mai"), "MODEL_DISAGREEMENT_LOGIT"), row(t("Previsione valida per comprare"), t("{0} ore, prezzo mosso di meno di {1} in log-odds", st.forecast_max_age_hours ?? "–", fmt.dec(st.forecast_max_price_move ?? 0)), "FORECAST_MAX_AGE_HOURS / FORECAST_MAX_PRICE_MOVE"), row(t("Mercati sul prezzo di un asset"), st.exclude_price_markets ? t("esclusi") : t("ammessi"), "EXCLUDE_PRICE_MARKETS"), + row(t("Prezzo minimo di una quota"), st.longshot_min_price ? fmt.cents(st.longshot_min_price) : t("nessuno"), "LONGSHOT_MIN_PRICE"), + row(t("Forza delle evidenze dai fatti"), st.evidence_objective_half ? t("attiva (50% a {0})", fmt.dec(st.evidence_objective_half)) : t("spenta"), "EVIDENCE_OBJECTIVE_HALF"), + row(t("Pausa se il prezzo va contro"), st.clv_guard?.enabled ? t("sotto {0} di media su {1} scommesse", fmt.pts(st.clv_guard.min_avg), st.clv_guard.min_bets) : t("spenta"), "CLV_GUARD_*"), row(t("Come si uniscono Jev e prezzo"), st.blend_method === "linear" ? t("media lineare") : t("media in log-odds"), "BLEND_METHOD"), row(t("Calibrazione di Jev (a, b)"), `${fmt.num3(st.jev_calib_a ?? 0)} · ${fmt.num3(st.jev_calib_b ?? 1)}`, "JEV_CALIB_A / JEV_CALIB_B"), row(t("Chiamate a Jev per previsione"), String(st.jev_samples ?? 1), "JEV_SAMPLES"), diff --git a/tests/conftest.py b/tests/conftest.py index baea787..83e3b46 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -24,6 +24,9 @@ os.environ["MODEL_WEIGHT_MAX"] = "0.5" os.environ["MODEL_DISAGREEMENT_LOGIT"] = "0" os.environ["EXCLUDE_PRICE_MARKETS"] = "false" +# ... and without the objective evidence strength and the long-shot rule (tests/test_evidence.py) +os.environ["EVIDENCE_OBJECTIVE_HALF"] = "0" +os.environ["LONGSHOT_MIN_PRICE"] = "0" # Fast client-side rate limits in tests (test_ratelimit.py sets its own) os.environ["JEV_RPM"] = "6000" os.environ["GROQ_RPM"] = "6000" diff --git a/tests/test_evidence.py b/tests/test_evidence.py new file mode 100644 index 0000000..ebda7ef --- /dev/null +++ b/tests/test_evidence.py @@ -0,0 +1,152 @@ +"""Long shots, outside view, closing-line guard and objective evidence strength.""" +from datetime import timedelta +from types import SimpleNamespace + +import pytest +from sqlalchemy import select + +from backend.betting import economics, guard, portfolio +from backend.betting.profiles import get_profile +from backend.betting.strategy import buy_levels +from backend.config import settings +from backend.db.database import SessionLocal +from backend.db.models import Market, MarketPrediction, PaperBet, PaperExclusion +from backend.markets import matching +from backend.markets.service import build_jev_request, predict_market +from tests.conftest import login_client +from tests.test_portfolio import NOW, book, make_market, make_prediction # noqa: F401 (fixture) +from tests.test_strategy import HURDLE, PROFILE, fc + + +# ---------- 1. Long shots ---------- + +def test_no_buys_below_ten_cents_on_either_side(monkeypatch): + monkeypatch.setattr(settings, "LONGSHOT_MIN_PRICE", 0.10) + profile = get_profile("aggressivo") + cheap = economics.Quote(asks=[(0.06, 1e5)], mid=0.055, fee_bps=0, source="book") + ev = economics.evaluate(signal="BUY_YES", p_yes=0.30, sigma=0.0, quote=cheap, days=30, profile=profile, + equity=1000, available_cash=1000, exposure=economics.Exposure(), liquidity=1e6, risk_free_rate=0.04) + assert ev.verdict == "NO" and [r["code"] for r in ev.reasons if r["blocking"]] == ["longshot"] + ev = economics.evaluate(signal="BUY_NO", p_yes=0.70, sigma=0.0, quote=cheap, days=30, profile=profile, + equity=1000, available_cash=1000, exposure=economics.Exposure(), liquidity=1e6, risk_free_rate=0.04) + assert any(r["code"] == "longshot" for r in ev.reasons) + # The strategy never suggests buying YES under 10¢ either + levels = buy_levels(fc(model=0.8), PROFILE, 400, 30, 0.05, HURDLE) + assert levels["yes_limit"] is None or levels["yes_limit"] >= 0.10 + + +# ---------- 3. Outside view ---------- + +async def test_jev_is_asked_for_the_base_rate_first(db, book, jev_client): + jev_client(noul_value=0.3, score_value=3.0) + async with SessionLocal() as s: + m = await make_market(s, yes=0.35) + state, questions = build_jev_request(m, []) + assert "base_rate" in questions and "base rate" in questions["resolves_yes"].instructions.lower() + p = await predict_market(s, m) + assert p.base_rate == pytest.approx(0.3) + + +# ---------- 6. Objective evidence strength ---------- + +def _item(quality=0.75, corroboration=1, url="https://www.reuters.com/x", relevance=None, age_h=1.0): + now = NOW + link = SimpleNamespace(relevance=relevance) + article = SimpleNamespace(url=url, published_at=now - timedelta(hours=age_h), fetched_at=now) + source = SimpleNamespace(url=url) + return matching.EvidenceItem(link, article, None, source, 1.0, corroboration, quality) + + +def test_objective_evidence_grows_with_confirmations_primary_sources_and_freshness(): + one = matching.objective_evidence([_item()], now=NOW, half=1.5) + assert 0.2 < one < 0.35 + assert matching.objective_evidence([_item()] * 3, now=NOW, half=1.5) == pytest.approx(0.5, abs=0.05) + confirmed = matching.objective_evidence([_item(corroboration=3)], now=NOW, half=1.5) + primary = matching.objective_evidence([_item(url="https://www.federalreserve.gov/newsevents/x")], now=NOW, half=1.5) + old = matching.objective_evidence([_item(age_h=200)], now=NOW, half=1.5) + irrelevant = matching.objective_evidence([_item(relevance=0.1)], now=NOW, half=1.5) + assert confirmed > primary > one > old and one > irrelevant + assert matching.is_primary_source(SimpleNamespace(url="https://www.bls.gov/news.release/cpi.nr0.htm"), None) + assert matching.objective_evidence([_item()], half=0) is None + + +async def test_forecast_uses_the_lower_of_jev_and_the_facts(db, book, jev_client, monkeypatch): + """Jev rates the single linked article as strong evidence (3/4): the facts say it is one item.""" + monkeypatch.setattr(settings, "EVIDENCE_OBJECTIVE_HALF", 1.5) + jev_client(noul_value=0.8, score_value=3.0) + async with SessionLocal() as s: + m = await make_market(s, yes=0.35) + p = await predict_market(s, m) + assert p.jev_evidence_strength == pytest.approx(0.75) + assert p.objective_evidence < 0.5 and p.evidence_strength == pytest.approx(p.objective_evidence, abs=1e-4) + assert p.signal == "HOLD" # below MIN_EVIDENCE: no signal from one article + + +# ---------- 4. Closing-line guard ---------- + +async def _bets_moving_against(s, n, category="Economy", start=0): + """n markets bought at 40¢ YES more than an hour ago, now at 35¢: 5 points against each. + The portfolio started before them (the guard only counts bets placed since the start).""" + row = await portfolio.get_settings(s) + row.started_at = NOW - timedelta(days=1) + for i in range(start, start + n): + m = Market(id=f"g{i}", question=f"Guard {i}?", yes_price=0.35, volume=1e6, liquidity=1e5, + category=category, end_date=NOW + timedelta(days=30), event_slug=f"g{i}") + s.add(m) + s.add(PaperBet(market_id=m.id, side="YES", shares=10, avg_price=0.40, stake=4.0, fee=0.0, p_side=0.5, + p_conservative=0.45, expected_profit=1.0, preset="bilanciato", placed_by="auto", + created_at=NOW - timedelta(hours=3))) + await s.commit() + + +async def test_guard_excludes_a_losing_category_then_pauses_all_automatic_bets(db, book): + async with SessionLocal() as s: + await _bets_moving_against(s, 5, category="Crypto") + target = Market(id="t1", question="Next?", yes_price=0.35, category="Crypto") + s.add(target) + await s.commit() + # 5 crypto bets, all 5 points against: the category goes, the rest can still bet + assert not await guard.allows_auto_bet(s, target) + excl = (await s.execute(select(PaperExclusion))).scalar_one() + assert (excl.kind, excl.value) == ("category", "Crypto") and "5" in excl.label + other = Market(id="t2", question="Other?", yes_price=0.35, category="Politics") + s.add(other) + await s.commit() + assert await guard.allows_auto_bet(s, other) # 5 bets overall: below CLV_GUARD_MIN_BETS + + await _bets_moving_against(s, 4, category="Economy", start=10) + assert not await guard.allows_auto_bet(s, other) # 9 bets overall, all against: pause + st = await guard.status(s) + assert st["paused"] and st["n"] == 9 and st["avg_move"] == pytest.approx(-0.05) + assert "9" in st["reason"] + + async with login_client("viewer") as api: + assert (await api.post("/portfolio/guard/resume")).status_code == 403 + assert (await api.get("/portfolio")).json()["guard"]["paused"] is True + async with login_client("admin") as api: + r = await api.post("/portfolio/guard/resume") + assert r.status_code == 200 and r.json()["paused"] is False and r.json()["n"] == 0 # old bets not counted + async with SessionLocal() as s: + assert await guard.allows_auto_bet(s, await s.get(Market, "t2")) + + +async def test_guard_ignores_bets_too_young_to_have_moved_and_manual_bets_are_not_blocked(db, book): + async with SessionLocal() as s: + await _bets_moving_against(s, 10) + for b in (await s.execute(select(PaperBet))).scalars().all(): + b.created_at = NOW - timedelta(minutes=10) + await s.commit() + target = Market(id="t3", question="Next?", yes_price=0.35, category="Politics") + s.add(target) + await s.commit() + assert await guard.allows_auto_bet(s, target) + # Paused: an automatic bet is refused, a manual one still goes through + m = await make_market(s, mid="m-fed", yes=0.35) + s_row = await portfolio.get_settings(s) + s_row.paused_at, s_row.paused_reason = NOW, "test" + await s.commit() + p = await make_prediction(s, m) + ev = await portfolio.evaluate_prediction(s, m, p) + assert ev.verdict in ("GO", "SMALL") + assert await portfolio.maybe_place_bet(s, m, p, ev, placed_by="auto") is None + assert await portfolio.maybe_place_bet(s, m, p, ev, placed_by="manual") is not None