Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9fae78c5 | ||
|
|
b6153f5859 | ||
|
|
4928a3c1e4 | ||
|
|
43d9577fb2 | ||
|
|
1797b79f7b | ||
|
|
060fc89953 | ||
|
|
c5ffc37820 | ||
|
|
7ebb87aede | ||
|
|
02cbfc0b94 | ||
|
|
4867141c4b | ||
|
|
f5ac302a86 | ||
|
|
4002f03d0c | ||
|
|
96f31acf16 | ||
|
|
5aa54eb423 | ||
|
|
e137116e7f | ||
|
|
340c8523cf | ||
|
|
3a353c7e5b | ||
|
|
823914789d | ||
|
|
98abd96fd2 | ||
|
|
df988a36b6 | ||
|
|
664ecab174 | ||
|
|
34fd1f8719 | ||
|
|
d51d47c921 | ||
|
|
8febd32136 | ||
|
|
9abaae44fd |
+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
|
||||
```
|
||||
+121
-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"
|
||||
@@ -209,6 +215,66 @@ async def get_attribution():
|
||||
return {"attribution": attribution, "total_attributed_trades": total}
|
||||
|
||||
|
||||
@app.get("/api/metrics/manifold-matches")
|
||||
async def get_manifold_matches():
|
||||
"""Manifold match audit — version-split summary and recent match attempts.
|
||||
|
||||
summary.current_version — stats for the active matcher (MANIFOLD_MATCHER_VERSION):
|
||||
version — the matcher version string
|
||||
total_accepted — matches accepted (score >= 0.40, inversion unambiguous)
|
||||
total_rejected — matches rejected (low score or ambiguous inversion)
|
||||
total_no_results — no Manifold market found or API error
|
||||
avg_match_score — mean Jaccard score for accepted matches
|
||||
used_in_trade — accepted matches that were actually executed
|
||||
summary.all_time — accepted/rejected/no_results across every matcher version.
|
||||
summary.legacy.accepted_without_outcome_type — pre-outcome-guard accepted
|
||||
records that the current matcher would reject (not counted in current_version).
|
||||
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.
|
||||
used_in_trade=True only when status='accepted' AND a trade was actually executed.
|
||||
"""
|
||||
data = await db.get_manifold_matches(limit=50)
|
||||
for match in data["recent_matches"]:
|
||||
ts = match.get("timestamp")
|
||||
if ts is not None and hasattr(ts, "isoformat"):
|
||||
match["timestamp"] = ts.isoformat()
|
||||
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.
|
||||
@@ -219,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
|
||||
|
||||
@@ -248,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
|
||||
),
|
||||
}
|
||||
|
||||
+376
-16
@@ -4,6 +4,8 @@ import os
|
||||
from typing import Optional
|
||||
import asyncpg
|
||||
|
||||
from bot.data.manifold import MANIFOLD_MATCHER_VERSION
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -36,11 +38,15 @@ class Database:
|
||||
entry_price, shares, fee_usdc, net_cost, timestamp, reasoning, paper,
|
||||
edge_gross, edge_net, prior_prob, final_prob,
|
||||
mid_price, spread_estimate, commission, family_key,
|
||||
feat_fg_lo, feat_mom_lo, feat_news_lo, feat_mfld_lo, feat_btc_dom_lo
|
||||
feat_fg_lo, feat_mom_lo, feat_news_lo, feat_mfld_lo, feat_btc_dom_lo,
|
||||
mfld_market_id, mfld_market_title, mfld_market_url,
|
||||
mfld_prob_raw, mfld_prob_final, mfld_inverted,
|
||||
mfld_match_score, mfld_match_reason, mfld_match_status
|
||||
) VALUES (
|
||||
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,
|
||||
$13,$14,$15,$16,$17,$18,$19,$20,
|
||||
$21,$22,$23,$24,$25
|
||||
$21,$22,$23,$24,$25,
|
||||
$26,$27,$28,$29,$30,$31,$32,$33,$34
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
@@ -53,6 +59,10 @@ class Database:
|
||||
# Phase 6 feature log-odds
|
||||
trade.feat_fg_lo, trade.feat_mom_lo, trade.feat_news_lo,
|
||||
trade.feat_mfld_lo, trade.feat_btc_dom_lo,
|
||||
# Manifold audit fields
|
||||
trade.mfld_market_id, trade.mfld_market_title, trade.mfld_market_url,
|
||||
trade.mfld_prob_raw, trade.mfld_prob_final, trade.mfld_inverted,
|
||||
trade.mfld_match_score, trade.mfld_match_reason, trade.mfld_match_status,
|
||||
)
|
||||
|
||||
async def save_daily_metrics(self, metrics: dict) -> None:
|
||||
@@ -142,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:
|
||||
@@ -149,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
|
||||
@@ -218,8 +252,14 @@ class Database:
|
||||
COUNT(*) AS total_trades,
|
||||
COUNT(*) FILTER (WHERE closed_at IS NULL) AS open_count,
|
||||
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) AS resolved_count,
|
||||
AND (excluded_from_metrics IS NOT TRUE)) AS resolved_count,
|
||||
|
||||
COALESCE(SUM(net_cost)
|
||||
FILTER (WHERE closed_at IS NULL), 0) AS total_deployed,
|
||||
@@ -232,15 +272,17 @@ class Database:
|
||||
FILTER (WHERE closed_at IS NULL
|
||||
AND edge_net IS NOT NULL), 0) AS unrealized_pnl_est,
|
||||
|
||||
-- Realized PnL: closed trades with a known resolution.
|
||||
-- close_pnl is computed at close time from actual resolution.
|
||||
-- Realized PnL: admin-excluded trades omitted (close_pnl=0 by convention
|
||||
-- but excluded explicitly so they don't skew the aggregate).
|
||||
COALESCE(SUM(close_pnl)
|
||||
FILTER (WHERE closed_at IS NOT NULL
|
||||
AND close_pnl IS NOT NULL), 0) AS realized_pnl,
|
||||
AND close_pnl IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)), 0) AS realized_pnl,
|
||||
|
||||
COUNT(*) FILTER (WHERE closed_at IS NOT NULL
|
||||
AND close_pnl IS NOT NULL
|
||||
AND close_pnl > 0) AS wins_realized,
|
||||
AND close_pnl > 0
|
||||
AND (excluded_from_metrics IS NOT TRUE)) AS wins_realized,
|
||||
|
||||
-- Calibration (Brier score transformed to higher-is-better):
|
||||
-- 1 − AVG((final_prob − resolution)²) on resolved trades.
|
||||
@@ -248,12 +290,15 @@ class Database:
|
||||
-- resolution is 1.0 (YES won) or 0.0 (NO won).
|
||||
-- Perfect calibration → 1.0 | Random → ~0.75 | Worst → 0.0
|
||||
-- Returns NULL if fewer than 10 resolved trades with final_prob.
|
||||
-- Admin-excluded trades omitted from both threshold and average.
|
||||
CASE
|
||||
WHEN COUNT(*) FILTER (WHERE resolution IS NOT NULL
|
||||
AND final_prob IS NOT NULL) >= 10
|
||||
AND final_prob IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)) >= 10
|
||||
THEN 1.0 - AVG((final_prob - resolution) * (final_prob - resolution))
|
||||
FILTER (WHERE resolution IS NOT NULL
|
||||
AND final_prob IS NOT NULL)
|
||||
AND final_prob IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE))
|
||||
ELSE NULL
|
||||
END AS calibration_score
|
||||
|
||||
@@ -285,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.
|
||||
|
||||
@@ -360,22 +435,27 @@ class Database:
|
||||
feat_fg_lo AS fval,
|
||||
edge_net, net_cost, fee_usdc, closed_at, close_pnl
|
||||
FROM trades WHERE feat_fg_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
UNION ALL
|
||||
SELECT 'mom', 0.05, feat_mom_lo,
|
||||
edge_net, net_cost, fee_usdc, closed_at, close_pnl
|
||||
FROM trades WHERE feat_mom_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
UNION ALL
|
||||
SELECT 'news', 0.10, feat_news_lo,
|
||||
edge_net, net_cost, fee_usdc, closed_at, close_pnl
|
||||
FROM trades WHERE feat_news_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
UNION ALL
|
||||
SELECT 'mfld', 0.10, feat_mfld_lo,
|
||||
edge_net, net_cost, fee_usdc, closed_at, close_pnl
|
||||
FROM trades WHERE feat_mfld_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
UNION ALL
|
||||
SELECT 'btc_dom', 0.05, feat_btc_dom_lo,
|
||||
edge_net, net_cost, fee_usdc, closed_at, close_pnl
|
||||
FROM trades WHERE feat_btc_dom_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
)
|
||||
SELECT
|
||||
feature,
|
||||
@@ -459,6 +539,7 @@ class Database:
|
||||
) AS dominant
|
||||
FROM trades
|
||||
WHERE feat_fg_lo IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
)
|
||||
SELECT
|
||||
COALESCE(dominant, 'none') AS dominant_feature,
|
||||
@@ -493,6 +574,285 @@ class Database:
|
||||
return result
|
||||
|
||||
|
||||
async def save_manifold_audit(
|
||||
self,
|
||||
audit_id: str,
|
||||
poly_market_id: str,
|
||||
poly_question: str,
|
||||
search_query: str,
|
||||
mfld_market_id: Optional[str],
|
||||
mfld_market_title: Optional[str],
|
||||
mfld_market_url: Optional[str],
|
||||
prob_raw: Optional[float],
|
||||
prob_final: Optional[float],
|
||||
inverted: bool,
|
||||
match_score: Optional[float],
|
||||
match_reason: Optional[str],
|
||||
match_status: str,
|
||||
poly_outcome_type: Optional[str] = None,
|
||||
mfld_outcome_type: Optional[str] = None,
|
||||
matcher_version: Optional[str] = None,
|
||||
) -> None:
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute("""
|
||||
INSERT INTO manifold_match_audit (
|
||||
id, poly_market_id, poly_question, search_query,
|
||||
mfld_market_id, mfld_market_title, mfld_market_url,
|
||||
prob_raw, prob_final, inverted,
|
||||
match_score, match_reason, match_status, used_in_trade,
|
||||
poly_outcome_type, mfld_outcome_type, matcher_version
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,FALSE,$14,$15,$16)
|
||||
""",
|
||||
audit_id, poly_market_id, poly_question, search_query,
|
||||
mfld_market_id, mfld_market_title, mfld_market_url,
|
||||
prob_raw, prob_final, inverted,
|
||||
match_score, match_reason, match_status,
|
||||
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(
|
||||
"UPDATE manifold_match_audit SET used_in_trade = TRUE WHERE id = $1",
|
||||
audit_id,
|
||||
)
|
||||
|
||||
async def get_manifold_matches(self, limit: int = 50) -> dict:
|
||||
"""Manifold match audit, with summary split by matcher version.
|
||||
|
||||
The summary separates the current matcher (MANIFOLD_MATCHER_VERSION) from
|
||||
all-time totals and from legacy pre-outcome-guard records, whose accepted
|
||||
matches would now be rejected by the outcome-compatibility guard and so
|
||||
must not be conflated with current-version stats.
|
||||
"""
|
||||
async with self._pool.acquire() as conn:
|
||||
current = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE match_status = 'accepted') AS total_accepted,
|
||||
COUNT(*) FILTER (WHERE match_status = 'rejected') AS total_rejected,
|
||||
COUNT(*) FILTER (WHERE match_status = 'no_results') AS total_no_results,
|
||||
AVG(match_score) FILTER (WHERE match_status = 'accepted') AS avg_match_score,
|
||||
COUNT(*) FILTER (WHERE used_in_trade = TRUE) AS used_in_trade
|
||||
FROM manifold_match_audit
|
||||
WHERE matcher_version = $1
|
||||
""", MANIFOLD_MATCHER_VERSION)
|
||||
all_time = await conn.fetchrow("""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE match_status = 'accepted') AS total_accepted,
|
||||
COUNT(*) FILTER (WHERE match_status = 'rejected') AS total_rejected,
|
||||
COUNT(*) FILTER (WHERE match_status = 'no_results') AS total_no_results
|
||||
FROM manifold_match_audit
|
||||
""")
|
||||
legacy = await conn.fetchrow("""
|
||||
SELECT COUNT(*) AS accepted_without_outcome_type
|
||||
FROM manifold_match_audit
|
||||
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)
|
||||
AND mfld_match_status = 'accepted'
|
||||
AND feat_mfld_lo IS NOT NULL
|
||||
AND ABS(feat_mfld_lo) > 0.0001
|
||||
AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_fg_lo, 0))
|
||||
AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_mom_lo, 0))
|
||||
AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_news_lo, 0))
|
||||
AND ABS(feat_mfld_lo) > ABS(COALESCE(feat_btc_dom_lo, 0))
|
||||
""")
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM manifold_match_audit ORDER BY timestamp DESC LIMIT $1",
|
||||
limit,
|
||||
)
|
||||
return {
|
||||
"summary": {
|
||||
"current_version": {
|
||||
"version": MANIFOLD_MATCHER_VERSION,
|
||||
"total_accepted": int(current["total_accepted"] or 0),
|
||||
"total_rejected": int(current["total_rejected"] or 0),
|
||||
"total_no_results": int(current["total_no_results"] or 0),
|
||||
"avg_match_score": _f(current["avg_match_score"]),
|
||||
"used_in_trade": int(current["used_in_trade"] or 0),
|
||||
},
|
||||
"all_time": {
|
||||
"total_accepted": int(all_time["total_accepted"] or 0),
|
||||
"total_rejected": int(all_time["total_rejected"] or 0),
|
||||
"total_no_results": int(all_time["total_no_results"] or 0),
|
||||
},
|
||||
"legacy": {
|
||||
"accepted_without_outcome_type":
|
||||
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."""
|
||||
return float(v) if v is not None else None
|
||||
|
||||
+227
-75
@@ -2,34 +2,49 @@
|
||||
Manifold Markets client — cross-platform prediction market probability signals.
|
||||
|
||||
For each Polymarket question, searches Manifold for a matching binary market
|
||||
by keyword overlap and returns its probability as a calibration signal.
|
||||
by keyword overlap and returns a ManifoldMatchResult with full audit metadata.
|
||||
|
||||
Inversion guard: if the Manifold market's winning side (Republican / Democrat)
|
||||
is the complement of the Polymarket question's winning side, the probability is
|
||||
automatically inverted (1 - prob). This prevents "Democrats win Ohio governor"
|
||||
from consuming the probability of a Manifold market titled "Republicans win Ohio
|
||||
governor" without adjustment.
|
||||
Match threshold: >= 0.40 Jaccard overlap (raised from 0.25 for stricter semantics).
|
||||
|
||||
Rejection guard: if the match score falls below _MATCH_THRESHOLD the market is
|
||||
rejected, even if inversion would otherwise apply. All decisions are logged at
|
||||
INFO so they can be audited per-cycle.
|
||||
Outcome compatibility guard (conservative):
|
||||
- Conditional Manifold markets ("If X, will Y?" / "Conditional on..." / "Assuming..."
|
||||
/ "Given that..." / mid-sentence "...if X is nominated, will...") are rejected:
|
||||
a premise-gated question is not equivalent to a direct outcome question even when
|
||||
token overlap is high. reason='conditional_market'.
|
||||
- Each side is classified into an outcome_type (nomination | primary_win |
|
||||
general_win | conditional | other). Matches with differing outcome_type — or any
|
||||
conditional side — are rejected. reason='outcome_mismatch: poly=... manifold=...'.
|
||||
|
||||
Cache TTL: 30 minutes (Manifold markets move slowly vs our 60 s cycle).
|
||||
Match threshold: >= 0.25 keyword overlap ratio between significant tokens.
|
||||
Inversion guard (conservative):
|
||||
- If Polymarket question names a party (democrat/republican) AND the matched
|
||||
Manifold market names the OPPOSITE party → invert probability (1 - prob).
|
||||
- If Polymarket question names a party AND Manifold market has NO party keyword
|
||||
→ reject with reason='ambiguous_inversion' (can't determine if inversion applies).
|
||||
- All other cases: no inversion, accept if score >= threshold.
|
||||
- Ante duda, reject.
|
||||
|
||||
Cache TTL: 30 minutes.
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
# Version tag for every audit record this matcher produces. Persisted to
|
||||
# manifold_match_audit.matcher_version so metrics can isolate current-version
|
||||
# stats from legacy/pre-versioning records. Do NOT change this value once set;
|
||||
# bump to a new string only when matcher semantics change materially.
|
||||
MANIFOLD_MATCHER_VERSION = "v3_outcome_guard"
|
||||
|
||||
MANIFOLD_API = "https://api.manifold.markets/v0"
|
||||
CACHE_TTL_SEC = 1800 # 30 minutes
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_MATCH_THRESHOLD = 0.25
|
||||
_MATCH_THRESHOLD = 0.40 # raised from 0.25
|
||||
|
||||
_STOP_WORDS = frozenset([
|
||||
"will", "the", "a", "an", "is", "are", "was", "were", "be", "been",
|
||||
@@ -43,9 +58,24 @@ _STOP_WORDS = frozenset([
|
||||
"before", "during", "until", "against", "between", "through",
|
||||
])
|
||||
|
||||
# Mutually exclusive political parties used for complement detection
|
||||
_REPUBLICAN_WORDS = frozenset(["republican", "republicans", "gop"])
|
||||
_DEMOCRAT_WORDS = frozenset(["democrat", "democrats", "democratic"])
|
||||
_DEMOCRAT_WORDS = frozenset(["democrat", "democrats", "democratic"])
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManifoldMatchResult:
|
||||
status: str # 'accepted' | 'rejected' | 'no_results'
|
||||
prob_final: Optional[float] = None
|
||||
prob_raw: Optional[float] = None
|
||||
market_id: Optional[str] = None # Manifold internal market ID
|
||||
market_title: Optional[str] = None
|
||||
market_url: Optional[str] = None
|
||||
match_score: Optional[float] = None # 0-1 Jaccard
|
||||
match_reason: Optional[str] = None # human-readable explanation
|
||||
inverted: bool = False
|
||||
search_query: str = ""
|
||||
poly_outcome_type: Optional[str] = None # nomination|primary_win|general_win|conditional|other
|
||||
mfld_outcome_type: Optional[str] = None
|
||||
|
||||
|
||||
def _significant_words(text: str) -> set[str]:
|
||||
@@ -69,27 +99,53 @@ def _detect_party(text: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _best_match_with_audit(
|
||||
poly_question: str,
|
||||
results: list[dict],
|
||||
) -> tuple[Optional[dict], float, bool]:
|
||||
"""
|
||||
Find the best-matching open binary Manifold market.
|
||||
# ── Conditional-market detection (Task 1) ──────────────────────────────────────
|
||||
# A market is "conditional" when its resolution is gated on a premise rather than
|
||||
# asking the outcome directly (e.g. "If X is the nominee, will he win?"). Such a
|
||||
# market is NOT equivalent to a direct outcome question even with high token overlap.
|
||||
_CONDITIONAL_PREFIXES = ("if ", "conditional on", "assuming ", "given that")
|
||||
# " if <clause>," — a mid-sentence conditional clause closed by a comma.
|
||||
_CONDITIONAL_CLAUSE_RE = re.compile(r"\sif\s[^,]*,")
|
||||
|
||||
Returns (match, score, needs_inversion):
|
||||
match — best result dict, or None if below threshold
|
||||
score — keyword overlap score of best candidate (even if rejected)
|
||||
needs_inversion — True when Manifold market favours the OPPOSITE party/side
|
||||
to the Polymarket question (probability should be 1 - prob)
|
||||
|
||||
def _is_conditional(text: str) -> bool:
|
||||
"""True if the question is phrased conditionally (premise-gated)."""
|
||||
t = (text or "").strip().lower()
|
||||
if t.startswith(_CONDITIONAL_PREFIXES):
|
||||
return True
|
||||
return bool(_CONDITIONAL_CLAUSE_RE.search(t))
|
||||
|
||||
|
||||
def _classify_outcome(text: str) -> str:
|
||||
"""
|
||||
Coarse classification of what a question is *asking about*, used to reject
|
||||
matches whose outcomes are not equivalent even when tokens overlap.
|
||||
|
||||
Returns one of: nomination | primary_win | general_win | conditional | other.
|
||||
Order matters: conditional is checked first (premise-gated), then nomination
|
||||
(which subsumes "primary nominee"), then primary, then general election.
|
||||
"""
|
||||
t = (text or "").strip().lower()
|
||||
if t.startswith(_CONDITIONAL_PREFIXES):
|
||||
return "conditional"
|
||||
if any(k in t for k in ("nominee", "nominated", "nomination")):
|
||||
return "nomination"
|
||||
if any(k in t for k in ("primary", "win the primary", "first round")):
|
||||
return "primary_win"
|
||||
if any(k in t for k in ("win the election", "win the race",
|
||||
"win the seat", "general election")):
|
||||
return "general_win"
|
||||
return "other"
|
||||
|
||||
|
||||
def _find_best_candidate(poly_question: str, results: list[dict]) -> tuple[Optional[dict], float]:
|
||||
"""Find the highest-scoring open binary Manifold market by Jaccard overlap."""
|
||||
poly_words = _significant_words(poly_question)
|
||||
poly_party = _detect_party(poly_question)
|
||||
if not poly_words:
|
||||
return None, 0.0, False
|
||||
return None, 0.0
|
||||
|
||||
best_score = 0.0
|
||||
best: Optional[dict] = None
|
||||
best_needs_inv = False
|
||||
|
||||
for result in results:
|
||||
if result.get("outcomeType") != "BINARY":
|
||||
@@ -106,18 +162,14 @@ def _best_match_with_audit(
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = result
|
||||
manifold_party = _detect_party(title)
|
||||
# Inversion is warranted only when both sides are unambiguously detected
|
||||
# and they are confirmed opposites (republican ≠ democrat).
|
||||
best_needs_inv = (
|
||||
poly_party is not None
|
||||
and manifold_party is not None
|
||||
and poly_party != manifold_party
|
||||
)
|
||||
|
||||
if best_score >= _MATCH_THRESHOLD and best is not None:
|
||||
return best, best_score, best_needs_inv
|
||||
return None, best_score, False
|
||||
return best, best_score
|
||||
|
||||
|
||||
def _market_url(match: dict) -> Optional[str]:
|
||||
slug = match.get("slug", "")
|
||||
creator = match.get("creatorUsername", "")
|
||||
return f"https://manifold.markets/{creator}/{slug}" if slug else None
|
||||
|
||||
|
||||
class ManifoldClient:
|
||||
@@ -125,27 +177,32 @@ class ManifoldClient:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._client = httpx.AsyncClient(timeout=15)
|
||||
# question → (fetched_at_monotonic, probability_or_None)
|
||||
self._cache: dict[str, tuple[float, Optional[float]]] = {}
|
||||
# question → (fetched_at_monotonic, ManifoldMatchResult)
|
||||
self._cache: dict[str, tuple[float, ManifoldMatchResult]] = {}
|
||||
|
||||
async def get_probability(self, question: str) -> Optional[float]:
|
||||
async def get_match(self, question: str) -> ManifoldMatchResult:
|
||||
"""
|
||||
Return Manifold probability for a matching market, or None.
|
||||
Return a ManifoldMatchResult for the given Polymarket question.
|
||||
|
||||
Probability is already adjusted for party-direction inversion when
|
||||
the matched Manifold market is the complement of our question.
|
||||
|
||||
Full audit log is emitted at INFO for every resolved query.
|
||||
status='accepted' → prob_final is set and ready to use as signal
|
||||
status='rejected' → match found but failed quality/inversion check
|
||||
status='no_results' → API returned no results or call failed
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cached = self._cache.get(question)
|
||||
if cached and (now - cached[0]) < CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
|
||||
poly_outcome = _classify_outcome(question)
|
||||
|
||||
query = _build_search_query(question)
|
||||
if not query:
|
||||
self._cache[question] = (now, None)
|
||||
return None
|
||||
result = ManifoldMatchResult(
|
||||
status="no_results", search_query="",
|
||||
poly_outcome_type=poly_outcome,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
try:
|
||||
resp = await self._client.get(
|
||||
@@ -154,45 +211,140 @@ class ManifoldClient:
|
||||
)
|
||||
resp.raise_for_status()
|
||||
results = resp.json()
|
||||
except Exception as e:
|
||||
log.warning("Manifold API error for %r: %s", question[:40], e)
|
||||
self._cache[question] = (now, None)
|
||||
return None
|
||||
except Exception as exc:
|
||||
log.warning("Manifold API error for %r: %s", question[:40], exc)
|
||||
result = ManifoldMatchResult(
|
||||
status="no_results", search_query=query,
|
||||
poly_outcome_type=poly_outcome,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
match, score, needs_inv = _best_match_with_audit(question, results)
|
||||
if not results:
|
||||
result = ManifoldMatchResult(
|
||||
status="no_results", search_query=query,
|
||||
poly_outcome_type=poly_outcome,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
if match is None:
|
||||
best, score = _find_best_candidate(question, results)
|
||||
|
||||
# ── Score threshold ───────────────────────────────────────────────────
|
||||
if best is None or score < _MATCH_THRESHOLD:
|
||||
reason = f"jaccard={score:.2f}<{_MATCH_THRESHOLD:.2f}"
|
||||
log.info(
|
||||
"Manifold no_match: %-50s | best_score=%.2f < %.2f | query=%r",
|
||||
"Manifold REJECTED %-50s | score=%.2f < threshold=%.2f | query=%r",
|
||||
question[:50], score, _MATCH_THRESHOLD, query,
|
||||
)
|
||||
self._cache[question] = (now, None)
|
||||
return None
|
||||
result = ManifoldMatchResult(
|
||||
status="rejected",
|
||||
market_title=best.get("question") if best else None,
|
||||
match_score=score if best else None,
|
||||
match_reason=reason,
|
||||
search_query=query,
|
||||
poly_outcome_type=poly_outcome,
|
||||
mfld_outcome_type=_classify_outcome(best.get("question", "")) if best else None,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
prob_raw = float(match["probability"])
|
||||
prob_final = (1.0 - prob_raw) if needs_inv else prob_raw
|
||||
# ── Outcome compatibility + inversion analysis (conservative) ─────────
|
||||
mfld_title = best.get("question", "")
|
||||
mfld_outcome = _classify_outcome(mfld_title)
|
||||
poly_party = _detect_party(question)
|
||||
manifold_party = _detect_party(mfld_title)
|
||||
|
||||
# Build market URL from slug (best-effort; may be missing)
|
||||
slug = match.get("slug", "")
|
||||
creator = match.get("creatorUsername", "")
|
||||
url = f"https://manifold.markets/{creator}/{slug}" if slug else "n/a"
|
||||
poly_words = _significant_words(question)
|
||||
mfld_words = _significant_words(mfld_title)
|
||||
matched_tokens = sorted(poly_words & mfld_words)[:6]
|
||||
|
||||
inverted = False
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
# Task 1 — conditional Manifold market is never equivalent to a direct
|
||||
# outcome question, regardless of token overlap.
|
||||
if _is_conditional(mfld_title):
|
||||
rejection_reason = "conditional_market: manifold question is conditional"
|
||||
# Task 2 — outcome types must match; any conditional side is rejected.
|
||||
elif (poly_outcome == "conditional" or mfld_outcome == "conditional"
|
||||
or poly_outcome != mfld_outcome):
|
||||
rejection_reason = (
|
||||
f"outcome_mismatch: poly={poly_outcome} manifold={mfld_outcome}"
|
||||
)
|
||||
elif poly_party is not None:
|
||||
if manifold_party is None:
|
||||
# Poly specifies a party; Manifold does not → can't verify inversion safety
|
||||
rejection_reason = (
|
||||
f"ambiguous_inversion: poly_party={poly_party}, mfld_party=none"
|
||||
)
|
||||
elif manifold_party != poly_party:
|
||||
# Clear opposite parties — apply inversion
|
||||
inverted = True
|
||||
# manifold_party == poly_party → same party, no inversion needed
|
||||
|
||||
if rejection_reason is not None:
|
||||
url = _market_url(best)
|
||||
log.info(
|
||||
"Manifold REJECTED %-50s | score=%.2f | reason=%s\n"
|
||||
" mfld_title: %s",
|
||||
question[:50], score, rejection_reason, best.get("question", "")[:70],
|
||||
)
|
||||
result = ManifoldMatchResult(
|
||||
status="rejected",
|
||||
market_id=str(best.get("id", "")) or None,
|
||||
market_title=best.get("question"),
|
||||
market_url=url,
|
||||
match_score=score,
|
||||
match_reason=(
|
||||
f"jaccard={score:.2f}, tokens={matched_tokens}, {rejection_reason}"
|
||||
),
|
||||
search_query=query,
|
||||
poly_outcome_type=poly_outcome,
|
||||
mfld_outcome_type=mfld_outcome,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
# ── Accepted ──────────────────────────────────────────────────────────
|
||||
prob_raw = float(best["probability"])
|
||||
prob_final = (1.0 - prob_raw) if inverted else prob_raw
|
||||
url = _market_url(best)
|
||||
|
||||
match_reason = f"jaccard={score:.2f}, tokens={matched_tokens}"
|
||||
if inverted:
|
||||
match_reason += f", inverted=party({poly_party}≠{manifold_party})"
|
||||
|
||||
log.info(
|
||||
"Manifold %s: %-50s\n"
|
||||
" poly_question: %s\n"
|
||||
" manifold_title: %s\n"
|
||||
" manifold_url: %s\n"
|
||||
" match_score: %.2f | prob_raw=%.3f | inverted=%s | prob_final=%.3f",
|
||||
"MATCH_INVERTED" if needs_inv else "MATCH",
|
||||
"Manifold %s %-50s\n"
|
||||
" poly: %s\n"
|
||||
" mfld: %s\n"
|
||||
" url: %s\n"
|
||||
" score=%.2f | raw=%.3f | inverted=%s | final=%.3f",
|
||||
"ACCEPTED_INVERTED" if inverted else "ACCEPTED ",
|
||||
question[:50],
|
||||
question,
|
||||
match.get("question", ""),
|
||||
url,
|
||||
score, prob_raw, needs_inv, prob_final,
|
||||
best.get("question", ""),
|
||||
url or "n/a",
|
||||
score, prob_raw, inverted, prob_final,
|
||||
)
|
||||
|
||||
self._cache[question] = (now, prob_final)
|
||||
return prob_final
|
||||
result = ManifoldMatchResult(
|
||||
status="accepted",
|
||||
prob_final=prob_final,
|
||||
prob_raw=prob_raw,
|
||||
market_id=str(best.get("id", "")) or None,
|
||||
market_title=best.get("question"),
|
||||
market_url=url,
|
||||
match_score=score,
|
||||
match_reason=match_reason,
|
||||
inverted=inverted,
|
||||
search_query=query,
|
||||
poly_outcome_type=poly_outcome,
|
||||
mfld_outcome_type=mfld_outcome,
|
||||
)
|
||||
self._cache[question] = (now, result)
|
||||
return result
|
||||
|
||||
async def close(self) -> None:
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -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:
|
||||
|
||||
+139
-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;
|
||||
@@ -168,6 +169,103 @@ ALTER TABLE trades ADD COLUMN IF NOT EXISTS feat_btc_dom_lo DOUBLE PRECISION;
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_feat_fg ON trades(feat_fg_lo) WHERE feat_fg_lo IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_feat_mfld ON trades(feat_mfld_lo) WHERE feat_mfld_lo IS NOT NULL;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Manifold match audit — per-trade columns in trades
|
||||
--
|
||||
-- Persisted for every trade where Manifold was queried (status='accepted').
|
||||
-- mfld_match_status: 'accepted' | 'rejected' | 'no_results'
|
||||
-- mfld_inverted: TRUE when prob_final = 1 - prob_raw (party complement match)
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_id TEXT;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_title TEXT;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_market_url TEXT;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_prob_raw DOUBLE PRECISION;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_prob_final DOUBLE PRECISION;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_inverted BOOLEAN;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_score DOUBLE PRECISION;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_reason TEXT;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS mfld_match_status TEXT;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Manifold match audit table — records every Manifold query attempt
|
||||
--
|
||||
-- Populated for ALL queries: accepted, rejected, and no_results.
|
||||
-- used_in_trade=TRUE is set after executor confirms a trade was executed.
|
||||
-- poly_market_id: Market.id from the Polymarket Market dataclass (never NULL).
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS manifold_match_audit (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TIMESTAMPTZ DEFAULT NOW(),
|
||||
poly_market_id TEXT NOT NULL,
|
||||
poly_question TEXT NOT NULL,
|
||||
search_query TEXT,
|
||||
mfld_market_id TEXT,
|
||||
mfld_market_title TEXT,
|
||||
mfld_market_url TEXT,
|
||||
prob_raw DOUBLE PRECISION,
|
||||
prob_final DOUBLE PRECISION,
|
||||
inverted BOOLEAN DEFAULT FALSE,
|
||||
match_score DOUBLE PRECISION,
|
||||
match_reason TEXT,
|
||||
match_status TEXT NOT NULL,
|
||||
used_in_trade BOOLEAN DEFAULT FALSE,
|
||||
poly_outcome_type TEXT,
|
||||
mfld_outcome_type TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_audit_timestamp ON manifold_match_audit(timestamp DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_audit_status ON manifold_match_audit(match_status);
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_audit_poly_mkt ON manifold_match_audit(poly_market_id);
|
||||
|
||||
-- Backfill outcome-type columns on pre-existing tables (idempotent).
|
||||
ALTER TABLE manifold_match_audit ADD COLUMN IF NOT EXISTS poly_outcome_type TEXT;
|
||||
ALTER TABLE manifold_match_audit ADD COLUMN IF NOT EXISTS mfld_outcome_type TEXT;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Matcher versioning — separate current-matcher metrics from legacy records
|
||||
--
|
||||
-- matcher_version tags each audit row with the matcher that produced it
|
||||
-- (MANIFOLD_MATCHER_VERSION in bot/data/manifold.py). This lets the metrics
|
||||
-- endpoint isolate current_version stats from pre-versioning records, whose
|
||||
-- accepted matches would now be rejected by the outcome-compatibility guard.
|
||||
--
|
||||
-- Backfill is one-shot and idempotent (only touches NULL matcher_version rows):
|
||||
-- * rows with no outcome types → 'legacy_pre_outcome_guard' (pre outcome-guard;
|
||||
-- accepted without any outcome-type validation)
|
||||
-- * rows with an outcome type → 'v2_outcome_guard_no_version' (existed between
|
||||
-- the outcome-guard and this versioning; real version not persisted)
|
||||
-- We tag rather than infer the exact version that wasn't recorded.
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE manifold_match_audit ADD COLUMN IF NOT EXISTS matcher_version TEXT;
|
||||
|
||||
UPDATE manifold_match_audit
|
||||
SET matcher_version = 'legacy_pre_outcome_guard'
|
||||
WHERE matcher_version IS NULL
|
||||
AND poly_outcome_type IS NULL
|
||||
AND mfld_outcome_type IS NULL;
|
||||
|
||||
UPDATE manifold_match_audit
|
||||
SET matcher_version = 'v2_outcome_guard_no_version'
|
||||
WHERE matcher_version IS NULL
|
||||
AND (poly_outcome_type IS NOT NULL OR mfld_outcome_type IS NOT NULL);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_mfld_audit_version ON manifold_match_audit(matcher_version);
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Metric exclusion — administrative closure flag
|
||||
--
|
||||
-- excluded_from_metrics: TRUE for trades closed for non-signal reasons
|
||||
-- (bad matcher, data error, admin close). These trades are excluded from
|
||||
-- win_rate, calibration_score, realized_pnl, and feature attribution.
|
||||
-- exclusion_reason: free-text label for the exclusion cause.
|
||||
-- e.g. 'invalid_manifold_match_legacy'
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS excluded_from_metrics BOOLEAN DEFAULT FALSE;
|
||||
ALTER TABLE trades ADD COLUMN IF NOT EXISTS exclusion_reason TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_trades_excluded ON trades(excluded_from_metrics)
|
||||
WHERE excluded_from_metrics = TRUE;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Fix 3: extended metrics_daily columns for DB-computed metrics
|
||||
--
|
||||
@@ -182,3 +280,41 @@ ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS realized_pnl DOUBLE PRE
|
||||
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS open_count INTEGER;
|
||||
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS closed_count INTEGER;
|
||||
ALTER TABLE metrics_daily ADD COLUMN IF NOT EXISTS resolved_count INTEGER;
|
||||
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
-- Checkpoint alerts — one-shot and rate-limited Telegram observation alerts
|
||||
--
|
||||
-- fired_at: timestamp of the first fire (immutable for one-shot checkpoints)
|
||||
-- last_fired_at: updated on every fire (used for rate-limiting repeatable alerts)
|
||||
-- ─────────────────────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS checkpoint_alerts (
|
||||
checkpoint_name TEXT PRIMARY KEY,
|
||||
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);
|
||||
|
||||
+87
-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:
|
||||
@@ -57,6 +81,16 @@ class Trade:
|
||||
feat_news_lo: float = 0.0
|
||||
feat_mfld_lo: float = 0.0
|
||||
feat_btc_dom_lo: float = 0.0
|
||||
# ── Manifold match audit ──────────────────────────────────────────────────
|
||||
mfld_market_id: Optional[str] = None
|
||||
mfld_market_title: Optional[str] = None
|
||||
mfld_market_url: Optional[str] = None
|
||||
mfld_prob_raw: Optional[float] = None
|
||||
mfld_prob_final: Optional[float] = None
|
||||
mfld_inverted: bool = False
|
||||
mfld_match_score: Optional[float] = None
|
||||
mfld_match_reason: Optional[str] = None
|
||||
mfld_match_status: Optional[str] = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
return (
|
||||
@@ -98,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
|
||||
@@ -176,6 +210,16 @@ class PaperExecutor:
|
||||
feat_news_lo=order.feat_news_lo,
|
||||
feat_mfld_lo=order.feat_mfld_lo,
|
||||
feat_btc_dom_lo=order.feat_btc_dom_lo,
|
||||
# Manifold audit
|
||||
mfld_market_id=order.mfld_market_id,
|
||||
mfld_market_title=order.mfld_market_title,
|
||||
mfld_market_url=order.mfld_market_url,
|
||||
mfld_prob_raw=order.mfld_prob_raw,
|
||||
mfld_prob_final=order.mfld_prob_final,
|
||||
mfld_inverted=order.mfld_inverted,
|
||||
mfld_match_score=order.mfld_match_score,
|
||||
mfld_match_reason=order.mfld_match_reason,
|
||||
mfld_match_status=order.mfld_match_status,
|
||||
)
|
||||
|
||||
# Update paper portfolio
|
||||
@@ -185,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)
|
||||
)
|
||||
|
||||
@@ -206,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
|
||||
@@ -215,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
-44
@@ -11,11 +11,17 @@ 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
|
||||
from bot.data.db import Database
|
||||
from bot.notify.checkpoints import CheckpointMonitor
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -26,6 +32,54 @@ 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,
|
||||
@@ -38,9 +92,23 @@ async def run_trading_loop(
|
||||
) -> None:
|
||||
"""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))
|
||||
@@ -136,11 +204,16 @@ 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)
|
||||
cycle_trades += 1
|
||||
# Mark manifold audit record as used in this trade
|
||||
if signal.mfld_audit_id:
|
||||
try:
|
||||
await db.mark_manifold_audit_used(signal.mfld_audit_id)
|
||||
except Exception as exc:
|
||||
log.warning("Failed to mark manifold audit used: %s", exc)
|
||||
|
||||
# 8. [CYCLE SUMMARY] — one block per cycle, stable format for grep/compare
|
||||
stats = strategy.get_cycle_stats()
|
||||
@@ -152,7 +225,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"
|
||||
@@ -170,9 +253,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"],
|
||||
@@ -187,14 +268,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,
|
||||
)
|
||||
|
||||
# 9. Update daily metrics
|
||||
await metrics.update_daily_summary()
|
||||
|
||||
# 10. Checkpoint alerts — one-shot / rate-limited Telegram notifications
|
||||
current_portfolio = executor.get_portfolio()
|
||||
try:
|
||||
await checkpoint_monitor.check_all(
|
||||
db,
|
||||
exposure_pct=current_portfolio.exposure_pct,
|
||||
exposure_cap_pct=risk.max_exposure_pct,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning("checkpoint_monitor.check_all failed: %s", exc)
|
||||
|
||||
except Exception as e:
|
||||
log.error("Error in trading loop: %s", e, exc_info=True)
|
||||
|
||||
@@ -204,14 +294,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.
|
||||
"""
|
||||
@@ -250,8 +343,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",
|
||||
})
|
||||
@@ -289,31 +380,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")
|
||||
@@ -329,7 +396,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"],
|
||||
@@ -337,12 +403,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:
|
||||
@@ -375,7 +440,7 @@ async def main() -> None:
|
||||
external = ExternalDataClient()
|
||||
news = NewsClient()
|
||||
manifold = ManifoldClient()
|
||||
strategy = BayesianStrategy(news=news, manifold=manifold)
|
||||
strategy = BayesianStrategy(news=news, manifold=manifold, db=db)
|
||||
risk = RiskManager(max_position_pct=0.05, max_exposure_pct=0.30)
|
||||
executor = PaperExecutor(db=db, bankroll=PAPER_BANKROLL) if PAPER_MODE else None
|
||||
metrics = MetricsTracker(db=db)
|
||||
@@ -395,7 +460,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})",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
"""One-shot and rate-limited Telegram checkpoint alerts.
|
||||
|
||||
Called from the main trading loop at the end of each cycle.
|
||||
Errors are swallowed — checkpoint failures must never break the loop.
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from bot.notify import telegram
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_EXPOSURE_COOLDOWN_HOURS = 6
|
||||
|
||||
|
||||
class CheckpointMonitor:
|
||||
|
||||
async def check_all(
|
||||
self,
|
||||
db,
|
||||
exposure_pct: float,
|
||||
exposure_cap_pct: float,
|
||||
) -> None:
|
||||
for name, coro in [
|
||||
("primer_match_accepted", self._check_primer_match_accepted(db)),
|
||||
("primer_trade_phase6", self._check_primer_trade_phase6(db)),
|
||||
("primer_resolved", self._check_primer_resolved(db)),
|
||||
("exposure_cerca_cap", self._check_exposure_cerca_cap(db, exposure_pct, exposure_cap_pct)),
|
||||
]:
|
||||
try:
|
||||
await coro
|
||||
except Exception as exc:
|
||||
log.warning("checkpoint %s failed: %s", name, exc)
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def _one_shot_fired(self, db, name: str) -> bool:
|
||||
async with db._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT 1 FROM checkpoint_alerts WHERE checkpoint_name = $1", name
|
||||
)
|
||||
return row is not None
|
||||
|
||||
async def _mark_one_shot(self, db, name: str) -> None:
|
||||
async with db._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"INSERT INTO checkpoint_alerts (checkpoint_name, fired_at) VALUES ($1, NOW())",
|
||||
name,
|
||||
)
|
||||
|
||||
async def _last_fired_at(self, db, name: str) -> Optional[datetime]:
|
||||
async with db._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT last_fired_at FROM checkpoint_alerts WHERE checkpoint_name = $1",
|
||||
name,
|
||||
)
|
||||
if row is None:
|
||||
return None
|
||||
return row["last_fired_at"]
|
||||
|
||||
async def _upsert_repeatable(self, db, name: str) -> None:
|
||||
async with db._pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO checkpoint_alerts (checkpoint_name, fired_at, last_fired_at)
|
||||
VALUES ($1, NOW(), NOW())
|
||||
ON CONFLICT (checkpoint_name) DO UPDATE SET last_fired_at = NOW()
|
||||
""",
|
||||
name,
|
||||
)
|
||||
|
||||
# ── checkpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _check_primer_match_accepted(self, db) -> None:
|
||||
if await self._one_shot_fired(db, "primer_match_accepted"):
|
||||
return
|
||||
|
||||
async with db._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT match_score, poly_question, mfld_market_title
|
||||
FROM manifold_match_audit
|
||||
WHERE match_status = 'accepted'
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
score = float(row["match_score"] or 0.0)
|
||||
poly_q = (row["poly_question"] or "")[:60]
|
||||
mfld_t = (row["mfld_market_title"] or "")[:60]
|
||||
|
||||
await telegram._send(
|
||||
f"✅ Primer match Manifold accepted — score={score:.2f} "
|
||||
f"poly='{poly_q}' mfld='{mfld_t}'"
|
||||
)
|
||||
await self._mark_one_shot(db, "primer_match_accepted")
|
||||
log.info("checkpoint primer_match_accepted fired")
|
||||
|
||||
async def _check_primer_trade_phase6(self, db) -> None:
|
||||
if await self._one_shot_fired(db, "primer_trade_phase6"):
|
||||
return
|
||||
|
||||
async with db._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT question, mfld_match_score, edge_net
|
||||
FROM trades
|
||||
WHERE mfld_match_score IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
question = (row["question"] or "")[:70]
|
||||
score = float(row["mfld_match_score"] or 0.0)
|
||||
edge = float(row["edge_net"] or 0.0)
|
||||
|
||||
await telegram._send(
|
||||
f"🎯 Primer trade Phase-6 limpio — {question} "
|
||||
f"score={score:.2f} edge={edge:.3f}"
|
||||
)
|
||||
await self._mark_one_shot(db, "primer_trade_phase6")
|
||||
log.info("checkpoint primer_trade_phase6 fired")
|
||||
|
||||
async def _check_primer_resolved(self, db) -> None:
|
||||
if await self._one_shot_fired(db, "primer_resolved"):
|
||||
return
|
||||
|
||||
async with db._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT question, resolution, close_pnl
|
||||
FROM trades
|
||||
WHERE resolution IS NOT NULL
|
||||
AND (excluded_from_metrics IS NOT TRUE)
|
||||
ORDER BY closed_at ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
question = (row["question"] or "")[:70]
|
||||
resolution = float(row["resolution"] or 0.0)
|
||||
pnl = float(row["close_pnl"] or 0.0)
|
||||
|
||||
await telegram._send(
|
||||
f"🏁 Primer mercado resuelto — {question} "
|
||||
f"result={resolution} pnl={pnl:.2f}"
|
||||
)
|
||||
await self._mark_one_shot(db, "primer_resolved")
|
||||
log.info("checkpoint primer_resolved fired")
|
||||
|
||||
async def _check_exposure_cerca_cap(
|
||||
self, db, exposure_pct: float, exposure_cap_pct: float
|
||||
) -> None:
|
||||
if exposure_pct < 0.80 * exposure_cap_pct:
|
||||
return
|
||||
|
||||
last = await self._last_fired_at(db, "exposure_cerca_cap")
|
||||
if last is not None:
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
if last.tzinfo is None:
|
||||
last = last.replace(tzinfo=timezone.utc)
|
||||
elapsed_hours = (now_utc - last).total_seconds() / 3600
|
||||
if elapsed_hours < _EXPOSURE_COOLDOWN_HOURS:
|
||||
return
|
||||
|
||||
await telegram._send(
|
||||
f"⚠️ Exposure al 80% del cap — revisar posiciones "
|
||||
f"({exposure_pct * 100:.1f}% / {exposure_cap_pct * 100:.1f}%)"
|
||||
)
|
||||
await self._upsert_repeatable(db, "exposure_cerca_cap")
|
||||
log.info(
|
||||
"checkpoint exposure_cerca_cap fired (%.1f%% / %.1f%%)",
|
||||
exposure_pct * 100, exposure_cap_pct * 100,
|
||||
)
|
||||
@@ -62,6 +62,17 @@ class Order:
|
||||
feat_news_lo: float = 0.0
|
||||
feat_mfld_lo: float = 0.0
|
||||
feat_btc_dom_lo: float = 0.0
|
||||
# Manifold audit fields (propagated from TradingSignal → Trade → DB)
|
||||
mfld_audit_id: Optional[str] = None
|
||||
mfld_market_id: Optional[str] = None
|
||||
mfld_market_title: Optional[str] = None
|
||||
mfld_market_url: Optional[str] = None
|
||||
mfld_prob_raw: Optional[float] = None
|
||||
mfld_prob_final: Optional[float] = None
|
||||
mfld_inverted: bool = False
|
||||
mfld_match_score: Optional[float] = None
|
||||
mfld_match_reason: Optional[str] = None
|
||||
mfld_match_status: Optional[str] = None
|
||||
|
||||
|
||||
class RiskManager:
|
||||
@@ -159,4 +170,15 @@ class RiskManager:
|
||||
feat_news_lo=signal.feat_news_lo,
|
||||
feat_mfld_lo=signal.feat_mfld_lo,
|
||||
feat_btc_dom_lo=signal.feat_btc_dom_lo,
|
||||
# Manifold audit
|
||||
mfld_audit_id=signal.mfld_audit_id,
|
||||
mfld_market_id=signal.mfld_market_id,
|
||||
mfld_market_title=signal.mfld_market_title,
|
||||
mfld_market_url=signal.mfld_market_url,
|
||||
mfld_prob_raw=signal.mfld_prob_raw,
|
||||
mfld_prob_final=signal.mfld_prob_final,
|
||||
mfld_inverted=signal.mfld_inverted,
|
||||
mfld_match_score=signal.mfld_match_score,
|
||||
mfld_match_reason=signal.mfld_match_reason,
|
||||
mfld_match_status=signal.mfld_match_status,
|
||||
)
|
||||
|
||||
+285
-59
@@ -12,16 +12,21 @@ 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
|
||||
from bot.data.external import ExternalSignals
|
||||
from bot.data.manifold import MANIFOLD_MATCHER_VERSION, ManifoldMatchResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from bot.data.news import NewsClient
|
||||
from bot.data.manifold import ManifoldClient
|
||||
from bot.data.db import Database
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,11 +63,60 @@ 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 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)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -106,6 +160,21 @@ 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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -170,6 +239,19 @@ class TradingSignal:
|
||||
feat_news_lo: float = 0.0
|
||||
feat_mfld_lo: float = 0.0
|
||||
feat_btc_dom_lo: float = 0.0
|
||||
# ── Manifold match audit (propagated → Order → Trade → DB) ───────────────
|
||||
# mfld_audit_id: UUID of the manifold_match_audit row; used to mark
|
||||
# used_in_trade=TRUE after executor confirms the trade was executed.
|
||||
mfld_audit_id: Optional[str] = None
|
||||
mfld_market_id: Optional[str] = None
|
||||
mfld_market_title: Optional[str] = None
|
||||
mfld_market_url: Optional[str] = None
|
||||
mfld_prob_raw: Optional[float] = None
|
||||
mfld_prob_final: Optional[float] = None
|
||||
mfld_inverted: bool = False
|
||||
mfld_match_score: Optional[float] = None
|
||||
mfld_match_reason: Optional[str] = None
|
||||
mfld_match_status: Optional[str] = None
|
||||
|
||||
|
||||
class BayesianStrategy:
|
||||
@@ -201,10 +283,12 @@ class BayesianStrategy:
|
||||
self,
|
||||
news: Optional["NewsClient"] = None,
|
||||
manifold: Optional["ManifoldClient"] = None,
|
||||
db: Optional["Database"] = None,
|
||||
) -> None:
|
||||
self._signal_count = 0
|
||||
self._news = news
|
||||
self._manifold = manifold
|
||||
self._db = db
|
||||
self._news_queries_this_cycle = 0
|
||||
# Per-cycle counters — reset by reset_cycle(), read by get_cycle_stats()
|
||||
self._skip_family: int = 0
|
||||
@@ -277,13 +361,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"]
|
||||
@@ -351,53 +440,64 @@ 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
|
||||
@@ -419,18 +519,129 @@ class BayesianStrategy:
|
||||
# Signal 5: Manifold cross-market probability (politics + tech)
|
||||
# Applies a log-odds adjustment proportional to divergence from prior.
|
||||
# No query budget — 30 min cache means network cost is paid once per cycle.
|
||||
# Now uses ManifoldMatchResult for stricter semantic validation and audit.
|
||||
manifold_log_adj = 0.0
|
||||
manifold_used = False
|
||||
if (is_politics or is_tech) and self._manifold is not None:
|
||||
manifold_prob = await self._manifold.get_probability(market.question)
|
||||
if manifold_prob is not None:
|
||||
manifold_used = True
|
||||
self._manifold_fetched += 1
|
||||
m_clamped = max(0.05, min(0.95, manifold_prob))
|
||||
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_prob:.2f}")
|
||||
manifold_result: Optional[ManifoldMatchResult] = None
|
||||
audit_id: Optional[str] = None
|
||||
|
||||
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],
|
||||
)
|
||||
|
||||
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],
|
||||
)
|
||||
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
|
||||
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
@@ -560,6 +771,21 @@ 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 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,
|
||||
mfld_prob_raw=manifold_result.prob_raw if manifold_result else None,
|
||||
mfld_prob_final=manifold_result.prob_final if manifold_result else None,
|
||||
mfld_inverted=manifold_result.inverted if manifold_result else False,
|
||||
mfld_match_score=manifold_result.match_score if manifold_result else None,
|
||||
mfld_match_reason=manifold_result.match_reason if manifold_result else None,
|
||||
mfld_match_status=manifold_result.status 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
@@ -1,7 +1,7 @@
|
||||
# Core
|
||||
asyncpg==0.29.0
|
||||
httpx==0.27.0
|
||||
fastapi==0.111.0
|
||||
fastapi==0.137.1
|
||||
uvicorn[standard]==0.29.0
|
||||
pydantic==2.7.0
|
||||
|
||||
|
||||
@@ -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,152 @@
|
||||
"""
|
||||
Tests for the Manifold outcome-compatibility guard.
|
||||
|
||||
Regression: a Polymarket *nomination* question must not match a Manifold
|
||||
*conditional* question ("If X is the nominee, will he win?") even at Jaccard=1.0.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from bot.data.manifold import (
|
||||
ManifoldClient,
|
||||
_classify_outcome,
|
||||
_is_conditional,
|
||||
)
|
||||
|
||||
|
||||
# ── _is_conditional ────────────────────────────────────────────────────────────
|
||||
def test_is_conditional_prefixes():
|
||||
assert _is_conditional("If Graham Platner is the nominee, will he win?")
|
||||
assert _is_conditional("Conditional on a recession, will rates fall?")
|
||||
assert _is_conditional("Assuming Trump runs, will he win?")
|
||||
assert _is_conditional("Given that X happens, will Y?")
|
||||
|
||||
|
||||
def test_is_conditional_midsentence_clause():
|
||||
assert _is_conditional("Will Biden, if he is nominated, win the election?")
|
||||
|
||||
|
||||
def test_is_not_conditional():
|
||||
assert not _is_conditional("Will Graham Platner be the Democratic nominee?")
|
||||
assert not _is_conditional("Will the GOP win the Senate?")
|
||||
# "if" without a closing comma clause is not flagged
|
||||
assert not _is_conditional("What happens if everything goes right")
|
||||
|
||||
|
||||
# ── _classify_outcome ───────────────────────────────────────────────────────────
|
||||
def test_classify_nomination():
|
||||
assert _classify_outcome("Will X be the Democratic nominee for Senate?") == "nomination"
|
||||
assert _classify_outcome("Will X be nominated?") == "nomination"
|
||||
# "primary nominee" → nomination (checked before primary)
|
||||
assert _classify_outcome("Will X be the primary nominee?") == "nomination"
|
||||
|
||||
|
||||
def test_classify_primary_win():
|
||||
assert _classify_outcome("Will X win the primary?") == "primary_win"
|
||||
assert _classify_outcome("Will X advance in the first round?") == "primary_win"
|
||||
|
||||
|
||||
def test_classify_general_win():
|
||||
assert _classify_outcome("Will X win the election?") == "general_win"
|
||||
assert _classify_outcome("Will X win the seat?") == "general_win"
|
||||
assert _classify_outcome("Will X win the general election?") == "general_win"
|
||||
|
||||
|
||||
def test_classify_conditional():
|
||||
assert _classify_outcome("If X is the nominee, will he win?") == "conditional"
|
||||
assert _classify_outcome("Assuming a runoff, who wins?") == "conditional"
|
||||
|
||||
|
||||
def test_classify_other():
|
||||
assert _classify_outcome("Will it rain tomorrow?") == "other"
|
||||
|
||||
|
||||
# ── End-to-end get_match with a stubbed Manifold API ────────────────────────────
|
||||
class _StubResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _StubHTTP:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
async def get(self, *args, **kwargs):
|
||||
return _StubResponse(self._payload)
|
||||
|
||||
async def aclose(self):
|
||||
pass
|
||||
|
||||
|
||||
async def _match(poly, mfld_market):
|
||||
client = ManifoldClient()
|
||||
client._client = _StubHTTP([mfld_market])
|
||||
try:
|
||||
return await client.get_match(poly)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
def test_graham_platner_conditional_rejected():
|
||||
"""Poly nomination vs Manifold conditional → rejected (Task 4.1)."""
|
||||
poly = ("Will Graham Platner be the Democratic nominee for Senate "
|
||||
"in Maine in 2026?")
|
||||
mfld_market = {
|
||||
"outcomeType": "BINARY",
|
||||
"probability": 0.55,
|
||||
"question": ("If Graham Platner is the Democratic nominee for Senate "
|
||||
"in Maine, will he win the general election?"),
|
||||
"id": "abc123",
|
||||
"slug": "graham-platner-win",
|
||||
"creatorUsername": "someone",
|
||||
}
|
||||
result = asyncio.run(_match(poly, mfld_market))
|
||||
|
||||
assert result.status == "rejected"
|
||||
assert result.match_reason is not None
|
||||
assert ("conditional" in result.match_reason
|
||||
or "outcome_mismatch" in result.match_reason)
|
||||
# outcome types are classified and available for persistence
|
||||
assert result.poly_outcome_type == "nomination"
|
||||
assert result.mfld_outcome_type == "conditional"
|
||||
|
||||
|
||||
def test_outcome_mismatch_nomination_vs_general_rejected():
|
||||
"""Poly nomination vs Manifold general_win (non-conditional) → rejected."""
|
||||
poly = "Will Jane Doe be the Republican nominee for Governor?"
|
||||
mfld_market = {
|
||||
"outcomeType": "BINARY",
|
||||
"probability": 0.4,
|
||||
"question": "Will Jane Doe win the election for Governor?",
|
||||
"id": "x", "slug": "jane-doe", "creatorUsername": "u",
|
||||
}
|
||||
result = asyncio.run(_match(poly, mfld_market))
|
||||
|
||||
assert result.status == "rejected"
|
||||
assert "outcome_mismatch" in result.match_reason
|
||||
assert result.poly_outcome_type == "nomination"
|
||||
assert result.mfld_outcome_type == "general_win"
|
||||
|
||||
|
||||
def test_matching_nomination_accepted():
|
||||
"""Poly nomination vs Manifold nomination (same outcome) → accepted."""
|
||||
poly = "Will Graham Platner be the Democratic nominee for Senate in Maine?"
|
||||
mfld_market = {
|
||||
"outcomeType": "BINARY",
|
||||
"probability": 0.62,
|
||||
"question": "Will Graham Platner be the Democratic Senate nominee in Maine?",
|
||||
"id": "ok", "slug": "platner-nominee", "creatorUsername": "u",
|
||||
}
|
||||
result = asyncio.run(_match(poly, mfld_market))
|
||||
|
||||
assert result.status == "accepted"
|
||||
assert result.poly_outcome_type == "nomination"
|
||||
assert result.mfld_outcome_type == "nomination"
|
||||
assert result.prob_final == pytest.approx(0.62)
|
||||
@@ -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