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)
| Condition | Default threshold | Source |
|---|---|---|
| Earnings within recency window | 3–10 trading days ago | screener_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 gate | Pass | app/screener/gate.quality_gate |
| Basing confirmation | RSI recovery off <35, or SMA 10/20 reclaimed | screener_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
| State | Meaning |
|---|---|
primed | All five conditions met — dislocation confirmed + stabilization underway |
partial | Core four conditions met (earnings + gap + surprise + gate) but basing unconfirmed |
none | Any 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
- Revenue surprise:
yfinance.get_earnings_dates()returns no revenue data.rev_surpriseis usuallyNULL. The screen uses EPS surprise as primary and treatsrev_surpriseas a secondary fallback. Either non-null metric ≥ 0 clears the surprise gate. - BMO/AMC timing:
yfinancereturns no pre/post-market indicator. The gap on the print day (gap_pct) serves as a proxy.
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:
screener_config.json:archetypes.a1.guidance_cut_caveat- The
evaluate_post_earnings_overreactiondocstring inapp/screener/archetypes.py tests/test_archetypes_a1.py::test_guidance_cut_caveat_documented
Where it lives in the code
- Pure evaluator:
app/screener/archetypes.py::evaluate_post_earnings_overreaction(feats: dict) -> dict— no I/O, deterministic, unit-testable. - Caller/assembler:
app/screener/archetypes.py::score_a1_for_ticker(db, ticker, as_of_date, info)— async, gathers features + earnings + gate from DB. - Thresholds:
app/signals/config/screener_config.json → archetypes.a1 - Registry:
app/signals/config/signal_definitions.json—a1_post_earnings_score,a1_post_earnings_state,a1_post_earnings_triggers - Tests:
tests/test_archetypes_a1.py
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
| Condition | Default threshold | Source |
|---|---|---|
| Single-day drop | ≤ −3% (log return) | screener_features_daily.ret_1d |
| No own-name news | Absence in news_items on the date | news_store (weak flag — see caveat) |
| Borrowed drop | own_ret − (beta_60d × sector_ret) unexplained component ≥ 0.5pp | screener_features_daily.beta_60d + sector ETF features |
| Quality gate | Pass | app/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
- Pure evaluator:
app/screener/archetypes.py::evaluate_oversold_sympathy(feats: dict) -> dict - Caller/assembler:
app/screener/archetypes.py::score_a2_for_ticker(db, ticker, as_of_date, info) - Thresholds:
app/signals/config/screener_config.json → archetypes.a2 - Registry:
a2_sympathy_score,a2_sympathy_state,a2_sympathy_triggers - Tests:
tests/test_archetypes_a2.py
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
| Condition | Default threshold | Source |
|---|---|---|
| Short interest extreme | short_pct_float ≥ 15% | yfinance info.shortPercentOfFloat (normalised from decimal) |
| Price holding | ret_20d ≥ −5% AND dist_52w_low > 0 (not at new lows) | screener_features_daily.ret_20d / dist_52w_low |
| Quality gate | Pass | app/screener/gate.quality_gate |
| [OPTIONS LEG — pending Phase 3] | Put/call skew extreme | co_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
- Pure evaluator:
app/screener/archetypes.py::evaluate_positioning_extreme(feats: dict) -> dict - Caller/assembler:
app/screener/archetypes.py::score_a3_for_ticker(db, ticker, as_of_date, info) - Thresholds:
app/signals/config/screener_config.json → archetypes.a3 - Registry:
a3_positioning_score,a3_positioning_state,a3_positioning_triggers - Tests:
tests/test_archetypes_a3.py
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
| Condition | Default threshold | Source |
|---|---|---|
| Tight realized vol | realized_vol_pct_1y ≤ 20 (20th percentile) | screener_features_daily.realized_vol_pct_1y |
| Pattern | Inside-day OR NR7 (narrowest range of 7 days) | screener_features_daily.inside_day / nr7 |
| Dated catalyst | Upcoming earnings within proximity window (default 30d) | Forward calendar (passed by assembler) |
| Quality gate | Pass | app/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
- Pure evaluator:
app/screener/archetypes.py::evaluate_precatalyst_coiling(feats: dict) -> dict - Caller/assembler:
app/screener/archetypes.py::score_a4_for_ticker(db, ticker, as_of_date, info, days_to_catalyst=...) - Thresholds:
app/signals/config/screener_config.json → archetypes.a4 - Registry:
a4_coiling_score,a4_coiling_state,a4_coiling_triggers - Tests:
tests/test_archetypes_a4.py
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
| Condition | Threshold | Source |
|---|---|---|
| Mechanical window | Date inside the rebalance/tax-loss calendar OR VIX ≥ 30 | screener_config.json:archetypes.a5.rebalance_windows + daily_signals.vix_close |
| Beta-residual | own_ret − beta_60d × spy_ret ≤ −2pp | screener_features_daily.ret_1d / beta_60d + SPY features |
| Quality gate | Pass | app/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)
| Window | Period | Note |
|---|---|---|
| S&P 500 Q1 rebalance | March 14–21 (approx third Friday) | Exact date announced by S&P |
| S&P 500 Q2 rebalance | June 14–21 | — |
| S&P 500 Q3 rebalance | September 14–21 | — |
| S&P 500 Q4 rebalance | December 14–21 | — |
| Russell reconstitution | June 21–28 (last Friday of June) | See gotcha below |
| December tax-loss selling | December 1–24 | Broad 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
- Pure evaluator:
app/screener/archetypes.py::evaluate_mechanical_reversion(feats: dict) -> dict - Window helper:
app/screener/archetypes.py::_is_in_rebalance_window(date_str: str) -> (bool, str|None) - Caller/assembler:
app/screener/archetypes.py::score_a5_for_ticker(db, ticker, as_of_date, info) - Thresholds + calendar:
app/signals/config/screener_config.json → archetypes.a5 - Registry:
a5_mechanical_score,a5_mechanical_state,a5_mechanical_triggers - Tests:
tests/test_archetypes_a5.py
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
| Condition | Threshold | Source |
|---|---|---|
| Historical earnings move | Computable from ≥1 past earnings event | screener_earnings.get_earnings_as_of (last 8 events) |
| Dated catalyst | Upcoming earnings known | Forward calendar (passed by assembler) |
| Quality gate | Pass | app/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
- Pure evaluator:
app/screener/archetypes.py::evaluate_vol_mispricing(feats: dict) -> dict - hist_move helper:
app/screener/archetypes.py::_compute_hist_earnings_move(rows, recency_weight) -> float|None - Caller/assembler:
app/screener/archetypes.py::score_a6_for_ticker(db, ticker, as_of_date, info, days_to_catalyst=...) - Thresholds:
app/signals/config/screener_config.json → archetypes.a6 - Registry:
a6_vol_score,a6_vol_state,a6_vol_triggers - Tests:
tests/test_archetypes_a6.py
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
-
Record at flag time — every time a candidate is flagged (status
neworpersisting),record_screener_predictionswrites onescreener_predictionsrow 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). -
Mature realized returns —
backfill_screener_forward_returns(run daily viascripts/backfill_screener_forward_returns.py) sweeps for predictions whoseflag_date + horizon_dayshas elapsed, looks up the forward close price fromscreener_features_daily, and writesrealized_return = (forward_price − flag_price) / flag_price. This is always an UPDATE, never a DELETE. -
Compute metrics —
get_screener_calibrationreads 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 atGET /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
app/signals/screener_predictions.py— prediction record, backfill, metric mathscripts/backfill_screener_forward_returns.py— idempotent CLI backfill runnerapp/signals/config/screener_config.json → calibration— SSOT for horizons + min_matured_samplestests/test_screener_calibration.py— test coverage