The signals and markets tables existed since Phase 2/5 but never had a
writer; the replay engine (phase plan line 2.1) needs a per-(market, cycle)
archive of what the strategy saw and decided. This wires them up:
- signals: one row per evaluated market per cycle, now carrying INPUTS
(news_sentiment, feat_*_lo, volume_24h, days_to_resolution) plus the
existing outputs (probs, edges, gates, skip_reason). skip_reason is
granular: unsupported/no_signals/prior_extreme/family/edge_net/
confidence/reentry_guard. news_budget_skipped distinguishes "GNews not
asked" (5-query budget) from "no news".
- ext_snapshots: one row per cycle with the ExternalSignals snapshot;
signals rows join on cycle_ts.
- markets: metadata upserted each cycle (replay rebuilds Market from it).
- Retention: prune > SIGNALS_RETENTION_DAYS (default 90) once a day.
- SIGNAL_RECORDER_ENABLED (default true) gates all DB writes; every write
is try/except — the recorder can never break trading.
Strategy changes are purely additive (record accumulation at each exit
path of evaluate()); no weights, thresholds, gates or sizing touched,
per the freeze in the current phase plan.
Tests: 10 new deterministic tests (85 total passing). Schema migration
dry-run validated against prod postgres inside a rolled-back transaction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-mortem NVIDIA 631181: one uncorroborated high-weight signal (legacy
Manifold 0.13 at weight 0.6) flipped a 0.845 market to 0.431 and lost.
With Manifold observational-only and macro signals gated behind
is_non_price, GNews (weight 1.5) is the only live signal able to move
politics markets 20-30 pp against the order-book consensus. This adds a
catastrophic fuse, not a fine calibration:
- apply_news_guardrail(): when |news_lo| >= NEWS_MATERIAL_LOGODDS_THRESHOLD
(0.10) and every other signal (fg, mom, btc_dom, mfld) is below it,
clamp the posterior to prior ± MAX_NEWS_ONLY_PROB_SHIFT (0.25). Any
corroborating material signal disables the clamp. Config via env
(NEWS_GUARDRAIL_ENABLED=true by default).
- edge_gross/edge_net computed from the clamped posterior; raw_final_prob
preserved in reasoning (persisted via trades.reasoning — no schema
migration) and in the NEWS_MATERIAL log line.
- guardrail_changed_trade_decision: raw edge crossed the regime gate but
the clamped edge no longer does (fuse prevented a trade). Note: with
the default 0.25 band the clamped edge_net is 0.21, above every regime
minimum, so the flag only fires with a tighter configured band.
- Observability gated on materiality: NEWS_MATERIAL per-market line and a
compact NEWS SUMMARY cycle line, only when with_news > 0 — no flood
from the ~145 news-less markets per cycle.
- 9 deterministic tests (extreme clamp, in-band passthrough, corroboration,
inclusive threshold, disabled, changed_decision).
No changes to NEWS_LOGODDS_WEIGHT, Manifold flags, edge thresholds,
sizing, payout, resolution, or historical trades.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
httpx logs every request URL at INFO level, and the GNews search URL
carries the API key as a `?token=` query param, so GNEWS_API_KEY was
written in plaintext into the pod logs on every news query. Raise the
httpx/httpcore loggers to WARNING so request URLs never reach INFO.
The bot's own GNews log lines only print the sanitised keyword query
(NewsClient._build_query), never the token, so they are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two minor faults found during the GNews capture/prioritisation diagnostic:
1. Hyphens/dashes reached the GNews query verbatim. '-' is GNews's exclusion
operator, so a token like "El-Sayed" returned HTTP 400 and wasted a query.
_PUNCT_RE now strips '-', en dash and em dash to spaces.
2. The per-cycle GNews budget counter incremented in evaluate() before
get_sentiment() checked the API key, so with no key configured the
[CYCLE SUMMARY] reported a phantom "gnews_queries_used: 5/5" with zero real
requests. Added NewsClient.enabled and gated the GNews block on it; with no
key the counter stays 0/5 and no spurious SKIP_GNEWS_PRIORITY is logged.
No behaviour change when a key is present.
Prioritisation itself was confirmed correct and is left untouched: politics
markets are sorted by gnews_priority DESC and prior-extreme markets return
before the budget is consumed, so no query is ever spent on a market that
cannot trade.
Tests: tests/test_news_query.py (4 new); full suite 66 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Path-based build selection diffed against github.event.before:
- bot/, api/, requirements.txt -> bot + api images (both COPY the same
python sources; only the CMD differs)
- Dockerfile -> bot only; Dockerfile.api -> api only
- dashboard/ -> dashboard only
- .gitea/workflows/ci.yml, first push or force push -> all (safe fallback)
- anything else (tests/, docs) -> no builds, no manifest update
The k8s-manifests sed only bumps tags of rebuilt images, so unchanged
deployments keep their current tag and don't restart. Registry login,
buildx, verification and manifest update are all skipped when nothing
needs building. Telegram message now lists what was built.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sharpe_ratio was hardcoded to 0.0 in MetricsTracker and exposed as
'or 0' in /api/summary. With only 1 resolved trade (~40 flat days plus
one +299 jump) any computed Sharpe is statistically meaningless, so:
- bot/metrics/sharpe.py: annualized Sharpe (sqrt(365)) from daily
total_pnl closes, normalized by bankroll; sharpe_with_gate() returns
None + status until >=30 days observed AND >=10 resolved trades.
- Database.get_daily_pnl_closes(): last metrics_daily snapshot per UTC
day, oldest first — the return series input.
- MetricsTracker: stores the real (gated) Sharpe in the snapshot, NULL
below the gate; log line now includes sharpe.
- /api/summary: live Sharpe + sharpe_status/days_observed/min_* fields
explaining why it is null; resolved_count now live from COUNT(*).
- promotion_ready: requires resolved>=10, days>=30, and non-null
win_rate/calibration/sharpe plus existing thresholds — a single lucky
resolved trade can no longer promote.
- Dashboard Sharpe card shows the insufficient-sample explanation when
null instead of a bare em dash.
Tests: 13 new in tests/test_sharpe_gate.py (formula, gate, API contract,
tracker snapshot); verified failing pre-fix. Suite: 62 passed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Shows /api/summary cash_available (now consistent with the executor's cash
model) next to Capital Deployed, with its share of bankroll as subtitle and
progress bar.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bug #5: metrics.record_trade() only delegated to save_trade(), which
executor.execute() already calls — every trade was written twice (deduped
only by ON CONFLICT DO NOTHING). Remove the redundant call and the now-dead
method. RealExecutor.execute() raises NotImplementedError, so real mode is
unaffected.
Bug #6 (CYCLE SUMMARY): manifold accepted/rejected counters only increment
on the active-signal path, so with MANIFOLD_SIGNAL_ENABLED=false they always
printed 0/0 — print 'manifold_signal: disabled' instead.
family_conflicts_prevented duplicated blocked_by_family (same counter
printed twice); removed. gnews_cap was a dead variable with a misleading
comment; removed.
Bug #7 (/api/summary): total_trades was len() over a LIMIT-500 query —
capped once history grows; counts now come from COUNT(*) via
compute_metrics_from_db. cash_available was reimplemented in the API;
extract cash_available() in paper.py (same formula, unchanged) and feed it
from get_open_position_data() — the exact source/helper
PaperExecutor.initialize() uses. Test asserts API and executor report
identical cash for the same DB state.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
asyncio.create_task() results were discarded, and the event loop only holds
a weak reference to running tasks — a pending notification could be
garbage-collected before executing, silently dropping Telegram messages
(documented asyncio pitfall).
Route the three notification call sites (trade_opened, trade_legacy_closed,
trade_closed) through _notify_in_background(), which stores the task in a
module-level set and discards it on completion. Notifications stay
fire-and-forget; no business logic changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 excluded momentum and Fear & Greed from politics/tech/events
markets; Phase 4 fixed ticker detection. But the BTC-dominance signal was
still applied to non-price markets that legitimately mention a ticker
('Will the ETH ETF be approved?'), despite having no demonstrated causality
for non-price outcomes. Reuse the existing is_non_price gate so the
contribution stays 0.0 -> feat_btc_dom_lo = 0.0 for those markets.
Price-market behavior unchanged: ETH/altcoin/general-crypto markets keep
the +/-0.03 dominance adjustment.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Substring matching over question_lower flagged non-crypto markets as crypto:
'dissolved' matched 'sol', 'Canada' matched 'ada', 'Seth' matched 'eth'.
Those false flags armed the BTC-dominance signal (btc_dom_lo=+0.06 observed
on politics markets in production).
Short tickers (btc, eth, sol, xrp, doge, ltc, bnb, ada, avax) now go through
has_token(), which requires non-alphanumeric boundaries so 'ETH', '$ETH' and
'ETH/USD' still match. Long unambiguous names (bitcoin, ethereum, solana,
cardano, ...) remain substring checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
For politics/tech/events markets there is no above/below price notion, so
is_price_above defaulted to False (or flipped on accidental wording like
"reach") and sign-inverted the macro adjustments: BTC +5% or high Fear&Greed
subtracted probability from YES on "Will X win the election?" markets.
Skip both signals entirely for non-price markets: contributions stay 0.0,
feat_mom_lo / feat_fg_lo persist as 0.0. Price markets (BTC/ETH/crypto)
keep the exact current behavior, including the below-market sign flip.
Removes the now-dead BTC(sentiment) momentum branch and its 0.5 attenuator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolved_count shared the final_prob IS NOT NULL filter with
calibration_score, so the resolved legacy Paxton trade (no signal data)
didn't count: realized_pnl=+309.06 and wins_realized=1 but resolved_count=0.
resolved_count now only requires resolution + not excluded; calibration
keeps the final_prob requirement since it scores against the estimate.
Validated against prod DB: new filter returns 1, old returned 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cycle-10 resolution check found market 562186 resolved (Paxton YES) but the
close failed with asyncpg AmbiguousParameterError: Postgres cannot infer the
type of a bare '$3 IS NOT NULL' in the close_pnl CASE. Reproduced via PREPARE
in the postgres pod; fixed by casting every $3 use to double precision.
The failed DB write also left memory/DB diverged: close_position() popped the
position and credited cash before persisting, so the retry at cycle 20 skipped
the market (pnl=n/a) while the DB row stayed open. Now the DB write happens
first and memory mutates only on success; check_resolutions() also isolates
per-market close failures so one error doesn't abort the cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- PolymarketClient.get_market_resolution(): query Gamma API by market id;
resolved only when closed AND uma status final AND outcome prices binary
(never settle on disputed/ambiguous outcomes)
- bot/main.py: check_resolutions() runs every 10 cycles (~10 min) in paper
mode, settles open positions via PaperExecutor.close_position()
- close_reason now persisted as 'resolved' (resolution has its own column)
- tests/test_resolution_detector.py: 10 tests covering API parsing shapes
and the BUY_NO settlement flow; 27/27 suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Legacy scan called ManifoldClient.get_probability(), removed in the v3
matcher migration, causing AttributeError when positions had changed
family keys. The block used Manifold to escalate positions to
CLOSE_RECOMMENDED (inversion detection) — a trading decision forbidden
under MANIFOLD_SIGNAL_ENABLED=false — so the dependency is removed
entirely; the scan keeps family re-keying and sibling-conflict logic.
- PaperExecutor.close_position() computed cash += position_cost * resolution,
ignoring direction: a winning BUY_NO (resolution=0.0) paid out $0 and
reported a loss. Now settles per trade:
BUY_YES: payout = shares * resolution
BUY_NO: payout = shares * (1 - resolution)
with pnl = payout - net_cost; Telegram win/loss keys off pnl > 0.
Adds read-only Database.get_open_trades_for_market().
- tests/test_paper_close.py covers the 4 deterministic payout cases;
tests/conftest.py shims datetime.UTC for local Python 3.10 (prod is 3.11).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-category coverage audit showed coverage_rate=0.0 across every category in the
bot's current universe, so any edge Manifold produced was false edge. Retire it as
an ACTIVE trading signal while keeping the full audit/coverage/cooldown trail for a
future reactivation decision.
Two module-level flags in bayesian.py (read from env):
- MANIFOLD_SIGNAL_ENABLED (default False): when False, Manifold never touches the
edge model — manifold_log_adj stays 0.0 (no posterior shift), no confidence bump,
feat_mfld_lo=0.0 (so it can never be the dominant feature), no trade contribution,
and mfld_audit_id is not propagated so the audit's used_in_trade stays FALSE.
- MANIFOLD_AUDIT_ENABLED (default True): matcher still runs; audit/coverage rows and
cooldowns are still written. The matcher is only called when a flag is on.
When signal is disabled, logs and reasoning carry "Manifold: observational_only".
Endpoints /api/metrics/manifold-matches and /api/metrics/manifold-coverage,
cooldowns, audit tables and existing trades are unchanged. No code or tables removed.
Other signals, thresholds, exposure, risk manager and the executor are untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Measure real Manifold coverage per semantic market category counted by UNIQUE
market (not audit rows, which are inflated by repeated re-evaluation). Base table
is manifold_match_audit filtered to v3_outcome_guard; each poly_market_id is
collapsed to one row, LEFT JOIN trades for family_key, category inferred from
family_key when present else from poly_question (gubernatorial / mayoral / senate
/ primary-republican / primary-democrat / big-tech / geopolitics / other).
Per category: unique_evaluated / unique_accepted / unique_rejected /
unique_no_results / coverage_rate, ordered by unique_evaluated DESC. Summary adds
total_unique_evaluated, total_unique_accepted, overall_coverage_rate,
categories_with_coverage. Read-only: new db method + endpoint only; matcher,
scheduler, cooldowns, thresholds and exposure untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trading loop re-evaluated the same ~22 politics/tech markets every ~60s,
flooding manifold_match_audit with ~76k rows (~3,500 attempts/market) of which
none carried new information, making the metrics uninterpretable.
Add per-market persistent cooldowns:
- New table manifold_eval_cooldown (poly_market_id PK, last_evaluated_at,
last_status, retry_after, cooldown_reason) created via run_migrations.
- bayesian.evaluate() consults the cooldown BEFORE calling the matcher and skips
the call entirely while now() < retry_after — no matcher call, no audit row,
no signal (equivalent to no_results). After a real evaluation it upserts the
cooldown with a verdict-dependent backoff: no_results/low_score/outcome_mismatch/
ambiguous_inversion -> 24h, conditional_market -> 7d, accepted -> 1h.
- manifold.py stays a pure client/matcher with no DB access.
- New db methods get_manifold_cooldown / upsert_manifold_cooldown.
- /api/metrics/manifold-matches summary gains unique_markets
{evaluated, accepted, coverage_rate} for per-market (not per-attempt) coverage.
Matching logic, MANIFOLD_MATCHER_VERSION, MANIFOLD_LOGODDS_WEIGHT, edge/exposure
thresholds and existing audit rows are unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The trades_dominated_by_mfld counter omitted the excluded_from_metrics
filter, so the admin-closed Maine governor trade inflated it to 1 while
attribution/features (which exclude such trades) were empty.
Add excluded_from_metrics IS NOT TRUE and mfld_match_status = 'accepted'
to the query so the counter is consistent with the attribution and
feature-metrics endpoints.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add MANIFOLD_MATCHER_VERSION="v3_outcome_guard" tag persisted to
manifold_match_audit.matcher_version so metrics can isolate current-matcher
stats from pre-versioning records, whose accepted matches the outcome
guard would now reject.
- schema: add matcher_version column + index; idempotent startup backfill
tagging NULL rows as legacy_pre_outcome_guard (no outcome types) or
v2_outcome_guard_no_version (has outcome type, version not persisted)
- save_manifold_audit: write matcher_version on every new record
- get_manifold_matches: split summary into current_version / all_time /
legacy; recent_matches now carry matcher_version
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reject false-positive matches where Jaccard overlap is high but the outcome is
not equivalent (e.g. Poly nomination vs Manifold "If X is nominee, will he win").
- _is_conditional(): detect conditional Manifold markets (If/Conditional on/
Assuming/Given that prefixes + mid-sentence " if ...," clauses) -> reject with
reason "conditional_market".
- _classify_outcome(): classify into nomination|primary_win|general_win|
conditional|other; reject when poly/mfld types differ or either is conditional
-> reason "outcome_mismatch: poly=... manifold=...".
- Persist poly_outcome_type/mfld_outcome_type on ManifoldMatchResult, in
manifold_match_audit (CREATE + idempotent ALTER), save_manifold_audit() and
the bayesian call site.
- Tests covering classification, conditional detection and the Graham Platner
regression (now rejected); valid nomination<->nomination still accepted.
Untouched: _MATCH_THRESHOLD (0.40), MANIFOLD_LOGODDS_WEIGHT, edge thresholds,
exposure, trading logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New module bot/notify/telegram.py — httpx async, fire-and-forget via
asyncio.create_task, swallows all errors so notifications never affect
trade execution.
Three alert types:
📈/📉 TRADE ABIERTO — direction, size, edge_net (in execute())
✅/❌ GANADO/PERDIDO — approx PnL (in close_position())
🔒 LEGACY CLOSE — recovered capital + reason (in close_legacy_position())
close_position() and close_legacy_position() gain an optional question=""
param so the message shows the market name instead of market_id.
bot/main.py updated to pass question= to close_legacy_position().
Credentials (TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID) read from env vars
injected via bot-secrets k8s secret.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Verify all 3 images exist in Gitea registry via Docker API before updating manifests
- Validate YAML of modified manifests after sed (python3 yaml.safe_load)
- Notify Telegram on success/failure with job status (if: always())
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Legacy builder (DOCKER_BUILDKIT=0) cannot handle OCI image indexes from
registry-cache, causing fallback to Docker Hub which is unreachable.
BuildKit sends proper OCI Accept headers and reads buildkitd.toml to use
HTTP for both the registry-cache mirror and internal Gitea registry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker in DinD cannot reach git.chemavx.xyz (Cloudflare) from within
the cluster — TCP :443 times out. Switch docker login/build/push to
gitea.gitea.svc.cluster.local:3000 (insecure, same backend storage).
k8s manifest updates still reference git.chemavx.xyz for node pulls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker 24's embedded BuildKit ignores the http:// prefix in registry-mirrors
and always attempts HTTPS, breaking the local pull-through cache.
DOCKER_BUILDKIT=0 uses the legacy builder which respects the daemon mirror
config correctly. Cache still works via --cache-from + buildcache tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The docker-container buildkitd driver creates an isolated process that
cannot use DinD's registry-mirror config, and the cluster's registry-cache
returns 500 on BuildKit's ?ns=docker.io mirror protocol.
Plain docker build routes through the DinD daemon directly, which already
has registry-mirrors configured for docker.io pull-through cache.
Uses BUILDKIT_INLINE_CACHE=1 for layer caching between builds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BuildKit docker-container driver runs isolated from DinD daemon config,
so it needs its own mirror declaration to route docker.io pulls through
the cluster-local pull-through cache instead of Cloudflare CDN directly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds alpha attribution by dominant signal feature — which feat_*_lo had
the largest absolute log-odds value on each trade.
Changes:
- _dominant_feature() helper in api/main.py: picks the winning feature
from signal_components (threshold 0.0001, same as "triggered" in
/api/metrics/features)
- _enrich_trade() refactored to single exit point; adds dominant_feature
field to every open trade in /api/trades
- compute_attribution_from_db() in db.py: VALUES subquery finds dominant
feature per trade in SQL, then aggregates trade_count/avg_edge_net/
unrealized_pnl_est/realized_pnl/resolved_count/win_rate per group
- /api/metrics/attribution endpoint: returns attribution dict + total_attributed_trades
No schema changes, no strategy changes. Pure observability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eliminates the phantom 0.4% exposure overage after pod restarts.
During live trading execute() stores size_usdc in portfolio.positions and
deducts net_cost from cash — so total_value = bankroll − fees and
exposure_pct = sum(size_usdc) / (bankroll − fees).
Old initialize() stored net_cost in positions, making total_value = bankroll
and inflating exposure_pct (observed: 30.085% vs runtime 29.670%).
Fix: new get_open_position_data() returns both {market_id: size_usdc} and
total_net_cost in one query; initialize() uses size_usdc for positions and
total_net_cost for cash — identical model to execute().
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>