institutional-trader/backend/database/premarketgaprangescreener.sql

363 lines
13 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ===========================
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:3009: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 readytotrade playbooks
1) GapandGo (trend continuation from the bell)
Objective: ride early momentum in the gap direction.
Scan:
abs(pm_gap_pct) ≥ 13 (tickerdependent)
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 OR5high).
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/15min swing lows or VWAP.
When to stand down: activity_range_pct ≥ 57% and activity_rvol < 1.0 → often prone to early fades.
2) GapFade to VWAP/Fill (meanreversion)
Objective: fade exhausted gaps back toward prior close or VWAP.
Scan:
abs(pm_gap_pct) ≥ 35
activity_rvol ≤ 0.9 (quiet)
has_alert = false (or alert_types lacks “Block/Options/AnnualHigh”)
Optional: open_above_pdh = false for gapups; reverse for gapdowns.
Plan (short example on gapup):
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% gapfill.
Abort: if reclaim of activity_high on volume (activity_rvol jumps >1.2), switch to Plan 1.
3) ORBreak + Alert Confluence (no PM, but institutional tone)
Objective: trade names without PM that show strong OR5 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 (OR5 high) with a quick retest.
Stop: under activity_low.
Targets: +1R → priorday 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 gapups), no “Block/Options/AnnualHigh” alert.
How to turn columns into orders (repeatable process)
Sort by abs(pm_gap_pct) to see the tapes gravity wells.
Tag each symbol as PM or OR5 mode:
If has_premarket = true, your activity_ = PM*.
Else, activity_ = OR5* (treat those levels as your “opening range”).
Keep only whats 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 isnt fetching extended hours. The scanner still works (OR5 mode), but PMspecific fields will be quiet.
If pm_rvol is 0 yet activity_rvol > 0, thats expected in OR5 mode; work with activity_rvol for decisions.
If gap_and_go_candidate = true with has_premarket = false, that means open > OR5 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 OR5.
open_above_pdh / gap_and_go_candidate → trendday potential and early go/nogo.
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)—youve got a trade thats both findable and repeatable.*/