Commit Graph

54 Commits

Author SHA1 Message Date
dennisthiessen 6511a1020b feat: exclude NEUTRAL setups from the activation gate (default on)
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 56s
Deploy / deploy (push) Successful in 34s
A NEUTRAL ("No Clear Setup") recommendation means the engine found no clear
directional trade, yet such setups could still qualify and even be crowned the
top pick purely on momentum rank (e.g. an extended momentum leader with a far,
5%-probability target). A NEUTRAL signal isn't actionable, so it shouldn't
qualify.

New `exclude_neutral` activation flag (default on): setup_qualifies drops setups
whose recommended_action is NEUTRAL. It lives in the shared gate, so it flows
through the dashboard's qualified/top-pick selection, the track record's
qualified stats, and the backtest (which computes recommended_action and gates on
meets_core). Toggleable in Admin → Settings → Activation; the frontend mirror and
activationSummary ("directional") match.

Re-run the backtest after enabling to confirm it holds/improves expectancy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 15:19:07 +02:00
dennisthiessen 20a1c143f3 fix: surface empty OHLCV fetch as a warning, not success
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m25s
Deploy / deploy (push) Successful in 46s
Fetching a symbol the provider doesn't cover (e.g. RHM/Rheinmetall — Alpaca
serves US listings only) returned 0 bars but reported "complete · Successfully
ingested 0 records", which the UI showed as green success.

fetch_and_ingest now returns a distinct `no_data` status when the provider
returns nothing AND the ticker has no history (vs. "already up to date" when bars
exist). The fetch endpoint maps it to a `warning` source status, and the fetch
toast renders it as ⚠ with the provider message instead of success.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:27:41 +02:00
dennisthiessen 7e9a6cd7ec fix: only count matured setups in the live track record
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 1m1s
Deploy / deploy (push) Successful in 36s
The outcome stats were dominated by quick stop-outs: near stops resolve as losses
within days while far targets take weeks, so a young sample (mostly pending,
0 expired) skewed sharply negative (e.g. 13.8% hit / -0.46R vs the backtest's
35.8% / +0.18R) — a maturation artifact, not a real result.

get_performance_stats now counts only setups whose full ~30-day window has
elapsed (_MATURITY_DAYS), so winners had as long as losers (unbiased, and
comparable to the backtest). A new `maturing` count reports the younger setups
held back. The Track Record UI relabels "Evaluated" -> "Matured", shows the
maturing count, and explains the window in the empty state + methodology note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:41:48 +02:00
dennisthiessen 8bcbbfcfd0 fix: show benchmark job in admin; harden + split deploy workflow
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 48s
Deploy / deploy (push) Successful in 28s
- admin_service: register benchmark_collector in VALID_JOB_NAMES, JOB_LABELS and
  PIPELINE_MEMBERS. The Admin → Jobs list is built from these hardcoded sets, not
  the scheduler, so the job was registered but invisible/untriggerable.

- deploy.yml:
  - SSH: verify the host key (StrictHostKeyChecking=yes) now that known_hosts is
    supplied; move private-key cleanup to an `if: always()` step.
  - Add a concurrency guard so deploys serialize.
  - Health-check the service after restart (127.0.0.1:8998/api/v1/health).
  - Align CI Python to 3.12 (matches prod); pip + npm caching.
  - Clarify the Postgres service only validates migrations (tests use SQLite);
    drop the redundant DATABASE_URL from the pytest step.
  - Split the monolithic "Deploy to server" step into named steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:01:09 +02:00
dennisthiessen 30effa89b7 feat: ticker search, watchlist momentum column, alpha vs S&P 500
Deploy / lint (push) Successful in 6s
Deploy / test (push) Failing after 12s
Deploy / deploy (push) Has been skipped
Three usability fixes:

1. Global ticker search in the sidebar (TickerSearch) — typeahead over the
   tracked universe that opens a ticker's detail page without adding it to the
   watchlist. Also wired into the mobile nav.

2. Watchlist table shows the ticker's 12-1 momentum percentile (the top-pick
   selector) instead of the noisy full S/R-level list. Enriched from the setup
   already loaded in watchlist_service._enrich_entry — no extra query.

3. Alpha vs the S&P 500 on paper trades (open + closed). New benchmark_prices
   table + benchmark_service store SPY daily closes (a standalone series, not a
   Ticker, so it never enters the scanner / momentum ranking / rankings) via a
   new daily-pipeline step. paper_trade_service computes per-trade
   benchmark_return / alpha_pct / alpha_usd over each holding period; the open-
   trades table, dashboard, and closed-trades panel surface per-trade and total
   alpha. The list read path never makes a provider call.

Deploy: alembic upgrade head, then run the benchmark/daily job once to populate
SPY closes (alpha shows "—" until then).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 08:44:40 +02:00
dennisthiessen 65dd53baa3 feat: Telegram alert on regime quadrant change (hysteresis + cooldown)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 27s
Fires once when the regime monitor shifts quadrant (regime index x early
warning), so you don't have to watch the tab. Two guards against spam:

- Hysteresis: each axis only flips once the value crosses its divider by a
  margin, so a point parked on a boundary keeps its quadrant instead of
  flip-flopping day to day.
- Cooldown: a genuine change stays quiet for a few days after the last alert.

Seeds the baseline silently on first run; reuses the existing Telegram dispatch
+ AlertLog. New per-trigger toggle in Admin → Alerts (on by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:05:01 +02:00
dennisthiessen 66444af65c feat: score-history chart on the regime tab
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 25s
Plots the index, early-warning, and combined scores over time beneath the live
gauges, with a 1M/3M/6M/All range toggle and band reference lines — so the trend
and any divergence between the scores is visible, not just today's snapshot.

- Backend: GET /regime/history + get_regime_history (the three scores per
  snapshot date from regime_snapshots).
- Frontend: recharts line chart, lazy-loaded so recharts ships in its own
  regime-tab chunk instead of nearly doubling the main bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:48:42 +02:00
dennisthiessen 02b8df58f0 fix: populate early-warning/combined on the latest snapshot + recent history
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 24s
The early-warning score showed n/a because it required an exact date match
between the live benchmark (Alpaca, may have today's bar) and the stored
universe breadth (DB, often a day behind), which blanked the newest snapshot —
the one the UI displays.

- Look up the divergence as-of the snapshot date (newest value within a 7-day
  lag) instead of requiring an exact match.
- Backfill early_warning + combined onto recent existing snapshots (the index
  history predates this signal) so the 7/30-day trends populate on the first run
  rather than only filling in over the coming weeks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:31:02 +02:00
dennisthiessen 613fc756ec feat: separate live early-warning + combined score on the regime tab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 40s
Deploy / deploy (push) Successful in 24s
The event study showed the breadth-divergence signal genuinely leads (warned
before 7/11 drawdowns, ~6 weeks median, where the coincident baseline almost
never did). Surface it live to observe before deciding how to embed it — kept
separate from the index, not folded into its weights.

- regime_monitor daily job now computes breadth-divergence live and attaches a
  separate early_warning score plus a combined blend (weighted mean, default
  0.6/0.4, configurable via combined_weights) to each snapshot, including the
  backfill so the 7/30-day trends populate immediately. Stored in breakdown_json
  — no schema change. Best-effort: a breadth failure can't break the index.
- get_regime_monitor returns the index, early_warning, and combined scores each
  with 7/30-day deltas.
- Regime tab shows three gauges (generalized ScoreGauge): coincident index,
  early warning, and a compact combined blend. Stale snapshots render "—".

Note: the daily regime job now also does a universe-wide breadth scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 15:23:37 +02:00
dennisthiessen 7c5fb1138d feat: sharpen the event study — more events, fair baseline, per-event view
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s
The first run gave only 2 events (N=2 is anecdote, not evidence) and an unfairly
weak coincident baseline, so the +42d lead couldn't be trusted. This makes the
measurement meaningful:

- More, cleaner events: default drawdown threshold 15%→10%, and dedup switched
  from "recover to the high" to a rising-edge + cooldown (40d), so distinct
  drawdowns each register instead of merging.
- Fair comparison: each indicator now warns at its OWN 80th percentile instead of
  a shared absolute 60, removing the artifact that muted the coincident baseline.
- Per-event breakdown (date · depth · breadth lead · coincident lead) so a median
  over a tiny sample can't hide an apples-to-oranges comparison — you see whether
  both warned on the same drawdown.
- Surface precision/recall (best row) + base rate per indicator — the honest edge
  read, not just lead time.

Re-run the Event Study job to regenerate the cached report in the new shape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:54:29 +02:00
dennisthiessen 824c15cf69 feat: breadth-divergence early-warning indicator + event study
Adds a leading-by-construction candidate and the harness to measure whether it
actually leads regime breaks, before any of it earns weight in the live index.

- breadth_service: % of the stored universe above its own 200-DMA + a divergence
  score (benchmark price up while breadth falls, nudged by low breadth). Genuinely
  leading because it keys on divergence, not level. Not wired into the live score.
- event_study_service: detect drawdown events on the benchmark, then measure each
  indicator's median lead time (event-centered) and precision/recall vs. the base
  rate (signal-centered). Compares breadth-divergence against the deterministic
  coincident price composite (reuses the regime price sub-scores). Price/breadth
  only — reproducible, no LLM/FRED.
- Manual "Event Study" job (Admin → Jobs), GET /regime/event-study, and an
  inline early-warning panel on the Regime tab with an honest small-sample caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 14:08:52 +02:00
dennisthiessen ebff19940b feat: add standalone AI/Tech regime-change monitor tab
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 27s
A new /regime tab scoring how far the AI/Tech bull regime has deteriorated
toward a re-rating as a single 0-100 index with per-signal breakdown and a
7/30-day trend. Intentionally decoupled: nothing reads its output to gate or
score trades — the daily-pipeline membership is scheduling only.

- regime_monitor_service: price sub-scores (P1-P6 via Alpaca, like
  market_regime), VIX + HY credit spreads via a small FRED helper, weighted
  aggregation over available signals (missing source -> n/a, dropped from the
  denominator), one snapshot row/day, and a ~90-day history backfill by
  replaying the already-fetched series as-of each past day.
- F1/F3 fundamentals proposed by the configured grounded LLM (reuses
  sentiment_provider_service config resolution), with a manual override + lock.
- regime_snapshots table (migration 011); endpoints on the existing market
  router; admin-editable weights/threshold; standalone /regime page.

Data needs: prices via Alpaca, VIX/credit via FRED (optional key — signals show
n/a without it). No LLM needed for history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:51:45 +02:00
dennisthiessen 437ceacfc1 refactor: dedupe scheduler logging/runtime, centralize SystemSetting access, fix rankings N+1
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 27s
Behavior-preserving cleanup (345 tests pass, ruff clean):

- scheduler: replace 62 inline logger.x(json.dumps({...})) calls with a
  _log_event helper, and collapse 11 identical _job_runtime dicts into an
  _idle_runtime() factory over _JOB_NAMES.
- settings: add app/services/settings_store.py (get_setting/get_value/get_map/
  upsert_setting) and route ~13 hand-rolled SystemSetting queries + two
  identical _settings_map helpers through it.
- scoring.get_rankings: collapse the per-ticker N+1 (3-4 queries + a commit each)
  into 2 bulk reads + a single conditional commit; drop the redundant re-fetch.
  Lazy recompute-on-read is preserved. Adds first tests for get_rankings.

Net ~ -245 lines across the touched modules.
2026-06-24 11:23:39 +02:00
dennisthiessen f48d8705de remove min_target_probability gate + add chart time-range presets
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 24s
min_target_probability is gone: it filtered on the probability model the
calibration has repeatedly shown to be weak and overconfident, it was redundant
with the momentum gate, and as an off-by-default knob it just invited bad tuning.
Removed from the backend gate, activation config/schema, the frontend mirror
(qualifiesSetup / activationSummary), and ActivationSettings. The probability
model stays where it does real work (primary-target selection + display).

Charts: with multi-year history the all-bars default was unreadable. Added
time-range presets (1M / 3M / 6M / YTD / 1Y / 3Y / 5Y / All), defaulting to 1Y;
clicking a preset always re-applies (snaps back after a manual zoom). Y-axis
autoscale and wheel-zoom / drag-pan were already there.

339 backend tests pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:24:35 +02:00
dennisthiessen 605f95098c momentum gate: long-only + wire the percentile onto live setups
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 47s
Deploy / deploy (push) Successful in 24s
Part 1 — long-only. The momentum edge is long top-momentum; the gate was
qualifying shorts on high-momentum names (fighting the trend), which showed as
the -0.13R Short(qual.) drag. While the gate is active, shorts no longer qualify
(backend qualification, backtest _momentum_qualifies, and the frontend mirror).

Part 2 — production wiring. Live setups now carry a real momentum rank, so the
dashboard, the Track Record's qualified stats, and outcome evaluation all gate on
the same value instead of deferring to floors:
- new momentum_service.compute_momentum_percentiles: 12-1 momentum per ticker,
  ranked across the universe into a {symbol: percentile} map.
- the daily R:R scan ranks the universe up front and stores each setup's
  percentile (new trade_setups.momentum_percentile column, migration 010).
- enhance_trade_setup mutates the same row, so the percentile is preserved;
  _trade_setup_to_dict + TradeSetupResponse expose it to the API.

Until a fresh scan runs, pre-existing setups have a null percentile and the gate
falls back to floors for them (longs) / excludes them (shorts) — they fill in on
the next scan. 341 backend tests pass; frontend build clean.

Needs the alembic upgrade (migration 010) on deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 07:07:38 +02:00
dennisthiessen 7060b9a019 parallelize the backtest across worker processes (true multi-core)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 38s
Deploy / deploy (push) Successful in 25s
The replay was CPU-bound and single-core: the earlier asyncio.to_thread offload
kept the API responsive but, because of the GIL, ran on one core. Per-ticker
replay is independent, so fan it out across worker processes (which sidestep the
GIL) for real multi-core speedup.

- New `settings.backtest_workers` (default 4), capped to cpu_count-1 so a core
  stays free for the web server.
- Uses a `forkserver` context (workers forked from a clean single-threaded
  server — avoids the fork-with-threads deadlock); falls back to `fork`. On
  spawn-only platforms (Windows) and for 1-ticker runs it uses the thread path,
  so dev/tests are unaffected.
- Worker takes primitive column arrays (cheap to pickle), rebuilds bars, and
  returns (candidates, plain-dict signal series) — both picklable across the
  process boundary. Bars are still fetched in the event loop (ORM-safe).
- Pool creation is guarded: if the pool can't start, the job falls back to the
  sequential thread path instead of failing.

334 backend tests pass (parallel path is POSIX/server-only, so it's covered by
construction + the picklability/worker-count tests; the thread fallback is
exercised by the run_backtest smoke test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 23:20:20 +02:00
dennisthiessen ef523474ad replace EV activation gate with cross-sectional 12-1 momentum ranking
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 26s
The 5-year backtest confirmed the EV gate adds negative value (high threshold =
worst expectancy) and that 12-1 month momentum is the one price signal with a
plausible, right-signed cross-sectional IC (~0.05). So "qualified" now means:
clears the R:R + confidence floors AND the ticker ranks in the top
`min_momentum_percentile` of the universe by 12-1 momentum that week.

- qualification.py: drop expected_value_r / the EV gate; add a momentum-percentile
  gate (duck-typed `momentum_percentile`, only enforced when attached + threshold
  set, else defers to floors). Mirrored in frontend qualification.ts.
- activation config/schema: min_expected_value -> min_momentum_percentile
  (default 80 = top quintile). ActivationSettings, DashboardPage (ranks/【shows】
  momentum instead of EV), and the BacktestPanel sweep follow.
- backtest: rank each ISO week's universe by 12-1 momentum, assign a percentile,
  and qualify the top slice; the sweep now sweeps the percentile cutoff.

Also offload the backtest's per-ticker compute to a worker thread so the heavy
~5y run no longer blocks the API event loop (the "backend offline" flicker).

Production setups don't carry momentum_percentile yet — wiring the scanner to
attach it (a universe momentum-rank step) is the next step; until then the live
gate defers to floors while the backtest measures the momentum selection. 330
backend tests pass; frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 22:42:24 +02:00
dennisthiessen 099846513b deepen OHLCV history + make the factor-IC pass honest about overlap/regime
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 25s
Two changes so the cross-sectional signal results can actually be trusted.

(a) History depth — the binding constraint. Ingestion defaulted to 365 days, so
long-lookback factors (12-month momentum, 52-week high) were only computable on a
handful of weeks at the tail, and every IC reflected a single market regime.
- New `settings.ohlcv_history_days` (default 1825 ≈ 5y); new tickers backfill this
  far instead of 1 year.
- New manual "data_backfill" job (Admin → Jobs) re-fetches the full window for
  every ticker, ignoring incremental resume — run once to deepen existing
  1-year histories. Idempotent (upsert); resumes after rate limits.

(b) Factor-IC honesty. The IC was averaged over weekly rebalances whose 30-day
forward windows overlap, inflating the t-stat ~sqrt(6)x.
- IC now measured on NON-OVERLAPPING windows (weeks thinned to ~HORIZON apart).
- Each signal carries a `reliable` flag (>= 12 independent windows); BacktestPanel
  greys out and de-stars thin signals so a lucky 9-week IC of 0.3 can't masquerade
  as an edge.

332 backend tests pass; frontend build clean. No migration (config + job + an
added JSON field on the cached backtest report).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 18:20:59 +02:00
dennisthiessen 402025692a add cross-sectional signal evaluation (factor rank-IC) to the backtest
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 40s
Deploy / deploy (push) Successful in 26s
The per-setup hit-rate report can't tell whether a signal predicts returns —
only how a target/stop structure built on one performs. This adds a
cross-sectional factor-IC pass: each week the universe is ranked by a price-only
signal and graded by its rank correlation (Spearman IC) and top-minus-bottom-
quintile spread against the forward 30-day return.

Candidate signals (point-in-time from price; sentiment/fundamentals have no
history in the replay): 12-1/6-1/3-1 month momentum, 1-month reversal,
price-vs-200d SMA, proximity to the 52-week high (George/Hwang), and 126-day
realized volatility (low-vol anomaly).

Reuses the existing per-ticker replay loop (no new data, no second DB pass);
results land in the cached backtest_report as `signal_eval` and render as a
"Signal edge" table in BacktestPanel beside the calibration curve.

330 backend tests pass (10 new in test_signal_eval); frontend build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 17:58:40 +02:00
dennisthiessen c34f3cb1a4 redesign activation gate to expected value + make pipelines cron-configurable
Deploy / lint (push) Successful in 9s
Deploy / test (push) Successful in 46s
Deploy / deploy (push) Successful in 28s
Diagnosing "no qualified signals for 5 days": setups were generated but none
qualified. The gate required BOTH a high min_rr (2.0) AND a high
min_target_probability (60), which became contradictory after the Jun-15
probability recalibration — probability already embeds R:R via the 1/(rr+1) ruin
term, so high-R:R targets are inherently low-probability and nothing cleared both.

Gate is now expected value (R): p*rr - (1-p) from the primary target's
probability. R:R and confidence stay as floors; high-conviction / exclude-conflicts
/ min-target-probability become optional tighteners (default off). Defaults:
min_expected_value=0.15, min_rr=1.2, min_confidence=55. EV is only enforced when
computable. Migration 009 clears stored activation_* rows so the new defaults
apply. Backtest sweeps min_expected_value instead of target probability.

Scheduling: pipelines are now cron-configurable in Admin -> Jobs. daily_pipeline
(full, default 0 7 * * *) plus a new light intraday_pipeline (OHLCV + outcome eval,
default hourly US session) that keeps prices/live-R:R current without setup churn.
Fundamentals on its own early weekly cron. Timezone configurable (default
Europe/Berlin). Moving interval->CronTrigger also fixes the restart-deferral bug
where an interval job's countdown resets on every process restart.

319 backend unit tests pass; frontend tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:46:38 +02:00
dennisthiessen 9008865d75 run fundamentals weekly, not daily — it's quarterly-ish data
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 24s
Pulled the fundamental collector out of the daily pipeline (where it re-fetched
near-identical numbers every day and burned free-tier API quota) and made it an
independent weekly job. P/E/market-cap drift with price but the score buckets
them coarsely; revenue growth and earnings surprise only change at quarterly
earnings. Added "weekly" to the frequency map; fundamental_fetch_frequency now
defaults to weekly (configurable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:23:16 +02:00
dennisthiessen e982487abd coordinate jobs: daily pipeline orchestrator runs the flow in order
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 39s
Deploy / deploy (push) Successful in 25s
Jobs were independent 24h timers with no ordering, so the scanner could run on
stale OHLCV, and manual runs desynced the offsets. New daily_pipeline job runs
the data→signal flow in dependency order: OHLCV → fundamentals → sentiment →
R:R scan → outcome eval (+paper close) → market regime. Each step keeps its own
enable flag and runtime status; a failing step is logged and the pipeline
continues.

The member jobs are registered PAUSED (no auto-fire) so they only run via the
pipeline — but stay manually triggerable from Admin → Jobs (shown as "runs in
daily pipeline"). Alerts (hourly), ticker universe sync, and backtest keep their
own independent cadence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 10:16:41 +02:00
dennisthiessen fb3b8d18d7 complete paper trading: auto-close on stop/target + My Trades realized record
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 38s
Deploy / deploy (push) Successful in 25s
resolve_open_trades walks the daily bars after each open trade and closes it at
the target (target hit) or stop (stop/ambiguous), leaving undecided trades open.
Runs nightly inside the outcome evaluator (so it's coordinated with fresh OHLCV)
and on its manual trigger. New "My Trades" section at the top of Signals → Track
Record shows realized hit-rate, expectancy (avg R), total R, total P&L, and a
closed-trades table — your actual results, separate from the theoretical signal
record below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:49:28 +02:00
dennisthiessen e5166ed668 sentiment: LLM buy/hold/avoid + full analysis, and search-budget scoping
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 21s
Richer LLM output (same grounded call, ~no extra cost):
- All providers now also return a recommendation (buy/hold/avoid) and a thorough
  reasoning paragraph; Gemini now actually captures reasoning + grounding
  citations (it was dropping them). Stored on sentiment_scores (migration 008),
  exposed in the API; display-only — NOT fed into the composite/EV.
- Ticker Sentiment panel shows an "LLM view" badge and a "Full analysis & sources"
  expander with the complete reasoning + citations.

Search-budget scoping (Gemini grounding free tier = 5000/mo):
- collect_sentiment now targets only watchlist + open paper trades + top-N by
  composite, skips tickers refreshed within sentiment_fresh_hours (72h), and caps
  per run (sentiment_max_per_run). Once the relevant set is fresh, runs spend 0
  searches until it ages out — bounding monthly usage well under the free tier.
- Widened sentiment lookback to 7d (scoring + display) so sparser collection
  still feeds the dimension score.

Deploy: alembic upgrade (sentiment_scores.recommendation). Switch provider to
Gemini Flash in Admin for the cost win (grounded, cheapest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:34:19 +02:00
dennisthiessen a69557f5d8 add paper trading: mark a setup as taken, track open P&L, sell
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
New paper_trades table (migration 007) + service/router. "Mark as taken" on each
setup card (shares prefilled from position sizing, entry from current price, both
editable) records a simulated trade. Overview gains an Open Trades table that
marks each position to the latest close — P&L in $, %, and R-multiples — with a
total unrealized P&L footer and a Sell button to close at the current price.
Closed trades are retained for future realized-P&L reporting.

Deploy: alembic upgrade (new paper_trades table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 06:33:56 +02:00
dennisthiessen 050abc6f71 backtest: add min target-probability sweep
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 40s
Deploy / deploy (push) Successful in 26s
Re-applies the activation gate at several min_target_probability thresholds
(60→30, other conditions fixed) over the already-replayed candidates, so the
trade-off between how many setups qualify and their expectancy is visible in one
table — the cheap "optimize" half of Phase 2. Candidates now carry meets_core +
best_prob so the sweep needs no re-replay. New sweep table in BacktestPanel with
the current threshold starred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 06:13:30 +02:00
dennisthiessen 9d2e1e74bf fix probability over-confidence: model target-before-stop, not just touch
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
Backtest (32k setups) showed the touch-only probability model was ~2x
over-confident — predicted 70% hit 39%, predicted 88% hit 46% — because it
ignored the competing stop. estimate_probability now multiplies the reach
probability (touch within horizon) by the two-barrier gambler's-ruin ratio
1/(R:R+1) = P(target before stop). A 3:1 setup now reads ~25% base, not ~70%,
which lines up with realized rates. Strength/alignment modulation unchanged.

Recalibrates every probability and the EV ranking; the min_target_probability
gate threshold now means roughly what it says. Re-run the backtest to confirm
the calibration table flattens toward the diagonal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:52:09 +02:00
dennisthiessen 6df67ad7ae add backtest harness (Phase 1): historical replay + hit-rate & calibration reports
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 25s
Replays the price-derived engine over stored OHLCV: at each weekly as-of date,
rebuild the setup from bars <= D (no lookahead) and walk the actual forward bars
for the realized outcome. Reports realized hit-rate/expectancy of qualified
setups (and all setups, by direction) plus a probability calibration curve
(predicted target prob vs realized hit rate).

Reuses pure functions throughout; extracted compute_technical_from_arrays /
compute_momentum_from_closes from scoring_service so live and backtest stay in
sync. Runs as a weekly/triggerable 'backtest' job caching the report in a
SystemSetting; GET /backtest/report serves it. Sentiment/fundamentals held
neutral (no point-in-time history) — calibrates the price/S-R/probability machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 20:14:07 +02:00
dennisthiessen f0b92a9718 add earnings-date guard — warn when a report falls in the target horizon
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 25s
Finnhub's earnings calendar now supplies next_earnings_date through the
fundamentals chain; persisted on fundamental_data (migration 006) and exposed in
the fundamentals API. The recommendation panel warns when earnings fall within
the ~30-day target horizon (a report can gap price through stop/target) and
otherwise shows the next date. Informational only.

Deploy: run alembic upgrade (new fundamental_data.next_earnings_date column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:44:08 +02:00
dennisthiessen c4f2673799 add market-regime guard (SPY trend) — inform + warn
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 25s
New market_regime_service computes a benchmark (SPY) trend from its 50/200-day
SMAs, cached in a SystemSetting and refreshed by a nightly job; GET /market/regime
exposes it. Dashboard shows a regime banner; setup cards flag a counter-trend
caution when a setup fights the regime (LONG in a bearish market / SHORT in a
bullish one). Informational only — nothing is suppressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 12:34:07 +02:00
dennisthiessen ff48e4a3ff scope S/R proximity alerts to watchlist only
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 22s
Qualified tickers already get their own "qualified setup" alert, so an S/R
proximity ping on them is redundant noise. Drop the watchlist ∪ qualified scope
(remove now-unused _alert_scope_tickers) and alert only on watchlist tickers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:22:46 +02:00
dennisthiessen e355368748 generate targets from S/R zones, not raw levels (consistency + strength)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s
Trade-setup targets now pre-merge near-duplicate S/R levels into zone
representatives (same 2% clusterer as chart + alerts) before generate_targets
runs. A clustered wall (e.g. 183 + 185) becomes one target carrying the zone's
COMBINED strength (capped 100) instead of two near-identical targets that each
undervalue the wall — which also feeds a more honest reach-probability via the
S/R-strength magnet. Representative price is the zone's near edge; the strongest
constituent's id is retained. Singleton levels pass through unchanged, so the
downstream band-spreading / probability / primary-selection pipeline and its
tests are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:20:15 +02:00
dennisthiessen 88239e6ef8 S/R alerts: nearest zone only, scoped to watchlist + qualified, merged levels
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 24s
Three fixes to over-firing S/R proximity alerts:
- Route through cluster_sr_zones (the same merger the chart uses) instead of raw
  SRLevel rows, so near-duplicate levels (e.g. CVX 183 + 185) collapse into one
  zone and one alert.
- Alert only the single NEAREST strong zone per ticker, not every nearby level.
- Scope to watchlist + qualified-setup tickers via _alert_scope_tickers (was
  iterating all watchlist entries only; qualified setups are now included too).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 10:07:06 +02:00
dennisthiessen 5d41ccac1c add Telegram alerts: qualified setups, S/R proximity, score drops, daily digest
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 23s
Closes the action loop — instead of polling the dashboard, the platform pushes
actionable signals to Telegram. New hourly 'alerts' job dispatches four
toggleable triggers, deduped via a new alert_log table (cooldown-based for
qualified/S-R/digest, watermark-based for score deterioration). Admin → Settings
gains a Telegram panel (write-only bot token, chat ID, per-trigger toggles, Send
Test). Credentials follow DB > env precedence (TELEGRAM_BOT_TOKEN / _CHAT_ID).

Backend: alert_service + AlertLog model + migration 005, scheduler job, admin
endpoints/schema. Frontend: AlertSettings panel, hooks, api, types.

Deploy: run alembic upgrade (new alert_log table).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:42:18 +02:00
dennisthiessen 801df41b4d report per-ticker R:R scanner progress (sidebar stuck at 0%)
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 23s
scan_rr set the total then called scan_all_tickers as one opaque await, so the
runtime snapshot's processed count stayed 0 until the whole scan finished and
jumped straight to 100%. scan_all_tickers now takes an optional progress_callback
invoked per ticker; the scheduler wires it to _runtime_progress so the sidebar's
live indicator advances as tickers are scanned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:59:28 +02:00
dennisthiessen 90618d186f add track-record reset; drop dead distance_penalty_factor knob
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 36s
Deploy / deploy (push) Successful in 24s
Track Record: new "Reset" action (POST /admin/track-record/reset) deletes all
trade setups so stats start fresh after material scoring/setup changes — live
setups regenerate on the next scan. Guarded by a confirm dialog.

Recommendation config: remove distance_penalty_factor, which was exposed in the
admin UI but consumed nowhere (the touch-probability model superseded it). A
knob that silently does nothing is worse than no knob. Remaining defaults are
left as-is — they're reasonable, and the honest way to tune them is backtesting
against accumulated outcomes, not invented "researched" numbers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:44:02 +02:00
dennisthiessen 6e06f51bb6 make watchlist fully manual; add price + day-change, two-block overview
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
Per design decision: the watchlist is now purely user-curated (no auto-seeding
of the top-10), so the auto_populate/dismissed machinery is removed and removals
are plain deletes. Each entry is enriched with latest close + day-over-day move.

Overview now shows two clear blocks: Top Setups (what to trade) and My Watchlist
(my names with current price and today's %). Market watchlist table drops the
now-meaningless auto/manual Type column in favour of Price and Day columns.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:25:04 +02:00
dennisthiessen 0e9f1846f6 fix watchlist remove (was undone by auto-populate); add watch toggle on ticker page
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 23s
Removing a ticker did nothing because get_watchlist re-runs auto_populate on
every read, instantly re-adding any top-ranked ticker the user had just removed.
Removals are now tombstoned as a "dismissed" entry_type: auto-population skips
them, the list hides them, and a later manual add revives the row. Also exposes
an Add/Remove-watchlist toggle in the ticker detail header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 14:17:27 +02:00
dennisthiessen da83f027e1 Drop over-progressed setups via live R:R; refresh trades on fetch
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 25s
Answers "why does a too-far-progressed setup still show": setups are only
recalculated by the scheduled R:R scan and manual fetch; at creation
entry == current price (0% progress), so over-progression is a
between-scans drift effect and must be judged at read time.

- /trades now attaches current_price (latest close per ticker).
- Qualification drops setups whose R:R recomputed from the current price
  falls below min_rr — i.e. price already ran toward target (reward
  consumed) or through the stop. Reuses the existing min_rr threshold
  instead of a separate progress %; far cleaner (a 3:1 is already ~1:1
  by 33% progress). Skipped for historical setups (no current_price).
- Fix: useFetchSymbolData now invalidates the trades queries, so a fetch/
  recompute actually refreshes confidence/setups in the UI (was the cause
  of the stale 100% confidence lingering after recompute).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 14:02:10 +02:00
dennisthiessen 316226096b Fix score refresh, add granular fetch and live job status
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 22s
Scores never updated ("101d ago"): get_score only recomputes stale/
missing dimensions, but nothing marked them stale on new data, and there
was no scheduled scoring job.
- Fetch endpoint force-recomputes dimensions + composite.
- Scheduled scan (scan_all_tickers) refreshes scores per ticker, so
  scores stay current globally, not just on manual fetch.

Granular fetch: /ingestion/fetch accepts a sources filter; the freshness
bar gets a per-row refresh button (OHLCV/Sentiment/Fundamentals fetch
that provider only — marked paid; S/R/Scores recompute for free). Header
button is now "Fetch All".

Job visibility: GET /jobs/running (any user) + sidebar live indicator
showing running scheduled jobs with progress, polled every 10s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 13:10:15 +02:00
dennisthiessen 3aebfd72d3 Spread trade targets across distance bands
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
MKC showed 5 targets all far/Aggressive: target selection was top-5 by
quality (0.35*R:R + ...), and R:R grows with distance, so far levels
crowded out every nearby one.

generate_targets now selects for spread: always include the nearest
level, plus the best-quality representative from each distance band
(Conservative <=2.9 ATR, Moderate <=4.6 ATR, Aggressive beyond), then
fill remaining slots by quality. Restores a Conservative/Moderate/
Aggressive mix with the nearest target always present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 12:44:59 +02:00
dennisthiessen 8c89396987 Make target probability and classification distance-aware
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
Fixes nonsensical "Conservative @ 90%" on far targets (AJG: +39% target
labelled Conservative/90%).

- Probability = chance of touching the level within the outcome horizon
  under a driftless random walk: 2*(1 - Phi(d / (ATR*sqrt(T)))), T=30d to
  match the outcome evaluator. Distance (in ATR) dominates; strength and
  alignment modulate modestly. Far targets are now correctly low-prob.
- Classification derived from that probability (>=60 Conservative,
  40-60 Moderate, <40 Aggressive) instead of distance-rank.
- Combined with the most-likely-worthwhile primary pick, the nearest
  strong resistance becomes the Conservative primary; far levels demote
  to low-probability stretch targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 12:26:36 +02:00
dennisthiessen 5a0e8c8258 Fix sidebar username, Signals filter clarity and layout
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 24s
- JWT now carries a username claim; sidebar shows "Signed in as <name>"
  instead of the bare user id (sub). Re-login required for the new claim.
- Signals: Min R:R / Min Confidence inputs reflect the effective filter —
  auto-filled from the activation gate when "Qualified only" is on, reset
  to 0 when off (no more misleading 0 while the gate is active).
- Signals layout: Run Scanner moved to its own action row (it's a job
  trigger, not a filter); qualified toggle grouped with the refinement
  filters under one Filters panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-14 12:11:39 +02:00
dennisthiessen d3eb8a2b97 Fix scoring/recommendation correctness and calibration
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s
Triggered by CNC showing "LONG (High Confidence)" with SHORT reasoning
and no long setup.

- A: recommendation action + reasoning are ticker-level and identical
  on both setups; reasoning always matches the shown action
- B: recommended_action only picks a direction with a tradeable setup;
  strong bias with no setup (e.g. price at ATH) → NEUTRAL with an
  explanatory reason instead of a fake LONG_HIGH
- C: confidence is a directional-agreement model — opposing signals push
  it below 50 (SHORT on a 92-technical/99-momentum stock ~0%, not 55%)
- D: fundamental score requires >=2 real metrics (market-cap-only no
  longer yields a high score)
- E: RSI score peaks at healthy momentum (~60) and penalizes
  overbought/oversold extremes instead of treating RSI 90 as maximal
- F: fundamentals chain merges fields across providers (FMP market cap
  + Finnhub P/E) instead of stopping at the first with any field
- NEUTRAL label: "No Clear Setup" (covers untradeable-bias case)

Scores recompute on next scan/scoring run; C and E shift score
distributions intentionally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:34:36 +02:00
dennisthiessen ffb609d38f Fix xAI sentiment: use Agent Tools web_search (Live Search deprecated)
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 31s
Deploy / deploy (push) Successful in 23s
xAI returned 410 — search_parameters/Live Search is retired. Route xAI
through the Responses API web_search tool instead (same path as OpenAI):
- OpenAISentimentProvider parametrized with base_url / tool_type / source
- xAI builds it against https://api.x.ai/v1 with the web_search tool
- Drop the dead Live Search code from the generic compatible provider
- Frontend label: "xAI Grok — web search"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:30:45 +02:00
dennisthiessen 126c3b3c17 Add DeepSeek/xAI/OpenAI-compatible sentiment providers; custom dark dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 22s
Providers (admin-switchable, no redeploy):
- DeepSeek and any OpenAI-compatible endpoint (OpenRouter, Together,
  Groq, local Ollama) via a generic Chat Completions adapter + base_url
- xAI Grok with Live Search (search_parameters web+X, citations) —
  grounded tier alongside OpenAI and Gemini
- DeepSeek / generic compatible endpoints are ungrounded (no web
  search); UI shows an amber warning and labels each provider's grounding
- Optional env fallbacks DEEPSEEK_API_KEY / XAI_API_KEY

UI: replace native <select> (unstyleable white popup on Windows) with a
custom dark Dropdown component everywhere — sentiment provider, scanner
filters, market sort, indicators, admin universe, user role.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:42:04 +02:00
dennisthiessen d53ed972d1 Add multi-factor conviction gate to activation
Deploy / lint (push) Successful in 8s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 26s
Make "qualified" mean an edge candidate, not just R:R + confidence.
The gate now also requires (all admin-configurable, defaults on):
- high conviction: recommended_action LONG_HIGH / SHORT_HIGH only
- clean read: risk_level Low (no contradicting signals)
- probable primary target: best target probability >= min (default 60)

- Shared predicate: app/services/qualification.py +
  frontend/src/lib/qualification.ts (mirrored)
- Activation config extended (min_target_probability,
  require_high_conviction, exclude_conflicts) with bool-aware
  get/update + validation
- /trades/performance switched to ?qualified_only=true, applying
  the full gate server-side; confidence breakdown stays unfiltered
- Dashboard "Qualified", Signals "Qualified only" toggle, and
  Track Record all use the one gate; Admin gains the new controls

Sentiment provider runtime config (prior change) included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 11:50:42 +02:00
dennisthiessen 6da65b8d8f Add activation thresholds: qualified-signal defaults and views
Deploy / lint (push) Successful in 7s
Deploy / test (push) Successful in 32s
Deploy / deploy (push) Successful in 24s
Admin-configurable thresholds (min R:R, default 2.0; min confidence,
default 70%) defining what counts as an actionable signal:

- Admin Settings: new Activation Thresholds panel
  (GET/PUT /admin/settings/activation)
- GET /trades/activation exposes values to all users with access
- Signals/Setups: filters initialize from activation values
- Track Record: "Qualified signals only" toggle (default on) via
  min_rr/min_confidence params on /trades/performance; the
  confidence breakdown always covers the full population so the
  thresholds can be validated against outcomes
- Dashboard: "Qualified" metric and qualified-first Top Setups
- Outcome evaluator unchanged: every setup is still evaluated

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:16:04 +02:00
dennisthiessen d139dd0390 Integrate unused indicators into technical scoring; fix indicator dropdown
Deploy / lint (push) Successful in 5s
Deploy / test (push) Successful in 28s
Deploy / deploy (push) Successful in 22s
- Technical dimension now uses all directional indicators:
  0.30*ADX + 0.20*EMA + 0.20*RSI + 0.15*EMA_Cross (bullish=80 /
  neutral=50 / bearish=20) + 0.10*Volume_Profile (POC proximity)
  + 0.05*Pivot_Points (structure confluence); weights re-normalize
  when data is insufficient, as before
- ATR stays out of scoring (volatility input for scanner stops,
  not a directional signal)
- IndicatorSelector uses the shared Select so the option list is
  dark instead of the native white popup
- Update technical scoring tests for the six-component breakdown

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:17:07 +02:00
dennisthiessen 21ed83c56c Add trade setup outcome tracking and performance stats
Deploy / lint (push) Successful in 25s
Deploy / test (push) Successful in 1m7s
Deploy / deploy (push) Successful in 25s
Closes the feedback loop on R:R scanner signals:

- Nightly outcome_evaluator job replays unresolved setups against daily
  OHLCV bars: target_hit / stop_hit / ambiguous (same-bar, counted as
  loss) / expired after OUTCOME_EVALUATION_MAX_BARS (default 30)
- Migration 004: evaluated_at + outcome_date on trade_setups
- GET /trades/performance: hit rate, expectancy (avg R), total R with
  breakdowns by direction, recommended action, and confidence bucket
- New Performance page (stat cards, breakdown tables, Evaluate Now,
  methodology disclosure) wired into sidebar and mobile nav
- 17 new unit tests for evaluation logic and stats aggregation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 19:23:57 +02:00