Skip to content

KB / framework

Screener Archetypes — Structural Dislocation Catalog

Last verified

The single-name screener surfaces structural dislocations — situations where price has moved away from reported fundamentals for mechanical or sentiment-driven reasons that are likely to self-correct. Each archetype is a specific flavor of dislocation with its own screen criteria, dislocation metric, and state machine.

Archetypes are implemented in app/screener/archetypes.py and follow a uniform scoring contract (see §6.4 of improvements/single-name-system-plan.md). Session 1.2 ships A1. Sessions 1.3+ will add A2–A6.


A1. Post-earnings overreaction recovery (bread and butter)

Status: Live (Session 1.2). Canonical id: post_earnings_overreaction.

Thesis

Price gapped down on earnings as if the print were a disaster. The actual number wasn’t — EPS met or beat expectations. The gap between price reaction and reported reality is the dislocation. When the dust settles and the name stops falling (basing), the setup is primed.

Screen criteria (all five must hold for primed)

ConditionDefault thresholdSource
Earnings within recency window3–10 trading days agoscreener_earnings.report_date
Gap on print day≤ −8%screener_earnings.gap_pct
EPS or revenue surprise≥ 0 (met or beat)screener_earnings.eps_surprise or rev_surprise
Quality gatePassapp/screener/gate.quality_gate
Basing confirmationRSI recovery off <35, or SMA 10/20 reclaimedscreener_features_daily.rsi_14/sma_dist_10/sma_dist_20

All thresholds live in app/signals/config/screener_config.json under archetypes.a1. Do not hardcode them.

State machine

StateMeaning
primedAll five conditions met — dislocation confirmed + stabilization underway
partialCore four conditions met (earnings + gap + surprise + gate) but basing unconfirmed
noneAny core condition unmet — ticker does not qualify

Dislocation metric

divergence = (-gap_pct) × max(surprise, 0)

A big drop and a good number ranks highest. The basing bonus (default ×1.15) is applied when stabilization confirms, rewarding setups where the hardest-to-time part of the trade is already behind it.

score_norm is None in Session 1.2. Cross-archetype percentile normalization is Session 1.4 (rank.py).

Data limitations

KNOWN LIMITATION — Guidance cuts

A clean EPS beat accompanied by a guidance cut will show surprise ≥ 0 but deserves the selloff.

This is the most important caveat for A1. The pure-numeric screen sees only the reported EPS vs estimate — it cannot see forward guidance, management commentary, or the qualitative tone of the call. A company that reports a 4% EPS beat and cuts next-quarter guidance will score well on the divergence metric even though the market’s reaction was entirely rational.

Mitigation: The screen is a starting filter, not a complete thesis. Before acting on any A1 signal, always verify guidance commentary from the earnings release or call transcript. The calibration loop (Session 1.7) will surface whether A1 candidates with high divergence scores historically recover, or whether some subset (likely guidance-cut situations) does not.

This limitation is documented in:

Where it lives in the code

Example primed output

{
  "archetype": "post_earnings_overreaction",
  "score": 14.72,
  "score_norm": null,
  "state": "primed",
  "triggers": [
    "gap -11.2%",
    "EPS surprise +4.2%",
    "earnings 5d ago",
    "quality:pass",
    "RSI 32->41"
  ],
  "dislocation": {
    "gap_pct": -11.2,
    "eps_surprise": 0.042,
    "rev_surprise": null,
    "days_since_earnings": 5,
    "rsi_14": 41.0,
    "sma_dist_10": -0.03,
    "sma_dist_20": -0.05,
    "basing_confirmed": true,
    "basing_method": "rsi_recovery",
    "gate_pass": true,
    "surprise_used": 0.042
  }
}

A2. Oversold sympathy selloff

Status: Live (Session 1.3). Canonical id: oversold_sympathy.

Thesis

A good company gets dragged down by a bad peer or sector dump, on no own-name news. The drop is borrowed — the selling is sector-contagion, not company-specific. Once the contagion fades, the name should revert toward its fundamentals.

Screen criteria

ConditionDefault thresholdSource
Single-day drop≤ −3% (log return)screener_features_daily.ret_1d
No own-name newsAbsence in news_items on the datenews_store (weak flag — see caveat)
Borrowed dropown_ret − (beta_60d × sector_ret) unexplained component ≥ 0.5ppscreener_features_daily.beta_60d + sector ETF features
Quality gatePassapp/screener/gate.quality_gate

primed requires: all four conditions. partial requires: down day + quality + one of (newsless flag, borrowed_drop).

Dislocation metric

borrowed_drop_pct = own_ret_pct − (beta_60d × sector_ret_pct)
score = abs(borrowed_drop_pct)

The unexplained-by-sector component of the drop, in percentage-points. A larger unexplained drop (holding quality) ranks higher.

KNOWN LIMITATION — news_store coverage

“No news in our store” ≠ “no news.” The news_items table ingests only two feeds (Iran International + FXStreet via the platform’s news source). Company-specific press releases, analyst downgrades, management pre-announcements, and most equity-specific news are not in the store. Treat the absence flag as a weak supporting signal — it means the platform’s feeds didn’t flag this ticker, not that no news exists.

Always verify external news sources before trading an A2 candidate. Documented in screener_config.json:archetypes.a2.no_news_caveat.

Where it lives in the code

Example primed output

{
  "archetype": "oversold_sympathy",
  "score": 2.6,
  "score_norm": null,
  "state": "primed",
  "triggers": [
    "ret_1d -5.0%",
    "quality:pass",
    "no_own_news (weak flag — store coverage limited)",
    "borrowed_drop -2.60pp (unexplained)",
    "sector_explained 0.80×-3.0%=-2.4pp"
  ],
  "dislocation": {
    "ret_1d": -0.05,
    "borrowed_drop_pct": -2.6,
    "beta_60d": 0.8,
    "sector_ret_pct": -3.0,
    "has_own_news": false,
    "gate_pass": true
  }
}

A3. Positioning extreme vs price

Status: Live (Session 1.3). Canonical id: positioning_extreme. Options leg (put/call skew) pending Phase 3.

Thesis

Bearish positioning is at an extreme — short interest is high, positioning is crowded — while price refuses to break. The fuel is loaded; if price holds and forces covering, the move is asymmetric. The non-obvious edge is short-extreme PLUS price refusing to confirm. Without the price-divergence leg this is just a list of hated stocks.

Screen criteria

ConditionDefault thresholdSource
Short interest extremeshort_pct_float ≥ 15%yfinance info.shortPercentOfFloat (normalised from decimal)
Price holdingret_20d ≥ −5% AND dist_52w_low > 0 (not at new lows)screener_features_daily.ret_20d / dist_52w_low
Quality gatePassapp/screener/gate.quality_gate
[OPTIONS LEG — pending Phase 3]Put/call skew extremeco_options_daily when options_legs_enabled=true

primed requires all non-options conditions. partial requires one leg.

Dislocation metric

Score = count of fired triggers (mirrors squeeze.py trigger-count shape).

KNOWN LIMITATION — short interest correctness

High short interest is often correct. The non-obvious edge is the price-divergence leg — the name refuses to confirm the bear thesis despite heavy positioning. This leg is mandatory and is not relaxed when options data is absent.

OPTIONS LEG — FLAGGED

The put/call skew leg requires Schwab per-name options chain data (Phase 3, Session 3.4). When archetypes.options_legs_enabled=false (default): the skew leg is skipped and an explicit trigger "options leg: pending Phase 3" is added. The non-options half computes and returns a valid contract dict.

Where it lives in the code

Example partial output (options off)

{
  "archetype": "positioning_extreme",
  "score": 2.0,
  "score_norm": null,
  "state": "primed",
  "triggers": [
    "short_pct 22.0% (extreme)",
    "ret_20d -2.0% (holding)",
    "dist_52w_low +12.0% (above lows)",
    "price_holding:confirmed",
    "options leg: pending Phase 3",
    "quality:pass"
  ],
  "dislocation": {
    "short_pct_float": 22.0,
    "ret_20d": -0.02,
    "dist_52w_low": 0.12,
    "short_extreme": true,
    "price_holding": true,
    "options_legs_enabled": false,
    "gate_pass": true
  }
}

A4. Pre-catalyst coiling

Status: Live (Session 1.3). Canonical id: precatalyst_coiling. IV leg pending Phase 3.

Thesis

Realized vol compresses into a dated catalyst while IV is still cheap. The setup is a vol/positioning opportunity — when vol compresses before a known catalyst, any break resolves with amplified force. This resolves BOTH ways — not a directional setup.

Screen criteria

ConditionDefault thresholdSource
Tight realized volrealized_vol_pct_1y ≤ 20 (20th percentile)screener_features_daily.realized_vol_pct_1y
PatternInside-day OR NR7 (narrowest range of 7 days)screener_features_daily.inside_day / nr7
Dated catalystUpcoming earnings within proximity window (default 30d)Forward calendar (passed by assembler)
Quality gatePassapp/screener/gate.quality_gate
[OPTIONS LEG — pending Phase 3]IV not yet bid (iv_pctile low)co_options_daily when options_legs_enabled=true

primed requires: tight rvol + pattern + catalyst in window + quality. partial requires: tight rvol + one of (pattern, catalyst).

Dislocation metric

coil = (1 − rvol_pctile/100) × proximity_factor

Where proximity_factor = max(0, 1 − days_to_catalyst / max_days). When options enabled: × (1 − iv_pctile/100).

KNOWN LIMITATION — directionality

Coiling resolves BOTH ways. This is a vol/positioning setup. A tight range into earnings breaks violently up or down. Structure should be non-directional (straddle/strangle/narrow stop) rather than assuming direction.

Catalyst date source

The forward earnings calendar is passed by the assembler (score_a4_for_ticker), which derives days_to_catalyst from market.py:_fetch_calendar() (forward-looking, per Law 3). The screener_earnings table holds PAST events only. When no upcoming catalyst is found, days_to_catalyst=None and the evaluator degrades gracefully.

OPTIONS LEG — FLAGGED

The IV-percentile leg requires co_options_daily (Phase 3). When off: the two-factor coil score (rvol × proximity) is used and "options leg: pending Phase 3" is added to triggers.

Where it lives in the code

Example primed output (options off)

{
  "archetype": "precatalyst_coiling",
  "score": 0.645,
  "score_norm": null,
  "state": "primed",
  "triggers": [
    "rvol_pctile 12.0 (tight ≤20)",
    "quality:pass",
    "NR7: confirmed",
    "catalyst 8d away (in window)",
    "NOTE: vol setup — resolves both ways (not directional)",
    "options leg: pending Phase 3"
  ],
  "dislocation": {
    "realized_vol_pct_1y": 12.0,
    "inside_day": false,
    "nr7": true,
    "days_to_catalyst": 8,
    "proximity_factor": 0.7333,
    "iv_pctile": null,
    "options_legs_enabled": false,
    "coil_score": 0.645
  }
}

A5. Mechanical-selling mean reversion

Status: Live (Session 1.3). Canonical id: mechanical_reversion.

Thesis

Forced or price-insensitive sellers (index rebalance, tax-loss harvesting, VIX-spike margin cascade) push a name below fair without a fundamental reason. The reversion thesis is cleanest when the name fell beyond its SPY-beta on a known forced-flow day — that’s the signal that selling was mechanical, not informed.

Screen criteria

ConditionThresholdSource
Mechanical windowDate inside the rebalance/tax-loss calendar OR VIX ≥ 30screener_config.json:archetypes.a5.rebalance_windows + daily_signals.vix_close
Beta-residualown_ret − beta_60d × spy_ret ≤ −2ppscreener_features_daily.ret_1d / beta_60d + SPY features
Quality gatePassapp/screener/gate.quality_gate

primed requires: mechanical trigger + residual below floor + quality. partial requires: mechanical trigger present but residual insufficient.

Dislocation metric

beta_residual_pct = own_ret_pct − beta_60d × spy_ret_pct
score = abs(beta_residual_pct)

Rebalance / tax-loss calendar (SSOT: screener_config.json)

WindowPeriodNote
S&P 500 Q1 rebalanceMarch 14–21 (approx third Friday)Exact date announced by S&P
S&P 500 Q2 rebalanceJune 14–21
S&P 500 Q3 rebalanceSeptember 14–21
S&P 500 Q4 rebalanceDecember 14–21
Russell reconstitutionJune 21–28 (last Friday of June)See gotcha below
December tax-loss sellingDecember 1–24Broad window; institutional + retail

Russell reconstitution gotcha: Russell additions are real supply/demand shifts (new index buyers forced in), not mechanical noise. The clean A5 signal on Russell is the beta-residual on confirmed deletions (forced sellers), not additions. Documented in screener_config.json:archetypes.a5.rebalance_windows[*]._gotcha.

VIX accessor

VIX close is read from daily_signals.vix_close for the evaluation date (same column app/sources/market.py writes on every cycle). When absent (e.g. backtest date predates signals data), vix_close defaults to None and only the calendar trigger is evaluated.

Where it lives in the code

Example primed output

{
  "archetype": "mechanical_reversion",
  "score": 4.2,
  "score_norm": null,
  "state": "primed",
  "triggers": [
    "quality:pass",
    "rebalance_window: tax_loss_selling",
    "beta_residual -4.20pp (beyond beta)",
    "beta_explained: 0.80×-1.0%=-0.8pp"
  ],
  "dislocation": {
    "ret_1d_pct": -5.0,
    "beta_60d": 0.8,
    "spy_ret_pct": -1.0,
    "beta_residual_pct": -4.2,
    "vix_close": 18.0,
    "trade_date": "2026-12-18",
    "in_rebalance_window": true,
    "rebalance_window_name": "tax_loss_selling",
    "vix_spike": false,
    "gate_pass": true
  }
}

A6. Vol mispricing (cheap implied vs credible real move)

Status: Historical-move half live (Session 1.3). Canonical id: vol_mispricing. Implied-move leg (full vol_edge) pending Phase 3.

Thesis

The options market is pricing a smaller move than the name historically makes on earnings. The implied-move half (ATM straddle / spot) requires Schwab chain data deferred to Phase 3. Session 1.3 ships the historical-move half: compute and surface hist_earnings_move (recency-weighted mean |gap| over the last ~8 earnings), ready to compare once implied-move data arrives.

Screen criteria

ConditionThresholdSource
Historical earnings moveComputable from ≥1 past earnings eventscreener_earnings.get_earnings_as_of (last 8 events)
Dated catalystUpcoming earnings knownForward calendar (passed by assembler)
Quality gatePassapp/screener/gate.quality_gate
[OPTIONS LEG — pending Phase 3]implied_move < hist_earnings_move (vol_edge > 1)co_options_daily when options_legs_enabled=true

Without the options leg, the maximum state is partial. primed is reserved for Phase 3 when vol_edge > 1.

Dislocation metric

hist_earnings_move = recency-weighted mean of abs(gap_pct) over last N events
recency_weight = 0.7^i  (i=0 most recent), normalised

vol_edge = hist_earnings_move / implied_move  (Phase 3 — > 1 means implied is cheap)

Score without options = hist_earnings_move (the raw historical move in %). Score with options = vol_edge.

KNOWN LIMITATION — historical move ≠ future move

Regime shifts (a company that matured, grew out of high-growth, or changed business model) mean recent moves may be structurally different from a 2-year average. Recency weighting (0.7^i) mitigates this — the most-recent event gets ~2.9× the weight of an event 4 quarters back — but cannot fully adjust for structural changes. Documented in screener_config.json:archetypes.a6.hist_move_caveat.

OPTIONS LEG — FLAGGED

The implied-move leg requires co_options_daily (Phase 3, Session 3.4 → Schwab ATM straddle / spot). When options_legs_enabled=false (default): implied_move is not used, vol_edge is not computed, and trigger "vol_edge: options leg pending Phase 3" is added. hist_earnings_move IS surfaced in the dislocation dict for audit.

Where it lives in the code

Example partial output (options off)

{
  "archetype": "vol_mispricing",
  "score": 9.64,
  "score_norm": null,
  "state": "partial",
  "triggers": [
    "quality:pass",
    "hist_earnings_move 9.6% (weighted avg of 4 events, w=0.7)",
    "catalyst 10d away",
    "vol_edge: options leg pending Phase 3"
  ],
  "dislocation": {
    "hist_earnings_move": 9.64,
    "n_events_used": 4,
    "days_to_catalyst": 10,
    "implied_move": null,
    "vol_edge": null,
    "options_legs_enabled": false,
    "gate_pass": true
  }
}

Scoring contract (all archetypes)

Every archetype evaluator returns:

{
    "archetype": str,         # canonical id (Law 3)
    "score": float,           # raw dislocation metric (comparable within archetype)
    "score_norm": float|None, # percentile/z within archetype's trailing dist (Session 1.4)
    "state": "primed"|"partial"|"none",
    "triggers": [str, ...],   # human-readable fired conditions
    "dislocation": {dict},    # auditable raw evidence
}

The uniform shape lets rank.py (Session 1.4) treat all archetypes identically and lets the calibration loop (Session 1.7) attribute predictions without special-casing.


Calibration loop (Session 1.7)

The screener calibration system was shipped in Session 1.7 as Working Agreement #9 — calibration is non-negotiable.

How it works

  1. Record at flag time — every time a candidate is flagged (status new or persisting), record_screener_predictions writes one screener_predictions row per (ticker, archetype, horizon) capturing the forecast context: score_norm, state, confluence_count, regime, and the ticker’s closing price at flag time (flag_price).

  2. Mature realized returnsbackfill_screener_forward_returns (run daily via scripts/backfill_screener_forward_returns.py) sweeps for predictions whose flag_date + horizon_days has elapsed, looks up the forward close price from screener_features_daily, and writes realized_return = (forward_price − flag_price) / flag_price. This is always an UPDATE, never a DELETE.

  3. Compute metricsget_screener_calibration reads all matured rows and returns per-archetype hit-rate / MAE / Brier score with sample size and a Wilson-score 95% CI. The API route (Session 1.6) exposes this at GET /api/v1/screener/calibration.

Horizons

Two forward-return horizons are tracked: 30 days and 45 days (configurable in screener_config.json → calibration.horizons). The 30-day horizon captures near-term recovery plays; 45 days gives slower mean-reversion setups time to work.

The “uncalibrated” label

Every archetype surface labels itself “uncalibrated” until at least 30 matured samples exist (screener_config.json → calibration.min_matured_samples). An archetype with 2 matured samples showing 100% hit rate is labeled uncalibrated — the label is honest, not a bug.

Deferred maturation

Forward-return maturation requires 30–45 calendar days of real elapsed time. The calibration machinery is wired and persisting predictions from the first day candidates are flagged. The matured-sample accumulation is a deferred checkpoint, revisited at Phase 5.4. If you see only “uncalibrated” labels, that is correct behaviour.

Retention

screener_predictions is keep-forever. There is no DELETE path. tests/test_retention_guard.py enforces this via both GUARDED_FILES and RETENTION_PROTECTED_TABLES.

Code locations