363 lines
13 KiB
SQL
363 lines
13 KiB
SQL
/* ===========================
|
||
PRE-MARKET SCANNER (FIXED)
|
||
=========================== */
|
||
|
||
with knobs as (
|
||
select
|
||
null::text as bucket_name, -- 'SEMIS' | 'BANKS' | NULL/'ALL'
|
||
0.0::numeric as min_abs_gap_pct, -- tighten later (e.g., 1.0)
|
||
0.0::numeric as min_rvol, -- tighten later (e.g., 1.2)
|
||
false as require_alert
|
||
),
|
||
pick_day as (
|
||
select max(d_et)::date as d0
|
||
from public.minute_bars_et
|
||
where session='rth'
|
||
),
|
||
universe as (
|
||
select distinct m.symbol
|
||
from public.minute_bars_et m, pick_day d, knobs k
|
||
where m.d_et=d.d0
|
||
and (k.bucket_name is null or k.bucket_name='ALL'
|
||
or m.symbol in (select symbol from public.bucket_members where bucket=k.bucket_name))
|
||
),
|
||
|
||
-- PRE-MARKET aggregates
|
||
pm as (
|
||
select
|
||
m.symbol, m.d_et,
|
||
count(*) as pm_bars,
|
||
min(m.low) as pm_low_raw,
|
||
max(m.high) as pm_high_raw,
|
||
sum(m.volume)::numeric as pm_vol_raw,
|
||
(select close from public.minute_bars_et x
|
||
where x.symbol=m.symbol and x.d_et=m.d_et and x.session='pre'
|
||
order by x.ts desc limit 1) as pm_last_raw
|
||
from public.minute_bars_et m, pick_day d
|
||
where m.d_et=d.d0 and m.session='pre'
|
||
and m.symbol in (select symbol from universe)
|
||
group by m.symbol, m.d_et
|
||
),
|
||
|
||
-- 09:30 open (ET)
|
||
open_0930 as (
|
||
select u.symbol,
|
||
(select o.open from public.minute_bars_et o, pick_day d
|
||
where o.symbol=u.symbol and o.d_et=d.d0
|
||
and o.session='rth' and o.hh=9 and o.mm=30
|
||
limit 1) as open_0930
|
||
from universe u
|
||
),
|
||
|
||
-- Opening range 09:30–09:34 as fallback
|
||
or5 as (
|
||
select
|
||
m.symbol, m.d_et,
|
||
max(m.high) as or_hi,
|
||
min(m.low) as or_lo,
|
||
sum(m.volume)::numeric as or_vol
|
||
from public.minute_bars_et m, pick_day d
|
||
where m.d_et=d.d0 and m.session='rth'
|
||
and (m.hh=9 and m.mm between 30 and 34)
|
||
and m.symbol in (select symbol from universe)
|
||
group by m.symbol, m.d_et
|
||
),
|
||
|
||
-- Prev trading day close/high
|
||
prev_day as (
|
||
select distinct on (d.symbol)
|
||
d.symbol,
|
||
d.close as prior_close,
|
||
d.high as pdh
|
||
from public.daily_bars d, pick_day pd
|
||
where d.date < pd.d0
|
||
order by d.symbol, d.date desc
|
||
),
|
||
|
||
-- 20-session PM average (RVOL denom)
|
||
hist_pm as (
|
||
select symbol, avg(pm_vol)::numeric as avg20_pm_vol
|
||
from (
|
||
select symbol, d_et, sum(volume)::numeric as pm_vol
|
||
from public.minute_bars_et, pick_day d
|
||
where session='pre'
|
||
and d_et >= (d.d0 - interval '40 day')::date
|
||
and d_et < d.d0
|
||
group by symbol, d_et
|
||
) t group by symbol
|
||
),
|
||
|
||
-- 20-session OR-5 average (fallback RVOL denom)
|
||
hist_or as (
|
||
select symbol, avg(or_vol)::numeric as avg20_or_vol
|
||
from (
|
||
select m.symbol, m.d_et,
|
||
sum(m.volume)::numeric as or_vol
|
||
from public.minute_bars_et m
|
||
where m.session='rth' and (m.hh=9 and m.mm between 30 and 34)
|
||
group by m.symbol, m.d_et
|
||
) x
|
||
where x.d_et < (select d0 from pick_day)
|
||
group by symbol
|
||
),
|
||
|
||
-- Alerts (read CamelCase table safely)
|
||
alerts_raw as (
|
||
select
|
||
case
|
||
when coalesce(to_jsonb(a)->>'Date', to_jsonb(a)->>'date',
|
||
to_jsonb(a)->>'AlertDate', to_jsonb(a)->>'alert_date') ~ '^\d{4}-\d{2}-\d{2}$'
|
||
then coalesce(to_jsonb(a)->>'Date', to_jsonb(a)->>'date',
|
||
to_jsonb(a)->>'AlertDate', to_jsonb(a)->>'alert_date')::date
|
||
when coalesce(to_jsonb(a)->>'Date', to_jsonb(a)->>'date',
|
||
to_jsonb(a)->>'AlertDate', to_jsonb(a)->>'alert_date') ~ '^\d{2}/\d{2}/\d{4}$'
|
||
then to_date(coalesce(to_jsonb(a)->>'Date', to_jsonb(a)->>'date',
|
||
to_jsonb(a)->>'AlertDate', to_jsonb(a)->>'alert_date'), 'MM/DD/YYYY')
|
||
else null
|
||
end as alert_date,
|
||
coalesce(to_jsonb(a)->>'Ticker', to_jsonb(a)->>'ticker',
|
||
to_jsonb(a)->>'Symbol', to_jsonb(a)->>'symbol') as symbol,
|
||
coalesce(to_jsonb(a)->>'Type', to_jsonb(a)->>'type') as alert_type,
|
||
nullif(regexp_replace(coalesce(to_jsonb(a)->>'Notional', to_jsonb(a)->>'notional'),
|
||
'[,\s]', '', 'g'), '')::numeric as notional,
|
||
nullif(regexp_replace(coalesce(to_jsonb(a)->>'Pct_of_Avg30Day',
|
||
to_jsonb(a)->>'pct_of_avg30day',
|
||
to_jsonb(a)->>'Pct_of_Avg30',
|
||
to_jsonb(a)->>'pct_of_avg30'),
|
||
'[,%\s]', '', 'g'), '')::numeric as pct_of_avg30
|
||
from public."AlertStream_monthly" a
|
||
),
|
||
alerts as (
|
||
select
|
||
r.symbol,
|
||
count(*) as alert_count,
|
||
max(r.notional) as max_alert_notional,
|
||
max(r.pct_of_avg30) as max_pct_of_avg30,
|
||
string_agg(distinct r.alert_type, ', ' order by r.alert_type) as alert_types
|
||
from alerts_raw r, pick_day d
|
||
where r.alert_date = d.d0
|
||
and r.symbol in (select symbol from universe)
|
||
group by r.symbol
|
||
),
|
||
|
||
scan as (
|
||
select
|
||
u.symbol,
|
||
|
||
-- reference price (PM last → 09:30 → prior close)
|
||
coalesce(pm.pm_last_raw, o.open_0930, pd.prior_close) as ref_price,
|
||
|
||
pd.prior_close,
|
||
|
||
round( (100 * (coalesce(pm.pm_last_raw, o.open_0930, pd.prior_close)
|
||
/ nullif(pd.prior_close,0.0) - 1))::numeric, 2 ) as pm_gap_pct,
|
||
|
||
-- activity = PM if exists, else OR5
|
||
coalesce(pm.pm_high_raw, or5.or_hi, o.open_0930, pd.prior_close) as activity_high,
|
||
coalesce(pm.pm_low_raw, or5.or_lo, o.open_0930, pd.prior_close) as activity_low,
|
||
coalesce(pm.pm_vol_raw, or5.or_vol, 0)::numeric as activity_vol,
|
||
|
||
case
|
||
when coalesce(pm.pm_bars,0) > 0 and coalesce(pm.pm_last_raw,0) <> 0
|
||
then round( (100 * ((pm.pm_high_raw - pm.pm_low_raw)
|
||
/ nullif(pm.pm_last_raw,0.0)))::numeric, 2 )
|
||
when or5.or_hi is not null and or5.or_lo is not null and o.open_0930 is not null
|
||
then round( (100 * ((or5.or_hi - or5.or_lo)
|
||
/ nullif(o.open_0930,0.0)))::numeric, 2 )
|
||
else 0::numeric
|
||
end as activity_range_pct,
|
||
|
||
-- activity RVOL (PM RVOL if PM exists, else OR5 RVOL)
|
||
case
|
||
when coalesce(pm.pm_bars,0) > 0
|
||
then coalesce(round( (pm.pm_vol_raw / nullif(hp.avg20_pm_vol,0))::numeric, 2 ),
|
||
round( (or5.or_vol / nullif(ho.avg20_or_vol,0))::numeric, 2 ), 0)
|
||
else coalesce(round( (or5.or_vol / nullif(ho.avg20_or_vol,0))::numeric, 2 ), 0)
|
||
end as activity_rvol,
|
||
|
||
-- PM-only fields (pm_rvol now falls back to OR5 rvol if PM avg is missing)
|
||
coalesce(pm.pm_high_raw, or5.or_hi, o.open_0930, pd.prior_close) as pm_high,
|
||
coalesce(pm.pm_low_raw, or5.or_lo, o.open_0930, pd.prior_close) as pm_low,
|
||
coalesce(pm.pm_vol_raw, 0)::numeric as pm_vol,
|
||
coalesce(round( (pm.pm_vol_raw / nullif(hp.avg20_pm_vol,0))::numeric, 2 ),
|
||
round( (or5.or_vol / nullif(ho.avg20_or_vol,0))::numeric, 2 ),
|
||
0) as pm_rvol, -- <-- FIXED: fallback so it's not stuck at 0
|
||
|
||
coalesce(pm.pm_bars > 0, false) as has_premarket,
|
||
|
||
o.open_0930,
|
||
pd.pdh,
|
||
|
||
-- "go" logic: PM high → OR5 high → PDH
|
||
case
|
||
when coalesce(pm.pm_bars,0) > 0 and pm.pm_high_raw is not null
|
||
then o.open_0930 > pm.pm_high_raw
|
||
when or5.or_hi is not null
|
||
then o.open_0930 > or5.or_hi
|
||
else o.open_0930 > pd.pdh
|
||
end as gap_and_go_candidate,
|
||
|
||
(o.open_0930 > pd.pdh) as open_above_pdh,
|
||
|
||
-- alerts
|
||
coalesce(a.alert_count,0) as alert_count,
|
||
coalesce(a.alert_types,'') as alert_types,
|
||
coalesce(a.max_alert_notional,0) as max_alert_notional,
|
||
coalesce(a.max_pct_of_avg30,0) as max_pct_of_avg30,
|
||
(a.symbol is not null) as has_alert
|
||
|
||
from universe u
|
||
left join pm on pm.symbol = u.symbol
|
||
left join prev_day pd on pd.symbol = u.symbol
|
||
left join open_0930 o on o.symbol = u.symbol
|
||
left join or5 on or5.symbol = u.symbol
|
||
left join hist_pm hp on hp.symbol = u.symbol
|
||
left join hist_or ho on ho.symbol = u.symbol
|
||
left join alerts a on a.symbol = u.symbol
|
||
|
||
where pd.prior_close is not null
|
||
and abs( (100 * (coalesce(pm.pm_last_raw, o.open_0930, pd.prior_close)
|
||
/ nullif(pd.prior_close,0.0) - 1)) ) >= (select min_abs_gap_pct from knobs)
|
||
and (
|
||
(coalesce(pm.pm_bars,0) > 0 and
|
||
coalesce((pm.pm_vol_raw / nullif(hp.avg20_pm_vol,0)),
|
||
(or5.or_vol / nullif(ho.avg20_or_vol,0)), 0) >= (select min_rvol from knobs))
|
||
or (coalesce(pm.pm_bars,0) = 0 and
|
||
(coalesce(or5.or_vol,0) / nullif(ho.avg20_or_vol,0)) >= (select min_rvol from knobs))
|
||
or (select min_rvol from knobs) = 0
|
||
)
|
||
and (not (select require_alert from knobs) or a.symbol is not null)
|
||
)
|
||
|
||
select *
|
||
from scan
|
||
order by abs(pm_gap_pct) desc nulls last, pm_gap_pct desc;
|
||
|
||
|
||
/*Three ready‑to‑trade playbooks
|
||
1) Gap‑and‑Go (trend continuation from the bell)
|
||
|
||
Objective: ride early momentum in the gap direction.
|
||
Scan:
|
||
|
||
abs(pm_gap_pct) ≥ 1–3 (ticker‑dependent)
|
||
|
||
activity_rvol ≥ 1.2
|
||
|
||
gap_and_go_candidate = true
|
||
|
||
Optional: has_alert = true AND alert_types ~ '(Block|Options|AnnualHigh)'
|
||
|
||
Plan (long example):
|
||
|
||
Trigger: break and hold above activity_high (PMH if PM exists; else OR‑5‑high).
|
||
|
||
Stop: below activity_low or below VWAP after reclaim.
|
||
|
||
First target: +1R or measured move = (activity_high − activity_low) added to the breakout.
|
||
|
||
Trail: under 5/15‑min swing lows or VWAP.
|
||
|
||
When to stand down: activity_range_pct ≥ 5–7% and activity_rvol < 1.0 → often prone to early fades.
|
||
|
||
2) Gap‑Fade to VWAP/Fill (mean‑reversion)
|
||
|
||
Objective: fade exhausted gaps back toward prior close or VWAP.
|
||
Scan:
|
||
|
||
abs(pm_gap_pct) ≥ 3–5
|
||
|
||
activity_rvol ≤ 0.9 (quiet)
|
||
|
||
has_alert = false (or alert_types lacks “Block/Options/AnnualHigh”)
|
||
|
||
Optional: open_above_pdh = false for gap‑ups; reverse for gap‑downs.
|
||
|
||
Plan (short example on gap‑up):
|
||
|
||
Trigger: failure at activity_high (wick through and close back below) or VWAP rejection.
|
||
|
||
Stop: above the failure wick or above activity_high.
|
||
|
||
Targets: VWAP → prior_close → 50% gap‑fill.
|
||
|
||
Abort: if reclaim of activity_high on volume (activity_rvol jumps >1.2), switch to Plan 1.
|
||
|
||
3) OR‑Break + Alert Confluence (no PM, but institutional tone)
|
||
|
||
Objective: trade names without PM that show strong OR‑5 and alert footprint.
|
||
Scan:
|
||
|
||
has_premarket = false
|
||
|
||
activity_rvol ≥ 1.3
|
||
|
||
alert_count > 0 and alert_types ~ '(Block|Options|AnnualHigh)'
|
||
|
||
open_above_pdh = true (long bias) or below PDL for shorts.
|
||
|
||
Plan (long):
|
||
|
||
Trigger: break of activity_high (OR‑5 high) with a quick retest.
|
||
|
||
Stop: under activity_low.
|
||
|
||
Targets: +1R → prior‑day range extension → round numbers.
|
||
|
||
Practical filters that work
|
||
|
||
Liquidity guardrails: ref_price ≥ 5 and activity_vol ≥ 100k (adjust per strategy).
|
||
|
||
Momentum set: abs(pm_gap_pct) ≥ 2, activity_rvol ≥ 1.3, activity_range_pct ≥ 2, has_alert OR open_above_pdh.
|
||
|
||
Fade set: abs(pm_gap_pct) ≥ 4, activity_rvol ≤ 0.9, open_above_pdh = false (for gap‑ups), no “Block/Options/AnnualHigh” alert.
|
||
|
||
How to turn columns into orders (repeatable process)
|
||
|
||
Sort by abs(pm_gap_pct) to see the tape’s gravity wells.
|
||
|
||
Tag each symbol as PM or OR5 mode:
|
||
|
||
If has_premarket = true, your activity_ = PM*.
|
||
|
||
Else, activity_ = OR‑5* (treat those levels as your “opening range”).
|
||
|
||
Keep only what’s busy: activity_rvol ≥ 1.2 (momentum) or ≤ 0.9 (fade).
|
||
|
||
Layer alerts: prioritize has_alert and the heavy types (Block, Options, AnnualHigh).
|
||
|
||
Plan levels:
|
||
|
||
Longs → buy breaks/retests of activity_high; risk to activity_low.
|
||
|
||
Shorts → sell breaks/rejections of activity_low; risk to activity_high.
|
||
|
||
Size off the range: position size = risk_$ / (activity_high − activity_low).
|
||
|
||
Manage: partial at +1R, trail under swings or VWAP; stop if the thesis (level/VWAP) is lost.
|
||
|
||
Quick sanity checks you can apply on your sheet
|
||
|
||
If has_premarket = false across the board, your data source isn’t fetching extended hours. The scanner still works (OR‑5 mode), but PM‑specific fields will be quiet.
|
||
|
||
If pm_rvol is 0 yet activity_rvol > 0, that’s expected in OR‑5 mode; work with activity_rvol for decisions.
|
||
|
||
If gap_and_go_candidate = true with has_premarket = false, that means open > OR‑5 high (a legit momentum tell even without PM).
|
||
|
||
TL;DR mapping: column → decisions
|
||
|
||
pm_gap_pct → rank & bias (biggest ± first).
|
||
|
||
activity_high/low → breakout/stop levels.
|
||
|
||
activity_vol & activity_rvol → participation; ≥1.2 momentum, ≤0.9 fade.
|
||
|
||
activity_range_pct → expansion vs reversion regime.
|
||
|
||
has_premarket → interpret activity_ as PM or OR‑5.
|
||
|
||
open_above_pdh / gap_and_go_candidate → trend‑day potential and early go/no‑go.
|
||
|
||
alerts (count/types/notional) → catalyst & conviction filter.
|
||
|
||
When you glue those together—a bias (gap), proof of life (RVOL), clean levels (activity high/low), and a catalyst (alerts)—you’ve got a trade that’s both findable and repeatable.*/ |