Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f84bc3ec7 | ||
|
|
9e21ecac21 | ||
|
|
a3ec69d2be | ||
|
|
af0d1fbc59 | ||
|
|
54fc8fa11a | ||
|
|
b6153f5859 | ||
|
|
4928a3c1e4 | ||
|
|
43d9577fb2 | ||
|
|
1797b79f7b | ||
|
|
060fc89953 | ||
|
|
c5ffc37820 | ||
|
|
7ebb87aede | ||
|
|
02cbfc0b94 | ||
|
|
4867141c4b | ||
|
|
f5ac302a86 | ||
|
|
4002f03d0c | ||
|
|
96f31acf16 | ||
|
|
5aa54eb423 | ||
|
|
e137116e7f | ||
|
|
340c8523cf | ||
|
|
3a353c7e5b | ||
|
|
823914789d | ||
|
|
98abd96fd2 |
+89
-10
@@ -19,15 +19,64 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ssl-verify: false
|
||||
# Full history: needed to diff against github.event.before
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set image tag
|
||||
id: tag
|
||||
run: echo "TAG=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Detect changed components
|
||||
id: changes
|
||||
run: |
|
||||
BEFORE="${{ github.event.before }}"
|
||||
CHANGED=""
|
||||
case "$BEFORE" in
|
||||
""|0000000000000000000000000000000000000000)
|
||||
echo "First push or unknown base — building all images"
|
||||
CHANGED="__all__"
|
||||
;;
|
||||
*)
|
||||
if git cat-file -e "$BEFORE" 2>/dev/null; then
|
||||
CHANGED=$(git diff --name-only "$BEFORE" "$GITHUB_SHA")
|
||||
else
|
||||
echo "Base commit $BEFORE not in history (force push?) — building all images"
|
||||
CHANGED="__all__"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
|
||||
if [ "$CHANGED" = "__all__" ]; then
|
||||
BOT=true; API=true; DASH=true
|
||||
else
|
||||
BOT=false; API=false; DASH=false
|
||||
matches() { echo "$CHANGED" | grep -qE "$1"; }
|
||||
# The workflow itself affects every image build
|
||||
if matches '^\.gitea/workflows/ci\.yml$'; then BOT=true; API=true; DASH=true; fi
|
||||
# bot and api images both COPY bot/, api/ and requirements.txt
|
||||
if matches '^(bot/|api/|requirements\.txt$)'; then BOT=true; API=true; fi
|
||||
if matches '^Dockerfile$'; then BOT=true; fi
|
||||
if matches '^Dockerfile\.api$'; then API=true; fi
|
||||
# dashboard image builds from the dashboard/ context only
|
||||
if matches '^dashboard/'; then DASH=true; fi
|
||||
fi
|
||||
|
||||
ANY=false
|
||||
if [ "$BOT" = "true" ] || [ "$API" = "true" ] || [ "$DASH" = "true" ]; then ANY=true; fi
|
||||
echo "build_bot=$BOT" >> $GITHUB_OUTPUT
|
||||
echo "build_api=$API" >> $GITHUB_OUTPUT
|
||||
echo "build_dashboard=$DASH" >> $GITHUB_OUTPUT
|
||||
echo "build_any=$ANY" >> $GITHUB_OUTPUT
|
||||
echo "Will build: bot=$BOT api=$API dashboard=$DASH"
|
||||
|
||||
- name: Log in to registry
|
||||
if: steps.changes.outputs.build_any == 'true'
|
||||
run: echo "${{ secrets.CI_TOKEN }}" | docker login gitea.gitea.svc.cluster.local:3000 -u chemavx --password-stdin
|
||||
|
||||
- name: Create buildx builder
|
||||
if: steps.changes.outputs.build_any == 'true'
|
||||
run: |
|
||||
cat > /tmp/buildkitd.toml << 'EOF'
|
||||
[registry."registry-cache.registry-cache.svc.cluster.local:5000"]
|
||||
@@ -50,6 +99,7 @@ jobs:
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
- name: Build and push bot image
|
||||
if: steps.changes.outputs.build_bot == 'true'
|
||||
run: |
|
||||
TAG=${{ steps.tag.outputs.TAG }}
|
||||
docker buildx build \
|
||||
@@ -61,6 +111,7 @@ jobs:
|
||||
-f Dockerfile .
|
||||
|
||||
- name: Build and push API image
|
||||
if: steps.changes.outputs.build_api == 'true'
|
||||
run: |
|
||||
TAG=${{ steps.tag.outputs.TAG }}
|
||||
docker buildx build \
|
||||
@@ -72,6 +123,7 @@ jobs:
|
||||
-f Dockerfile.api .
|
||||
|
||||
- name: Build and push dashboard image
|
||||
if: steps.changes.outputs.build_dashboard == 'true'
|
||||
run: |
|
||||
TAG=${{ steps.tag.outputs.TAG }}
|
||||
docker buildx build \
|
||||
@@ -84,6 +136,7 @@ jobs:
|
||||
dashboard
|
||||
|
||||
- name: Verify images in registry
|
||||
if: steps.changes.outputs.build_any == 'true'
|
||||
run: |
|
||||
TAG=${{ steps.tag.outputs.TAG }}
|
||||
check_image() {
|
||||
@@ -98,11 +151,18 @@ jobs:
|
||||
fi
|
||||
echo "OK: chemavx/${image}:${TAG} verified in registry"
|
||||
}
|
||||
check_image polymarket-bot
|
||||
check_image polymarket-bot-api
|
||||
check_image polymarket-bot-dashboard
|
||||
if [ "${{ steps.changes.outputs.build_bot }}" = "true" ]; then
|
||||
check_image polymarket-bot
|
||||
fi
|
||||
if [ "${{ steps.changes.outputs.build_api }}" = "true" ]; then
|
||||
check_image polymarket-bot-api
|
||||
fi
|
||||
if [ "${{ steps.changes.outputs.build_dashboard }}" = "true" ]; then
|
||||
check_image polymarket-bot-dashboard
|
||||
fi
|
||||
|
||||
- name: Update k8s manifests
|
||||
if: steps.changes.outputs.build_any == 'true'
|
||||
run: |
|
||||
pip3 install pyyaml -q
|
||||
|
||||
@@ -114,12 +174,20 @@ jobs:
|
||||
git clone ${{ env.K8S_MANIFESTS_REPO }} /tmp/k8s-manifests
|
||||
cd /tmp/k8s-manifests
|
||||
|
||||
sed -i "s|image: .*polymarket-bot[^-].*|image: git.chemavx.xyz/chemavx/polymarket-bot:${TAG}|g" \
|
||||
polymarket-bot/deployment-bot.yaml
|
||||
sed -i "s|image: .*polymarket-bot-api.*|image: git.chemavx.xyz/chemavx/polymarket-bot-api:${TAG}|g" \
|
||||
polymarket-bot/deployment-api.yaml
|
||||
sed -i "s|image: .*polymarket-bot-dashboard.*|image: git.chemavx.xyz/chemavx/polymarket-bot-dashboard:${TAG}|g" \
|
||||
polymarket-bot/deployment-dashboard.yaml
|
||||
# Only bump the tag of images that were actually rebuilt: the others
|
||||
# keep their current (still existing) tag in the registry.
|
||||
if [ "${{ steps.changes.outputs.build_bot }}" = "true" ]; then
|
||||
sed -i "s|image: .*polymarket-bot[^-].*|image: git.chemavx.xyz/chemavx/polymarket-bot:${TAG}|g" \
|
||||
polymarket-bot/deployment-bot.yaml
|
||||
fi
|
||||
if [ "${{ steps.changes.outputs.build_api }}" = "true" ]; then
|
||||
sed -i "s|image: .*polymarket-bot-api.*|image: git.chemavx.xyz/chemavx/polymarket-bot-api:${TAG}|g" \
|
||||
polymarket-bot/deployment-api.yaml
|
||||
fi
|
||||
if [ "${{ steps.changes.outputs.build_dashboard }}" = "true" ]; then
|
||||
sed -i "s|image: .*polymarket-bot-dashboard.*|image: git.chemavx.xyz/chemavx/polymarket-bot-dashboard:${TAG}|g" \
|
||||
polymarket-bot/deployment-dashboard.yaml
|
||||
fi
|
||||
sed -i "s|imagePullPolicy: Never|imagePullPolicy: Always|g" \
|
||||
polymarket-bot/deployment-bot.yaml \
|
||||
polymarket-bot/deployment-api.yaml \
|
||||
@@ -154,10 +222,21 @@ jobs:
|
||||
TAG: ${{ steps.tag.outputs.TAG }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
|
||||
BUILD_BOT: ${{ steps.changes.outputs.build_bot }}
|
||||
BUILD_API: ${{ steps.changes.outputs.build_api }}
|
||||
BUILD_DASH: ${{ steps.changes.outputs.build_dashboard }}
|
||||
run: |
|
||||
TAG="${TAG:-${GITHUB_SHA:0:8}}"
|
||||
BUILT=""
|
||||
[ "$BUILD_BOT" = "true" ] && BUILT="${BUILT}bot "
|
||||
[ "$BUILD_API" = "true" ] && BUILT="${BUILT}api "
|
||||
[ "$BUILD_DASH" = "true" ] && BUILT="${BUILT}dashboard "
|
||||
if [ "$JOB_STATUS" = "success" ]; then
|
||||
MSG="✅ Deploy polymarket-bot:${TAG} completado"
|
||||
if [ -n "$BUILT" ]; then
|
||||
MSG="✅ Deploy polymarket-bot:${TAG} completado (imágenes: ${BUILT% })"
|
||||
else
|
||||
MSG="✅ CI polymarket-bot:${TAG} OK — sin cambios de imagen, nada que desplegar"
|
||||
fi
|
||||
else
|
||||
MSG="❌ Deploy polymarket-bot:${TAG} fallido (status: ${JOB_STATUS})"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# polymarket-bot
|
||||
|
||||
Bot de paper-trading para Polymarket con estrategia bayesiana, API FastAPI y
|
||||
dashboard React. Corre en k3s vía GitOps (Gitea Actions → registry → ArgoCD).
|
||||
|
||||
## Componentes
|
||||
|
||||
| Componente | Código | Imagen | CMD |
|
||||
|---|---|---|---|
|
||||
| bot | `bot/` | `polymarket-bot` | `python3 -m bot.main` |
|
||||
| api | `api/` (+ `bot/` como librería) | `polymarket-bot-api` | `uvicorn api.main:app` |
|
||||
| dashboard | `dashboard/` | `polymarket-bot-dashboard` | nginx estático |
|
||||
|
||||
Dashboard: https://polymarket.chemavx.xyz
|
||||
|
||||
## CI/CD
|
||||
|
||||
`.gitea/workflows/ci.yml` construye **solo las imágenes cuyos ficheros
|
||||
cambiaron** en el push (diff contra `github.event.before`):
|
||||
|
||||
- `bot/`, `api/`, `requirements.txt` → bot + api (ambas imágenes copian las
|
||||
mismas fuentes Python; solo cambia el CMD)
|
||||
- `Dockerfile` → bot · `Dockerfile.api` → api · `dashboard/` → dashboard
|
||||
- `.gitea/workflows/ci.yml`, primer push o force-push → todas (fallback seguro)
|
||||
- `tests/`, docs → ninguna (la CI no construye ni despliega nada)
|
||||
|
||||
Las imágenes se tagean con `${GITHUB_SHA::8}`; el CI actualiza solo los
|
||||
deployments reconstruidos en `k8s-manifests/polymarket-bot/` y ArgoCD
|
||||
sincroniza vía webhook en segundos.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -q
|
||||
```
|
||||
+91
-24
@@ -11,6 +11,12 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from bot.data.db import Database
|
||||
from bot.executor.paper import cash_available
|
||||
from bot.metrics.sharpe import (
|
||||
MIN_DAYS_OBSERVED,
|
||||
MIN_RESOLVED_TRADES,
|
||||
sharpe_with_gate,
|
||||
)
|
||||
|
||||
# Phase 6 format (Phase 6+): values already in log-odds space.
|
||||
# "fg_lo=+0.1200 mom_lo=+0.0000 news_lo=+0.0000 mfld_lo=-0.7483 btc_dom_lo=+0.0000"
|
||||
@@ -226,6 +232,10 @@ async def get_manifold_matches():
|
||||
summary.trades_dominated_by_mfld — non-excluded accepted-match trades where
|
||||
feat_mfld_lo is the largest signal (consistent with attribution/features,
|
||||
which also exclude excluded_from_metrics trades).
|
||||
summary.unique_markets — distinct-market coverage (per-market, not per-attempt):
|
||||
evaluated — DISTINCT poly_market_id in manifold_match_audit (all versions)
|
||||
accepted — DISTINCT poly_market_id accepted by the current matcher
|
||||
coverage_rate — accepted / evaluated (null when evaluated=0)
|
||||
|
||||
recent_matches: last 50 rows from manifold_match_audit, newest first, each
|
||||
tagged with matcher_version.
|
||||
@@ -239,6 +249,32 @@ async def get_manifold_matches():
|
||||
return data
|
||||
|
||||
|
||||
@app.get("/api/metrics/manifold-coverage")
|
||||
async def get_manifold_coverage():
|
||||
"""Manifold coverage by semantic market category, counted by UNIQUE market.
|
||||
|
||||
Unlike the raw audit counters (which count per-attempt and are inflated by the
|
||||
trading loop re-evaluating the same markets), this measures real coverage:
|
||||
how many DISTINCT markets in each category the matcher found an accepted
|
||||
Manifold counterpart for. Base table is manifold_match_audit filtered to the
|
||||
current matcher (v3_outcome_guard); category is inferred from trade family_key
|
||||
when available, otherwise from the question text.
|
||||
|
||||
coverage_by_category — one entry per category, ordered by unique_evaluated DESC:
|
||||
category — gubernatorial | mayoral | senate | primary-republican |
|
||||
primary-democrat | big-tech | geopolitics | other
|
||||
unique_evaluated — distinct markets audited in this category
|
||||
unique_accepted — distinct markets with at least one accepted match
|
||||
unique_rejected — distinct markets with at least one rejected match
|
||||
unique_no_results — distinct markets with at least one no_results outcome
|
||||
coverage_rate — unique_accepted / unique_evaluated (null if evaluated=0)
|
||||
summary — total_unique_evaluated, total_unique_accepted, overall_coverage_rate
|
||||
(null if nothing evaluated), categories_with_coverage (categories with
|
||||
unique_accepted > 0).
|
||||
"""
|
||||
return await db.get_manifold_coverage_by_category()
|
||||
|
||||
|
||||
@app.get("/api/summary")
|
||||
async def get_summary():
|
||||
"""Dashboard summary card data.
|
||||
@@ -249,28 +285,49 @@ async def get_summary():
|
||||
PnL and performance metrics come from the latest metrics_daily snapshot,
|
||||
which is written by the bot every cycle via MetricsTracker.update_daily_summary().
|
||||
After Fix 3, that snapshot is also DB-computed — not dependent on pod restarts.
|
||||
sharpe_ratio is the exception: it is recomputed live here from the daily
|
||||
PnL-close series (same sharpe_with_gate the tracker uses), so the
|
||||
explanation fields (sharpe_status, days_observed) always match the value.
|
||||
"""
|
||||
latest_metrics, open_trades, all_trades, inverted, legacy_count = await asyncio.gather(
|
||||
db.get_metrics_history(days=1),
|
||||
db.get_recent_trades(limit=500, status="open"),
|
||||
db.get_recent_trades(limit=500),
|
||||
db.get_recently_closed_inverted(hours=24),
|
||||
db.get_legacy_incomplete_count(),
|
||||
latest_metrics, counts, position_data, inverted, legacy_count, daily_closes = (
|
||||
await asyncio.gather(
|
||||
db.get_metrics_history(days=1),
|
||||
db.compute_metrics_from_db(),
|
||||
db.get_open_position_data(),
|
||||
db.get_recently_closed_inverted(hours=24),
|
||||
db.get_legacy_incomplete_count(),
|
||||
db.get_daily_pnl_closes(),
|
||||
)
|
||||
)
|
||||
|
||||
latest = latest_metrics[0] if latest_metrics else {}
|
||||
paper_bankroll = float(os.getenv("PAPER_BANKROLL", "10000"))
|
||||
total_deployed = sum(t.get("net_cost", 0) for t in open_trades)
|
||||
total_trades = int(counts["total_trades"] or 0)
|
||||
resolved_count = int(counts.get("resolved_count") or 0)
|
||||
# Same source PaperExecutor.initialize() uses to reconstruct cash:
|
||||
# total_net_cost_open = SUM(net_cost) over open trades, uncapped.
|
||||
_, total_net_cost_open = position_data
|
||||
total_deployed = total_net_cost_open
|
||||
|
||||
# Sharpe: computed live from the daily PnL curve (same function the
|
||||
# tracker uses for the snapshot). None + status while the minimum-sample
|
||||
# gate (>=30 days observed, >=10 resolved trades) is not met — a Sharpe
|
||||
# over 1 resolved trade is statistically meaningless.
|
||||
days_observed = len(daily_closes)
|
||||
sharpe, sharpe_status = sharpe_with_gate(daily_closes, paper_bankroll, resolved_count)
|
||||
|
||||
win_rate = latest.get("win_rate")
|
||||
calibration = latest.get("calibration_score")
|
||||
|
||||
return {
|
||||
# ── Portfolio state (live from DB) ──────────────────────────────────
|
||||
"paper_mode": os.getenv("PAPER_MODE", "true") == "true",
|
||||
"paper_bankroll": paper_bankroll,
|
||||
"total_trades": len(all_trades), # exact, from DB
|
||||
"open_trades_count": len(open_trades), # exact, from DB
|
||||
"closed_trades_count": len(all_trades) - len(open_trades), # exact
|
||||
"total_trades": total_trades, # COUNT(*), uncapped
|
||||
"open_trades_count": int(counts["open_count"] or 0), # COUNT(*), uncapped
|
||||
"closed_trades_count": int(counts["closed_count"] or 0), # COUNT(*), uncapped
|
||||
"total_deployed": total_deployed, # exact, from DB
|
||||
"cash_available": max(0.0, paper_bankroll - total_deployed), # exact
|
||||
"cash_available": cash_available(paper_bankroll, total_net_cost_open),
|
||||
"legacy_incomplete_count": legacy_count, # exact, from DB
|
||||
"reentry_guard_blocks_24h": len(inverted), # exact, from DB
|
||||
|
||||
@@ -278,31 +335,41 @@ async def get_summary():
|
||||
# unrealized_pnl_est: open positions, edge_net × net_cost − fee.
|
||||
# Estimated — uses model signal, not live price. Source: open trades.
|
||||
# realized_pnl: closed positions with known resolution.
|
||||
# Exact — computed from (resolution − entry_price) × shares.
|
||||
# Exact — payout − net_cost per trade (net of fee), matches logs/Telegram.
|
||||
# total_pnl: sum of both.
|
||||
"unrealized_pnl_est": latest.get("unrealized_pnl_est") or 0,
|
||||
"realized_pnl": latest.get("realized_pnl") or 0,
|
||||
"total_pnl": latest.get("total_pnl") or 0,
|
||||
|
||||
# ── Performance metrics (from latest metrics_daily snapshot) ─────────
|
||||
# ── Performance metrics ──────────────────────────────────────────────
|
||||
# win_rate: fraction of resolved closed trades where close_pnl > 0.
|
||||
# null if fewer than 5 resolved trades. Source: closed+resolved trades.
|
||||
# sharpe_ratio: 0.0 — requires daily-return time series (not yet tracked).
|
||||
# sharpe_ratio: annualized Sharpe of the daily total_pnl curve, computed
|
||||
# live from metrics_daily. null while the minimum-sample gate fails
|
||||
# (sharpe_status explains why). Source: bot/metrics/sharpe.py.
|
||||
# calibration_score: 1 − Brier score on resolved trades (higher = better).
|
||||
# null if fewer than 10 resolved trades. Source: closed+resolved trades.
|
||||
"win_rate": latest.get("win_rate"), # null if < 5 resolved
|
||||
"sharpe_ratio": latest.get("sharpe_ratio") or 0, # 0.0 until tracked
|
||||
"calibration_score": latest.get("calibration_score"), # null if < 10 resolved
|
||||
"win_rate": win_rate, # null if < 5 resolved
|
||||
"sharpe_ratio": sharpe, # null if gate fails
|
||||
"sharpe_status": sharpe_status, # ok | insufficient_sample | zero_variance
|
||||
"days_observed": days_observed,
|
||||
"min_days_required": MIN_DAYS_OBSERVED,
|
||||
"min_resolved_required": MIN_RESOLVED_TRADES,
|
||||
"calibration_score": calibration, # null if < 10 resolved
|
||||
|
||||
# ── Counters from snapshot ───────────────────────────────────────────
|
||||
"resolved_count": latest.get("resolved_count") or 0,
|
||||
# ── Counters (live from DB) ──────────────────────────────────────────
|
||||
"resolved_count": resolved_count,
|
||||
|
||||
# ── Promotion gate ───────────────────────────────────────────────────
|
||||
# All thresholds must pass; null metrics count as not-ready.
|
||||
# Never promote on a tiny sample: requires the resolved/days minimums
|
||||
# AND non-null metrics AND all thresholds. A single lucky resolved
|
||||
# trade must not flip this to true.
|
||||
"promotion_ready": (
|
||||
(latest.get("sharpe_ratio") or 0) >= 0.5
|
||||
and (latest.get("win_rate") or 0) >= 0.52
|
||||
and (latest.get("calibration_score") or 0) >= 0.7
|
||||
and len(all_trades) >= 50
|
||||
resolved_count >= MIN_RESOLVED_TRADES
|
||||
and days_observed >= MIN_DAYS_OBSERVED
|
||||
and win_rate is not None and win_rate >= 0.52
|
||||
and calibration is not None and calibration >= 0.7
|
||||
and sharpe is not None and sharpe >= 0.5
|
||||
and total_trades >= 50
|
||||
),
|
||||
}
|
||||
|
||||
+229
-8
@@ -152,6 +152,20 @@ class Database:
|
||||
""")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_open_trades_for_market(self, market_id: str) -> list[dict]:
|
||||
"""Return direction, shares and net_cost for each open trade in a market.
|
||||
|
||||
Used by PaperExecutor.close_position() to compute the settlement
|
||||
payout per direction (BUY_NO pays out when resolution = 0.0).
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT direction, shares, net_cost FROM trades "
|
||||
"WHERE market_id = $1 AND closed_at IS NULL",
|
||||
market_id,
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def close_paper_position(
|
||||
self, market_id: str, reason: str = "", resolution: Optional[float] = None
|
||||
) -> None:
|
||||
@@ -159,19 +173,29 @@ class Database:
|
||||
|
||||
resolution: 1.0 if YES resolved, 0.0 if NO resolved, None if unknown
|
||||
(legacy closes, inversion fixes). When resolution is provided, close_pnl
|
||||
is computed in SQL so it matches the stored entry_price and shares exactly.
|
||||
is computed in SQL per row as payout − net_cost — NET of fee, the single
|
||||
PnL definition shared with PaperExecutor.close_position() (logs/Telegram):
|
||||
BUY_YES: resolution * shares − net_cost
|
||||
BUY_NO: (1 − resolution) * shares − net_cost
|
||||
paper.py aggregates payout − net_cost over these same open rows, so
|
||||
SUM(close_pnl) per market equals the pnl it reports exactly. The
|
||||
aggregate is intentionally NOT passed in as a parameter: writing it to
|
||||
every row would double-count markets with more than one open trade.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
# $3 is cast on every use: Postgres cannot infer the parameter type
|
||||
# from a bare "$3 IS NOT NULL" and fails the prepare with
|
||||
# AmbiguousParameterError otherwise.
|
||||
await conn.execute("""
|
||||
UPDATE trades
|
||||
SET closed_at = NOW(),
|
||||
close_reason = $2,
|
||||
resolution = $3,
|
||||
resolution = $3::double precision,
|
||||
close_pnl = CASE
|
||||
WHEN $3 IS NOT NULL AND direction = 'BUY_YES'
|
||||
THEN ($3::double precision - entry_price) * shares
|
||||
WHEN $3 IS NOT NULL AND direction = 'BUY_NO'
|
||||
THEN ((1.0 - $3::double precision) - entry_price) * shares
|
||||
WHEN $3::double precision IS NOT NULL AND direction = 'BUY_YES'
|
||||
THEN ($3::double precision * shares) - net_cost
|
||||
WHEN $3::double precision IS NOT NULL AND direction = 'BUY_NO'
|
||||
THEN ((1.0 - $3::double precision) * shares) - net_cost
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE market_id = $1 AND closed_at IS NULL
|
||||
@@ -230,8 +254,11 @@ class Database:
|
||||
COUNT(*) FILTER (WHERE closed_at IS NOT NULL) AS closed_count,
|
||||
-- excluded_from_metrics trades are omitted from resolved_count,
|
||||
-- realized_pnl, wins_realized, and calibration_score.
|
||||
-- resolved_count does NOT require final_prob: legacy trades
|
||||
-- without signal data still count as resolved. Calibration
|
||||
-- below keeps the final_prob requirement (it needs the
|
||||
-- estimated probability to score).
|
||||
COUNT(*) FILTER (WHERE resolution IS NOT NULL
|
||||
AND final_prob IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)) AS resolved_count,
|
||||
|
||||
COALESCE(SUM(net_cost)
|
||||
@@ -303,12 +330,42 @@ class Database:
|
||||
return result
|
||||
|
||||
async def get_metrics_history(self, days: int = 42) -> list[dict]:
|
||||
"""Return the closing snapshot of each UTC day, newest day first.
|
||||
|
||||
metrics_daily receives one snapshot per trading cycle (~1/min), so a
|
||||
plain LIMIT over raw rows would cover minutes, not days. DISTINCT ON
|
||||
collapses each calendar day to its last snapshot, making `days` bound
|
||||
actual days. history[0] remains the most recent snapshot overall.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM metrics_daily ORDER BY timestamp DESC LIMIT $1", days
|
||||
"""
|
||||
SELECT DISTINCT ON (timestamp::date) *
|
||||
FROM metrics_daily
|
||||
ORDER BY timestamp::date DESC, timestamp DESC
|
||||
LIMIT $1
|
||||
""", days
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def get_daily_pnl_closes(self) -> list[float]:
|
||||
"""Return the closing total_pnl of every observed UTC day, oldest first.
|
||||
|
||||
One value per calendar day with at least one metrics_daily snapshot
|
||||
(the day's last snapshot, same collapse rule as get_metrics_history).
|
||||
This is the input series for the Sharpe ratio: len() = days observed,
|
||||
consecutive deltas = daily PnL changes.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT DISTINCT ON (timestamp::date) total_pnl
|
||||
FROM metrics_daily
|
||||
ORDER BY timestamp::date ASC, timestamp DESC
|
||||
"""
|
||||
)
|
||||
return [float(r["total_pnl"] or 0) for r in rows]
|
||||
|
||||
async def backfill_feature_columns(self) -> int:
|
||||
"""Back-populate feat_*_lo for trades created before Phase 6.
|
||||
|
||||
@@ -553,6 +610,46 @@ class Database:
|
||||
poly_outcome_type, mfld_outcome_type, matcher_version,
|
||||
)
|
||||
|
||||
async def get_manifold_cooldown(self, poly_market_id: str) -> Optional[dict]:
|
||||
"""Return the cooldown row for a market, or None if no cooldown is recorded.
|
||||
|
||||
The caller decides whether the cooldown is still active by comparing
|
||||
now() against retry_after (see bayesian.evaluate()).
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT poly_market_id, last_evaluated_at, last_status, "
|
||||
"retry_after, cooldown_reason FROM manifold_eval_cooldown "
|
||||
"WHERE poly_market_id = $1",
|
||||
poly_market_id,
|
||||
)
|
||||
return dict(row) if row else None
|
||||
|
||||
async def upsert_manifold_cooldown(
|
||||
self,
|
||||
poly_market_id: str,
|
||||
last_status: str,
|
||||
retry_after,
|
||||
cooldown_reason: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Insert or refresh the cooldown for a market after a real evaluation.
|
||||
|
||||
last_evaluated_at is stamped server-side with now(); retry_after is the
|
||||
caller-computed earliest re-evaluation time.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
INSERT INTO manifold_eval_cooldown (
|
||||
poly_market_id, last_evaluated_at, last_status,
|
||||
retry_after, cooldown_reason
|
||||
) VALUES ($1, now(), $2, $3, $4)
|
||||
ON CONFLICT (poly_market_id) DO UPDATE SET
|
||||
last_evaluated_at = now(),
|
||||
last_status = EXCLUDED.last_status,
|
||||
retry_after = EXCLUDED.retry_after,
|
||||
cooldown_reason = EXCLUDED.cooldown_reason
|
||||
""", poly_market_id, last_status, retry_after, cooldown_reason)
|
||||
|
||||
async def mark_manifold_audit_used(self, audit_id: str) -> None:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
@@ -592,6 +689,15 @@ class Database:
|
||||
WHERE matcher_version = 'legacy_pre_outcome_guard'
|
||||
AND match_status = 'accepted'
|
||||
""")
|
||||
unique_markets = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(DISTINCT poly_market_id) AS evaluated,
|
||||
COUNT(DISTINCT poly_market_id) FILTER (
|
||||
WHERE match_status = 'accepted'
|
||||
AND matcher_version = $1
|
||||
) AS accepted
|
||||
FROM manifold_match_audit
|
||||
""", MANIFOLD_MATCHER_VERSION)
|
||||
mfld_dominated = await conn.fetchrow("""
|
||||
SELECT COUNT(*) AS cnt FROM trades
|
||||
WHERE (excluded_from_metrics IS NOT TRUE)
|
||||
@@ -627,10 +733,125 @@ class Database:
|
||||
int(legacy["accepted_without_outcome_type"] or 0),
|
||||
},
|
||||
"trades_dominated_by_mfld": int(mfld_dominated["cnt"] or 0),
|
||||
"unique_markets": {
|
||||
"evaluated": int(unique_markets["evaluated"] or 0),
|
||||
"accepted": int(unique_markets["accepted"] or 0),
|
||||
"coverage_rate": (
|
||||
float(unique_markets["accepted"]) / float(unique_markets["evaluated"])
|
||||
if unique_markets["evaluated"] else None
|
||||
),
|
||||
},
|
||||
},
|
||||
"recent_matches": [dict(r) for r in rows],
|
||||
}
|
||||
|
||||
async def get_manifold_coverage_by_category(self) -> dict:
|
||||
"""Manifold coverage by semantic market category, counted by UNIQUE market.
|
||||
|
||||
Base table is manifold_match_audit filtered to the current matcher
|
||||
(v3_outcome_guard). Each poly_market_id is collapsed to one row first, so
|
||||
a market is counted once regardless of how many audit attempts or trades it
|
||||
has — this measures coverage, not retry volume.
|
||||
|
||||
Category is inferred from the market's trade family_key when available, else
|
||||
from poly_question (LEFT JOIN: audited markets that never produced a trade
|
||||
are kept). Buckets (accepted/rejected/no_results) are not mutually exclusive
|
||||
at the market level — a market that was no_results then later rejected counts
|
||||
in both — matching COUNT(DISTINCT CASE WHEN status=...) semantics.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch("""
|
||||
WITH audit AS (
|
||||
SELECT
|
||||
poly_market_id,
|
||||
MAX(poly_question) AS poly_question,
|
||||
bool_or(match_status = 'accepted') AS has_accepted,
|
||||
bool_or(match_status = 'rejected') AS has_rejected,
|
||||
bool_or(match_status = 'no_results') AS has_no_results
|
||||
FROM manifold_match_audit
|
||||
WHERE matcher_version = 'v3_outcome_guard'
|
||||
GROUP BY poly_market_id
|
||||
),
|
||||
fam AS (
|
||||
SELECT market_id, MAX(family_key) AS family_key
|
||||
FROM trades
|
||||
GROUP BY market_id
|
||||
),
|
||||
categorized AS (
|
||||
SELECT
|
||||
a.has_accepted, a.has_rejected, a.has_no_results,
|
||||
CASE
|
||||
WHEN f.family_key ILIKE '%gubernatorial%' THEN 'gubernatorial'
|
||||
WHEN f.family_key ILIKE '%mayoral%' THEN 'mayoral'
|
||||
WHEN f.family_key ILIKE '%senate%' THEN 'senate'
|
||||
WHEN f.family_key ILIKE '%republican%' THEN 'primary-republican'
|
||||
WHEN f.family_key ILIKE '%democrat%' THEN 'primary-democrat'
|
||||
WHEN f.family_key ILIKE '%openai%'
|
||||
OR f.family_key ILIKE '%nvidia%'
|
||||
OR f.family_key ILIKE '%anthropic%' THEN 'big-tech'
|
||||
-- family_key NULL or unmatched → infer from question
|
||||
WHEN a.poly_question ILIKE '%governor%'
|
||||
OR a.poly_question ILIKE '%gubernatorial%' THEN 'gubernatorial'
|
||||
WHEN a.poly_question ILIKE '%mayor%'
|
||||
OR a.poly_question ILIKE '%mayoral%' THEN 'mayoral'
|
||||
WHEN a.poly_question ILIKE '%senate%' THEN 'senate'
|
||||
WHEN a.poly_question ILIKE '%republican primary%' THEN 'primary-republican'
|
||||
WHEN a.poly_question ILIKE '%democratic primary%'
|
||||
OR a.poly_question ILIKE '%democrat primary%' THEN 'primary-democrat'
|
||||
WHEN a.poly_question ILIKE '%openai%'
|
||||
OR a.poly_question ILIKE '%nvidia%'
|
||||
OR a.poly_question ILIKE '%anthropic%' THEN 'big-tech'
|
||||
WHEN a.poly_question ILIKE '%russia%'
|
||||
OR a.poly_question ILIKE '%ukraine%'
|
||||
OR a.poly_question ILIKE '%israel%'
|
||||
OR a.poly_question ILIKE '%ceasefire%'
|
||||
OR a.poly_question ILIKE '%military%' THEN 'geopolitics'
|
||||
ELSE 'other'
|
||||
END AS category
|
||||
FROM audit a
|
||||
LEFT JOIN fam f ON f.market_id = a.poly_market_id
|
||||
)
|
||||
SELECT
|
||||
category,
|
||||
COUNT(*) AS unique_evaluated,
|
||||
COUNT(*) FILTER (WHERE has_accepted) AS unique_accepted,
|
||||
COUNT(*) FILTER (WHERE has_rejected) AS unique_rejected,
|
||||
COUNT(*) FILTER (WHERE has_no_results) AS unique_no_results
|
||||
FROM categorized
|
||||
GROUP BY category
|
||||
ORDER BY unique_evaluated DESC
|
||||
""")
|
||||
|
||||
coverage_by_category = []
|
||||
total_evaluated = 0
|
||||
total_accepted = 0
|
||||
categories_with_coverage = 0
|
||||
for r in rows:
|
||||
evaluated = int(r["unique_evaluated"] or 0)
|
||||
accepted = int(r["unique_accepted"] or 0)
|
||||
total_evaluated += evaluated
|
||||
total_accepted += accepted
|
||||
if accepted > 0:
|
||||
categories_with_coverage += 1
|
||||
coverage_by_category.append({
|
||||
"category": r["category"],
|
||||
"unique_evaluated": evaluated,
|
||||
"unique_accepted": accepted,
|
||||
"unique_rejected": int(r["unique_rejected"] or 0),
|
||||
"unique_no_results": int(r["unique_no_results"] or 0),
|
||||
"coverage_rate": (accepted / evaluated) if evaluated else None,
|
||||
})
|
||||
|
||||
return {
|
||||
"coverage_by_category": coverage_by_category,
|
||||
"summary": {
|
||||
"total_unique_evaluated": total_evaluated,
|
||||
"total_unique_accepted": total_accepted,
|
||||
"overall_coverage_rate": (total_accepted / total_evaluated) if total_evaluated else None,
|
||||
"categories_with_coverage": categories_with_coverage,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _f(v) -> Optional[float]:
|
||||
"""None-safe float cast for asyncpg Decimal/None values."""
|
||||
|
||||
+17
-1
@@ -51,7 +51,11 @@ _DATE_RE = re.compile(
|
||||
r"|\bQ[1-4]\b",
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
_PUNCT_RE = re.compile(r"[?!\"'.,;:()\[\]{}]")
|
||||
# Hyphens/dashes are GNews query operators (a leading '-' means "exclude the
|
||||
# next term"), so a token like "El-Sayed" makes the API return HTTP 400. Strip
|
||||
# them to spaces along with the rest of the punctuation so the query stays a
|
||||
# plain keyword list. – = en dash, — = em dash.
|
||||
_PUNCT_RE = re.compile(r"[?!\"'.,;:()\[\]{}\-–—]")
|
||||
|
||||
|
||||
class NewsClient:
|
||||
@@ -79,6 +83,18 @@ class NewsClient:
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""True only when a GNews API key is configured.
|
||||
|
||||
When False, get_sentiment() is a no-op that returns 0.0 without any
|
||||
network call, so callers must skip GNews entirely — including the
|
||||
per-cycle query budget accounting — instead of "spending" a query that
|
||||
never reaches the API (which inflated gnews_queries_used to a phantom
|
||||
5/5 while the key was missing).
|
||||
"""
|
||||
return bool(self._api_key)
|
||||
|
||||
async def get_sentiment(self, question: str) -> float:
|
||||
"""
|
||||
Return a sentiment score ∈ [-1.0, +1.0] for the market question.
|
||||
|
||||
@@ -211,6 +211,32 @@ class Market:
|
||||
category: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class MarketResolution:
|
||||
"""Resolution state of a market, from Gamma API.
|
||||
|
||||
resolution is the final YES outcome price: 1.0 = YES won, 0.0 = NO won.
|
||||
resolved is True only when the outcome is definitive — a market that is
|
||||
closed but still in UMA dispute/proposal reports resolved=False.
|
||||
"""
|
||||
resolved: bool
|
||||
resolution: Optional[float] = None
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
|
||||
def _parse_resolution_timestamp(raw: Optional[str]) -> Optional[datetime]:
|
||||
"""Parse Gamma timestamps: '2026-06-11 13:15:01+00' or '2026-06-11T13:15:01Z'."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OrderBook:
|
||||
market_id: str
|
||||
@@ -447,6 +473,74 @@ class PolymarketClient:
|
||||
)
|
||||
return markets
|
||||
|
||||
async def get_market_resolution(self, market_id: str) -> Optional[MarketResolution]:
|
||||
"""Fetch resolution state for a market by Gamma market id.
|
||||
|
||||
Observed Gamma API behaviour (GET /markets/{id}):
|
||||
open market → closed=false, umaResolutionStatus absent
|
||||
resolved market → closed=true, umaResolutionStatus="resolved",
|
||||
outcomePrices='["0", "1"]' (final YES price = outcome)
|
||||
unknown id → HTTP 404
|
||||
|
||||
Returns None on API errors (caller retries next check). A closed market
|
||||
whose outcome prices are not degenerate (0/1) or whose UMA status is not
|
||||
"resolved" yet (proposed/disputed) reports resolved=False — we never
|
||||
settle a position on an ambiguous outcome.
|
||||
"""
|
||||
try:
|
||||
resp = await self._client.get(f"{GAMMA_API}/markets/{market_id}")
|
||||
if resp.status_code == 404:
|
||||
log.warning("get_market_resolution: market %s not found (404)", market_id)
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
m = resp.json()
|
||||
except httpx.HTTPError as e:
|
||||
log.warning("get_market_resolution: API error for %s: %s", market_id, e)
|
||||
return None
|
||||
|
||||
if not m.get("closed"):
|
||||
return MarketResolution(resolved=False)
|
||||
|
||||
uma_status = (m.get("umaResolutionStatus") or "").lower()
|
||||
if uma_status and uma_status != "resolved":
|
||||
# Closed but UMA outcome still proposed/disputed — wait for finality
|
||||
return MarketResolution(resolved=False)
|
||||
|
||||
raw_prices = m.get("outcomePrices", [])
|
||||
if isinstance(raw_prices, str):
|
||||
import json as _json
|
||||
try:
|
||||
raw_prices = _json.loads(raw_prices)
|
||||
except ValueError:
|
||||
raw_prices = []
|
||||
try:
|
||||
yes_final = float(raw_prices[0])
|
||||
except (IndexError, TypeError, ValueError):
|
||||
log.warning(
|
||||
"get_market_resolution: market %s closed but outcomePrices "
|
||||
"unparseable: %r", market_id, m.get("outcomePrices"),
|
||||
)
|
||||
return MarketResolution(resolved=False)
|
||||
|
||||
if yes_final >= 0.99:
|
||||
resolution = 1.0
|
||||
elif yes_final <= 0.01:
|
||||
resolution = 0.0
|
||||
else:
|
||||
# Closed but prices not settled at 0/1 (partial / ambiguous outcome)
|
||||
log.warning(
|
||||
"get_market_resolution: market %s closed with non-binary final "
|
||||
"price %.3f — not settling", market_id, yes_final,
|
||||
)
|
||||
return MarketResolution(resolved=False)
|
||||
|
||||
resolved_at = (
|
||||
_parse_resolution_timestamp(m.get("closedTime"))
|
||||
or _parse_resolution_timestamp(m.get("umaEndDate"))
|
||||
or _parse_resolution_timestamp(m.get("endDate"))
|
||||
)
|
||||
return MarketResolution(resolved=True, resolution=resolution, resolved_at=resolved_at)
|
||||
|
||||
async def get_order_book(self, token_id: str) -> Optional[OrderBook]:
|
||||
"""Get order book for a specific token."""
|
||||
try:
|
||||
|
||||
+30
-3
@@ -113,9 +113,10 @@ CREATE INDEX IF NOT EXISTS idx_trades_closed ON trades(closed_at) WHERE closed_a
|
||||
-- Fix 3: market resolution and realized P&L per trade
|
||||
--
|
||||
-- resolution: 1.0 if YES resolved, 0.0 if NO resolved, NULL if not yet settled.
|
||||
-- close_pnl: realized P&L in USDC at close time.
|
||||
-- BUY_YES: (resolution - entry_price) * shares
|
||||
-- BUY_NO: ((1 - resolution) - entry_price) * shares
|
||||
-- close_pnl: realized P&L in USDC at close time — NET of fee (payout − net_cost),
|
||||
-- the same definition PaperExecutor.close_position() reports in logs/Telegram.
|
||||
-- BUY_YES: resolution * shares - net_cost
|
||||
-- BUY_NO: (1 - resolution) * shares - net_cost
|
||||
-- NULL if closed without a known resolution (legacy closes, inversion fixes).
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS close_pnl DOUBLE PRECISION;
|
||||
@@ -291,3 +292,29 @@ CREATE TABLE IF NOT EXISTS checkpoint_alerts (
|
||||
fired_at TIMESTAMPTZ NOT NULL,
|
||||
last_fired_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Manifold evaluation cooldown — per-market backoff for the Manifold matcher
|
||||
--
|
||||
-- The trading loop re-evaluates the same ~stable set of politics/tech markets
|
||||
-- every cycle (~60s). Most resolve to a stable terminal verdict (no Manifold
|
||||
-- coverage, low-score, outcome mismatch, conditional market) that will not change
|
||||
-- on the next cycle. Re-querying them every minute floods manifold_match_audit
|
||||
-- with redundant rows and makes the metrics uninterpretable.
|
||||
--
|
||||
-- This table records, per poly_market_id, when the market was last evaluated and
|
||||
-- the earliest time it should be evaluated again (retry_after). evaluate() in
|
||||
-- bot/strategy/bayesian.py consults it BEFORE calling the matcher and skips the
|
||||
-- call (and the audit write) entirely while now() < retry_after.
|
||||
--
|
||||
-- last_status / cooldown_reason are stored for observability only.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS manifold_eval_cooldown (
|
||||
poly_market_id TEXT PRIMARY KEY,
|
||||
last_evaluated_at TIMESTAMPTZ NOT NULL,
|
||||
last_status TEXT NOT NULL,
|
||||
retry_after TIMESTAMPTZ NOT NULL,
|
||||
cooldown_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_cooldown_retry ON manifold_eval_cooldown(retry_after);
|
||||
|
||||
+67
-14
@@ -22,6 +22,30 @@ log = logging.getLogger(__name__)
|
||||
# NOTE: this is a heuristic — see COMMISSION_RATE in bayesian.py for context.
|
||||
POLYMARKET_FEE = 0.02 # 2%
|
||||
|
||||
# Strong references to in-flight notification tasks. The event loop only
|
||||
# keeps a weak reference to tasks created via create_task(), so without this
|
||||
# set a pending Telegram notification could be garbage-collected before it
|
||||
# runs. Tasks remove themselves from the set on completion.
|
||||
_background_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _notify_in_background(coro) -> None:
|
||||
"""Fire-and-forget a Telegram notification, keeping the task referenced."""
|
||||
task = asyncio.create_task(coro)
|
||||
_background_tasks.add(task)
|
||||
task.add_done_callback(_background_tasks.discard)
|
||||
|
||||
|
||||
def cash_available(bankroll: float, total_net_cost_open: float) -> float:
|
||||
"""Cash left after the net cost (fees included) of all open positions.
|
||||
|
||||
Single source of truth for the cash figure, shared by
|
||||
PaperExecutor.initialize() and the /api/summary endpoint so both always
|
||||
report the same number for the same DB state.
|
||||
total_net_cost_open comes from Database.get_open_position_data().
|
||||
"""
|
||||
return max(0.0, bankroll - total_net_cost_open)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trade:
|
||||
@@ -108,7 +132,7 @@ class PaperExecutor:
|
||||
|
||||
positions_value = sum(positions_size.values())
|
||||
self._portfolio.positions = positions_size
|
||||
self._portfolio.cash = max(0.0, self._portfolio.cash - total_net_cost)
|
||||
self._portfolio.cash = cash_available(self._portfolio.cash, total_net_cost)
|
||||
|
||||
total_value = self._portfolio.cash + positions_value
|
||||
exposure_pct = positions_value / total_value if total_value > 0 else 0.0
|
||||
@@ -205,7 +229,7 @@ class PaperExecutor:
|
||||
# Persist to DB
|
||||
await self._db.save_trade(trade)
|
||||
|
||||
asyncio.create_task(
|
||||
_notify_in_background(
|
||||
telegram.trade_opened(trade.question, trade.direction, trade.size_usdc, trade.edge_net)
|
||||
)
|
||||
|
||||
@@ -226,7 +250,7 @@ class PaperExecutor:
|
||||
"LEGACY_CLOSE market=%s | returned $%.2f to cash | %s",
|
||||
market_id, cost, reason[:80],
|
||||
)
|
||||
asyncio.create_task(
|
||||
_notify_in_background(
|
||||
telegram.trade_legacy_closed(question or market_id, cost, reason)
|
||||
)
|
||||
return cost
|
||||
@@ -235,24 +259,53 @@ class PaperExecutor:
|
||||
"""Close a paper position after market resolution.
|
||||
|
||||
resolution: 1.0 if YES won, 0.0 if NO won.
|
||||
Persists resolution and close_pnl to DB (computed via SQL from stored
|
||||
entry_price and shares). Returns approximate P&L for logging.
|
||||
Settlement payout per trade:
|
||||
BUY_YES: shares * resolution
|
||||
BUY_NO: shares * (1 - resolution)
|
||||
pnl = payout - net_cost.
|
||||
Persists resolution and close_pnl to DB. Returns realized P&L for
|
||||
logging, or None if no position is open.
|
||||
"""
|
||||
if market_id not in self._portfolio.positions:
|
||||
return None
|
||||
|
||||
position_cost = self._portfolio.positions.pop(market_id)
|
||||
self._portfolio.cash += position_cost * resolution # pay out winnings
|
||||
position_cost = self._portfolio.positions[market_id]
|
||||
open_trades = await self._db.get_open_trades_for_market(market_id)
|
||||
|
||||
if open_trades:
|
||||
payout = sum(
|
||||
float(t["shares"])
|
||||
* (resolution if t["direction"] == "BUY_YES" else 1.0 - resolution)
|
||||
for t in open_trades
|
||||
)
|
||||
net_cost = sum(float(t["net_cost"]) for t in open_trades)
|
||||
pnl = payout - net_cost
|
||||
else:
|
||||
# In-memory position with no open DB trades: direction/shares are
|
||||
# unknown, so settle at break-even instead of guessing the payout.
|
||||
log.warning(
|
||||
"close_position: no open DB trades for market %s — "
|
||||
"settling at break-even", market_id,
|
||||
)
|
||||
payout = position_cost
|
||||
pnl = 0.0
|
||||
|
||||
# Persist first, mutate memory after: if the DB write fails, the
|
||||
# in-memory portfolio must keep the position so the next resolution
|
||||
# check can retry the close.
|
||||
await self._db.close_paper_position(
|
||||
market_id,
|
||||
reason=f"market_resolved resolution={resolution:.1f}",
|
||||
reason="resolved",
|
||||
resolution=resolution,
|
||||
)
|
||||
approx_pnl = position_cost * resolution - position_cost
|
||||
log.info("Closed position in %s, resolution=%.1f", market_id, resolution)
|
||||
asyncio.create_task(
|
||||
telegram.trade_closed(question or market_id, approx_pnl)
|
||||
|
||||
self._portfolio.positions.pop(market_id)
|
||||
self._portfolio.cash += payout
|
||||
log.info(
|
||||
"Closed position in %s, resolution=%.1f payout=$%.2f pnl=%+.2f",
|
||||
market_id, resolution, payout, pnl,
|
||||
)
|
||||
# Approximate PnL: settlement value minus cost. Exact value is in close_pnl.
|
||||
return approx_pnl
|
||||
_notify_in_background(
|
||||
telegram.trade_closed(question or market_id, pnl)
|
||||
)
|
||||
return pnl
|
||||
|
||||
+109
-43
@@ -11,7 +11,12 @@ from bot.data.polymarket import PolymarketClient, Market, market_family_key
|
||||
from bot.data.external import ExternalDataClient
|
||||
from bot.data.news import NewsClient
|
||||
from bot.data.manifold import ManifoldClient
|
||||
from bot.strategy.bayesian import BayesianStrategy, gnews_priority, MAX_NEWS_QUERIES_PER_CYCLE
|
||||
from bot.strategy.bayesian import (
|
||||
BayesianStrategy,
|
||||
gnews_priority,
|
||||
MAX_NEWS_QUERIES_PER_CYCLE,
|
||||
MANIFOLD_SIGNAL_ENABLED,
|
||||
)
|
||||
from bot.risk.manager import RiskManager
|
||||
from bot.executor.paper import PaperExecutor
|
||||
from bot.metrics.tracker import MetricsTracker
|
||||
@@ -22,11 +27,65 @@ logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
# httpx logs every request URL at INFO, and the GNews URL carries the API key as
|
||||
# a `?token=` query param — that would leak GNEWS_API_KEY in plaintext into the
|
||||
# pod logs. Raise httpx/httpcore to WARNING so request URLs never reach INFO.
|
||||
# The bot's own GNews log lines only print the sanitised query, not the token.
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
log = logging.getLogger("bot.main")
|
||||
|
||||
PAPER_MODE = os.getenv("PAPER_MODE", "true").lower() == "true"
|
||||
PAPER_BANKROLL = float(os.getenv("PAPER_BANKROLL", "10000"))
|
||||
|
||||
# Check open positions for market resolution every N trading cycles (~N minutes
|
||||
# at the 60s cycle cadence). Keeps Gamma API load at ~1 request per open
|
||||
# position per 10 minutes.
|
||||
RESOLUTION_CHECK_INTERVAL = 10
|
||||
|
||||
|
||||
async def check_resolutions(
|
||||
poly: PolymarketClient,
|
||||
executor: PaperExecutor,
|
||||
db: Database,
|
||||
) -> None:
|
||||
"""Detect resolved markets and settle their open paper positions.
|
||||
|
||||
For each open position, asks the Gamma API whether the market resolved.
|
||||
On a definitive resolution, PaperExecutor.close_position() settles the
|
||||
payout, persists close_reason='resolved' + resolution + close_pnl, and
|
||||
sends the Telegram notification.
|
||||
"""
|
||||
positions = await db.get_open_position_details()
|
||||
checked = 0
|
||||
resolved = 0
|
||||
for pos in positions:
|
||||
market_id = str(pos["market_id"])
|
||||
try:
|
||||
res = await poly.get_market_resolution(market_id)
|
||||
except Exception as exc:
|
||||
log.warning("Resolution check failed for market %s: %s", market_id, exc)
|
||||
continue
|
||||
checked += 1
|
||||
if res is None or not res.resolved or res.resolution is None:
|
||||
continue
|
||||
try:
|
||||
pnl = await executor.close_position(
|
||||
market_id, res.resolution, question=pos.get("question") or "",
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error("Failed to close resolved market %s: %s", market_id, exc)
|
||||
continue
|
||||
resolved += 1
|
||||
log.info(
|
||||
"MARKET_RESOLVED market_id=%s resolution=%.1f pnl=%s | %s",
|
||||
market_id,
|
||||
res.resolution,
|
||||
f"{pnl:+.2f}" if pnl is not None else "n/a",
|
||||
(pos.get("question") or "")[:60],
|
||||
)
|
||||
log.info("Resolution check: %d positions checked, %d resolved", checked, resolved)
|
||||
|
||||
|
||||
async def run_trading_loop(
|
||||
poly: PolymarketClient,
|
||||
@@ -40,9 +99,22 @@ async def run_trading_loop(
|
||||
"""Main trading loop — runs every 60 seconds."""
|
||||
log.info("Trading loop started. PAPER_MODE=%s", PAPER_MODE)
|
||||
checkpoint_monitor = CheckpointMonitor()
|
||||
cycle_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
cycle_count += 1
|
||||
|
||||
# 0. Resolution detector — every RESOLUTION_CHECK_INTERVAL cycles,
|
||||
# settle paper positions whose market resolved on Polymarket.
|
||||
# Runs before evaluation so freed cash/families are usable this cycle.
|
||||
if (
|
||||
PAPER_MODE
|
||||
and isinstance(executor, PaperExecutor)
|
||||
and cycle_count % RESOLUTION_CHECK_INTERVAL == 0
|
||||
):
|
||||
await check_resolutions(poly, executor, db)
|
||||
|
||||
# 1. Fetch active markets (90-day window)
|
||||
markets = await poly.get_active_markets()
|
||||
log.info("Found %d active markets", len(markets))
|
||||
@@ -138,7 +210,6 @@ async def run_trading_loop(
|
||||
# 7. Execute (paper)
|
||||
trade = await executor.execute(order)
|
||||
if trade:
|
||||
await metrics.record_trade(trade)
|
||||
log.info("Trade executed: %s", trade)
|
||||
# Block this family for the rest of the cycle (Phase 2)
|
||||
occupied_families.add(signal.family_key)
|
||||
@@ -160,7 +231,17 @@ async def run_trading_loop(
|
||||
if denom == 0:
|
||||
return "0% (0/0)"
|
||||
return f"{n * 100 // denom}% ({n}/{denom})"
|
||||
gnews_cap = strategy._news_queries_this_cycle # already updated by reset below
|
||||
|
||||
# The accepted/rejected counters only increment on the active-signal
|
||||
# path, so with the signal disabled they always print 0/0 — say
|
||||
# "disabled" instead of pretending the matcher found nothing.
|
||||
if MANIFOLD_SIGNAL_ENABLED:
|
||||
manifold_summary = (
|
||||
f" manifold_matches_accepted: {stats['manifold_matches_accepted']}\n"
|
||||
f" manifold_matches_rejected: {stats['manifold_matches_rejected']}"
|
||||
)
|
||||
else:
|
||||
manifold_summary = " manifold_signal: disabled"
|
||||
|
||||
log.info(
|
||||
"[CYCLE SUMMARY]\n"
|
||||
@@ -178,9 +259,7 @@ async def run_trading_loop(
|
||||
" gnews_queries_used: %d/%d\n"
|
||||
" reentry_guard_blocked: %d\n"
|
||||
" legacy_incomplete_seen: %d\n"
|
||||
" family_conflicts_prevented: %d\n"
|
||||
" manifold_matches_accepted: %d\n"
|
||||
" manifold_matches_rejected: %d",
|
||||
"%s",
|
||||
n_total,
|
||||
n_uncertainty,
|
||||
stats["max_edge_gross"],
|
||||
@@ -195,11 +274,23 @@ async def run_trading_loop(
|
||||
stats["gnews_queries_used"], MAX_NEWS_QUERIES_PER_CYCLE,
|
||||
reentry_guard_count,
|
||||
legacy_incomplete_count,
|
||||
stats["skip_family"],
|
||||
stats["manifold_matches_accepted"],
|
||||
stats["manifold_matches_rejected"],
|
||||
manifold_summary,
|
||||
)
|
||||
|
||||
# NEWS SUMMARY — one compact line, only on cycles where at least
|
||||
# one market had a material GNews contribution (never an empty
|
||||
# section on news-less cycles).
|
||||
if stats["news_with_material"] > 0:
|
||||
log.info(
|
||||
"NEWS SUMMARY | with_news=%d | avg_shift=%+.2f | "
|
||||
"max_shift=%+.2f | guardrail_applied=%d | changed_decisions=%d",
|
||||
stats["news_with_material"],
|
||||
stats["news_avg_shift"],
|
||||
stats["news_max_shift"],
|
||||
stats["news_guardrail_applied"],
|
||||
stats["news_changed_decisions"],
|
||||
)
|
||||
|
||||
# 9. Update daily metrics
|
||||
await metrics.update_daily_summary()
|
||||
|
||||
@@ -223,14 +314,17 @@ async def run_trading_loop(
|
||||
async def run_legacy_scan(
|
||||
db: Database,
|
||||
markets: list,
|
||||
manifold: ManifoldClient,
|
||||
executor: PaperExecutor,
|
||||
paper_mode: bool,
|
||||
) -> None:
|
||||
"""
|
||||
One-time startup scan: re-key all open DB positions with the current
|
||||
market_family_key() logic, detect contradictions, re-validate Manifold
|
||||
signals, and report KEEP / REVIEW / CLOSE_RECOMMENDED per position.
|
||||
market_family_key() logic, detect family conflicts, and report
|
||||
KEEP / REVIEW / CLOSE_RECOMMENDED per position.
|
||||
|
||||
Manifold is intentionally not consulted here: with
|
||||
MANIFOLD_SIGNAL_ENABLED=false it is observational-only and must not
|
||||
drive position closures.
|
||||
|
||||
In paper_mode: auto-closes all CLOSE_RECOMMENDED positions after logging.
|
||||
"""
|
||||
@@ -269,8 +363,6 @@ async def run_legacy_scan(
|
||||
"family_key_old": old_fk,
|
||||
"family_key_new": new_fk,
|
||||
"fk_changed": new_fk != old_fk,
|
||||
"manifold_prob_new": None,
|
||||
"manifold_inverted": False,
|
||||
"recommendation": "legacy_incomplete" if is_legacy_incomplete else "OK",
|
||||
"rec_reason": "edge_net and live market unavailable" if is_legacy_incomplete else "no family conflict",
|
||||
})
|
||||
@@ -308,31 +400,7 @@ async def run_legacy_scan(
|
||||
p["market_id"], p["family_key_old"] or "none", p["family_key_new"],
|
||||
)
|
||||
|
||||
# Step 3: Manifold re-query for positions whose family key changed
|
||||
for p in enriched:
|
||||
if p["live_market"] and p["fk_changed"]:
|
||||
prob = await manifold.get_probability(p["question"])
|
||||
p["manifold_prob_new"] = prob
|
||||
if prob is not None:
|
||||
# Detect if original trade direction conflicts with corrected Manifold signal
|
||||
if prob < 0.40 and p["direction"] == "BUY_YES":
|
||||
p["manifold_inverted"] = True
|
||||
note = f"Manifold:{prob:.3f} contradicts BUY_YES (inversion bug confirmed)"
|
||||
if p["recommendation"] in ("OK", "REVIEW"):
|
||||
p["recommendation"] = "CLOSE_RECOMMENDED"
|
||||
p["rec_reason"] = note
|
||||
else:
|
||||
p["rec_reason"] += f" | {note}"
|
||||
elif prob > 0.60 and p["direction"] == "BUY_NO":
|
||||
p["manifold_inverted"] = True
|
||||
note = f"Manifold:{prob:.3f} contradicts BUY_NO (inversion bug confirmed)"
|
||||
if p["recommendation"] in ("OK", "REVIEW"):
|
||||
p["recommendation"] = "CLOSE_RECOMMENDED"
|
||||
p["rec_reason"] = note
|
||||
else:
|
||||
p["rec_reason"] += f" | {note}"
|
||||
|
||||
# Step 4: log the full scan report (before any closures)
|
||||
# Step 3: log the full scan report (before any closures)
|
||||
n_close = sum(1 for p in enriched if p["recommendation"] == "CLOSE_RECOMMENDED")
|
||||
n_keep = sum(1 for p in enriched if p["recommendation"] == "KEEP")
|
||||
n_ok = sum(1 for p in enriched if p["recommendation"] == "OK")
|
||||
@@ -348,7 +416,6 @@ async def run_legacy_scan(
|
||||
" [%-18s] market=%-8s | dir=%-8s | edge_net=%+.3f\n"
|
||||
" stored_family: %s\n"
|
||||
" new_family: %s%s\n"
|
||||
" manifold_new: %s\n"
|
||||
" reason: %s",
|
||||
p["recommendation"],
|
||||
p["market_id"], p["direction"],
|
||||
@@ -356,12 +423,11 @@ async def run_legacy_scan(
|
||||
p["family_key_old"] or "none",
|
||||
p["family_key_new"],
|
||||
" [CHANGED]" if p["fk_changed"] else "",
|
||||
f"{p['manifold_prob_new']:.3f}" if p["manifold_prob_new"] is not None else "n/a",
|
||||
p["rec_reason"],
|
||||
)
|
||||
log.warning("━" * 70)
|
||||
|
||||
# Step 5: auto-close in paper mode
|
||||
# Step 4: auto-close in paper mode
|
||||
if paper_mode and n_close > 0 and isinstance(executor, PaperExecutor):
|
||||
log.warning("PAPER MODE: auto-closing %d CLOSE_RECOMMENDED position(s)...", n_close)
|
||||
for p in enriched:
|
||||
@@ -414,7 +480,7 @@ async def main() -> None:
|
||||
except Exception as e:
|
||||
log.warning("Could not fetch markets for legacy scan: %s — scan skipped", e)
|
||||
scan_markets = []
|
||||
await run_legacy_scan(db, scan_markets, manifold, executor, PAPER_MODE)
|
||||
await run_legacy_scan(db, scan_markets, executor, PAPER_MODE)
|
||||
|
||||
try:
|
||||
await run_trading_loop(poly, external, strategy, risk, executor, metrics, db)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Sharpe ratio from the paper portfolio's daily PnL curve, with a minimum-sample gate.
|
||||
|
||||
The input series is the closing total_pnl of each observed UTC day
|
||||
(Database.get_daily_pnl_closes). Daily returns are PnL deltas normalized by
|
||||
the paper bankroll:
|
||||
|
||||
r_t = (pnl_t − pnl_{t−1}) / bankroll
|
||||
|
||||
Sharpe = mean(r) / sample_std(r) × √365, annualized — prediction markets
|
||||
resolve every calendar day, so 365 is used instead of 252 trading days.
|
||||
Risk-free rate is taken as 0.
|
||||
|
||||
Gate: with a tiny sample (e.g. 1 resolved trade over a flat curve plus one
|
||||
+299 jump) any Sharpe value is statistically meaningless — artificially huge
|
||||
or tiny depending on where the jump lands. So no numeric Sharpe is exposed
|
||||
until BOTH minimums are met:
|
||||
|
||||
days observed >= MIN_DAYS_OBSERVED (30)
|
||||
resolved trades >= MIN_RESOLVED_TRADES (10)
|
||||
|
||||
Below either minimum the value is None with status "insufficient_sample".
|
||||
A perfectly flat curve (zero variance) also yields None ("zero_variance"):
|
||||
Sharpe is undefined there, not infinite.
|
||||
"""
|
||||
from statistics import mean, stdev
|
||||
from typing import Optional
|
||||
|
||||
MIN_DAYS_OBSERVED = 30
|
||||
MIN_RESOLVED_TRADES = 10
|
||||
ANNUALIZATION_DAYS = 365
|
||||
|
||||
SHARPE_OK = "ok"
|
||||
SHARPE_INSUFFICIENT = "insufficient_sample"
|
||||
SHARPE_ZERO_VARIANCE = "zero_variance"
|
||||
|
||||
|
||||
def daily_returns(daily_pnl_closes: list[float], bankroll: float) -> list[float]:
|
||||
"""Bankroll-normalized day-over-day returns from a daily PnL-close series."""
|
||||
return [
|
||||
(curr - prev) / bankroll
|
||||
for prev, curr in zip(daily_pnl_closes, daily_pnl_closes[1:])
|
||||
]
|
||||
|
||||
|
||||
def compute_sharpe(daily_pnl_closes: list[float], bankroll: float) -> Optional[float]:
|
||||
"""Annualized Sharpe of the daily PnL curve, or None if undefined.
|
||||
|
||||
None when there are fewer than 2 returns (need 3+ daily closes) or the
|
||||
return series has zero variance. No sample-size gate here — see
|
||||
sharpe_with_gate() for the exposed value.
|
||||
"""
|
||||
returns = daily_returns(daily_pnl_closes, bankroll)
|
||||
if len(returns) < 2:
|
||||
return None
|
||||
sd = stdev(returns)
|
||||
if sd == 0:
|
||||
return None
|
||||
return mean(returns) / sd * ANNUALIZATION_DAYS ** 0.5
|
||||
|
||||
|
||||
def sharpe_with_gate(
|
||||
daily_pnl_closes: list[float],
|
||||
bankroll: float,
|
||||
resolved_count: int,
|
||||
) -> tuple[Optional[float], str]:
|
||||
"""Return (sharpe, status) applying the minimum-sample gate.
|
||||
|
||||
status: "ok" — sharpe is a meaningful float
|
||||
"insufficient_sample" — sample below minimums, sharpe is None
|
||||
"zero_variance" — sample OK but flat curve, sharpe is None
|
||||
"""
|
||||
days_observed = len(daily_pnl_closes)
|
||||
if days_observed < MIN_DAYS_OBSERVED or resolved_count < MIN_RESOLVED_TRADES:
|
||||
return None, SHARPE_INSUFFICIENT
|
||||
sharpe = compute_sharpe(daily_pnl_closes, bankroll)
|
||||
if sharpe is None:
|
||||
return None, SHARPE_ZERO_VARIANCE
|
||||
return sharpe, SHARPE_OK
|
||||
+14
-9
@@ -15,13 +15,16 @@ win_rate Fraction of resolved closed trades with close_pnl > 0.
|
||||
NULL if fewer than 5 resolved trades.
|
||||
calibration_score 1 − AVG((final_prob − resolution)²) on resolved trades.
|
||||
Brier score (higher = better calibration). NULL if < 10 resolved.
|
||||
sharpe_ratio 0.0 — requires a daily-return time series, not yet tracked.
|
||||
sharpe_ratio Annualized Sharpe of the daily total_pnl curve (see
|
||||
bot/metrics/sharpe.py). NULL until the sample gate passes:
|
||||
>= 30 days observed AND >= 10 resolved trades.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, UTC
|
||||
|
||||
from bot.data.db import Database
|
||||
from bot.executor.paper import Trade
|
||||
from bot.metrics.sharpe import sharpe_with_gate
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -30,11 +33,6 @@ class MetricsTracker:
|
||||
def __init__(self, db: Database) -> None:
|
||||
self._db = db
|
||||
|
||||
async def record_trade(self, trade: Trade) -> None:
|
||||
"""Persist a trade to the DB. No in-memory accumulation."""
|
||||
await self._db.save_trade(trade)
|
||||
log.info("Trade recorded: %s", trade)
|
||||
|
||||
async def update_daily_summary(self) -> None:
|
||||
"""Compute metrics from DB and write a metrics_daily snapshot.
|
||||
|
||||
@@ -67,6 +65,12 @@ class MetricsTracker:
|
||||
|
||||
avg_edge = total_pnl / total_deployed if total_deployed > 0 else 0.0
|
||||
|
||||
# Sharpe: real value from the daily PnL curve, NULL while the sample
|
||||
# gate (>=30 days observed, >=10 resolved) is not met.
|
||||
bankroll = float(os.getenv("PAPER_BANKROLL", "10000"))
|
||||
daily_closes = await self._db.get_daily_pnl_closes()
|
||||
sharpe, sharpe_status = sharpe_with_gate(daily_closes, bankroll, resolved)
|
||||
|
||||
metrics = {
|
||||
"timestamp": datetime.now(UTC),
|
||||
"total_trades": int(raw["total_trades"]),
|
||||
@@ -80,7 +84,7 @@ class MetricsTracker:
|
||||
"total_pnl": total_pnl,
|
||||
"win_rate": win_rate,
|
||||
"avg_edge": avg_edge,
|
||||
"sharpe_ratio": 0.0, # requires daily-return series (not yet tracked)
|
||||
"sharpe_ratio": sharpe, # NULL until sample gate passes
|
||||
"calibration_score": calibration,
|
||||
"paper_mode": True,
|
||||
}
|
||||
@@ -89,9 +93,10 @@ class MetricsTracker:
|
||||
log.info(
|
||||
"Daily metrics | trades=%d (open=%d closed=%d resolved=%d) | "
|
||||
"unrealized=$%.2f realized=$%.2f total=$%.2f | "
|
||||
"win_rate=%s calibration=%s",
|
||||
"win_rate=%s calibration=%s sharpe=%s",
|
||||
metrics["total_trades"], open_count, closed_count, resolved,
|
||||
unrealized, realized, total_pnl,
|
||||
f"{win_rate:.1%}" if win_rate is not None else "n/a (<5)",
|
||||
f"{calibration:.3f}" if calibration is not None else "n/a (<10)",
|
||||
f"{sharpe:.2f}" if sharpe is not None else f"n/a ({sharpe_status})",
|
||||
)
|
||||
|
||||
+409
-123
@@ -12,9 +12,11 @@ Polymarket might reflect in a slow-moving order book.
|
||||
"""
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from bot.data.polymarket import Market, market_family_key
|
||||
@@ -61,11 +63,81 @@ NEWS_LOGODDS_WEIGHT = 1.5
|
||||
# Weaker than NEWS_LOGODDS_WEIGHT because Manifold can have illiquid/stale markets.
|
||||
MANIFOLD_LOGODDS_WEIGHT = 0.6
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
return os.getenv(name, str(default)).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
# ── Manifold activation flags ──────────────────────────────────────────────────
|
||||
# Manifold has been retired as an ACTIVE trading signal: a per-category coverage
|
||||
# audit (see /api/metrics/manifold-coverage) showed coverage_rate=0.0 across every
|
||||
# category in the bot's current universe, so any edge it produced was false edge.
|
||||
#
|
||||
# MANIFOLD_SIGNAL_ENABLED (default False): when False, Manifold is observational
|
||||
# only — its probability never touches the edge model: no manifold_log_adj, no
|
||||
# confidence bump, feat_mfld_lo stays 0.0 (so it can never be the dominant
|
||||
# feature), and it never contributes to a trade.
|
||||
# MANIFOLD_AUDIT_ENABLED (default True): when True the matcher still runs and
|
||||
# audit/coverage rows + cooldowns are written, preserving the trail so we can
|
||||
# decide later whether to reactivate Manifold in a universe with real coverage.
|
||||
# The matcher is only called when at least one flag is on.
|
||||
MANIFOLD_SIGNAL_ENABLED = _env_bool("MANIFOLD_SIGNAL_ENABLED", False)
|
||||
MANIFOLD_AUDIT_ENABLED = _env_bool("MANIFOLD_AUDIT_ENABLED", True)
|
||||
|
||||
# ── GNews guardrail (catastrophic fuse) ────────────────────────────────────────
|
||||
# Post-mortem NVIDIA 631181: a single strong signal (legacy Manifold 0.13 at
|
||||
# weight 0.6) flipped a 0.845 market to 0.431 and lost. With Manifold now
|
||||
# observational-only and macro signals gated behind is_non_price, GNews
|
||||
# (weight 1.5) is the only live signal that can move politics markets 20-30 pp
|
||||
# against the order-book consensus. This is NOT a fine calibration — it is a
|
||||
# fuse against the extreme case: one uncorroborated signal violently inverting
|
||||
# the market.
|
||||
#
|
||||
# NEWS_GUARDRAIL_ENABLED: master switch for the fuse.
|
||||
# MAX_NEWS_ONLY_PROB_SHIFT: when GNews is the ONLY material signal, the final
|
||||
# probability is clamped to prior ± this value. 0.25 still allows a 25 pp
|
||||
# move (edge_net 0.21 after costs) — trades still happen, sizing is bounded.
|
||||
# NEWS_MATERIAL_LOGODDS_THRESHOLD: a signal counts as *material* iff its
|
||||
# |log-odds contribution| >= this value. Below it, a signal is noise and
|
||||
# does NOT count as corroboration. If ANY other signal (fg, momentum,
|
||||
# btc_dom, manifold) is material, the fuse does not apply.
|
||||
NEWS_GUARDRAIL_ENABLED = _env_bool("NEWS_GUARDRAIL_ENABLED", True)
|
||||
MAX_NEWS_ONLY_PROB_SHIFT = float(os.getenv("MAX_NEWS_ONLY_PROB_SHIFT", "0.25"))
|
||||
NEWS_MATERIAL_LOGODDS_THRESHOLD = float(os.getenv("NEWS_MATERIAL_LOGODDS_THRESHOLD", "0.10"))
|
||||
|
||||
# GNews free tier: 100 req/day. We limit to 5 queries per trading cycle
|
||||
# (politics markets only) and rely on 6 h cache to stay within budget.
|
||||
MAX_NEWS_QUERIES_PER_CYCLE = 5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Manifold evaluation cooldown
|
||||
#
|
||||
# Per-market backoff so the trading loop stops re-querying Manifold (and flooding
|
||||
# manifold_match_audit) for markets whose verdict is stable. Computed from the
|
||||
# match result; longer for verdicts that essentially never change.
|
||||
# no_results → 24 h (Manifold has no market on this topic)
|
||||
# rejected/low_score → 24 h (best candidate below Jaccard threshold)
|
||||
# rejected/outcome_mism. → 24 h (outcome types differ)
|
||||
# rejected/ambiguous → 24 h (party named but inversion unverifiable)
|
||||
# rejected/conditional → 7 d (premise-gated market; structural, won't change)
|
||||
# accepted → 1 h (signal is live; refresh probability hourly)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def _cooldown_for(result: ManifoldMatchResult) -> tuple[timedelta, str]:
|
||||
"""Map a Manifold match result to (retry_delay, cooldown_reason)."""
|
||||
if result.status == "accepted":
|
||||
return timedelta(hours=1), "accepted"
|
||||
if result.status == "no_results":
|
||||
return timedelta(hours=24), "no_results"
|
||||
# rejected — classify by the reason text the matcher produced
|
||||
reason = result.match_reason or "rejected"
|
||||
if "conditional_market" in reason:
|
||||
return timedelta(days=7), reason
|
||||
# outcome_mismatch, ambiguous_inversion, and low_score (jaccard<threshold)
|
||||
# all settle in 24 h.
|
||||
return timedelta(hours=24), reason
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 4 — Regime-based minimum edge (uses edge_NET, not edge_gross)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -109,10 +181,61 @@ def _days_to_resolution(end_date: str) -> int:
|
||||
return 30
|
||||
|
||||
|
||||
def has_token(text: str, token: str) -> bool:
|
||||
"""
|
||||
True if `token` appears in `text` as a standalone word.
|
||||
|
||||
Short crypto tickers (eth, sol, ada, …) must NOT match inside ordinary
|
||||
words — "Seth", "dissolved", "Canada" — but must still match the usual
|
||||
market phrasings: "ETH", "$ETH", "ETH/USD", "SOL reach $200". Boundaries
|
||||
are any non-alphanumeric character (or start/end of string), so "$" and
|
||||
"/" delimit correctly.
|
||||
"""
|
||||
return re.search(
|
||||
rf"(?<![A-Za-z0-9]){re.escape(token)}(?![A-Za-z0-9])", text, re.IGNORECASE
|
||||
) is not None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Phase 3 — GNews priority scoring
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def apply_news_guardrail(
|
||||
prior: float,
|
||||
raw_final_prob: float,
|
||||
feat_news_lo: float,
|
||||
other_feats_lo: tuple[float, ...],
|
||||
) -> tuple[float, bool]:
|
||||
"""
|
||||
GNews guardrail (catastrophic fuse).
|
||||
|
||||
Clamp raw_final_prob to prior ± MAX_NEWS_ONLY_PROB_SHIFT when ALL hold:
|
||||
1. NEWS_GUARDRAIL_ENABLED
|
||||
2. |feat_news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD (news is material)
|
||||
3. every other signal's |log-odds contribution| is below the threshold
|
||||
(GNews is the ONLY material signal — no corroboration)
|
||||
|
||||
Returns (final_prob, guardrail_applied). guardrail_applied is True only
|
||||
when the clamp actually changed the value; a raw_final_prob already inside
|
||||
the band passes through untouched with applied=False.
|
||||
|
||||
Module globals are read at call time so tests can monkeypatch them.
|
||||
"""
|
||||
if not NEWS_GUARDRAIL_ENABLED:
|
||||
return raw_final_prob, False
|
||||
if abs(feat_news_lo) < NEWS_MATERIAL_LOGODDS_THRESHOLD:
|
||||
return raw_final_prob, False
|
||||
if any(abs(v) >= NEWS_MATERIAL_LOGODDS_THRESHOLD for v in other_feats_lo):
|
||||
return raw_final_prob, False # corroborated — fuse does not apply
|
||||
clamped = min(
|
||||
max(raw_final_prob, prior - MAX_NEWS_ONLY_PROB_SHIFT),
|
||||
prior + MAX_NEWS_ONLY_PROB_SHIFT,
|
||||
)
|
||||
if clamped == raw_final_prob:
|
||||
return raw_final_prob, False
|
||||
return clamped, True
|
||||
|
||||
|
||||
def gnews_priority(market: Market, news: "NewsClient") -> float:
|
||||
"""
|
||||
Score a market for GNews query priority (higher = more valuable to query).
|
||||
@@ -234,6 +357,10 @@ class BayesianStrategy:
|
||||
# (edge_gross, edge_net, regime_min) for every market that reached the
|
||||
# edge computation stage (passed prior-extreme, family, unsupported filters)
|
||||
self._evaluated_edges: list[tuple[float, float, float]] = []
|
||||
# GNews guardrail observability — only markets with material news
|
||||
self._news_shifts: list[float] = [] # final_prob - prior, signed
|
||||
self._news_guardrail_applied: int = 0
|
||||
self._news_changed_decisions: int = 0
|
||||
|
||||
def reset_cycle(self) -> None:
|
||||
"""Call once at the start of each trading cycle to reset per-cycle counters."""
|
||||
@@ -245,6 +372,9 @@ class BayesianStrategy:
|
||||
self._manifold_fetched = 0
|
||||
self._manifold_on_trade = 0
|
||||
self._evaluated_edges = []
|
||||
self._news_shifts = []
|
||||
self._news_guardrail_applied = 0
|
||||
self._news_changed_decisions = 0
|
||||
|
||||
def get_cycle_stats(self) -> dict:
|
||||
"""Return per-cycle counters for the [CYCLE SUMMARY] log block."""
|
||||
@@ -264,6 +394,14 @@ class BayesianStrategy:
|
||||
"gross_gt_004": sum(1 for g in all_gross if g > 0.04),
|
||||
"manifold_matches_accepted": self._manifold_on_trade,
|
||||
"manifold_matches_rejected": self._manifold_fetched - self._manifold_on_trade,
|
||||
# GNews guardrail — markets with |news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD
|
||||
"news_with_material": len(self._news_shifts),
|
||||
"news_avg_shift": (sum(self._news_shifts) / len(self._news_shifts))
|
||||
if self._news_shifts else 0.0,
|
||||
"news_max_shift": max(self._news_shifts, key=abs)
|
||||
if self._news_shifts else 0.0,
|
||||
"news_guardrail_applied": self._news_guardrail_applied,
|
||||
"news_changed_decisions": self._news_changed_decisions,
|
||||
}
|
||||
|
||||
async def evaluate(
|
||||
@@ -295,13 +433,18 @@ class BayesianStrategy:
|
||||
"below", "under", "less than", "lower", "drop",
|
||||
])
|
||||
|
||||
is_btc = "btc" in question_lower or "bitcoin" in question_lower
|
||||
is_eth = "eth" in question_lower or "ethereum" in question_lower
|
||||
is_sol = "sol" in question_lower or "solana" in question_lower
|
||||
is_xrp = "xrp" in question_lower or "ripple" in question_lower
|
||||
is_doge = "doge" in question_lower or "dogecoin" in question_lower
|
||||
# Short tickers need word boundaries: "Seth" contains "eth",
|
||||
# "dissolved" contains "sol", "Canada" contains "ada". Long
|
||||
# unambiguous names (bitcoin, ethereum, …) stay as substrings.
|
||||
is_btc = has_token(question_lower, "btc") or "bitcoin" in question_lower
|
||||
is_eth = has_token(question_lower, "eth") or "ethereum" in question_lower
|
||||
is_sol = has_token(question_lower, "sol") or "solana" in question_lower
|
||||
is_xrp = has_token(question_lower, "xrp") or "ripple" in question_lower
|
||||
is_doge = has_token(question_lower, "doge") or "dogecoin" in question_lower
|
||||
is_altcoin = is_sol or is_xrp or is_doge or any(
|
||||
w in question_lower for w in ["ltc", "litecoin", "bnb", "ada", "cardano", "avax", "avalanche"]
|
||||
has_token(question_lower, t) for t in ["ltc", "bnb", "ada", "avax"]
|
||||
) or any(
|
||||
w in question_lower for w in ["litecoin", "cardano", "avalanche"]
|
||||
)
|
||||
is_general_crypto = any(
|
||||
w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"]
|
||||
@@ -369,63 +512,79 @@ class BayesianStrategy:
|
||||
sources: list[str] = [f"Prior=poly({prior:.3f})"]
|
||||
adjustments: list[float] = []
|
||||
|
||||
# Signal 1: price momentum (asset-specific or BTC as sentiment proxy)
|
||||
if is_btc:
|
||||
momentum = ext.btc_change_24h
|
||||
asset_label = "BTC"
|
||||
elif is_eth:
|
||||
momentum = ext.eth_change_24h
|
||||
asset_label = "ETH"
|
||||
elif is_politics or is_tech or is_events:
|
||||
momentum = ext.btc_change_24h
|
||||
asset_label = "BTC(sentiment)"
|
||||
else:
|
||||
momentum = ext.total_market_cap_change
|
||||
asset_label = "total mktcap"
|
||||
# Momentum and Fear & Greed only make sense for price markets, where
|
||||
# is_price_above gives the adjustment a meaningful sign. For
|
||||
# politics/tech/events there is no above/below notion — is_price_above
|
||||
# defaults to False (or flips on accidental wording like "reach"), so
|
||||
# applying these signals just injected sign noise. Skip them entirely;
|
||||
# their contributions stay 0.0 → feat_mom_lo / feat_fg_lo = 0.0.
|
||||
is_non_price = is_politics or is_tech or is_events
|
||||
|
||||
# Signal 1: price momentum (asset-specific; price markets only)
|
||||
_momentum_contribution = 0.0
|
||||
if abs(momentum) > 2:
|
||||
momentum_adj = math.tanh(momentum / 20) * 0.15
|
||||
if is_politics or is_tech or is_events:
|
||||
momentum_adj *= 0.5
|
||||
_momentum_contribution = momentum_adj if is_price_above else -momentum_adj
|
||||
adjustments.append(_momentum_contribution)
|
||||
sources.append(f"{asset_label} 24h: {momentum:+.1f}%")
|
||||
if not is_non_price:
|
||||
if is_btc:
|
||||
momentum = ext.btc_change_24h
|
||||
asset_label = "BTC"
|
||||
elif is_eth:
|
||||
momentum = ext.eth_change_24h
|
||||
asset_label = "ETH"
|
||||
else:
|
||||
momentum = ext.total_market_cap_change
|
||||
asset_label = "total mktcap"
|
||||
|
||||
# Signal 2: Fear & Greed
|
||||
fg = ext.fear_greed_index
|
||||
if fg > 70:
|
||||
fg_adj = 0.06
|
||||
sources.append(f"Fear&Greed: {fg} (greed)")
|
||||
elif fg < 30:
|
||||
fg_adj = -0.06
|
||||
sources.append(f"Fear&Greed: {fg} (fear)")
|
||||
else:
|
||||
fg_adj = (fg - 50) / 50 * 0.04
|
||||
sources.append(f"Fear&Greed: {fg} (neutral)")
|
||||
_fg_contribution = fg_adj if is_price_above else -fg_adj
|
||||
adjustments.append(_fg_contribution)
|
||||
if abs(momentum) > 2:
|
||||
momentum_adj = math.tanh(momentum / 20) * 0.15
|
||||
_momentum_contribution = momentum_adj if is_price_above else -momentum_adj
|
||||
adjustments.append(_momentum_contribution)
|
||||
sources.append(f"{asset_label} 24h: {momentum:+.1f}%")
|
||||
|
||||
# Signal 3: BTC dominance — hurts altcoins when high
|
||||
# Signal 2: Fear & Greed (price markets only)
|
||||
_fg_contribution = 0.0
|
||||
if not is_non_price:
|
||||
fg = ext.fear_greed_index
|
||||
if fg > 70:
|
||||
fg_adj = 0.06
|
||||
sources.append(f"Fear&Greed: {fg} (greed)")
|
||||
elif fg < 30:
|
||||
fg_adj = -0.06
|
||||
sources.append(f"Fear&Greed: {fg} (fear)")
|
||||
else:
|
||||
fg_adj = (fg - 50) / 50 * 0.04
|
||||
sources.append(f"Fear&Greed: {fg} (neutral)")
|
||||
_fg_contribution = fg_adj if is_price_above else -fg_adj
|
||||
adjustments.append(_fg_contribution)
|
||||
|
||||
# Signal 3: BTC dominance — hurts altcoins when high (price markets only)
|
||||
# Like momentum and Fear & Greed above: no demonstrated causality for
|
||||
# politics/tech/events, even when they legitimately mention a ticker
|
||||
# ("Will the ETH ETF be approved?"). For non-price markets the
|
||||
# contribution stays 0.0 → feat_btc_dom_lo = 0.0.
|
||||
_btc_dom_contribution = 0.0
|
||||
if (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance > 55:
|
||||
_btc_dom_contribution = -0.03 if is_price_above else 0.03
|
||||
adjustments.append(_btc_dom_contribution)
|
||||
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (high → alt pressure)")
|
||||
elif (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance < 45:
|
||||
_btc_dom_contribution = 0.03 if is_price_above else -0.03
|
||||
adjustments.append(_btc_dom_contribution)
|
||||
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)")
|
||||
if not is_non_price:
|
||||
if (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance > 55:
|
||||
_btc_dom_contribution = -0.03 if is_price_above else 0.03
|
||||
adjustments.append(_btc_dom_contribution)
|
||||
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (high → alt pressure)")
|
||||
elif (is_eth or is_altcoin or is_general_crypto) and ext.btc_dominance < 45:
|
||||
_btc_dom_contribution = 0.03 if is_price_above else -0.03
|
||||
adjustments.append(_btc_dom_contribution)
|
||||
sources.append(f"BTC dom: {ext.btc_dominance:.1f}% (low → alt season)")
|
||||
|
||||
# Signal 4: GNews sentiment (politics only, budget-gated)
|
||||
# Phase 3: caller has pre-sorted markets by gnews_priority() so the
|
||||
# highest-value markets reach this block first.
|
||||
news_log_adj = 0.0
|
||||
if is_politics and self._news is not None:
|
||||
news_sentiment = 0.0
|
||||
# self._news.enabled gates the whole block: with no GNews API key the
|
||||
# client is a no-op, so we must not consume (or report) query budget for
|
||||
# it — see NewsClient.enabled.
|
||||
if is_politics and self._news is not None and self._news.enabled:
|
||||
if self._news_queries_this_cycle < MAX_NEWS_QUERIES_PER_CYCLE:
|
||||
self._news_queries_this_cycle += 1
|
||||
sentiment = await self._news.get_sentiment(market.question)
|
||||
if abs(sentiment) > 0.05:
|
||||
news_sentiment = sentiment
|
||||
news_log_adj = sentiment * NEWS_LOGODDS_WEIGHT
|
||||
sources.append(f"GNews: {sentiment:+.2f}")
|
||||
else:
|
||||
@@ -443,69 +602,123 @@ class BayesianStrategy:
|
||||
manifold_result: Optional[ManifoldMatchResult] = None
|
||||
audit_id: Optional[str] = None
|
||||
|
||||
if (is_politics or is_tech) and self._manifold is not None:
|
||||
manifold_result = await self._manifold.get_match(market.question)
|
||||
|
||||
# Persist audit record for ALL outcomes (accepted / rejected / no_results)
|
||||
if self._db is not None:
|
||||
if not market.id:
|
||||
log.error(
|
||||
"MANIFOLD_AUDIT: market.id is None/empty — skipping audit save | "
|
||||
"question=%r", market.question[:60],
|
||||
if ((is_politics or is_tech) and self._manifold is not None
|
||||
and (MANIFOLD_AUDIT_ENABLED or MANIFOLD_SIGNAL_ENABLED)):
|
||||
# ── Cooldown gate ────────────────────────────────────────────────
|
||||
# Skip markets whose Manifold verdict was recently settled to a
|
||||
# stable value. A skip is equivalent to a no-signal: the matcher is
|
||||
# NOT called and NO manifold_match_audit row is written, so only real
|
||||
# evaluations are recorded. See _cooldown_for() and the
|
||||
# manifold_eval_cooldown table.
|
||||
in_cooldown = False
|
||||
if self._db is not None and market.id:
|
||||
try:
|
||||
cd = await self._db.get_manifold_cooldown(market.id)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to read manifold cooldown: %s", exc)
|
||||
cd = None
|
||||
if cd is not None and datetime.now(timezone.utc) < cd["retry_after"]:
|
||||
in_cooldown = True
|
||||
log.info(
|
||||
"MANIFOLD_COOLDOWN skip market=%s | last_status=%s "
|
||||
"retry_after=%s | %s",
|
||||
market.id, cd["last_status"],
|
||||
cd["retry_after"].isoformat(), market.question[:50],
|
||||
)
|
||||
else:
|
||||
audit_id = str(uuid.uuid4())
|
||||
try:
|
||||
await self._db.save_manifold_audit(
|
||||
audit_id=audit_id,
|
||||
poly_market_id=market.id,
|
||||
poly_question=market.question,
|
||||
search_query=manifold_result.search_query,
|
||||
mfld_market_id=manifold_result.market_id,
|
||||
mfld_market_title=manifold_result.market_title,
|
||||
mfld_market_url=manifold_result.market_url,
|
||||
prob_raw=manifold_result.prob_raw,
|
||||
prob_final=manifold_result.prob_final,
|
||||
inverted=manifold_result.inverted,
|
||||
match_score=manifold_result.match_score,
|
||||
match_reason=manifold_result.match_reason,
|
||||
match_status=manifold_result.status,
|
||||
poly_outcome_type=manifold_result.poly_outcome_type,
|
||||
mfld_outcome_type=manifold_result.mfld_outcome_type,
|
||||
matcher_version=MANIFOLD_MATCHER_VERSION,
|
||||
|
||||
if not in_cooldown:
|
||||
manifold_result = await self._manifold.get_match(market.question)
|
||||
|
||||
# Persist audit record for ALL outcomes (accepted / rejected / no_results).
|
||||
# Gated by MANIFOLD_AUDIT_ENABLED so the audit/coverage trail and
|
||||
# cooldowns can be kept even while Manifold is observational-only.
|
||||
if MANIFOLD_AUDIT_ENABLED and self._db is not None:
|
||||
if not market.id:
|
||||
log.error(
|
||||
"MANIFOLD_AUDIT: market.id is None/empty — skipping audit save | "
|
||||
"question=%r", market.question[:60],
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to save manifold audit: %s", exc)
|
||||
audit_id = None
|
||||
else:
|
||||
audit_id = str(uuid.uuid4())
|
||||
try:
|
||||
await self._db.save_manifold_audit(
|
||||
audit_id=audit_id,
|
||||
poly_market_id=market.id,
|
||||
poly_question=market.question,
|
||||
search_query=manifold_result.search_query,
|
||||
mfld_market_id=manifold_result.market_id,
|
||||
mfld_market_title=manifold_result.market_title,
|
||||
mfld_market_url=manifold_result.market_url,
|
||||
prob_raw=manifold_result.prob_raw,
|
||||
prob_final=manifold_result.prob_final,
|
||||
inverted=manifold_result.inverted,
|
||||
match_score=manifold_result.match_score,
|
||||
match_reason=manifold_result.match_reason,
|
||||
match_status=manifold_result.status,
|
||||
poly_outcome_type=manifold_result.poly_outcome_type,
|
||||
mfld_outcome_type=manifold_result.mfld_outcome_type,
|
||||
matcher_version=MANIFOLD_MATCHER_VERSION,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to save manifold audit: %s", exc)
|
||||
audit_id = None
|
||||
|
||||
# Structured log — both forms for compatibility
|
||||
log.info(
|
||||
"MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s"
|
||||
" inverted=%s status=%s reason=%s",
|
||||
market.question, manifold_result.market_title,
|
||||
manifold_result.match_score, manifold_result.prob_raw,
|
||||
manifold_result.prob_final, manifold_result.inverted,
|
||||
manifold_result.status, manifold_result.match_reason,
|
||||
)
|
||||
log.info("MANIFOLD_MATCH", extra={
|
||||
"poly_question": market.question,
|
||||
"mfld_title": manifold_result.market_title,
|
||||
"score": manifold_result.match_score,
|
||||
"prob_raw": manifold_result.prob_raw,
|
||||
"prob_final": manifold_result.prob_final,
|
||||
"inverted": manifold_result.inverted,
|
||||
"status": manifold_result.status,
|
||||
"reason": manifold_result.match_reason,
|
||||
})
|
||||
# Record the cooldown so this market is not re-queried every
|
||||
# cycle. Written even if the audit save above failed — we
|
||||
# still performed a real evaluation.
|
||||
if market.id:
|
||||
delay, cd_reason = _cooldown_for(manifold_result)
|
||||
try:
|
||||
await self._db.upsert_manifold_cooldown(
|
||||
poly_market_id=market.id,
|
||||
last_status=manifold_result.status,
|
||||
retry_after=datetime.now(timezone.utc) + delay,
|
||||
cooldown_reason=cd_reason,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to save manifold cooldown: %s", exc)
|
||||
|
||||
if manifold_result.status == "accepted" and manifold_result.prob_final is not None:
|
||||
manifold_used = True
|
||||
self._manifold_fetched += 1
|
||||
m_clamped = max(0.05, min(0.95, manifold_result.prob_final))
|
||||
m_log = math.log(m_clamped / (1 - m_clamped))
|
||||
p_log = math.log(prior / (1 - prior))
|
||||
manifold_log_adj = (m_log - p_log) * MANIFOLD_LOGODDS_WEIGHT
|
||||
sources.append(f"Manifold:{manifold_result.prob_final:.2f}")
|
||||
# Structured log — both forms for compatibility
|
||||
log.info(
|
||||
"MANIFOLD_MATCH poly='%s' mfld='%s' score=%s raw=%s final=%s"
|
||||
" inverted=%s status=%s reason=%s",
|
||||
market.question, manifold_result.market_title,
|
||||
manifold_result.match_score, manifold_result.prob_raw,
|
||||
manifold_result.prob_final, manifold_result.inverted,
|
||||
manifold_result.status, manifold_result.match_reason,
|
||||
)
|
||||
log.info("MANIFOLD_MATCH", extra={
|
||||
"poly_question": market.question,
|
||||
"mfld_title": manifold_result.market_title,
|
||||
"score": manifold_result.match_score,
|
||||
"prob_raw": manifold_result.prob_raw,
|
||||
"prob_final": manifold_result.prob_final,
|
||||
"inverted": manifold_result.inverted,
|
||||
"status": manifold_result.status,
|
||||
"reason": manifold_result.match_reason,
|
||||
})
|
||||
|
||||
if (MANIFOLD_SIGNAL_ENABLED
|
||||
and manifold_result.status == "accepted"
|
||||
and manifold_result.prob_final is not None):
|
||||
# ACTIVE signal path — only when explicitly enabled.
|
||||
manifold_used = True
|
||||
self._manifold_fetched += 1
|
||||
m_clamped = max(0.05, min(0.95, manifold_result.prob_final))
|
||||
m_log = math.log(m_clamped / (1 - m_clamped))
|
||||
p_log = math.log(prior / (1 - prior))
|
||||
manifold_log_adj = (m_log - p_log) * MANIFOLD_LOGODDS_WEIGHT
|
||||
sources.append(f"Manifold:{manifold_result.prob_final:.2f}")
|
||||
elif not MANIFOLD_SIGNAL_ENABLED:
|
||||
# Observational-only: matched/audited but NEVER fed to the edge
|
||||
# model. manifold_log_adj stays 0.0 → no confidence bump,
|
||||
# feat_mfld_lo=0.0 (cannot be dominant), no trade contribution.
|
||||
log.info(
|
||||
"Manifold: observational_only — signal disabled "
|
||||
"(MANIFOLD_SIGNAL_ENABLED=false) | market=%s status=%s",
|
||||
market.id, manifold_result.status,
|
||||
)
|
||||
sources.append("Manifold: observational_only")
|
||||
|
||||
# Confidence cap: macro/politics/tech signals are weaker proxies
|
||||
confidence_cap = 0.65 if (is_macro or is_politics or is_tech or is_events) else 0.90
|
||||
@@ -513,8 +726,31 @@ class BayesianStrategy:
|
||||
# Posterior via log-odds updating
|
||||
log_odds_prior = math.log(prior / (1 - prior))
|
||||
total_adj = sum(adjustments)
|
||||
estimated_prob = _sigmoid(log_odds_prior + total_adj * 2 + news_log_adj + manifold_log_adj)
|
||||
estimated_prob = max(0.05, min(0.95, estimated_prob))
|
||||
# raw_final_prob: posterior BEFORE the news guardrail.
|
||||
raw_final_prob = _sigmoid(log_odds_prior + total_adj * 2 + news_log_adj + manifold_log_adj)
|
||||
raw_final_prob = max(0.05, min(0.95, raw_final_prob))
|
||||
|
||||
# Per-feature log-odds contributions (Phase 6) — computed here (not
|
||||
# after the edge gate) because the guardrail below needs them to decide
|
||||
# signal materiality.
|
||||
# fg / mom / btc_dom: probability-delta × 2 → log-odds.
|
||||
# news / mfld: already log-odds (LOGODDS_WEIGHT already applied).
|
||||
feat_fg_lo = _fg_contribution * 2
|
||||
feat_mom_lo = _momentum_contribution * 2
|
||||
feat_news_lo = news_log_adj
|
||||
feat_mfld_lo = manifold_log_adj
|
||||
feat_btc_dom_lo = _btc_dom_contribution * 2
|
||||
|
||||
# ── GNews guardrail (catastrophic fuse) ──────────────────────────────
|
||||
# When GNews is the ONLY material signal, clamp the posterior to
|
||||
# prior ± MAX_NEWS_ONLY_PROB_SHIFT. estimated_prob (post-guardrail) is
|
||||
# what edge/trading uses; raw_final_prob is kept for observability.
|
||||
estimated_prob, news_guardrail_applied = apply_news_guardrail(
|
||||
prior,
|
||||
raw_final_prob,
|
||||
feat_news_lo,
|
||||
(feat_fg_lo, feat_mom_lo, feat_btc_dom_lo, feat_mfld_lo),
|
||||
)
|
||||
|
||||
# ── Phase 1: edge_gross and edge_net ─────────────────────────────────
|
||||
raw_edge = estimated_prob - market.yes_price
|
||||
@@ -536,15 +772,6 @@ class BayesianStrategy:
|
||||
if manifold_log_adj != 0.0:
|
||||
confidence = min(confidence_cap, confidence + 0.08)
|
||||
|
||||
# Per-feature log-odds contributions (Phase 6).
|
||||
# fg / mom / btc_dom: probability-delta × 2 → log-odds.
|
||||
# news / mfld: already log-odds (LOGODDS_WEIGHT already applied).
|
||||
feat_fg_lo = _fg_contribution * 2
|
||||
feat_mom_lo = _momentum_contribution * 2
|
||||
feat_news_lo = news_log_adj
|
||||
feat_mfld_lo = manifold_log_adj
|
||||
feat_btc_dom_lo = _btc_dom_contribution * 2
|
||||
|
||||
feat_str = (
|
||||
f"fg_lo={feat_fg_lo:+.4f} mom_lo={feat_mom_lo:+.4f} "
|
||||
f"news_lo={feat_news_lo:+.4f} mfld_lo={feat_mfld_lo:+.4f} "
|
||||
@@ -556,6 +783,48 @@ class BayesianStrategy:
|
||||
passed_net = edge_net >= regime_min
|
||||
can_trade = passed_net and confidence >= MIN_CONFIDENCE
|
||||
|
||||
# ── Guardrail decision impact ────────────────────────────────────────
|
||||
# True when the un-clamped posterior's edge crossed the regime gate but
|
||||
# the clamped one no longer does — i.e. the fuse PREVENTED a trade.
|
||||
# Confidence is invariant under the clamp (it depends only on signal
|
||||
# agreement), so the edge gate is the only component that can flip.
|
||||
guardrail_changed_trade_decision = False
|
||||
if news_guardrail_applied:
|
||||
raw_edge_net = abs(raw_final_prob - market.yes_price) - TOTAL_COST_RATE
|
||||
guardrail_changed_trade_decision = (
|
||||
raw_edge_net >= regime_min and edge_net < regime_min
|
||||
)
|
||||
|
||||
# ── Guardrail observability — ONLY markets with material news ───────
|
||||
# Gated on materiality so the ~145 markets/cycle without news don't
|
||||
# flood the logs. posterior_before_news = everything except GNews.
|
||||
news_is_material = abs(feat_news_lo) >= NEWS_MATERIAL_LOGODDS_THRESHOLD
|
||||
if news_is_material:
|
||||
posterior_before_news = max(0.05, min(0.95, _sigmoid(
|
||||
log_odds_prior + total_adj * 2 + manifold_log_adj
|
||||
)))
|
||||
self._news_shifts.append(estimated_prob - prior)
|
||||
if news_guardrail_applied:
|
||||
self._news_guardrail_applied += 1
|
||||
if guardrail_changed_trade_decision:
|
||||
self._news_changed_decisions += 1
|
||||
log.info(
|
||||
"NEWS_MATERIAL %-50s | cat=%-12s | family=%-28s | "
|
||||
"prior=%.3f | before_news=%.3f | raw=%.3f | final=%.3f | "
|
||||
"sent=%+.2f | news_lo=%+.4f | "
|
||||
"edge_before_news=%.3f | edge_after_raw=%.3f | edge_after_guardrail=%.3f | "
|
||||
"guardrail=%s | changed_decision=%s | max_shift=%.2f",
|
||||
market.question[:50], category, family,
|
||||
prior, posterior_before_news, raw_final_prob, estimated_prob,
|
||||
news_sentiment, feat_news_lo,
|
||||
abs(posterior_before_news - market.yes_price),
|
||||
abs(raw_final_prob - market.yes_price),
|
||||
edge_gross,
|
||||
"applied" if news_guardrail_applied else "none",
|
||||
str(guardrail_changed_trade_decision).lower(),
|
||||
MAX_NEWS_ONLY_PROB_SHIFT,
|
||||
)
|
||||
|
||||
if not can_trade:
|
||||
# Increment the appropriate edge-net counter
|
||||
if edge_net <= 0:
|
||||
@@ -584,8 +853,21 @@ class BayesianStrategy:
|
||||
)
|
||||
return None
|
||||
|
||||
# When GNews participated, expose raw vs final and the guardrail verdict
|
||||
# (Task 4 of the guardrail spec); otherwise keep the legacy format.
|
||||
if news_log_adj != 0.0:
|
||||
prob_part = (
|
||||
f"Prior=poly({prior:.3f}) → raw={raw_final_prob:.3f} "
|
||||
f"→ final={estimated_prob:.3f} | "
|
||||
f"GNews sent={news_sentiment:+.2f} | "
|
||||
f"guardrail={'applied' if news_guardrail_applied else 'none'} | "
|
||||
f"changed_decision={str(guardrail_changed_trade_decision).lower()} | "
|
||||
f"max_shift={MAX_NEWS_ONLY_PROB_SHIFT:.2f} | "
|
||||
)
|
||||
else:
|
||||
prob_part = f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | "
|
||||
reasoning = (
|
||||
f"Prior=poly({prior:.3f}) → estimate={estimated_prob:.3f} | "
|
||||
prob_part +
|
||||
f"Poly price={market.yes_price:.3f} | "
|
||||
f"edge_gross={edge_gross:+.3f} | edge_net={edge_net:+.3f} | "
|
||||
f"regime_min={regime_min:.2f} | days={days} | "
|
||||
@@ -635,8 +917,12 @@ class BayesianStrategy:
|
||||
feat_news_lo=feat_news_lo,
|
||||
feat_mfld_lo=feat_mfld_lo,
|
||||
feat_btc_dom_lo=feat_btc_dom_lo,
|
||||
# Manifold match audit — propagated through Order → Trade → DB
|
||||
mfld_audit_id=audit_id,
|
||||
# Manifold match audit — propagated through Order → Trade → DB.
|
||||
# mfld_audit_id is the hook main.py uses to flip the audit row's
|
||||
# used_in_trade=TRUE; suppress it when observational so the trail
|
||||
# truthfully shows Manifold drove no trades. The mfld_* fields below
|
||||
# stay as observational record (feat_mfld_lo is already 0.0).
|
||||
mfld_audit_id=(audit_id if MANIFOLD_SIGNAL_ENABLED else None),
|
||||
mfld_market_id=manifold_result.market_id if manifold_result else None,
|
||||
mfld_market_title=manifold_result.market_title if manifold_result else None,
|
||||
mfld_market_url=manifold_result.market_url if manifold_result else None,
|
||||
|
||||
+12
-2
@@ -200,8 +200,12 @@ export default function App() {
|
||||
<MetricCard
|
||||
title="Sharpe"
|
||||
value={fmt(summary.sharpe_ratio)}
|
||||
subtitle="Objetivo ≥ 0.5"
|
||||
progress={Math.min(1, summary.sharpe_ratio / 2)}
|
||||
subtitle={
|
||||
summary.sharpe_ratio == null
|
||||
? `Muestra insuficiente: ${summary.resolved_count}/${summary.min_resolved_required} resueltos, ${summary.days_observed}/${summary.min_days_required} días`
|
||||
: 'Objetivo ≥ 0.5'
|
||||
}
|
||||
progress={summary.sharpe_ratio == null ? 0 : Math.min(1, summary.sharpe_ratio / 2)}
|
||||
progressColor={summary.sharpe_ratio >= 0.5 ? 'var(--green)' : 'var(--amber)'}
|
||||
/>
|
||||
<MetricCard
|
||||
@@ -216,6 +220,12 @@ export default function App() {
|
||||
value={fmtUSD(summary.total_deployed)}
|
||||
subtitle={`${summary.total_trades} trades`}
|
||||
/>
|
||||
<MetricCard
|
||||
title="Cash Disponible"
|
||||
value={fmtUSD(summary.cash_available)}
|
||||
subtitle={`${fmtPct(summary.cash_available / summary.paper_bankroll)} del bankroll`}
|
||||
progress={summary.cash_available / summary.paper_bankroll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Performance chart */}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
fastapi==0.111.0
|
||||
uvicorn[standard]==0.49.0
|
||||
uvicorn[standard]==0.29.0
|
||||
pydantic==2.7.0
|
||||
|
||||
# Polymarket (install from PyPI when ready for real trading)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Test environment shims.
|
||||
|
||||
The bot runs on python:3.11-slim in production; local dev machines may have
|
||||
3.10, which lacks datetime.UTC (added in 3.11). Alias it so modules using
|
||||
`from datetime import UTC` import cleanly under 3.10.
|
||||
"""
|
||||
import datetime
|
||||
|
||||
if not hasattr(datetime, "UTC"):
|
||||
datetime.UTC = datetime.timezone.utc
|
||||
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Tests for bug #7 — /api/summary must agree with the executor's cash model.
|
||||
|
||||
Regression: /api/summary computed total_trades as len() over a LIMIT-500
|
||||
query (capped once history grows) and reimplemented cash as
|
||||
bankroll - sum(net_cost of open trades) from that same capped query.
|
||||
|
||||
Fix: counts come from COUNT(*) (compute_metrics_from_db) and cash comes from
|
||||
cash_available() — the same helper PaperExecutor.initialize() uses — fed by
|
||||
the same source (get_open_position_data). This test runs both consumers
|
||||
against one fake DB state and asserts they report identical cash.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import api.main as api_main
|
||||
from bot.executor.paper import PaperExecutor, cash_available
|
||||
|
||||
|
||||
BANKROLL = 10_000.0 # PAPER_BANKROLL default used by both bot and API
|
||||
|
||||
|
||||
class FakeDB:
|
||||
"""One DB state served to both the API endpoint and the executor."""
|
||||
|
||||
def __init__(self, positions: dict[str, float], total_net_cost: float,
|
||||
total_trades: int, open_count: int):
|
||||
self._positions = positions
|
||||
self._total_net_cost = total_net_cost
|
||||
self._total = total_trades
|
||||
self._open = open_count
|
||||
|
||||
# Shared source: executor.initialize() and /api/summary both call this.
|
||||
async def get_open_position_data(self):
|
||||
return dict(self._positions), self._total_net_cost
|
||||
|
||||
# /api/summary only:
|
||||
async def get_metrics_history(self, days=1):
|
||||
return []
|
||||
|
||||
async def compute_metrics_from_db(self):
|
||||
return {
|
||||
"total_trades": self._total,
|
||||
"open_count": self._open,
|
||||
"closed_count": self._total - self._open,
|
||||
"resolved_count": 0,
|
||||
}
|
||||
|
||||
async def get_recently_closed_inverted(self, hours=24):
|
||||
return set()
|
||||
|
||||
async def get_legacy_incomplete_count(self):
|
||||
return 0
|
||||
|
||||
async def get_daily_pnl_closes(self):
|
||||
return []
|
||||
|
||||
|
||||
def _run(db: FakeDB, monkeypatch) -> tuple[dict, PaperExecutor]:
|
||||
monkeypatch.setattr(api_main, "db", db)
|
||||
monkeypatch.delenv("PAPER_BANKROLL", raising=False)
|
||||
|
||||
async def run():
|
||||
summary = await api_main.get_summary()
|
||||
ex = PaperExecutor(db=db, bankroll=BANKROLL)
|
||||
await ex.initialize()
|
||||
return summary, ex
|
||||
|
||||
return asyncio.run(run())
|
||||
|
||||
|
||||
def test_api_and_executor_report_same_cash(monkeypatch):
|
||||
db = FakeDB(
|
||||
positions={"m1": 100.0, "m2": 80.0},
|
||||
total_net_cost=183.60, # 180 + fees
|
||||
total_trades=12,
|
||||
open_count=2,
|
||||
)
|
||||
summary, ex = _run(db, monkeypatch)
|
||||
assert summary["cash_available"] == pytest.approx(ex.get_portfolio().cash)
|
||||
assert summary["cash_available"] == pytest.approx(
|
||||
cash_available(BANKROLL, 183.60)
|
||||
)
|
||||
assert summary["total_deployed"] == pytest.approx(183.60)
|
||||
|
||||
|
||||
def test_total_trades_not_capped_by_query_limit(monkeypatch):
|
||||
"""700 trades in DB: the old len(LIMIT 500) reported 500."""
|
||||
db = FakeDB(
|
||||
positions={"m1": 100.0},
|
||||
total_net_cost=102.0,
|
||||
total_trades=700,
|
||||
open_count=1,
|
||||
)
|
||||
summary, _ = _run(db, monkeypatch)
|
||||
assert summary["total_trades"] == 700
|
||||
assert summary["open_trades_count"] == 1
|
||||
assert summary["closed_trades_count"] == 699
|
||||
|
||||
|
||||
def test_cash_consistency_with_no_open_positions(monkeypatch):
|
||||
db = FakeDB(positions={}, total_net_cost=0.0, total_trades=0, open_count=0)
|
||||
summary, ex = _run(db, monkeypatch)
|
||||
assert summary["cash_available"] == pytest.approx(BANKROLL)
|
||||
assert ex.get_portfolio().cash == pytest.approx(BANKROLL)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Tests for FASE 4 — crypto ticker detection must use word boundaries.
|
||||
|
||||
Regression: short tickers were detected with substring matching over
|
||||
question_lower, so non-crypto markets triggered crypto flags:
|
||||
|
||||
"Israeli parliament dissolved" contains "sol" → is_sol / is_altcoin
|
||||
"Will Canada win Group B" contains "ada" → is_altcoin
|
||||
"Will Seth Moulton be the nominee" contains "eth" → is_eth
|
||||
|
||||
Those flags armed the BTC-dominance signal (btc_dom_lo=+0.06 observed in
|
||||
production on politics markets). The fix routes short tickers (btc, eth,
|
||||
sol, xrp, doge, ltc, bnb, ada, avax) through has_token(), which requires
|
||||
non-alphanumeric boundaries; long unambiguous names (bitcoin, ethereum,
|
||||
solana, cardano, …) remain substrings.
|
||||
|
||||
The is_* flags are internal to evaluate(), so the integration tests assert
|
||||
on btc_dom_lo parsed from the structured audit log (same technique as
|
||||
test_bayesian_macro_signals.py), with btc_dominance=60 so the signal fires
|
||||
whenever an ETH/altcoin flag is set.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.polymarket import Market
|
||||
from bot.strategy.bayesian import BayesianStrategy, has_token
|
||||
|
||||
BTC_DOM_RE = re.compile(r"btc_dom_lo=([+-]\d+\.\d+)")
|
||||
|
||||
|
||||
def _make_market(question: str, category: str) -> Market:
|
||||
return Market(
|
||||
id="mkt-test-1",
|
||||
condition_id="cond-test-1",
|
||||
question=question,
|
||||
yes_token_id="yes-tok",
|
||||
no_token_id="no-tok",
|
||||
yes_price=0.50,
|
||||
no_price=0.50,
|
||||
volume_24h=50_000.0,
|
||||
end_date="2026-07-15T00:00:00Z",
|
||||
active=True,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def _make_signals() -> ExternalSignals:
|
||||
# btc_dominance=60 (>55) arms the BTC-dominance signal for any market
|
||||
# flagged as ETH / altcoin / general-crypto.
|
||||
return ExternalSignals(
|
||||
btc_price=100_000.0,
|
||||
btc_change_24h=10.0,
|
||||
eth_price=4_000.0,
|
||||
eth_change_24h=8.0,
|
||||
btc_dominance=60.0,
|
||||
fear_greed_index=80,
|
||||
fear_greed_label="greed",
|
||||
total_market_cap_change=5.0,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_and_parse_btc_dom(question: str, category: str, caplog) -> float:
|
||||
"""Run BayesianStrategy.evaluate and return btc_dom_lo from the audit log."""
|
||||
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||
market = _make_market(question, category)
|
||||
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
|
||||
asyncio.run(
|
||||
strategy.evaluate(market, _make_signals(), occupied_families=set())
|
||||
)
|
||||
for record in caplog.records:
|
||||
m = BTC_DOM_RE.search(record.getMessage())
|
||||
if m:
|
||||
return float(m.group(1))
|
||||
pytest.fail(
|
||||
"No SKIP_EDGE_NET/TRADE log line with btc_dom_lo found; "
|
||||
f"got: {[r.getMessage() for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
# ── has_token unit tests ─────────────────────────────────────────────────────
|
||||
|
||||
def test_has_token_rejects_substrings_inside_words():
|
||||
assert has_token("israeli parliament dissolved by june 30?", "sol") is False
|
||||
assert has_token("will canada win group b?", "ada") is False
|
||||
assert has_token("will seth moulton be the nominee?", "eth") is False
|
||||
|
||||
def test_has_token_matches_common_market_formats():
|
||||
assert has_token("will eth hit $5000?", "eth") is True
|
||||
assert has_token("$eth above $5000?", "eth") is True
|
||||
assert has_token("eth/usd above 5000?", "eth") is True
|
||||
assert has_token("will sol reach $200?", "sol") is True
|
||||
assert has_token("will ada reach $1?", "ada") is True
|
||||
assert has_token("BTC to $150k?", "btc") is True # case-insensitive
|
||||
|
||||
|
||||
# ── Regression: false positives must not arm the BTC-dominance signal ───────
|
||||
|
||||
def test_israeli_parliament_market_is_not_sol(caplog):
|
||||
"""'dissolved' contains 'sol' — must NOT flag is_sol/is_altcoin."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Israeli parliament dissolved by June 30?", "politics", caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0
|
||||
|
||||
def test_canada_market_is_not_ada(caplog):
|
||||
"""'Canada' contains 'ada' — must NOT flag is_altcoin."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Will Canada win Group B?", "events", caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0
|
||||
|
||||
def test_seth_moulton_market_is_not_eth(caplog):
|
||||
"""'Seth' contains 'eth' — must NOT flag is_eth."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Will Seth Moulton be the nominee?", "politics", caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0
|
||||
|
||||
|
||||
# ── Real ticker mentions must keep working ───────────────────────────────────
|
||||
|
||||
def test_eth_market_detected(caplog):
|
||||
"""Standalone 'ETH' still flags is_eth: BTC-dom fires and momentum uses ETH."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Will ETH hit $5000?", "crypto/finance", caplog
|
||||
)
|
||||
assert btc_dom_lo != 0.0
|
||||
# Momentum picks the ETH branch only when is_eth is True.
|
||||
full_log = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "ETH 24h: +8.0%" in full_log
|
||||
|
||||
def test_dollar_eth_market_detected(caplog):
|
||||
"""'$ETH' format still flags is_eth."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"$ETH above $5000?", "crypto/finance", caplog
|
||||
)
|
||||
assert btc_dom_lo != 0.0
|
||||
full_log = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "ETH 24h: +8.0%" in full_log
|
||||
|
||||
def test_sol_market_detected(caplog):
|
||||
"""'SOL reach $200' still flags is_sol → is_altcoin → BTC-dom signal."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Will SOL reach $200?", "crypto/finance", caplog
|
||||
)
|
||||
# 'reach' → is_price_above, dominance 60 → -0.03 contribution → -0.06 log-odds
|
||||
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)
|
||||
|
||||
def test_ada_market_detected(caplog):
|
||||
"""'ADA reach $1' still flags is_altcoin."""
|
||||
btc_dom_lo = _evaluate_and_parse_btc_dom(
|
||||
"Will ADA reach $1?", "crypto/finance", caplog
|
||||
)
|
||||
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Tests for FASE 5 — BTC-dominance signal must not apply to non-price markets.
|
||||
|
||||
FASE 3 gated momentum and Fear & Greed behind is_non_price (politics / tech /
|
||||
events); FASE 4 fixed ticker detection so non-crypto questions no longer flag
|
||||
crypto assets by accident. But a non-price market that LEGITIMATELY mentions
|
||||
a ticker ("Will the ETH ETF be approved?") still armed the BTC-dominance
|
||||
signal, which has no demonstrated causality for non-price outcomes. FASE 5
|
||||
applies the same is_non_price gate to that signal.
|
||||
|
||||
Note: the dominance signal only fires for is_eth / is_altcoin /
|
||||
is_general_crypto markets — a pure-BTC question never receives it, so the
|
||||
pro-Bitcoin test below is a regression guard rather than a gate exercise;
|
||||
the ETH-ETF test is the one that fails without the gate.
|
||||
|
||||
Same caplog technique as test_bayesian_asset_detection.py: btc_dom_lo is
|
||||
parsed from the structured audit log, with btc_dominance=65 (>55) so the
|
||||
signal fires whenever it is allowed to.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.polymarket import Market
|
||||
from bot.strategy.bayesian import BayesianStrategy
|
||||
|
||||
BTC_DOM_RE = re.compile(r"btc_dom_lo=([+-]\d+\.\d+)")
|
||||
|
||||
|
||||
def _make_market(question: str, category: str) -> Market:
|
||||
return Market(
|
||||
id="mkt-test-1",
|
||||
condition_id="cond-test-1",
|
||||
question=question,
|
||||
yes_token_id="yes-tok",
|
||||
no_token_id="no-tok",
|
||||
yes_price=0.50,
|
||||
no_price=0.50,
|
||||
volume_24h=50_000.0,
|
||||
end_date="2026-07-15T00:00:00Z",
|
||||
active=True,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def _make_signals() -> ExternalSignals:
|
||||
# btc_dominance=65 (>55) arms the dominance signal wherever it is allowed.
|
||||
# Momentum kept below the 2% threshold so price-market tests isolate the
|
||||
# dominance contribution.
|
||||
return ExternalSignals(
|
||||
btc_price=100_000.0,
|
||||
btc_change_24h=1.0,
|
||||
eth_price=4_000.0,
|
||||
eth_change_24h=1.0,
|
||||
btc_dominance=65.0,
|
||||
fear_greed_index=50,
|
||||
fear_greed_label="neutral",
|
||||
total_market_cap_change=1.0,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate(question: str, category: str, caplog) -> tuple[float, str]:
|
||||
"""Run evaluate() and return (btc_dom_lo, full_log) from the audit log."""
|
||||
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||
market = _make_market(question, category)
|
||||
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
|
||||
asyncio.run(
|
||||
strategy.evaluate(market, _make_signals(), occupied_families=set())
|
||||
)
|
||||
full_log = "\n".join(r.getMessage() for r in caplog.records)
|
||||
for record in caplog.records:
|
||||
m = BTC_DOM_RE.search(record.getMessage())
|
||||
if m:
|
||||
return float(m.group(1)), full_log
|
||||
pytest.fail(
|
||||
"No SKIP_EDGE_NET/TRADE log line with btc_dom_lo found; "
|
||||
f"got: {[r.getMessage() for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
# ── Non-price markets: gate must zero the signal ─────────────────────────────
|
||||
|
||||
def test_politics_market_mentioning_eth_gets_no_btc_dom(caplog):
|
||||
"""Legitimate ETH mention in a politics market → btc_dom_lo == 0.0."""
|
||||
btc_dom_lo, full_log = _evaluate(
|
||||
"Will the ETH ETF be approved?", "politics", caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0
|
||||
assert "BTC dom" not in full_log
|
||||
|
||||
def test_politics_market_mentioning_bitcoin_gets_no_btc_dom(caplog):
|
||||
"""Legitimate Bitcoin mention in a politics market → btc_dom_lo == 0.0."""
|
||||
btc_dom_lo, full_log = _evaluate(
|
||||
"Will a pro-Bitcoin candidate win the election?", "politics", caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0
|
||||
assert "BTC dom" not in full_log
|
||||
|
||||
def test_tech_and_events_markets_get_no_btc_dom(caplog):
|
||||
for category in ("tech", "events"):
|
||||
caplog.clear()
|
||||
btc_dom_lo, full_log = _evaluate(
|
||||
"Will the ETH foundation launch the product?", category, caplog
|
||||
)
|
||||
assert btc_dom_lo == 0.0, f"BTC dominance applied to {category} market"
|
||||
assert "BTC dom" not in full_log
|
||||
|
||||
|
||||
# ── Price markets: current behavior preserved ───────────────────────────────
|
||||
|
||||
def test_eth_price_market_keeps_btc_dom(caplog):
|
||||
"""ETH price market with dominance 65 → signal fires as before."""
|
||||
btc_dom_lo, full_log = _evaluate(
|
||||
"Will ETH be above $5000?", "crypto/finance", caplog
|
||||
)
|
||||
# 'above' → is_price_above, dominance 65 > 55 → -0.03 → -0.06 log-odds
|
||||
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)
|
||||
assert "BTC dom: 65.0% (high → alt pressure)" in full_log
|
||||
|
||||
def test_altcoin_price_market_keeps_btc_dom(caplog):
|
||||
"""SOL price market with dominance 65 → signal fires as before."""
|
||||
btc_dom_lo, full_log = _evaluate(
|
||||
"Will SOL reach $200?", "crypto/finance", caplog
|
||||
)
|
||||
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)
|
||||
assert "BTC dom: 65.0% (high → alt pressure)" in full_log
|
||||
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Tests for FASE 3 — macro signals (momentum, Fear & Greed) must not apply to
|
||||
non-price markets (politics / tech / events).
|
||||
|
||||
Regression: for "Will X win the election?"-style questions, is_price_above is
|
||||
False, so positive BTC momentum and high Fear & Greed were sign-flipped into
|
||||
evidence AGAINST the YES outcome. The fix skips both signals entirely for
|
||||
politics/tech/events, leaving their contributions (and feat_mom_lo /
|
||||
feat_fg_lo) at 0.0.
|
||||
|
||||
evaluate_market only returns a TradingSignal on the TRADE path; on skips it
|
||||
returns None but always emits a structured log line containing the per-feature
|
||||
log-odds (fg_lo=… mom_lo=…). The tests parse that line via caplog.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.polymarket import Market
|
||||
from bot.strategy.bayesian import BayesianStrategy
|
||||
|
||||
FEAT_RE = re.compile(r"fg_lo=([+-]\d+\.\d+) mom_lo=([+-]\d+\.\d+)")
|
||||
|
||||
|
||||
def _make_market(question: str, category: str) -> Market:
|
||||
return Market(
|
||||
id="mkt-test-1",
|
||||
condition_id="cond-test-1",
|
||||
question=question,
|
||||
yes_token_id="yes-tok",
|
||||
no_token_id="no-tok",
|
||||
yes_price=0.50,
|
||||
no_price=0.50,
|
||||
volume_24h=50_000.0,
|
||||
end_date="2026-07-15T00:00:00Z",
|
||||
active=True,
|
||||
category=category,
|
||||
)
|
||||
|
||||
|
||||
def _make_signals() -> ExternalSignals:
|
||||
# Strong bullish macro environment: BTC +10%, extreme greed.
|
||||
return ExternalSignals(
|
||||
btc_price=100_000.0,
|
||||
btc_change_24h=10.0,
|
||||
eth_price=4_000.0,
|
||||
eth_change_24h=8.0,
|
||||
btc_dominance=50.0,
|
||||
fear_greed_index=80,
|
||||
fear_greed_label="greed",
|
||||
total_market_cap_change=5.0,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate_and_parse_feats(question: str, category: str, caplog) -> tuple[float, float]:
|
||||
"""Run BayesianStrategy.evaluate and return (feat_fg_lo, feat_mom_lo) from the audit log."""
|
||||
strategy = BayesianStrategy(news=None, manifold=None, db=None)
|
||||
market = _make_market(question, category)
|
||||
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
|
||||
asyncio.run(
|
||||
strategy.evaluate(market, _make_signals(), occupied_families=set())
|
||||
)
|
||||
for record in caplog.records:
|
||||
m = FEAT_RE.search(record.getMessage())
|
||||
if m:
|
||||
return float(m.group(1)), float(m.group(2))
|
||||
pytest.fail(
|
||||
"No SKIP_EDGE_NET/TRADE log line with feature contributions found; "
|
||||
f"got: {[r.getMessage() for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
def test_politics_market_ignores_momentum_and_fear_greed(caplog):
|
||||
"""Political market with BTC +10% and F&G=80 → both contributions 0.0."""
|
||||
feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats(
|
||||
"Will John Smith win the election?", "politics", caplog
|
||||
)
|
||||
assert feat_mom_lo == 0.0
|
||||
assert feat_fg_lo == 0.0
|
||||
# The signal sources must not mention momentum or Fear & Greed either.
|
||||
full_log = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "Fear&Greed" not in full_log
|
||||
assert "24h" not in full_log
|
||||
|
||||
|
||||
def test_tech_and_events_markets_ignore_macro_signals(caplog):
|
||||
for category in ("tech", "events"):
|
||||
caplog.clear()
|
||||
feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats(
|
||||
"Will the product launch happen this quarter?", category, caplog
|
||||
)
|
||||
assert feat_mom_lo == 0.0, f"momentum applied to {category} market"
|
||||
assert feat_fg_lo == 0.0, f"Fear&Greed applied to {category} market"
|
||||
|
||||
|
||||
def test_btc_market_keeps_momentum_and_fear_greed(caplog):
|
||||
"""BTC price market with BTC +10% and F&G=80 → current behavior preserved."""
|
||||
feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats(
|
||||
"Will Bitcoin be above $150,000 on July 1?", "crypto/finance", caplog
|
||||
)
|
||||
assert feat_mom_lo > 0
|
||||
assert feat_fg_lo > 0
|
||||
# Exact values: is_price_above=True ("above"), so contributions are positive.
|
||||
# momentum: tanh(10/20) * 0.15, ×2 → log-odds. F&G>70: +0.06, ×2 → log-odds.
|
||||
assert feat_mom_lo == pytest.approx(math.tanh(10 / 20) * 0.15 * 2, abs=1e-4)
|
||||
assert feat_fg_lo == pytest.approx(0.06 * 2, abs=1e-4)
|
||||
full_log = "\n".join(r.getMessage() for r in caplog.records)
|
||||
assert "Fear&Greed: 80 (greed)" in full_log
|
||||
assert "BTC 24h: +10.0%" in full_log
|
||||
|
||||
|
||||
def test_btc_below_market_sign_flip_preserved(caplog):
|
||||
"""'below' market: bullish macro lowers YES probability (sign flip intact)."""
|
||||
feat_fg_lo, feat_mom_lo = _evaluate_and_parse_feats(
|
||||
"Will Bitcoin drop below $50,000 by August?", "crypto/finance", caplog
|
||||
)
|
||||
assert feat_mom_lo < 0
|
||||
assert feat_fg_lo < 0
|
||||
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Tests for the GNews guardrail (catastrophic fuse).
|
||||
|
||||
Post-mortem NVIDIA 631181: one uncorroborated signal at high weight flipped a
|
||||
0.845 market to 0.431. With Manifold observational-only and macro signals
|
||||
gated behind is_non_price, GNews is the only live signal able to move politics
|
||||
markets 20-30 pp against the order-book consensus. The fuse clamps the
|
||||
posterior to prior ± MAX_NEWS_ONLY_PROB_SHIFT when GNews is the ONLY material
|
||||
signal (|log-odds| >= NEWS_MATERIAL_LOGODDS_THRESHOLD); any other material
|
||||
signal counts as corroboration and disables the clamp.
|
||||
|
||||
Politics markets have no macro adjustments, so full-path tests exercise the
|
||||
"GNews only" branch naturally; the corroboration branch is tested through the
|
||||
pure helper apply_news_guardrail().
|
||||
|
||||
evaluate() emits a NEWS_MATERIAL log line for every market whose news
|
||||
contribution is material (trade or skip); tests parse it via caplog.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
import bot.strategy.bayesian as bayesian
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.polymarket import Market
|
||||
from bot.strategy.bayesian import (
|
||||
NEWS_LOGODDS_WEIGHT,
|
||||
BayesianStrategy,
|
||||
apply_news_guardrail,
|
||||
)
|
||||
|
||||
NEWS_MATERIAL_RE = re.compile(
|
||||
r"NEWS_MATERIAL.*raw=(\d+\.\d+) \| final=(\d+\.\d+).*"
|
||||
r"guardrail=(applied|none) \| changed_decision=(true|false)"
|
||||
)
|
||||
|
||||
|
||||
def _logodds(p: float) -> float:
|
||||
return math.log(p / (1 - p))
|
||||
|
||||
|
||||
def _sentiment_for(prior: float, target_raw: float) -> float:
|
||||
"""Sentiment that moves `prior` to exactly `target_raw` via GNews alone."""
|
||||
return (_logodds(target_raw) - _logodds(prior)) / NEWS_LOGODDS_WEIGHT
|
||||
|
||||
|
||||
class FakeNews:
|
||||
"""Deterministic NewsClient stub returning a fixed sentiment."""
|
||||
|
||||
enabled = True
|
||||
|
||||
def __init__(self, sentiment: float) -> None:
|
||||
self._sentiment = sentiment
|
||||
|
||||
async def get_sentiment(self, question: str) -> float:
|
||||
return self._sentiment
|
||||
|
||||
def get_freshness(self, question: str) -> float:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _make_market(yes_price: float) -> Market:
|
||||
return Market(
|
||||
id="mkt-guardrail-1",
|
||||
condition_id="cond-guardrail-1",
|
||||
question="Will John Smith win the election?",
|
||||
yes_token_id="yes-tok",
|
||||
no_token_id="no-tok",
|
||||
yes_price=yes_price,
|
||||
no_price=1.0 - yes_price,
|
||||
volume_24h=50_000.0,
|
||||
end_date="2026-07-15T00:00:00Z", # politics <30 d → regime_min 0.08
|
||||
active=True,
|
||||
category="politics",
|
||||
)
|
||||
|
||||
|
||||
def _make_signals() -> ExternalSignals:
|
||||
# Neutral macro environment; irrelevant for politics (gated) but explicit.
|
||||
return ExternalSignals(
|
||||
btc_price=100_000.0,
|
||||
btc_change_24h=0.0,
|
||||
eth_price=4_000.0,
|
||||
eth_change_24h=0.0,
|
||||
btc_dominance=50.0,
|
||||
fear_greed_index=50,
|
||||
fear_greed_label="neutral",
|
||||
total_market_cap_change=0.0,
|
||||
valid=True,
|
||||
)
|
||||
|
||||
|
||||
def _evaluate(yes_price: float, sentiment: float, caplog) -> tuple[
|
||||
BayesianStrategy, tuple[float, float, str, str]
|
||||
]:
|
||||
"""Run evaluate() on a politics market and parse the NEWS_MATERIAL line."""
|
||||
strategy = BayesianStrategy(news=FakeNews(sentiment), manifold=None, db=None)
|
||||
market = _make_market(yes_price)
|
||||
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
|
||||
asyncio.run(strategy.evaluate(market, _make_signals(), occupied_families=set()))
|
||||
for record in caplog.records:
|
||||
m = NEWS_MATERIAL_RE.search(record.getMessage())
|
||||
if m:
|
||||
return strategy, (
|
||||
float(m.group(1)), float(m.group(2)), m.group(3), m.group(4)
|
||||
)
|
||||
pytest.fail(
|
||||
"No NEWS_MATERIAL log line found; got: "
|
||||
f"{[r.getMessage() for r in caplog.records]}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test 1 — extreme uncorroborated shift: clamp to prior - MAX_NEWS_ONLY_PROB_SHIFT
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_extreme_news_only_shift_is_clamped(caplog):
|
||||
"""prior=0.845, raw 0.431 (NVIDIA signature) → final clamped to 0.595."""
|
||||
strategy, (raw, final, guardrail, _) = _evaluate(
|
||||
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
|
||||
)
|
||||
assert raw == pytest.approx(0.431, abs=1e-3)
|
||||
assert guardrail == "applied"
|
||||
assert final >= 0.595
|
||||
assert final == pytest.approx(0.845 - bayesian.MAX_NEWS_ONLY_PROB_SHIFT, abs=1e-3)
|
||||
assert strategy.get_cycle_stats()["news_guardrail_applied"] == 1
|
||||
assert strategy.get_cycle_stats()["news_with_material"] == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test 2 — moderate shift inside the band: passes through untouched
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_moderate_news_shift_inside_band_not_clamped(caplog):
|
||||
"""prior=0.50, raw 0.62 → within ±0.25 band → final=0.62, no clamp."""
|
||||
strategy, (raw, final, guardrail, _) = _evaluate(
|
||||
yes_price=0.50, sentiment=_sentiment_for(0.50, 0.62), caplog=caplog
|
||||
)
|
||||
assert raw == pytest.approx(0.62, abs=1e-3)
|
||||
assert final == pytest.approx(0.62, abs=1e-3)
|
||||
assert guardrail == "none"
|
||||
assert strategy.get_cycle_stats()["news_guardrail_applied"] == 0
|
||||
# Still counted as a material-news market for the NEWS SUMMARY.
|
||||
assert strategy.get_cycle_stats()["news_with_material"] == 1
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test 3 — corroboration: any other material signal disables the fuse
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_corroborated_news_not_clamped():
|
||||
"""GNews material + another signal >= threshold → raw passes without clamp."""
|
||||
news_lo = _logodds(0.20) - _logodds(0.50) # ≈ -1.386, clearly material
|
||||
final, applied = apply_news_guardrail(
|
||||
prior=0.50,
|
||||
raw_final_prob=0.20,
|
||||
feat_news_lo=news_lo,
|
||||
other_feats_lo=(0.0, 0.15, 0.0, 0.0), # one corroborating signal
|
||||
)
|
||||
assert final == 0.20
|
||||
assert applied is False
|
||||
|
||||
|
||||
def test_corroboration_threshold_is_inclusive():
|
||||
"""|other| == threshold exactly counts as corroboration (>=, not >)."""
|
||||
final, applied = apply_news_guardrail(
|
||||
prior=0.50,
|
||||
raw_final_prob=0.20,
|
||||
feat_news_lo=-1.386,
|
||||
other_feats_lo=(bayesian.NEWS_MATERIAL_LOGODDS_THRESHOLD, 0.0, 0.0, 0.0),
|
||||
)
|
||||
assert final == 0.20
|
||||
assert applied is False
|
||||
|
||||
|
||||
def test_uncorroborated_helper_clamps():
|
||||
"""Same shift with only noise elsewhere → clamped to prior - 0.25."""
|
||||
final, applied = apply_news_guardrail(
|
||||
prior=0.50,
|
||||
raw_final_prob=0.20,
|
||||
feat_news_lo=-1.386,
|
||||
other_feats_lo=(0.05, -0.09, 0.0, 0.0), # all below threshold → noise
|
||||
)
|
||||
assert final == pytest.approx(0.25)
|
||||
assert applied is True
|
||||
|
||||
|
||||
def test_sub_material_news_never_clamped():
|
||||
"""|news_lo| below threshold → fuse not armed, whatever the shift."""
|
||||
final, applied = apply_news_guardrail(
|
||||
prior=0.50,
|
||||
raw_final_prob=0.10,
|
||||
feat_news_lo=0.09,
|
||||
other_feats_lo=(0.0, 0.0, 0.0, 0.0),
|
||||
)
|
||||
assert final == 0.10
|
||||
assert applied is False
|
||||
|
||||
|
||||
def test_guardrail_disabled_passthrough(monkeypatch):
|
||||
monkeypatch.setattr(bayesian, "NEWS_GUARDRAIL_ENABLED", False)
|
||||
final, applied = apply_news_guardrail(
|
||||
prior=0.845,
|
||||
raw_final_prob=0.431,
|
||||
feat_news_lo=-1.974,
|
||||
other_feats_lo=(0.0, 0.0, 0.0, 0.0),
|
||||
)
|
||||
assert final == 0.431
|
||||
assert applied is False
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Test 4 — changed_decision: the clamp moves the edge from tradeable to not
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_guardrail_changed_trade_decision(monkeypatch, caplog):
|
||||
"""
|
||||
With max_shift=0.10 the clamped edge (0.10 gross, 0.06 net) falls below the
|
||||
politics <30 d regime gate (0.08) while the raw edge (0.414 gross, 0.374
|
||||
net) crossed it → the fuse prevented the trade → changed_decision=true.
|
||||
|
||||
(With the default 0.25 the clamped edge_net is 0.21, above every regime
|
||||
minimum, so the flag can only fire with a tighter configured band.)
|
||||
"""
|
||||
monkeypatch.setattr(bayesian, "MAX_NEWS_ONLY_PROB_SHIFT", 0.10)
|
||||
strategy, (raw, final, guardrail, changed) = _evaluate(
|
||||
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
|
||||
)
|
||||
assert raw == pytest.approx(0.431, abs=1e-3)
|
||||
assert final == pytest.approx(0.745, abs=1e-3)
|
||||
assert guardrail == "applied"
|
||||
assert changed == "true"
|
||||
stats = strategy.get_cycle_stats()
|
||||
assert stats["news_changed_decisions"] == 1
|
||||
assert stats["news_guardrail_applied"] == 1
|
||||
|
||||
|
||||
def test_default_band_does_not_change_decision(caplog):
|
||||
"""Default 0.25 band: clamp binds but edge_net 0.21 still crosses the gate."""
|
||||
_, (_, _, guardrail, changed) = _evaluate(
|
||||
yes_price=0.845, sentiment=_sentiment_for(0.845, 0.431), caplog=caplog
|
||||
)
|
||||
assert guardrail == "applied"
|
||||
assert changed == "false"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Tests for the GNews layer minor fixes.
|
||||
|
||||
Two faults found during the GNews capture/prioritisation diagnostic:
|
||||
|
||||
1. Hyphens/dashes in a market question reached the GNews query verbatim and,
|
||||
because '-' is GNews's exclusion operator, produced HTTP 400
|
||||
(e.g. "Abdul El-Sayed Michigan Democratic Primary").
|
||||
|
||||
2. The per-cycle GNews budget counter incremented in evaluate() *before*
|
||||
get_sentiment() checked the API key, so with no key configured the
|
||||
[CYCLE SUMMARY] reported a phantom "gnews_queries_used: 5/5" even though
|
||||
zero real requests left the process.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from bot.data.news import NewsClient
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.polymarket import Market
|
||||
from bot.strategy.bayesian import BayesianStrategy
|
||||
|
||||
|
||||
# ── Fix 1: query sanitisation ────────────────────────────────────────────────
|
||||
|
||||
def test_build_query_strips_hyphen_that_breaks_gnews():
|
||||
q = NewsClient._build_query(
|
||||
"Will Abdul El-Sayed win the 2026 Michigan Democratic Primary?"
|
||||
)
|
||||
assert "-" not in q # the exclusion operator must be gone
|
||||
assert "El-Sayed" not in q
|
||||
assert "Sayed" in q # the meaningful token survives as its own word
|
||||
|
||||
|
||||
def test_build_query_strips_unicode_dashes():
|
||||
q = NewsClient._build_query("Trump–Putin summit — final outcome")
|
||||
assert "–" not in q and "—" not in q
|
||||
assert "Trump" in q and "Putin" in q
|
||||
|
||||
|
||||
# ── Fix 2: enabled property + budget accounting ──────────────────────────────
|
||||
|
||||
def test_enabled_reflects_api_key(monkeypatch):
|
||||
monkeypatch.delenv("GNEWS_API_KEY", raising=False)
|
||||
assert NewsClient().enabled is False
|
||||
monkeypatch.setenv("GNEWS_API_KEY", "deadbeefdeadbeefdeadbeefdeadbeef")
|
||||
assert NewsClient().enabled is True
|
||||
|
||||
|
||||
def _politics_market() -> Market:
|
||||
return Market(
|
||||
id="m1", condition_id="c1",
|
||||
question="Will candidate X win the 2026 governor election?",
|
||||
yes_token_id="y", no_token_id="n",
|
||||
yes_price=0.50, no_price=0.50, volume_24h=10_000.0,
|
||||
end_date="2026-07-15T00:00:00Z", active=True, category="politics",
|
||||
)
|
||||
|
||||
|
||||
def _signals() -> ExternalSignals:
|
||||
return ExternalSignals(
|
||||
btc_price=1.0, btc_change_24h=0.0, eth_price=1.0, eth_change_24h=0.0,
|
||||
btc_dominance=50.0, fear_greed_index=50, fear_greed_label="neutral",
|
||||
total_market_cap_change=0.0, valid=True,
|
||||
)
|
||||
|
||||
|
||||
def test_disabled_news_consumes_no_gnews_budget(monkeypatch):
|
||||
"""Regression: no API key → gnews_queries_used stays 0 (was a phantom 1+)."""
|
||||
monkeypatch.delenv("GNEWS_API_KEY", raising=False)
|
||||
news = NewsClient()
|
||||
assert news.enabled is False
|
||||
|
||||
strategy = BayesianStrategy(news=news, manifold=None, db=None)
|
||||
strategy.reset_cycle()
|
||||
asyncio.run(
|
||||
strategy.evaluate(_politics_market(), _signals(), occupied_families=set())
|
||||
)
|
||||
assert strategy.get_cycle_stats()["gnews_queries_used"] == 0
|
||||
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Tests for PaperExecutor.close_position() settlement payout.
|
||||
|
||||
Regression: the old code computed cash += position_cost * resolution, which
|
||||
ignores direction — a winning BUY_NO (resolution = 0.0) paid out $0.
|
||||
|
||||
Correct settlement:
|
||||
BUY_YES: payout = shares * resolution
|
||||
BUY_NO: payout = shares * (1 - resolution)
|
||||
pnl = payout - net_cost
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.executor import paper
|
||||
from bot.executor.paper import PaperExecutor
|
||||
|
||||
|
||||
class FakeDB:
|
||||
"""Minimal Database stub for close_position()."""
|
||||
|
||||
def __init__(self, trades_by_market: dict[str, list[dict]]):
|
||||
self._trades = trades_by_market
|
||||
self.closed: list[tuple] = []
|
||||
|
||||
async def get_open_trades_for_market(self, market_id: str) -> list[dict]:
|
||||
return self._trades.get(market_id, [])
|
||||
|
||||
async def close_paper_position(self, market_id, reason="", resolution=None):
|
||||
self.closed.append((market_id, reason, resolution))
|
||||
|
||||
|
||||
def _close(direction: str, resolution: float):
|
||||
"""Open one paper trade (size $100 @ 0.5 → 200 shares, net_cost $102)
|
||||
and settle it at `resolution`. Returns (pnl, executor, notifications)."""
|
||||
notifications: list[tuple] = []
|
||||
|
||||
async def fake_trade_closed(question, pnl):
|
||||
notifications.append((question, pnl))
|
||||
|
||||
async def run():
|
||||
db = FakeDB({
|
||||
"mkt1": [{"direction": direction, "shares": 200.0, "net_cost": 102.0}],
|
||||
})
|
||||
ex = PaperExecutor(db=db, bankroll=1000.0)
|
||||
ex._portfolio.cash = 898.0 # 1000 - net_cost spent at entry
|
||||
ex._portfolio.positions["mkt1"] = 100.0 # size_usdc, as execute() stores it
|
||||
|
||||
original = paper.telegram.trade_closed
|
||||
paper.telegram.trade_closed = fake_trade_closed
|
||||
try:
|
||||
pnl = await ex.close_position("mkt1", resolution, question="Test market?")
|
||||
await asyncio.sleep(0) # let the notification task run
|
||||
finally:
|
||||
paper.telegram.trade_closed = original
|
||||
return pnl, ex, db
|
||||
|
||||
pnl, ex, db = asyncio.run(run())
|
||||
return pnl, ex, db, notifications
|
||||
|
||||
|
||||
def test_buy_yes_wins():
|
||||
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
|
||||
assert pnl == pytest.approx(200.0 - 102.0) # payout = 200 * 1.0
|
||||
assert pnl > 0
|
||||
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
|
||||
assert notif[0][1] > 0 # Telegram reports a win
|
||||
|
||||
|
||||
def test_buy_yes_loses():
|
||||
pnl, ex, db, notif = _close("BUY_YES", resolution=0.0)
|
||||
assert pnl == pytest.approx(-102.0) # payout = 0
|
||||
assert pnl < 0
|
||||
assert ex._portfolio.cash == pytest.approx(898.0)
|
||||
assert notif[0][1] < 0 # Telegram reports a loss
|
||||
|
||||
|
||||
def test_buy_no_wins():
|
||||
pnl, ex, db, notif = _close("BUY_NO", resolution=0.0)
|
||||
assert pnl == pytest.approx(200.0 - 102.0) # payout = 200 * (1 - 0.0)
|
||||
assert pnl > 0
|
||||
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
|
||||
assert notif[0][1] > 0 # win despite resolution = 0.0
|
||||
|
||||
|
||||
def test_buy_no_loses():
|
||||
pnl, ex, db, notif = _close("BUY_NO", resolution=1.0)
|
||||
assert pnl == pytest.approx(-102.0) # payout = 200 * (1 - 1.0) = 0
|
||||
assert pnl < 0
|
||||
assert ex._portfolio.cash == pytest.approx(898.0)
|
||||
assert notif[0][1] < 0 # loss despite resolution = 1.0
|
||||
|
||||
|
||||
def test_position_is_removed_and_persisted():
|
||||
pnl, ex, db, notif = _close("BUY_YES", resolution=1.0)
|
||||
assert "mkt1" not in ex._portfolio.positions
|
||||
assert db.closed == [("mkt1", "resolved", 1.0)]
|
||||
|
||||
|
||||
def test_unknown_market_returns_none():
|
||||
async def run():
|
||||
ex = PaperExecutor(db=FakeDB({}), bankroll=1000.0)
|
||||
return await ex.close_position("nope", 1.0)
|
||||
assert asyncio.run(run()) is None
|
||||
|
||||
|
||||
def test_db_failure_keeps_position_for_retry():
|
||||
"""Regression: a DB error during close must not mutate the in-memory
|
||||
portfolio — otherwise the next resolution check skips the market
|
||||
(not in positions) and the DB row stays open forever."""
|
||||
|
||||
class FailingDB(FakeDB):
|
||||
async def close_paper_position(self, market_id, reason="", resolution=None):
|
||||
raise RuntimeError("db down")
|
||||
|
||||
async def run():
|
||||
db = FailingDB({
|
||||
"mkt1": [{"direction": "BUY_YES", "shares": 200.0, "net_cost": 102.0}],
|
||||
})
|
||||
ex = PaperExecutor(db=db, bankroll=1000.0)
|
||||
ex._portfolio.cash = 898.0
|
||||
ex._portfolio.positions["mkt1"] = 100.0
|
||||
with pytest.raises(RuntimeError):
|
||||
await ex.close_position("mkt1", 1.0)
|
||||
return ex
|
||||
|
||||
ex = asyncio.run(run())
|
||||
assert ex._portfolio.positions == {"mkt1": 100.0} # still open in memory
|
||||
assert ex._portfolio.cash == pytest.approx(898.0) # payout not credited
|
||||
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
Tests for the automatic market-resolution detector (Phase 2).
|
||||
|
||||
Covers:
|
||||
- PolymarketClient.get_market_resolution() parsing of real Gamma API shapes
|
||||
(resolved YES/NO, still open, UMA-disputed, ambiguous prices, 404, errors).
|
||||
- check_resolutions() in bot/main.py: a resolved market settles the open
|
||||
paper position via PaperExecutor.close_position() and persists
|
||||
close_reason='resolved' with the resolution value.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from bot.data.polymarket import PolymarketClient, MarketResolution
|
||||
from bot.executor import paper
|
||||
from bot.executor.paper import PaperExecutor
|
||||
from bot.main import check_resolutions
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# get_market_resolution() — Gamma API response parsing
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code: int, payload: dict | None = None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload or {}
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise httpx.HTTPStatusError(
|
||||
f"HTTP {self.status_code}", request=None, response=None
|
||||
)
|
||||
|
||||
|
||||
class FakeHTTPClient:
|
||||
def __init__(self, response):
|
||||
self._response = response
|
||||
self.requested_urls: list[str] = []
|
||||
|
||||
async def get(self, url, **kwargs):
|
||||
self.requested_urls.append(url)
|
||||
if isinstance(self._response, Exception):
|
||||
raise self._response
|
||||
return self._response
|
||||
|
||||
|
||||
def _resolution_for(response) -> MarketResolution | None:
|
||||
client = PolymarketClient()
|
||||
client._client = FakeHTTPClient(response)
|
||||
return asyncio.run(client.get_market_resolution("12345"))
|
||||
|
||||
|
||||
def _gamma_market(closed: bool, yes_price: str, no_price: str,
|
||||
uma_status: str | None = "resolved") -> dict:
|
||||
"""Mirror the real Gamma /markets/{id} payload shape (observed 2026-06-11)."""
|
||||
m = {
|
||||
"id": "12345",
|
||||
"question": "Test market?",
|
||||
"closed": closed,
|
||||
"active": True,
|
||||
"outcomePrices": json.dumps([yes_price, no_price]),
|
||||
"closedTime": "2026-06-11 13:15:01+00" if closed else None,
|
||||
"umaEndDate": "2026-06-11T13:15:01Z" if closed else None,
|
||||
"endDate": "2026-06-11T13:00:00Z",
|
||||
}
|
||||
if uma_status is not None:
|
||||
m["umaResolutionStatus"] = uma_status
|
||||
return m
|
||||
|
||||
|
||||
def test_resolution_no_won():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "0", "1")))
|
||||
assert res.resolved is True
|
||||
assert res.resolution == 0.0
|
||||
assert res.resolved_at is not None
|
||||
|
||||
|
||||
def test_resolution_yes_won():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "1", "0")))
|
||||
assert res.resolved is True
|
||||
assert res.resolution == 1.0
|
||||
|
||||
|
||||
def test_open_market_not_resolved():
|
||||
res = _resolution_for(FakeResponse(
|
||||
200, _gamma_market(False, "0.51", "0.49", uma_status=None)
|
||||
))
|
||||
assert res.resolved is False
|
||||
assert res.resolution is None
|
||||
|
||||
|
||||
def test_closed_but_uma_disputed_not_settled():
|
||||
res = _resolution_for(FakeResponse(
|
||||
200, _gamma_market(True, "0", "1", uma_status="disputed")
|
||||
))
|
||||
assert res.resolved is False
|
||||
|
||||
|
||||
def test_closed_with_ambiguous_prices_not_settled():
|
||||
res = _resolution_for(FakeResponse(200, _gamma_market(True, "0.6", "0.4")))
|
||||
assert res.resolved is False
|
||||
|
||||
|
||||
def test_market_not_found_returns_none():
|
||||
assert _resolution_for(FakeResponse(404)) is None
|
||||
|
||||
|
||||
def test_api_error_returns_none():
|
||||
assert _resolution_for(httpx.ConnectError("boom")) is None
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# check_resolutions() — detector loop settles paper positions
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeDB:
|
||||
"""Database stub: one open BUY_NO paper position."""
|
||||
|
||||
def __init__(self, trades_by_market: dict[str, list[dict]]):
|
||||
self._trades = trades_by_market
|
||||
self.closed: list[tuple] = []
|
||||
|
||||
async def get_open_position_details(self) -> list[dict]:
|
||||
return [
|
||||
{"market_id": mid, "question": t[0].get("question", ""),
|
||||
"direction": t[0]["direction"]}
|
||||
for mid, t in self._trades.items()
|
||||
]
|
||||
|
||||
async def get_open_trades_for_market(self, market_id: str) -> list[dict]:
|
||||
return self._trades.get(market_id, [])
|
||||
|
||||
async def close_paper_position(self, market_id, reason="", resolution=None):
|
||||
self.closed.append((market_id, reason, resolution))
|
||||
|
||||
|
||||
class FakePoly:
|
||||
def __init__(self, resolutions: dict[str, MarketResolution | None]):
|
||||
self._resolutions = resolutions
|
||||
self.checked: list[str] = []
|
||||
|
||||
async def get_market_resolution(self, market_id: str):
|
||||
self.checked.append(market_id)
|
||||
return self._resolutions.get(market_id)
|
||||
|
||||
|
||||
def _run_check(resolutions: dict, trades: dict):
|
||||
notifications: list[tuple] = []
|
||||
|
||||
async def fake_trade_closed(question, pnl):
|
||||
notifications.append((question, pnl))
|
||||
|
||||
async def run():
|
||||
db = FakeDB(trades)
|
||||
ex = PaperExecutor(db=db, bankroll=1000.0)
|
||||
for mid, t in trades.items():
|
||||
ex._portfolio.positions[mid] = sum(x["net_cost"] for x in t) - 2.0
|
||||
ex._portfolio.cash = 898.0
|
||||
poly = FakePoly(resolutions)
|
||||
|
||||
original = paper.telegram.trade_closed
|
||||
paper.telegram.trade_closed = fake_trade_closed
|
||||
try:
|
||||
await check_resolutions(poly, ex, db)
|
||||
await asyncio.sleep(0) # let notification task run
|
||||
finally:
|
||||
paper.telegram.trade_closed = original
|
||||
return db, ex, poly
|
||||
|
||||
db, ex, poly = asyncio.run(run())
|
||||
return db, ex, poly, notifications
|
||||
|
||||
|
||||
BUY_NO_TRADE = {
|
||||
"mkt1": [{
|
||||
"direction": "BUY_NO", "shares": 200.0, "net_cost": 102.0,
|
||||
"question": "Will X happen?",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def test_resolved_buy_no_position_is_closed():
|
||||
"""BUY_NO position + market resolved NO (resolution=0.0) → winning close."""
|
||||
db, ex, poly, notif = _run_check(
|
||||
{"mkt1": MarketResolution(resolved=True, resolution=0.0)},
|
||||
BUY_NO_TRADE,
|
||||
)
|
||||
assert poly.checked == ["mkt1"]
|
||||
# close_paper_position called with close_reason='resolved' and the resolution
|
||||
assert db.closed == [("mkt1", "resolved", 0.0)]
|
||||
# Position removed and payout credited: 200 shares * (1 - 0.0) = $200
|
||||
assert "mkt1" not in ex._portfolio.positions
|
||||
assert ex._portfolio.cash == pytest.approx(898.0 + 200.0)
|
||||
# Telegram notified with positive pnl (200 - 102)
|
||||
assert notif == [("Will X happen?", pytest.approx(98.0))]
|
||||
|
||||
|
||||
def test_unresolved_position_stays_open():
|
||||
db, ex, poly, notif = _run_check(
|
||||
{"mkt1": MarketResolution(resolved=False)},
|
||||
BUY_NO_TRADE,
|
||||
)
|
||||
assert poly.checked == ["mkt1"]
|
||||
assert db.closed == []
|
||||
assert "mkt1" in ex._portfolio.positions
|
||||
assert notif == []
|
||||
|
||||
|
||||
def test_api_failure_leaves_position_open():
|
||||
db, ex, poly, notif = _run_check({"mkt1": None}, BUY_NO_TRADE)
|
||||
assert db.closed == []
|
||||
assert "mkt1" in ex._portfolio.positions
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Tests for the real Sharpe ratio with minimum-sample gate.
|
||||
|
||||
Regression: sharpe_ratio was hardcoded to 0.0 in MetricsTracker and exposed
|
||||
as `latest.get("sharpe_ratio") or 0` in /api/summary, and promotion_ready
|
||||
could in principle flip on a statistically meaningless sample (e.g. 1
|
||||
resolved trade over ~40 days of flat PnL plus a single +299 jump).
|
||||
|
||||
Fix: bot/metrics/sharpe.py computes an annualized Sharpe from the daily
|
||||
total_pnl close series, gated to None ("insufficient_sample") below 30 days
|
||||
observed / 10 resolved trades. /api/summary exposes the value plus an
|
||||
explanation (sharpe_status, days_observed, min_* fields), and
|
||||
promotion_ready additionally requires the sample minimums and non-null
|
||||
metrics.
|
||||
"""
|
||||
import asyncio
|
||||
from statistics import mean, stdev
|
||||
|
||||
import pytest
|
||||
|
||||
import api.main as api_main
|
||||
from bot.metrics.sharpe import (
|
||||
MIN_DAYS_OBSERVED,
|
||||
MIN_RESOLVED_TRADES,
|
||||
SHARPE_INSUFFICIENT,
|
||||
SHARPE_OK,
|
||||
SHARPE_ZERO_VARIANCE,
|
||||
compute_sharpe,
|
||||
daily_returns,
|
||||
sharpe_with_gate,
|
||||
)
|
||||
from bot.metrics.tracker import MetricsTracker
|
||||
|
||||
|
||||
BANKROLL = 10_000.0
|
||||
|
||||
|
||||
def _closes_from_deltas(deltas: list[float], start: float = 0.0) -> list[float]:
|
||||
closes = [start]
|
||||
for d in deltas:
|
||||
closes.append(closes[-1] + d)
|
||||
return closes
|
||||
|
||||
|
||||
# ── Pure computation ─────────────────────────────────────────────────────────
|
||||
|
||||
def test_daily_returns_are_bankroll_normalized_deltas():
|
||||
closes = [0.0, 100.0, 50.0, 50.0]
|
||||
assert daily_returns(closes, BANKROLL) == pytest.approx([0.01, -0.005, 0.0])
|
||||
|
||||
|
||||
def test_compute_sharpe_matches_manual_formula():
|
||||
deltas = [10.0, 14.0, 8.0, 12.0, 6.0, 13.0, 9.0]
|
||||
closes = _closes_from_deltas(deltas)
|
||||
rets = [d / BANKROLL for d in deltas]
|
||||
expected = mean(rets) / stdev(rets) * 365 ** 0.5
|
||||
assert compute_sharpe(closes, BANKROLL) == pytest.approx(expected)
|
||||
assert compute_sharpe(closes, BANKROLL) > 0
|
||||
|
||||
|
||||
def test_compute_sharpe_undefined_cases_return_none():
|
||||
assert compute_sharpe([], BANKROLL) is None
|
||||
assert compute_sharpe([0.0], BANKROLL) is None
|
||||
assert compute_sharpe([0.0, 50.0], BANKROLL) is None # only 1 return
|
||||
assert compute_sharpe([0.0] * 40, BANKROLL) is None # zero variance
|
||||
|
||||
|
||||
# ── Minimum-sample gate ───────────────────────────────────────────────────────
|
||||
|
||||
def test_gate_blocks_current_situation_one_resolved_trade():
|
||||
"""~40 flat days plus a single +299 jump, 1 resolved trade → no Sharpe."""
|
||||
closes = [0.0] * 35 + [299.06] * 5
|
||||
sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=1)
|
||||
assert sharpe is None
|
||||
assert status == SHARPE_INSUFFICIENT
|
||||
# The raw (ungated) value would exist and be wildly misleading:
|
||||
assert compute_sharpe(closes, BANKROLL) is not None
|
||||
|
||||
|
||||
def test_gate_blocks_too_few_days_even_with_enough_resolved():
|
||||
closes = _closes_from_deltas([10.0, -5.0] * 10) # 21 days < 30
|
||||
sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=15)
|
||||
assert sharpe is None
|
||||
assert status == SHARPE_INSUFFICIENT
|
||||
|
||||
|
||||
def test_gate_passes_with_sufficient_sample():
|
||||
deltas = [10.0, 14.0, 8.0, 12.0, 6.0] * 8 # 40 returns → 41 days
|
||||
closes = _closes_from_deltas(deltas)
|
||||
sharpe, status = sharpe_with_gate(closes, BANKROLL, resolved_count=MIN_RESOLVED_TRADES)
|
||||
assert status == SHARPE_OK
|
||||
assert sharpe == pytest.approx(compute_sharpe(closes, BANKROLL))
|
||||
|
||||
|
||||
def test_gate_flat_curve_with_sufficient_sample_is_zero_variance():
|
||||
sharpe, status = sharpe_with_gate([0.0] * 40, BANKROLL, resolved_count=12)
|
||||
assert sharpe is None
|
||||
assert status == SHARPE_ZERO_VARIANCE
|
||||
|
||||
|
||||
# ── /api/summary ─────────────────────────────────────────────────────────────
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, daily_closes, resolved_count, total_trades=60,
|
||||
win_rate=0.6, calibration=0.8):
|
||||
self._closes = daily_closes
|
||||
self._resolved = resolved_count
|
||||
self._total = total_trades
|
||||
self._win_rate = win_rate
|
||||
self._calibration = calibration
|
||||
|
||||
async def get_metrics_history(self, days=1):
|
||||
return [{
|
||||
"win_rate": self._win_rate,
|
||||
"calibration_score": self._calibration,
|
||||
"unrealized_pnl_est": 0.0,
|
||||
"realized_pnl": 299.06,
|
||||
"total_pnl": 299.06,
|
||||
}]
|
||||
|
||||
async def compute_metrics_from_db(self):
|
||||
return {
|
||||
"total_trades": self._total,
|
||||
"open_count": self._total - self._resolved,
|
||||
"closed_count": self._resolved,
|
||||
"resolved_count": self._resolved,
|
||||
}
|
||||
|
||||
async def get_open_position_data(self):
|
||||
return {}, 0.0
|
||||
|
||||
async def get_recently_closed_inverted(self, hours=24):
|
||||
return set()
|
||||
|
||||
async def get_legacy_incomplete_count(self):
|
||||
return 0
|
||||
|
||||
async def get_daily_pnl_closes(self):
|
||||
return list(self._closes)
|
||||
|
||||
|
||||
def _summary(db, monkeypatch) -> dict:
|
||||
monkeypatch.setattr(api_main, "db", db)
|
||||
monkeypatch.delenv("PAPER_BANKROLL", raising=False)
|
||||
return asyncio.run(api_main.get_summary())
|
||||
|
||||
|
||||
def test_api_insufficient_sample_returns_null_with_explanation(monkeypatch):
|
||||
"""Current prod situation: 1 resolved, ~40 days → null Sharpe, not ready."""
|
||||
db = FakeDB(daily_closes=[0.0] * 35 + [299.06] * 5, resolved_count=1)
|
||||
s = _summary(db, monkeypatch)
|
||||
assert s["sharpe_ratio"] is None
|
||||
assert s["sharpe_status"] == SHARPE_INSUFFICIENT
|
||||
assert s["resolved_count"] == 1
|
||||
assert s["min_resolved_required"] == MIN_RESOLVED_TRADES == 10
|
||||
assert s["days_observed"] == 40
|
||||
assert s["min_days_required"] == MIN_DAYS_OBSERVED == 30
|
||||
# One lucky resolved trade must never promote, even with perfect
|
||||
# win_rate/calibration and 50+ trades.
|
||||
assert s["promotion_ready"] is False
|
||||
|
||||
|
||||
def test_api_sharpe_appears_with_sufficient_sample(monkeypatch):
|
||||
deltas = [10.0, 14.0, 8.0, 12.0, 6.0] * 8
|
||||
db = FakeDB(daily_closes=_closes_from_deltas(deltas), resolved_count=12)
|
||||
s = _summary(db, monkeypatch)
|
||||
assert s["sharpe_status"] == SHARPE_OK
|
||||
assert s["sharpe_ratio"] == pytest.approx(
|
||||
compute_sharpe(_closes_from_deltas(deltas), BANKROLL)
|
||||
)
|
||||
assert s["sharpe_ratio"] >= 0.5
|
||||
assert s["promotion_ready"] is True
|
||||
|
||||
|
||||
def test_api_not_ready_when_sharpe_below_threshold(monkeypatch):
|
||||
# Zero-drift curve: mean return ~0 → Sharpe ≈ 0 < 0.5
|
||||
deltas = [50.0, -50.0] * 20
|
||||
db = FakeDB(daily_closes=_closes_from_deltas(deltas), resolved_count=12)
|
||||
s = _summary(db, monkeypatch)
|
||||
assert s["sharpe_status"] == SHARPE_OK
|
||||
assert s["sharpe_ratio"] < 0.5
|
||||
assert s["promotion_ready"] is False
|
||||
|
||||
|
||||
def test_api_not_ready_when_metrics_null(monkeypatch):
|
||||
db = FakeDB(
|
||||
daily_closes=_closes_from_deltas([10.0, 14.0, 8.0, 12.0, 6.0] * 8),
|
||||
resolved_count=12,
|
||||
win_rate=None,
|
||||
calibration=None,
|
||||
)
|
||||
s = _summary(db, monkeypatch)
|
||||
assert s["sharpe_status"] == SHARPE_OK
|
||||
assert s["promotion_ready"] is False
|
||||
|
||||
|
||||
# ── MetricsTracker: no hardcoded 0.0 in the snapshot ─────────────────────────
|
||||
|
||||
class FakeTrackerDB:
|
||||
def __init__(self, daily_closes, resolved_count):
|
||||
self._closes = daily_closes
|
||||
self._resolved = resolved_count
|
||||
self.saved = None
|
||||
|
||||
async def compute_metrics_from_db(self):
|
||||
return {
|
||||
"total_trades": 60,
|
||||
"open_count": 40,
|
||||
"closed_count": 20,
|
||||
"resolved_count": self._resolved,
|
||||
"wins_realized": self._resolved,
|
||||
"unrealized_pnl_est": 0.0,
|
||||
"realized_pnl": 100.0,
|
||||
"total_deployed": 1000.0,
|
||||
"total_fees": 20.0,
|
||||
"calibration_score": 0.8,
|
||||
}
|
||||
|
||||
async def get_daily_pnl_closes(self):
|
||||
return list(self._closes)
|
||||
|
||||
async def save_daily_metrics(self, metrics):
|
||||
self.saved = metrics
|
||||
|
||||
|
||||
def test_tracker_stores_null_sharpe_below_gate(monkeypatch):
|
||||
monkeypatch.delenv("PAPER_BANKROLL", raising=False)
|
||||
db = FakeTrackerDB(daily_closes=[0.0] * 35 + [299.06] * 5, resolved_count=1)
|
||||
asyncio.run(MetricsTracker(db).update_daily_summary())
|
||||
assert db.saved is not None
|
||||
assert db.saved["sharpe_ratio"] is None
|
||||
|
||||
|
||||
def test_tracker_stores_real_sharpe_above_gate(monkeypatch):
|
||||
monkeypatch.delenv("PAPER_BANKROLL", raising=False)
|
||||
closes = _closes_from_deltas([10.0, 14.0, 8.0, 12.0, 6.0] * 8)
|
||||
db = FakeTrackerDB(daily_closes=closes, resolved_count=12)
|
||||
asyncio.run(MetricsTracker(db).update_daily_summary())
|
||||
assert db.saved["sharpe_ratio"] == pytest.approx(
|
||||
compute_sharpe(closes, BANKROLL)
|
||||
)
|
||||
assert db.saved["sharpe_ratio"] != 0.0
|
||||
Reference in New Issue
Block a user