Skip to content

feat(scanner): quality floor + regime-conditional ATR tilt (PRs D+E of intelligent risk-taking arc)#178

Merged
cipher813 merged 1 commit into
mainfrom
feat/scanner-quality-floor-and-regime-atr-tilt
May 13, 2026
Merged

feat(scanner): quality floor + regime-conditional ATR tilt (PRs D+E of intelligent risk-taking arc)#178
cipher813 merged 1 commit into
mainfrom
feat/scanner-quality-floor-and-regime-atr-tilt

Conversation

@cipher813
Copy link
Copy Markdown
Owner

Summary

  • PR D — `apply_quality_filter`: Piotroski-lite reject for names with no profitability signal. Pass if EITHER profitMargins > 0 OR returnOnEquity > 0 (lenient default); strict mode demands BOTH. Sector exemptions (Financial / Real Estate / Utilities) skip.
  • PR E — `apply_regime_atr_tilt`: regime-conditional within-sector ATR tilt. BULL drops bottom-quartile-by-ATR per sector; BEAR drops top. Sector grouping preserves OW/UW composition.
  • 14 new tests, full suite 1120 passed.

Why

Brian 2026-05-13 "no defensive in BULL" arc — these are the SOTA / institutional complement to the per-pick narrative penalty (research#177). Where the narrative penalty operates on agent-generated qual theses, these operate on the population candidate set BEFORE agents see it.

PR D closes the gap I flagged when shipping `max_atr_pct: 18 → 25` in the macro-shift PR — wider ATR aperture admits TSLA/RKLB-class growth but also admits lottery-ticket high-vol junk that has no profitability anchor. Quality floor BELOW the ceiling excludes the junk while keeping the legitimate growth.

PR E is the within-sector relative tilt (the missing leg of the three-knob stack that already includes sector OW/UW + ATR ceiling). In a confirmed bull regime, bottom-quartile-ATR Tech names aren't doing the work the regime calls for — drop them within Tech, keep the within-sector OW.

Companion PR

alpha-engine-config feat/scanner-quality-floor-and-regime-atr-tilt ships the YAML + config.py constants both functions read.

Wiring (manual one-time edit in gitignored data/population_selector.py)

The new functions are unused until called. Add after the existing `apply_balance_sheet_filter` invocation:
```python
if QUALITY_FLOOR_ENABLED:
candidates = apply_quality_filter(candidates, sector_map=sector_map)
if REGIME_ATR_TILT_ENABLED:
candidates = apply_regime_atr_tilt(
candidates, market_regime=market_regime, sector_map=sector_map,
)
```
Both flags default to `enabled: true` so the wire-in is the only thing standing between this code and live behavior.

Composes with merged PRs in this arc

  • config#167 + research#176 (macro shift × 2.5 + macro-sector coherence gate) ✅ merged
  • config#168 + research#177 (regime-conditional narrative penalty) ✅ merged

Together with this PR, five gates filter the population in BULL regime:

  1. Macro shift ±25pts → sector OW/UW dominates score
  2. Coherence gate → no NEW BUY in UW sector below score 80
  3. Narrative penalty → defensive thesis text → -12pts
  4. Quality floor → no profitability signal → REJECT before agent sees
  5. Regime ATR tilt → bottom-quartile sector vol → drop pre-agent

Test plan

  • 14 new tests pass (6 quality_floor + 8 atr_tilt)
  • Full suite 1120 passed (was 1106)
  • After config + research both merge AND population_selector.py wiring is added: Sat 5/16 SF brief shows higher-vol concentration in OW sectors + no unprofitable lottery-ticket names admitted

🤖 Generated with Claude Code

…nt risk-taking arc)

Two new scanner-side filter functions, both Brian's "no defensive in
BULL" arc complement to the per-pick narrative penalty (research#177).
Where the narrative penalty operates on agent-generated qual theses,
these operate on the population candidate set BEFORE agents see it —
SOTA / institutional framing.

PR D — apply_quality_filter (Piotroski-lite):
  Rejects names with no profitability signal. Default lenient mode (pass
  if EITHER profitMargins > 0 OR returnOnEquity > 0); strict mode (BOTH
  required) for BEAR regimes. Uses yfinance Ticker.info — same data
  source as apply_balance_sheet_filter, can run in the same fetch pass.
  Sector exemptions (Financial / Real Estate / Utilities) skip the gate
  where metrics don't apply. Fail-closed on fetch error (M7 pattern).

  Composes with the relaxed ATR ceiling shipped earlier in this arc
  (max_atr_pct=25 admits TSLA/RKLB-class growth) — quality floor BELOW
  the ceiling excludes lottery-ticket high-vol junk that would otherwise
  pass the wider aperture.

PR E — apply_regime_atr_tilt:
  In BULL regime: drops bottom-quartile-by-ATR per sector (those names
  aren't contributing risk in a regime that rewards risk). In BEAR:
  inverts to drop top-quartile (defensive tilt). NEUTRAL: no-op.
  Per-sector grouping ensures within-sector composition is preserved
  (an OW Tech sector doesn't lose all its names just because its mean
  ATR is high vs. Healthcare). Sectors below min_sector_size skip the
  tilt to avoid degenerate quartile cuts. Missing-ATR candidates kept
  as-is (fail-open on the ranking input).

  Composes with sector OW/UW (drives WHICH sectors get exposure) and
  max_atr_pct ceiling (drives the absolute upper bound). This is the
  within-sector RELATIVE tilt — the missing leg of the three-knob stack.

14 new tests (6 quality_floor + 8 atr_tilt) cover sector exemptions,
strict vs lenient mode, fail-closed, regime invert, sector isolation,
small-sector skip, missing-ATR fail-open, unknown-regime no-op.

Wiring (caller side, gitignored data/population_selector.py):
  After existing apply_balance_sheet_filter call:
    if QUALITY_FLOOR_ENABLED:
        candidates = apply_quality_filter(candidates, sector_map=sector_map)
    if REGIME_ATR_TILT_ENABLED:
        candidates = apply_regime_atr_tilt(
            candidates, market_regime=market_regime, sector_map=sector_map,
        )

Full suite 1120 passed (was 1106 before this PR). No regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cipher813 cipher813 merged commit 3e3d860 into main May 13, 2026
1 check passed
@cipher813 cipher813 deleted the feat/scanner-quality-floor-and-regime-atr-tilt branch May 13, 2026 23:47
cipher813 added a commit that referenced this pull request May 14, 2026
…ubstrate, 260513 plan) (#180)

Wires the Phase 1c factor composites (quality / momentum / value / low_vol,
shipped 2026-05-13 in scoring/factor_scoring.py per PR #179) into the
existing composite score via a regime-conditional convex blend.

Plan doc: ~/Development/alpha-engine-docs/private/factor-substrate-260513.md
ROADMAP entry: alpha-engine-config private-docs/ROADMAP.md ~L1276.

Changes:

  scoring/composite.py
    - compute_factor_subscore(profile, regime, regime_weights) — linear
      combination of the 4 within-sector factor composites with signed
      regime-conditional weights, renormalized by absolute-weight sum so
      partial coverage stays on the 0-100 scale, then clamped.
    - compute_composite_score gains factor_subscore + factor_weight kwargs
      (defaults 0/0.0 — fully backward-compatible). When both are non-zero
      the quant_qual_base is convex-combined with the subscore:
          weighted_base = (1 - factor_weight) × quant_qual_base
                        + factor_weight       × factor_subscore
      Returns echo factor_subscore + factor_weight_applied for capture.

  graph/research_graph.py
    - score_aggregator reads factors/profiles/latest.json once at the top
      (gracefully degrades when the artifact is missing — None subscore
      flows through the factor-weight=0 path in compute_composite_score).
    - Per-ticker subscore + breakdown threaded into investment_thesis +
      signals.json (factor_subscore, factor_weight_applied,
      factor_blend_breakdown) for downstream observability.
    - Single end-of-loop log line surfaces applied/skipped coverage.

  config.py
    - Loads aggregator.factor_blend block: FACTOR_BLEND_ENABLED,
      FACTOR_BLEND_WEIGHT, FACTOR_BLEND_REGIME_WEIGHTS{bull,bear,neutral}.

  tests/test_factor_blend.py
    - 22 tests covering compute_factor_subscore (regime branches, partial
      coverage, clamp bounds, none/empty inputs) + compute_composite_score
      factor-aware path (blend applied vs skipped, backward compatibility,
      macro-shift composition, clamping).

Acceptance (per ROADMAP Phase 3):
  ✓ _build_signals_payload outputs include factor_subscore +
    factor_blend_breakdown per signal
  ✓ score_aggregator log shows factor_blend coverage per run

YAML config block ships in companion alpha-engine-config PR; the
auto-deploy-on-config-changed workflow will pick up the change on merge
of both PRs.

Test plan:
  - [x] 22 new unit tests in tests/test_factor_blend.py — all pass
  - [x] Full suite: 1158 passing (was 1136 + 22)
  - [x] Smoke check: from config import FACTOR_BLEND_* loads cleanly
  - [ ] Sat 5/17 02:00 PT SF firing: confirm signals.json per-pick has
        factor_subscore populated, score_aggregator log shows non-zero
        applied count

Composes with:
  - PR #179 (Phase 1c — factor profile writer, MERGED)
  - PR #176/#177/#178 (tactical relief layer — narrative penalty +
    coherence gate kept; PR #177/#178 retired in Phase 4)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cipher813 added a commit that referenced this pull request May 14, 2026
…f factor substrate) (#181)

Phase 4 of the factor substrate arc (plan doc:
~/Development/alpha-engine-docs/private/factor-substrate-260513.md).

ROADMAP entry: alpha-engine-config/private-docs/ROADMAP.md ~L1276 (P0,
Sat 5/16 cron target).

NEW — Factor quality floor in _build_signals_payload:

  graph/research_graph.py
    - Structural floor on within-sector quality_score percentile (raw
      0-100 from the Phase 1c factor_scoring module), applied at the
      buy_candidates construction step alongside the existing macro-
      sector coherence gate.
    - Blocks NEW ENTER signals whose factor_quality_score is below the
      configured percentile floor (default 10.0 = bottom decile).
    - Exempt sectors bypass the floor (Financial / Real Estate /
      Utilities by default — where the underlying quality factor metrics
      do not apply).
    - Graceful degrade: tickers without a factor profile (None
      quality_score) pass through — same pattern as the rest of the
      factor blend.
    - factor_quality_score threaded onto investment_thesis → signals →
      universe → buy_candidates for end-to-end observability.

  config.py
    - New aggregator.factor_quality_floor block loader:
      FACTOR_QUALITY_FLOOR_ENABLED, FACTOR_QUALITY_FLOOR_MIN_PERCENTILE,
      FACTOR_QUALITY_FLOOR_EXEMPT_SECTORS.

  tests/test_factor_quality_floor.py
    - 7 new tests: below-floor blocked, at-floor allowed, exempt-sector
      bypass, missing quality_score lets through, disabled is no-op, gate
      only affects ENTER (not HOLD/EXIT), composition with macro-sector
      coherence gate.

RETIRED — narrative penalty + dormant scanner functions:

  scoring/composite.py
    - DELETE compute_narrative_regime_adjustment — the text-match
      defensive/growth marker logic from PR #177 is replaced by the
      factor-driven composite (Phase 3) that doesn't need string
      matching.

  graph/research_graph.py
    - DELETE the score_aggregator branch that called
      compute_narrative_regime_adjustment + the per-thesis
      narrative_regime_adj / narrative_regime_details fields.
    - DELETE NARRATIVE_* imports from config.

  data/scanner.py
    - DELETE apply_quality_filter (Piotroski-lite — replaced by the
      structural factor_quality_floor at the _build_signals_payload
      level, working on the within-sector factor percentile instead of
      raw yfinance profitMargins/ROE).
    - DELETE apply_regime_atr_tilt (regime-conditional ATR tilt —
      replaced by the factor blend's signed low_vol weight in
      aggregator.factor_blend: BULL: -0.10, BEAR: +0.40).
    - DELETE QUALITY_*/REGIME_ATR_* imports from config.
    - Both functions were dormant — `merged but unwired` per the
      plan doc's tactical-relief-layer table — no production callers
      existed.

  config.py
    - DELETE _NARRATIVE_PENALTY_CFG block + 8 constants.
    - DELETE _QUALITY_FLOOR_CFG block + 5 constants.
    - DELETE _REGIME_ATR_TILT_CFG block + 5 constants.

  tests/test_narrative_regime_penalty.py — DELETE (function gone).
  tests/test_scanner_quality_floor_and_atr_tilt.py — DELETE (functions gone).

Net change: -400 lines of dead-code + dead-test surface, +296 lines of
factor-quality-floor + tests. Suite 1133 → 1140 passing.

Acceptance (per ROADMAP Phase 4):
  ✓ data/scanner.py loses ~177 lines of dormant PR #178 functions
  ✓ scoring/composite.py loses 66 lines of PR #177 narrative logic
  ✓ Quality floor structural rejection lives in _build_signals_payload
    alongside macro-sector coherence gate
  ✓ Suite passes — no production callers were affected by the deletions

Companion alpha-engine-config PR rolls the corresponding YAML changes:
  - DELETE aggregator.narrative_regime_penalty block (40 lines)
  - DELETE scanner.quality_floor + scanner.regime_atr_tilt blocks
  - ADD aggregator.factor_quality_floor block

Composes with:
  - #179 (Phase 1c — factor profile writer, MERGED)
  - #180 (Phase 3 — factor blend wiring)
  - #176 (macro-sector coherence gate — KEPT; it gates on score, not
    quality, and the two compose at the same buy_candidates step)
  - #177 (narrative penalty — RETIRED here)
  - #178 (dormant scanner quality_floor + regime_atr_tilt — RETIRED here)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cipher813 added a commit that referenced this pull request May 14, 2026
… (Stage C.2 T3 prep) (#187)

Surfaces the quantitative regime substrate in the macro economist's
decision-capture snapshot so the LLM-as-judge sees it in agent_input.
Required by the upcoming alpha-engine-config rubric PR that adds a
``regime_decision_process`` dimension scoring the agent's regime call
against the substrate.

graph/decision_capture_helpers.py
- ``build_macro_economist_capture_payload`` now reads
  ``state["regime_substrate"]`` and includes it in the snapshot.
  ``None`` flows through cleanly (Stage A pre-deploy or non-blocking
  SF Catch tripped) — judge anchor handles substrate-absent case in
  the rubric (score 5, mirroring no-prior-macro-report convention).
- Summary string surfaces a compact substrate marker:
  ``regime_substrate=present(argmax=bear,intensity_z=-1.80)`` or
  ``regime_substrate=absent`` for operator/CW log clarity.

tests/test_decision_capture_integration.py (+3 new)
- regime_substrate present → ends up in snapshot + summary marker
- regime_substrate=None → snapshot field is None + summary marks absent
- regime_substrate missing entirely → treated as absent (defensive)

tests/test_prompts_no_inline_json_schema.py
- test_macro_agent_format_passes_canonical_sectors updated to pass
  ``regime_substrate="..."`` to ``_PROMPT_TEMPLATE.format()`` — the
  template added a {regime_substrate} placeholder in alpha-engine-config
  v1.3.0 (PR #178, merged). Without the kwarg the format call now
  raises KeyError. Pin uses the canonical "not available this run"
  fallback string so the test exercises the same shape the agent's
  _format_regime_substrate(None) emits.

1223 passed.

Composes with:
- alpha-engine-research PR #186 (merged — Stage C.1 substrate consumption)
- alpha-engine-config PR #178 (merged — macro_agent.txt v1.3.0)
- alpha-engine-config follow-up — adds the regime_decision_process
  rubric dimension that scores the agent against the now-visible
  substrate. Ships as a separate PR (rubric YAML, no code).

Reference: ~/Development/alpha-engine-docs/private/regime-v3-260514.md §5.3.3

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cipher813 added a commit that referenced this pull request May 19, 2026
…R 4) (#205)

Plan regime-drawdown-hysteresis-260518.md §6 item 4 (ROADMAP L99). The
producer arc (predictor #176/#177/#179, ae #193) shipped 2026-05-19 and
now fills substrate["drawdown"] + substrate["effective_regime"] weekly;
this is the research consumer surface.

- agents/macro_agent.py: new _format_drawdown_leg() renders the SPY
  tiered drawdown + book-vs-market excess as a continuous,
  market-grounded statement plus the composed effective_regime
  (most-protective over the ensemble). The HMM "weeks_in_current_state"
  line is reframed as a filter-stability DIAGNOSTIC, explicitly NOT a
  market-duration statement (origin: the 2026-05-15 brief's misleading
  "P(neutral)=1.00, 50 weeks in state").
- graph/research_graph.py: _build_regime_trend drops the "Weeks in
  State" column for a continuous "SPY Drawdown" + composed "Effective"
  column, and the Summary line carries the continuous statement
  ("SPY X% off trailing peak; book Y% off NAV HWM; Z pp deeper").

Fallbacks (both tested):
- absent-key: no substrate["drawdown"] (pre-#176/#179) ⇒ the leg block
  is omitted entirely — byte-identical to the prior HMM-only path.
- portfolio-unavailable: drawdown.excess.available=False ⇒ SPY leg
  still renders; excess line states it is unavailable; never raises.

flag-name note: consumers/config use drawdown_regime_enabled (matches
shipped predictor #178 + ROADMAP/SYSTEM_STATE), not the plan doc's
stray L214 regime_drawdown_enabled spelling.

Tests: +8 (drawdown columns, continuous summary, absent-key, NAV
unavailable, macro-prompt leg rendering). max_tokens lint allowlist
line ref bumped 476→562 (critic call relocated, unchanged 512 literal).
Full suite 1372 passed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant