Files
signal-platform/alembic/versions/020_drop_orphaned_activation_settings.py
dennisthiessenandClaude Opus 4.8 85b3ef618f Research: S/R levels, the target exit, and the entry gate
Investigated whether our support/resistance detection follows best practice
and whether we actually use it that way. Three findings, all backed by runs
against the prod snapshot and written up in docs/research/sr-levels-and-exits.md:

- The S/R target must NOT become an exit. Honoring it as a take-profit on top
  of the 3x ATR trail drops Sharpe 2.04 -> 1.47 and halves CAGR. Win rate rises
  (37.5% -> 40.0%), which is the tell: it truncates the right tail where
  momentum's edge lives.
- The clear-air fallback (synthesize a 3xATR target where no resistance exists,
  so 52-week-high breakouts stop being vetoed) looked strictly better in-sample
  (Sharpe 2.04 -> 2.07, CAGR 50.4% -> 62.3%, DD 21.4% -> 20.1%) but FAILED a
  real out-of-sample holdout: on entries after 2024-07-01 it is worse on Sharpe
  (2.78 -> 2.45) and Calmar, better only on raw CAGR. Not shipped.
- The detector itself is weak vs best practice (POC/VAH/VAL computed then
  discarded, HVN = any above-mean bin, 1.48x volume double-counting, "touch"
  counts pass-throughs, no round numbers), but its only causal path to P&L is
  the entry gate. Fix it for the displayed levels, not for returns.

Method note: nested lookback windows are NOT out-of-sample. The in-sample result
was clean, large, and consistent across five windows, and still did not survive
a proper entry-date split.

All research paths are off by default and the default report is unchanged:
  BACKTEST_RESEARCH_EXITS=1        take-profit exit rows
  BACKTEST_ATR_TARGET_FALLBACK=k   synthetic k*ATR target when S/R offers none
  BACKTEST_FALLBACK_CLEAR_AIR_ONLY=1  restrict that to genuinely clear air
  BACKTEST_HOLDOUT_SPLIT=YYYY-MM-DD   train/test split by entry date

Also fixes two reproducibility holes found while reconciling our local baseline
against the live report:

- create_backtest_snapshot.py now copies paper_% settings. The production
  monitor row replays the runtime exit policy via get_exit_policy(); without
  those keys a snapshot silently falls back to code defaults, so a live-tuned
  exit would never be reflected.
- Migration 020 drops activation_min_expected_value and
  activation_min_target_probability. Both are orphans of the June EV-gate
  redesign, read by no code path, but prod carries min_target_probability = 50.0
  which implies a probability floor that is not enforced (the real floor is the
  20% constant in qualification.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 15:05:34 +02:00

64 lines
2.2 KiB
Python

"""Drop the orphaned EV-gate activation settings.
``activation_min_expected_value`` and ``activation_min_target_probability`` are
leftovers from the June 2026 EV-gate redesign (migration 009). That gate was
superseded by the residual-momentum gate, and the current code reads neither key:
``admin_service._ACTIVATION_FLOAT_KEYS`` exposes only ``min_momentum_percentile``,
``min_rr`` and ``min_confidence``, and ``qualification.setup_qualifies`` gates on
those plus the hardcoded ``MIN_TARGET_PROBABILITY`` floor.
The rows are therefore inert but actively misleading: prod carries
``activation_min_target_probability = 50.0``, so anyone reading the DB (or an
Admin screen rendering it) would reasonably believe a 50% probability floor is
enforced. It is not — the real floor is the 20% constant in ``qualification.py``.
Reads never recreate them (``settings_store.get_value`` returns a default without
persisting), and the current Admin write path no longer emits these keys, so the
delete is permanent. Follows the precedent of migrations 009, 015 and 018.
Revision ID: 020
Revises: 019
Create Date: 2026-07-12 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "020"
down_revision = "019"
branch_labels = None
depends_on = None
ORPHANED_KEYS = (
"activation_min_expected_value",
"activation_min_target_probability",
)
def upgrade() -> None:
op.execute(
sa.text(
"DELETE FROM system_settings WHERE key IN "
"('activation_min_expected_value', 'activation_min_target_probability')"
)
)
def downgrade() -> None:
# Restore the values prod carried before the delete. They are inert either
# way — no code path reads them — but this keeps the downgrade faithful.
# ``updated_at`` is NOT NULL with only a Python-side default, so raw SQL must
# supply it explicitly.
op.execute(
sa.text(
"INSERT INTO system_settings (key, value, updated_at) VALUES "
"('activation_min_expected_value', '0.01', CURRENT_TIMESTAMP), "
"('activation_min_target_probability', '50.0', CURRENT_TIMESTAMP) "
"ON CONFLICT (key) DO NOTHING"
)
)