Skip to content

Commit 25ce43c

Browse files
feat: reusable technical-analysis indicators and signal history models (#109) (#113)
Phase 1 of the technical-analysis framework: - macros/technical_analysis: window primitives, price transforms, and a finite-window exponentially weighted mean (project-standard EMA/RMA for BigQuery, documented convergence) - technical_price_universe: unified adjusted OHLCV spine across sp500 stocks (split-adjusted), sector/fixed-income/currency ETFs, major indices, and global markets, with OHLC consistency clamping - technical_indicator_daily: SMA/EMA, RSI(Wilder), MACD, Bollinger, ATR/NATR, ADX/+DI/-DI, stochastic, ROC, Williams %R, CCI, OBV, MFI, Donchian, relative volume, z-score, 52w distance; warmup-gated NULLs - technical_signal_events: jinja signal registry (14 signals) with setup/triggered/active/completed/expired state machine - technical_signal_instances: forward returns at 1/5/10/21/63/126 bars, SPY-relative returns, MFE/MAE, worked labels (no lookahead outside this evaluation model) - technical_current_setups + agent_technical_signal_setups/history: operational current-state and reliability surfaces - deprecates SPY-only technical_signals (VIX columns not yet migrated) Validated with dbt build in dev: 7 models + 56 tests, all passing. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e9b3a57 commit 25ce43c

14 files changed

Lines changed: 1413 additions & 1 deletion
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{#
2+
Price-transform expressions (issue #109). These are plain column
3+
expressions — no window functions — so they can be used at any stage.
4+
#}
5+
6+
{% macro ta_typical_price(high='high', low='low', close='close') %}
7+
(({{ high }} + {{ low }} + {{ close }}) / 3)
8+
{% endmacro %}
9+
10+
11+
{% macro ta_median_price(high='high', low='low') %}
12+
(({{ high }} + {{ low }}) / 2)
13+
{% endmacro %}
14+
15+
16+
{% macro ta_weighted_close(high='high', low='low', close='close') %}
17+
(({{ high }} + {{ low }} + 2 * {{ close }}) / 4)
18+
{% endmacro %}
19+
20+
21+
{% macro ta_true_range(high='high', low='low', prev_close='prev_close') %}
22+
{#- prev_close is expected to be a precomputed LAG(close) column.
23+
Falls back to plain high-low on the first bar (prev_close NULL),
24+
matching TA-Lib behavior. -#}
25+
GREATEST(
26+
{{ high }} - {{ low }},
27+
COALESCE(ABS({{ high }} - {{ prev_close }}), {{ high }} - {{ low }}),
28+
COALESCE(ABS({{ low }} - {{ prev_close }}), {{ high }} - {{ low }})
29+
)
30+
{% endmacro %}
31+
32+
33+
{% macro ta_daily_return(close='close', prev_close='prev_close') %}
34+
SAFE_DIVIDE({{ close }} - {{ prev_close }}, NULLIF({{ prev_close }}, 0))
35+
{% endmacro %}
36+
37+
38+
{% macro ta_log_return(close='close', prev_close='prev_close') %}
39+
SAFE.LN(SAFE_DIVIDE({{ close }}, NULLIF({{ prev_close }}, 0)))
40+
{% endmacro %}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
{#
2+
Technical-analysis window primitives (issue #109).
3+
4+
Conventions:
5+
- Every macro accepts partition_by / order_by instead of hardcoding
6+
symbol or date. Default partition is the symbol-level grain of
7+
technical_price_universe: source_universe, symbol, exchange.
8+
- Warmup handling is the caller's responsibility: gate outputs with
9+
`CASE WHEN bars_available >= <window> THEN ... END` so indicators
10+
are NULL until enough lookback rows exist.
11+
#}
12+
13+
{% macro ta_default_partition() %}
14+
{{- return('source_universe, symbol, exchange') -}}
15+
{% endmacro %}
16+
17+
18+
{% macro ta_rolling(agg, column, window, partition_by=none, order_by='date') %}
19+
{#- Rolling aggregate over the trailing `window` rows (inclusive of current). -#}
20+
{%- set partition_by = partition_by or ta_default_partition() -%}
21+
{{ agg }}({{ column }}) OVER (
22+
PARTITION BY {{ partition_by }}
23+
ORDER BY {{ order_by }}
24+
ROWS BETWEEN {{ window - 1 }} PRECEDING AND CURRENT ROW
25+
)
26+
{% endmacro %}
27+
28+
29+
{% macro ta_rolling_prior(agg, column, window, partition_by=none, order_by='date') %}
30+
{#- Rolling aggregate over `window` rows ENDING at the prior row.
31+
Used for breakout levels (e.g. Donchian) so today's bar cannot
32+
confirm its own breakout. -#}
33+
{%- set partition_by = partition_by or ta_default_partition() -%}
34+
{{ agg }}({{ column }}) OVER (
35+
PARTITION BY {{ partition_by }}
36+
ORDER BY {{ order_by }}
37+
ROWS BETWEEN {{ window }} PRECEDING AND 1 PRECEDING
38+
)
39+
{% endmacro %}
40+
41+
42+
{% macro ta_lag(column, n=1, partition_by=none, order_by='date') %}
43+
{%- set partition_by = partition_by or ta_default_partition() -%}
44+
LAG({{ column }}, {{ n }}) OVER (
45+
PARTITION BY {{ partition_by }}
46+
ORDER BY {{ order_by }}
47+
)
48+
{% endmacro %}
49+
50+
51+
{% macro ta_lead(column, n=1, partition_by=none, order_by='date') %}
52+
{%- set partition_by = partition_by or ta_default_partition() -%}
53+
LEAD({{ column }}, {{ n }}) OVER (
54+
PARTITION BY {{ partition_by }}
55+
ORDER BY {{ order_by }}
56+
)
57+
{% endmacro %}
58+
59+
60+
{% macro ta_zscore(column, window, partition_by=none, order_by='date') %}
61+
{%- set partition_by = partition_by or ta_default_partition() -%}
62+
SAFE_DIVIDE(
63+
{{ column }} - {{ ta_rolling('AVG', column, window, partition_by, order_by) }},
64+
NULLIF({{ ta_rolling('STDDEV', column, window, partition_by, order_by) }}, 0)
65+
)
66+
{% endmacro %}
67+
68+
69+
{% macro ta_range_position(column, window, partition_by=none, order_by='date') %}
70+
{#- Position of the current value within the trailing rolling range:
71+
0.0 at the rolling minimum, 1.0 at the rolling maximum. -#}
72+
{%- set partition_by = partition_by or ta_default_partition() -%}
73+
SAFE_DIVIDE(
74+
{{ column }} - {{ ta_rolling('MIN', column, window, partition_by, order_by) }},
75+
NULLIF(
76+
{{ ta_rolling('MAX', column, window, partition_by, order_by) }}
77+
- {{ ta_rolling('MIN', column, window, partition_by, order_by) }},
78+
0
79+
)
80+
)
81+
{% endmacro %}
82+
83+
84+
{% macro ta_window_array(column, lookback, partition_by=none, order_by='date') %}
85+
{#- Trailing window of values as an ARRAY of single-field STRUCTs,
86+
oldest first, for use with ta_ewm_from_array. Values are wrapped
87+
in a STRUCT because BigQuery arrays cannot hold NULL elements and
88+
analytic ARRAY_AGG does not support IGNORE NULLS; NULL values are
89+
filtered inside the consuming subquery instead, which keeps each
90+
element's recency offset intact. -#}
91+
{%- set partition_by = partition_by or ta_default_partition() -%}
92+
ARRAY_AGG(STRUCT({{ column }} AS v)) OVER (
93+
PARTITION BY {{ partition_by }}
94+
ORDER BY {{ order_by }}
95+
ROWS BETWEEN {{ lookback - 1 }} PRECEDING AND CURRENT ROW
96+
)
97+
{% endmacro %}
98+
99+
100+
{% macro ta_ewm_from_array(array_col, alpha) %}
101+
{#- Exponentially weighted mean of a trailing-window array built by
102+
ta_window_array (oldest element first, newest last).
103+
104+
This is the project-standard EMA implementation for BigQuery
105+
(issue #109 open question): a finite-window, weight-normalized
106+
approximation of the recursive EMA. With a lookback of >= 4/alpha
107+
rows the truncated tail carries < 2% of total weight, so results
108+
match the recursive definition to well within float noise.
109+
110+
Weights are proportional to (1-alpha)^(-offset); dividing by the
111+
weight sum makes the constant factor cancel, so no array-length
112+
term is needed.
113+
114+
EMA(span): alpha = 2 / (span + 1)
115+
RMA/Wilder(n): alpha = 1 / n
116+
-#}
117+
(
118+
SELECT
119+
SAFE_DIVIDE(
120+
SUM(_e.v * POW({{ 1 - alpha }}, -_off)),
121+
NULLIF(SUM(POW({{ 1 - alpha }}, -_off)), 0)
122+
)
123+
FROM UNNEST({{ array_col }}) AS _e WITH OFFSET AS _off
124+
WHERE _e.v IS NOT NULL
125+
)
126+
{% endmacro %}
127+
128+
129+
{% macro ta_ewm_mean_abs_dev_from_array(array_col, reference) %}
130+
{#- Mean absolute deviation of array values from a fixed reference
131+
(used by CCI, which measures dispersion around the CURRENT SMA). -#}
132+
(
133+
SELECT AVG(ABS(_e.v - {{ reference }}))
134+
FROM UNNEST({{ array_col }}) AS _e
135+
)
136+
{% endmacro %}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
{{ config(
2+
description='Agent-friendly reliability summary of historical technical signals by universe, symbol, and signal (issue #109, phase 1)'
3+
) }}
4+
5+
/*
6+
Agent Technical Signal History
7+
8+
Aggregates technical_signal_instances into signal-reliability stats
9+
at two grains (flagged by aggregation_grain):
10+
- 'universe_signal': source_universe × signal
11+
- 'universe_symbol_signal': source_universe × symbol × signal
12+
13+
Hit rates are benchmark-relative (see worked_* definitions in
14+
technical_signal_instances) and computed only over instances old
15+
enough to be evaluable at each horizon.
16+
*/
17+
18+
{% set grains = [
19+
{'label': 'universe_signal', 'symbol_expr': "'ALL'", 'exchange_expr': "'ALL'"},
20+
{'label': 'universe_symbol_signal', 'symbol_expr': 'symbol', 'exchange_expr': 'exchange'},
21+
] %}
22+
23+
{% for grain in grains %}
24+
SELECT
25+
'{{ grain.label }}' AS aggregation_grain,
26+
source_universe,
27+
{{ grain.symbol_expr }} AS symbol,
28+
{{ grain.exchange_expr }} AS exchange,
29+
indicator_name,
30+
signal_name,
31+
signal_side,
32+
COUNT(*) AS total_triggers,
33+
MIN(entry_date) AS first_trigger_date,
34+
MAX(entry_date) AS last_trigger_date,
35+
COUNTIF(worked_21d IS NOT NULL) AS evaluable_21d,
36+
ROUND(AVG(CAST(worked_5d AS INT64)), 4) AS hit_rate_5d,
37+
ROUND(AVG(CAST(worked_21d AS INT64)), 4) AS hit_rate_21d,
38+
ROUND(AVG(CAST(worked_63d AS INT64)), 4) AS hit_rate_63d,
39+
ROUND(AVG(forward_return_21d), 6) AS avg_forward_return_21d,
40+
ROUND(AVG(relative_forward_return_21d), 6) AS avg_relative_return_21d,
41+
ROUND(AVG(relative_forward_return_63d), 6) AS avg_relative_return_63d,
42+
ROUND(AVG(max_favorable_excursion_21d), 6) AS avg_mfe_21d,
43+
ROUND(AVG(max_adverse_excursion_21d), 6) AS avg_mae_21d,
44+
ROUND(AVG(CASE WHEN volume_confirmed THEN CAST(worked_21d AS INT64) END), 4)
45+
AS hit_rate_21d_volume_confirmed
46+
FROM {{ ref('technical_signal_instances') }}
47+
GROUP BY
48+
source_universe,
49+
{% if grain.label == 'universe_symbol_signal' %}symbol, exchange,{% endif %}
50+
indicator_name,
51+
signal_name,
52+
signal_side
53+
{% if not loop.last %}
54+
UNION ALL
55+
{% endif %}
56+
{% endfor %}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{{ config(
2+
description='Agent-friendly view of current technical signal setups and active signals (issue #109, phase 1)'
3+
) }}
4+
5+
/*
6+
Agent Technical Signal Setups
7+
8+
One row per symbol × signal currently in a setup/triggered/active
9+
state, phrased for LLM-agent consumption: descriptive state text and
10+
the key numbers an agent needs to reason about the setup.
11+
*/
12+
13+
SELECT
14+
source_universe,
15+
symbol,
16+
exchange,
17+
date AS as_of_date,
18+
signal_name,
19+
indicator_name,
20+
signal_side,
21+
signal_state,
22+
CASE signal_state
23+
WHEN 'setup' THEN 'Pre-trigger condition present; signal has not fired yet'
24+
WHEN 'triggered' THEN 'Signal fired on the most recent bar'
25+
WHEN 'active' THEN 'Signal fired recently and is still inside its holding window'
26+
END AS state_description,
27+
signal_value,
28+
close AS last_close,
29+
relative_volume,
30+
volume_confirmed,
31+
trigger_date,
32+
setup_date,
33+
bars_since_trigger,
34+
max_holding_bars
35+
FROM {{ ref('technical_current_setups') }}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
version: 2
2+
3+
models:
4+
- name: agent_technical_signal_setups
5+
description: >
6+
Agent-friendly view of symbols currently in a technical setup,
7+
freshly triggered, or inside an active signal window (issue #109
8+
phase 1). One row per symbol × signal at the symbol's latest bar,
9+
with descriptive state text for LLM consumption.
10+
columns:
11+
- name: signal_state
12+
tests:
13+
- not_null
14+
- accepted_values:
15+
arguments:
16+
values: ['setup', 'triggered', 'active']
17+
- name: as_of_date
18+
tests:
19+
- not_null
20+
21+
- name: agent_technical_signal_history
22+
description: >
23+
Agent-friendly reliability summary of historical technical signals
24+
(issue #109 phase 1): trigger counts, benchmark-relative hit rates
25+
at 5/21/63 trading days, average relative returns, and excursion
26+
stats, at universe×signal and universe×symbol×signal grains.
27+
tests:
28+
- unique_combination:
29+
arguments:
30+
combination_of_columns:
31+
['aggregation_grain', 'source_universe', 'symbol', 'exchange', 'signal_name']
32+
columns:
33+
- name: aggregation_grain
34+
tests:
35+
- not_null
36+
- accepted_values:
37+
arguments:
38+
values: ['universe_signal', 'universe_symbol_signal']
39+
- name: total_triggers
40+
tests:
41+
- not_null
42+
- value_in_range:
43+
arguments:
44+
min_value: 1
45+
- name: hit_rate_21d
46+
tests:
47+
- value_in_range:
48+
arguments:
49+
min_value: 0
50+
max_value: 1
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
version: 2
2+
3+
models:
4+
- name: technical_signal_instances
5+
description: >
6+
Historical evaluation of every triggered technical signal (issue
7+
#109 phase 1). One row per source_universe, symbol, exchange,
8+
signal_name, entry_date. Forward returns at 1/5/10/21/63/126 trading
9+
days, SPY benchmark and relative returns, max favorable/adverse
10+
excursion over 21 bars, and benchmark-relative worked_* labels.
11+
Forward-looking data exists ONLY here — never in events/setups.
12+
Recent triggers keep NULL forward fields until enough bars elapse.
13+
tests:
14+
- unique_combination:
15+
arguments:
16+
combination_of_columns:
17+
['source_universe', 'symbol', 'exchange', 'signal_name', 'entry_date']
18+
columns:
19+
- name: entry_date
20+
tests:
21+
- not_null
22+
- name: entry_price
23+
tests:
24+
- not_null
25+
- value_in_range:
26+
arguments:
27+
min_value: 0
28+
- name: signal_side
29+
tests:
30+
- not_null
31+
- accepted_values:
32+
arguments:
33+
values: ['bullish', 'bearish']
34+
- name: max_adverse_excursion_21d
35+
description: >
36+
Lowest forward low vs entry over the next 21 bars. Usually
37+
negative, but can be positive if price never trades below entry.
38+
- name: max_favorable_excursion_21d
39+
description: >
40+
Highest forward high vs entry over the next 21 bars. Usually
41+
positive, but can be negative if price gaps down and stays there.

0 commit comments

Comments
 (0)