Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions dbt_project/macros/technical_analysis/ta_price_transforms.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{#
Price-transform expressions (issue #109). These are plain column
expressions — no window functions — so they can be used at any stage.
#}

{% macro ta_typical_price(high='high', low='low', close='close') %}
(({{ high }} + {{ low }} + {{ close }}) / 3)
{% endmacro %}


{% macro ta_median_price(high='high', low='low') %}
(({{ high }} + {{ low }}) / 2)
{% endmacro %}


{% macro ta_weighted_close(high='high', low='low', close='close') %}
(({{ high }} + {{ low }} + 2 * {{ close }}) / 4)
{% endmacro %}


{% macro ta_true_range(high='high', low='low', prev_close='prev_close') %}
{#- prev_close is expected to be a precomputed LAG(close) column.
Falls back to plain high-low on the first bar (prev_close NULL),
matching TA-Lib behavior. -#}
GREATEST(
{{ high }} - {{ low }},
COALESCE(ABS({{ high }} - {{ prev_close }}), {{ high }} - {{ low }}),
COALESCE(ABS({{ low }} - {{ prev_close }}), {{ high }} - {{ low }})
)
{% endmacro %}


{% macro ta_daily_return(close='close', prev_close='prev_close') %}
SAFE_DIVIDE({{ close }} - {{ prev_close }}, NULLIF({{ prev_close }}, 0))
{% endmacro %}


{% macro ta_log_return(close='close', prev_close='prev_close') %}
SAFE.LN(SAFE_DIVIDE({{ close }}, NULLIF({{ prev_close }}, 0)))
{% endmacro %}
136 changes: 136 additions & 0 deletions dbt_project/macros/technical_analysis/ta_primitives.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
{#
Technical-analysis window primitives (issue #109).

Conventions:
- Every macro accepts partition_by / order_by instead of hardcoding
symbol or date. Default partition is the symbol-level grain of
technical_price_universe: source_universe, symbol, exchange.
- Warmup handling is the caller's responsibility: gate outputs with
`CASE WHEN bars_available >= <window> THEN ... END` so indicators
are NULL until enough lookback rows exist.
#}

{% macro ta_default_partition() %}
{{- return('source_universe, symbol, exchange') -}}
{% endmacro %}


{% macro ta_rolling(agg, column, window, partition_by=none, order_by='date') %}
{#- Rolling aggregate over the trailing `window` rows (inclusive of current). -#}
{%- set partition_by = partition_by or ta_default_partition() -%}
{{ agg }}({{ column }}) OVER (
PARTITION BY {{ partition_by }}
ORDER BY {{ order_by }}
ROWS BETWEEN {{ window - 1 }} PRECEDING AND CURRENT ROW
)
{% endmacro %}


{% macro ta_rolling_prior(agg, column, window, partition_by=none, order_by='date') %}
{#- Rolling aggregate over `window` rows ENDING at the prior row.
Used for breakout levels (e.g. Donchian) so today's bar cannot
confirm its own breakout. -#}
{%- set partition_by = partition_by or ta_default_partition() -%}
{{ agg }}({{ column }}) OVER (
PARTITION BY {{ partition_by }}
ORDER BY {{ order_by }}
ROWS BETWEEN {{ window }} PRECEDING AND 1 PRECEDING
)
{% endmacro %}


{% macro ta_lag(column, n=1, partition_by=none, order_by='date') %}
{%- set partition_by = partition_by or ta_default_partition() -%}
LAG({{ column }}, {{ n }}) OVER (
PARTITION BY {{ partition_by }}
ORDER BY {{ order_by }}
)
{% endmacro %}


{% macro ta_lead(column, n=1, partition_by=none, order_by='date') %}
{%- set partition_by = partition_by or ta_default_partition() -%}
LEAD({{ column }}, {{ n }}) OVER (
PARTITION BY {{ partition_by }}
ORDER BY {{ order_by }}
)
{% endmacro %}


{% macro ta_zscore(column, window, partition_by=none, order_by='date') %}
{%- set partition_by = partition_by or ta_default_partition() -%}
SAFE_DIVIDE(
{{ column }} - {{ ta_rolling('AVG', column, window, partition_by, order_by) }},
NULLIF({{ ta_rolling('STDDEV', column, window, partition_by, order_by) }}, 0)
)
{% endmacro %}


{% macro ta_range_position(column, window, partition_by=none, order_by='date') %}
{#- Position of the current value within the trailing rolling range:
0.0 at the rolling minimum, 1.0 at the rolling maximum. -#}
{%- set partition_by = partition_by or ta_default_partition() -%}
SAFE_DIVIDE(
{{ column }} - {{ ta_rolling('MIN', column, window, partition_by, order_by) }},
NULLIF(
{{ ta_rolling('MAX', column, window, partition_by, order_by) }}
- {{ ta_rolling('MIN', column, window, partition_by, order_by) }},
0
)
)
{% endmacro %}


{% macro ta_window_array(column, lookback, partition_by=none, order_by='date') %}
{#- Trailing window of values as an ARRAY of single-field STRUCTs,
oldest first, for use with ta_ewm_from_array. Values are wrapped
in a STRUCT because BigQuery arrays cannot hold NULL elements and
analytic ARRAY_AGG does not support IGNORE NULLS; NULL values are
filtered inside the consuming subquery instead, which keeps each
element's recency offset intact. -#}
{%- set partition_by = partition_by or ta_default_partition() -%}
ARRAY_AGG(STRUCT({{ column }} AS v)) OVER (
PARTITION BY {{ partition_by }}
ORDER BY {{ order_by }}
ROWS BETWEEN {{ lookback - 1 }} PRECEDING AND CURRENT ROW
)
{% endmacro %}


{% macro ta_ewm_from_array(array_col, alpha) %}
{#- Exponentially weighted mean of a trailing-window array built by
ta_window_array (oldest element first, newest last).

This is the project-standard EMA implementation for BigQuery
(issue #109 open question): a finite-window, weight-normalized
approximation of the recursive EMA. With a lookback of >= 4/alpha
rows the truncated tail carries < 2% of total weight, so results
match the recursive definition to well within float noise.

Weights are proportional to (1-alpha)^(-offset); dividing by the
weight sum makes the constant factor cancel, so no array-length
term is needed.

EMA(span): alpha = 2 / (span + 1)
RMA/Wilder(n): alpha = 1 / n
-#}
(
SELECT
SAFE_DIVIDE(
SUM(_e.v * POW({{ 1 - alpha }}, -_off)),
NULLIF(SUM(POW({{ 1 - alpha }}, -_off)), 0)
)
FROM UNNEST({{ array_col }}) AS _e WITH OFFSET AS _off
WHERE _e.v IS NOT NULL
)
{% endmacro %}


{% macro ta_ewm_mean_abs_dev_from_array(array_col, reference) %}
{#- Mean absolute deviation of array values from a fixed reference
(used by CCI, which measures dispersion around the CURRENT SMA). -#}
(
SELECT AVG(ABS(_e.v - {{ reference }}))
FROM UNNEST({{ array_col }}) AS _e
)
{% endmacro %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{{ config(
description='Agent-friendly reliability summary of historical technical signals by universe, symbol, and signal (issue #109, phase 1)'
) }}

/*
Agent Technical Signal History

Aggregates technical_signal_instances into signal-reliability stats
at two grains (flagged by aggregation_grain):
- 'universe_signal': source_universe × signal
- 'universe_symbol_signal': source_universe × symbol × signal

Hit rates are benchmark-relative (see worked_* definitions in
technical_signal_instances) and computed only over instances old
enough to be evaluable at each horizon.
*/

{% set grains = [
{'label': 'universe_signal', 'symbol_expr': "'ALL'", 'exchange_expr': "'ALL'"},
{'label': 'universe_symbol_signal', 'symbol_expr': 'symbol', 'exchange_expr': 'exchange'},
] %}

{% for grain in grains %}
SELECT
'{{ grain.label }}' AS aggregation_grain,
source_universe,
{{ grain.symbol_expr }} AS symbol,
{{ grain.exchange_expr }} AS exchange,
indicator_name,
signal_name,
signal_side,
COUNT(*) AS total_triggers,
MIN(entry_date) AS first_trigger_date,
MAX(entry_date) AS last_trigger_date,
COUNTIF(worked_21d IS NOT NULL) AS evaluable_21d,
ROUND(AVG(CAST(worked_5d AS INT64)), 4) AS hit_rate_5d,
ROUND(AVG(CAST(worked_21d AS INT64)), 4) AS hit_rate_21d,
ROUND(AVG(CAST(worked_63d AS INT64)), 4) AS hit_rate_63d,
ROUND(AVG(forward_return_21d), 6) AS avg_forward_return_21d,
ROUND(AVG(relative_forward_return_21d), 6) AS avg_relative_return_21d,
ROUND(AVG(relative_forward_return_63d), 6) AS avg_relative_return_63d,
ROUND(AVG(max_favorable_excursion_21d), 6) AS avg_mfe_21d,
ROUND(AVG(max_adverse_excursion_21d), 6) AS avg_mae_21d,
ROUND(AVG(CASE WHEN volume_confirmed THEN CAST(worked_21d AS INT64) END), 4)
AS hit_rate_21d_volume_confirmed
FROM {{ ref('technical_signal_instances') }}
GROUP BY
source_universe,
{% if grain.label == 'universe_symbol_signal' %}symbol, exchange,{% endif %}
indicator_name,
signal_name,
signal_side
{% if not loop.last %}
UNION ALL
{% endif %}
{% endfor %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{{ config(
description='Agent-friendly view of current technical signal setups and active signals (issue #109, phase 1)'
) }}

/*
Agent Technical Signal Setups

One row per symbol × signal currently in a setup/triggered/active
state, phrased for LLM-agent consumption: descriptive state text and
the key numbers an agent needs to reason about the setup.
*/

SELECT
source_universe,
symbol,
exchange,
date AS as_of_date,
signal_name,
indicator_name,
signal_side,
signal_state,
CASE signal_state
WHEN 'setup' THEN 'Pre-trigger condition present; signal has not fired yet'
WHEN 'triggered' THEN 'Signal fired on the most recent bar'
WHEN 'active' THEN 'Signal fired recently and is still inside its holding window'
END AS state_description,
signal_value,
close AS last_close,
relative_volume,
volume_confirmed,
trigger_date,
setup_date,
bars_since_trigger,
max_holding_bars
FROM {{ ref('technical_current_setups') }}
50 changes: 50 additions & 0 deletions dbt_project/models/agents_preprocess/technical_agents_schema.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
version: 2

models:
- name: agent_technical_signal_setups
description: >
Agent-friendly view of symbols currently in a technical setup,
freshly triggered, or inside an active signal window (issue #109
phase 1). One row per symbol × signal at the symbol's latest bar,
with descriptive state text for LLM consumption.
columns:
- name: signal_state
tests:
- not_null
- accepted_values:
arguments:
values: ['setup', 'triggered', 'active']
- name: as_of_date
tests:
- not_null

- name: agent_technical_signal_history
description: >
Agent-friendly reliability summary of historical technical signals
(issue #109 phase 1): trigger counts, benchmark-relative hit rates
at 5/21/63 trading days, average relative returns, and excursion
stats, at universe×signal and universe×symbol×signal grains.
tests:
- unique_combination:
arguments:
combination_of_columns:
['aggregation_grain', 'source_universe', 'symbol', 'exchange', 'signal_name']
columns:
- name: aggregation_grain
tests:
- not_null
- accepted_values:
arguments:
values: ['universe_signal', 'universe_symbol_signal']
- name: total_triggers
tests:
- not_null
- value_in_range:
arguments:
min_value: 1
- name: hit_rate_21d
tests:
- value_in_range:
arguments:
min_value: 0
max_value: 1
41 changes: 41 additions & 0 deletions dbt_project/models/analysis/technical_schema.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
version: 2

models:
- name: technical_signal_instances
description: >
Historical evaluation of every triggered technical signal (issue
#109 phase 1). One row per source_universe, symbol, exchange,
signal_name, entry_date. Forward returns at 1/5/10/21/63/126 trading
days, SPY benchmark and relative returns, max favorable/adverse
excursion over 21 bars, and benchmark-relative worked_* labels.
Forward-looking data exists ONLY here — never in events/setups.
Recent triggers keep NULL forward fields until enough bars elapse.
tests:
- unique_combination:
arguments:
combination_of_columns:
['source_universe', 'symbol', 'exchange', 'signal_name', 'entry_date']
columns:
- name: entry_date
tests:
- not_null
- name: entry_price
tests:
- not_null
- value_in_range:
arguments:
min_value: 0
- name: signal_side
tests:
- not_null
- accepted_values:
arguments:
values: ['bullish', 'bearish']
- name: max_adverse_excursion_21d
description: >
Lowest forward low vs entry over the next 21 bars. Usually
negative, but can be positive if price never trades below entry.
- name: max_favorable_excursion_21d
description: >
Highest forward high vs entry over the next 21 bars. Usually
positive, but can be negative if price gaps down and stays there.
Loading
Loading