Skip to content

Bull pennant detection criteria — as of 2026-05-11

Read-only documentation pull from live code. No code changes made.

Source files inspected: - uriel/detect/pennant.py — 4-stage pipeline driver, pennant geometry scan, event emission - uriel/detect/flagpole.py — pole validation (validate_flagpole) - uriel/detect/trend_filter.py — EMA_55 slope gate (passes_trend_filter) - uriel/config.py — pydantic config models + defaults - uriel.toml — live threshold values - Project_Uriel_Charter_v1.5.pdf §§ 6.3, 7.1–7.5, 7.9 (Charter v1.5 is a housekeeping-only rev of v1.4 — no spec deltas)


1. Pennant geometry (Stage 1)

  • Duration: 5 to 15 bars, inclusive (min_duration_bars=5, max_duration_bars=15). Measured as end_idx − begin_idx where begin_idx is the bar immediately after the pole top and end_idx is the anchor (pennant's last bar).
  • Highs slope rule: linear regression slope of high over the window must be strictly < 0 (pennant.py:287).
  • Lows slope rule: linear regression slope of low over the window must be strictly > 0 (pennant.py:287).
  • Retrace depth (preliminary, pre-pole): (pole_top_close − min(close_in_pennant)) / pole_top_close ≤ max_retrace_pct × 1.5 — a generous pre-filter applied before the pole is located (pennant.py:302–304).
  • Retrace depth (final, close-based, pole-height-normalized): (pole_top_close − min(close_in_pennant)) / (pole_top_close − pole_start_close) ≤ 0.382 (pennant.py:313–317). Falls back to 999 (i.e. reject) if pole_top_close − pole_start_close ≤ 0.
  • Volume behavior (Stage 4 gate): mean(pennant_volume) ≤ mean(pole_volume) (pennant.py:325–330). If pole_avg_vol == 0 the check is skipped.
  • Dedup / skip-ahead: on a successful detection, skip_until_idx = end_idx + min_dur so the next iteration jumps past the just-emitted pennant (pennant.py:391).
  • Slope computation: vectorized via np.lib.stride_tricks.sliding_window_view + centered-x dot product, mathematically equivalent to the prior _linreg_slope (pennant.py:414–456). Held to this exact arithmetic order to preserve the post-9a.3 baseline.

2. Flagpole validation (Stage 2)

Implemented in uriel/detect/flagpole.py. Searches backward from the pennant's first bar (= pole top) for a qualifying pole; iterates lookback = min_duration_bars … max_duration_bars and keeps the longest qualifying pole.

  • Magnitude — dual gate (both must pass):
  • Gate A (percent): (pole_top_close − pole_start_close) / pole_start_close ≥ 0.12 (flagpole.py:73–76).
  • Gate B (ATR-normalized): (pole_top_close − pole_start_close) / ATR(20)[pole_start_idx] ≥ 4.0 (flagpole.py:78–84).
  • Duration: 1 to 10 bars from pole start to pole top (flagpole.py:64).
  • Volume: mean(pole_volume) / mean(volume_over_20_bars_preceding_pole_start) ≥ 1.5 (flagpole.py:87–100). If baseline window is empty (pole starts at index 0) the ratio is NaN and the check is skipped (passes).
  • ATR(20): Wilder's smoothing (ewm(alpha=1/20, min_periods=20, adjust=False)), computed once per ticker (pennant.py:209–219).

3. Trend validation (Stage 3)

Implemented in uriel/detect/trend_filter.py.

  • Rule: EMA_55[pole_start_idx] ≥ EMA_55[pole_start_idx − 10]. Flat or rising passes; declining fails (trend_filter.py:45).
  • Measurement: close.ewm(span=55, adjust=False) (pennant.py:222); lookback 10 trading days (config.py:54, uriel.toml).
  • Exclusions: declining EMA_55, or NaN at either endpoint, or pole_start_idx − 10 < 0 (trend_filter.py:34–42).

4. Detection order — as implemented

Per pennant.py:_detect_for_ticker, for each (end_idx, win_len) pair:

  1. Pennant geometry pre-filter: slope signs, prelim retrace vs pole_top (pennant.py:283–304).
  2. Flagpole validation: validate_flagpole(...) — dual magnitude gate + duration + pole volume (pennant.py:307–308).
  3. Refined retrace depth gate: retrace ≤ 38.2 % of pole height (pennant.py:312–317).
  4. Trend validation: passes_trend_filter(...) — EMA_55 slope ≥ 0 over 10-day lookback (pennant.py:321).
  5. Pennant volume gate: mean(pennant_vol) ≤ mean(pole_vol) (pennant.py:325–330).
  6. Record event: geometry + pole metrics + volume ratio + volume trend + range-contraction ratio written to pattern_events.pattern_metadata JSON (pennant.py:355–388).

5. Other filters / gates applied at detection time

  • Active-ticker filter: SELECT symbol FROM tickers WHERE active = TRUE is the universe (pennant.py:49). Liquidity floor ($1 M ADV per Charter §4) is enforced via the universe build, not at detection time.
  • History minimum: ticker is skipped if n_bars < 300 (pennant.py:188–190). Logged at INFO, not written to ingest_failures (Phase 9e.2 / Finding 5).
  • Scan start offset: the sliding window only emits from end_idx ≥ 220 (pennant.py:250), leaving room for indicator warm-up + max-pole lookback.
  • Cooldown — recent-scan only: detect_pennants_recent(...) skips any ticker that already has a pattern_events row within the last 30 days (pennant.py:115–122, pennant.py:136). The full historical scan detect_pennants(...) does not apply cooldown.
  • Dedup at detection time: in-loop skip_until_idx prevents overlapping anchors within a single ticker run (pennant.py:391); recent-scan additionally dedups against pattern_events.event_id already in the DB (pennant.py:149).
  • No squeeze gate at detection (consistent with Charter §7.5: squeeze is a feature, not a gate).

6. Constants and thresholds — reference table

Parameter Value Source file:line
pennant.min_duration_bars 5 uriel.toml:32, config.py:68
pennant.max_duration_bars 15 uriel.toml:33, config.py:69
pennant.max_retrace_pct 0.382 uriel.toml:31, config.py:67
flagpole.min_magnitude_pct 12.0 (%) uriel.toml:23, config.py:58
flagpole.min_atr_multiple 4.0 uriel.toml:24, config.py:59
flagpole.min_duration_bars 1 uriel.toml:26, config.py:61
flagpole.max_duration_bars 10 uriel.toml:25, config.py:60
flagpole.volume_ratio_min 1.5 uriel.toml:27, config.py:62
flagpole.volume_baseline_days 20 uriel.toml:28, config.py:63
trend_filter.ema_period 55 uriel.toml:19, config.py:53
trend_filter.slope_lookback_days 10 uriel.toml:20, config.py:54
overlap.cooldown_days 30 uriel.toml:47, config.py:86
Hardcoded: scan start offset 220 bars pennant.py:250
Hardcoded: min history 300 bars pennant.py:188
Hardcoded: prelim retrace multiplier 1.5 × max_retrace pennant.py:303
Hardcoded: ATR period 20 (Wilder) pennant.py:217

7. Discrepancies vs Charter v1.4 / v1.5 §7

  1. Gate A measurement basis. Charter §7.2 says "Gate A (percent): Close-to-high move ≥ 12 % from pole start to pole top." flagpole.py's docstring repeats the "close-to-high" wording, but the implementation is close-to-close(close_arr[pole_top_idx] − close_arr[start_idx]) / close_arr[start_idx]. Same for Gate B (close-to-close, not close-to-high) (flagpole.py:73, 82). Either the charter wording or the implementation is off; live behavior is close-to-close.

  2. Cooldown / overlap handling in recent-scan path. Charter §7.9 specifies that overlapping pennants are recorded with overlapping = true and excluded only from the primary precursor search. detect_pennants_recent instead skips entire tickers that are in the 30-day cooldown window (pennant.py:117–140) — those overlap candidates are never written to pattern_events. The full historical detect_pennants applies no cooldown at all and emits everything, so the historical study path is consistent with the charter; only the recent-scan path diverges.

  3. overlapping field not populated at detection time. Charter §6.2 lists overlapping as a boolean on pattern_events; the detector's metadata JSON (pennant.py:355–378) does not include it, and the INSERT (pennant.py:31–40) writes only event_id, symbol, event_date, pattern_type, pattern_metadata. If the column exists, it is populated by a downstream pass, not the detector.

  4. Detection-order substage count. Charter §7.3 enumerates three gating substages (Pennant → Flagpole → Trend), then "Feature recording — no gating." The implementation interleaves a fourth hard gate (Q4 pennant volume ≤ pole volume, charter §7.5 Q4) after the trend filter and before recording (pennant.py:325–330). This is consistent with §7.5 calling Q4 a hard rule, but it is not listed as a separate substage in §7.3. Organizational only — no behavioral divergence from §7.5.

  5. Warm-up minima not specified in charter. n_bars ≥ 300 and start_pos = 220 are hardcoded in pennant.py (lines 188, 250). Reasonable for 200-SMA + 20-ATR + max-pole warm-up, but unspecified in §7.

  6. Liquidity floor location. Charter §4 sets $1 M 30-day ADV as a universe filter. The detector itself has no liquidity gate; it relies on tickers.active = TRUE (pennant.py:49) as a proxy. Not a divergence if universe construction enforces ADV at the active flag, but worth confirming.

End of report.