complete app
test / test (push) Successful in 9s

This commit is contained in:
2026-09-17 17:39:22 -05:00
parent 4d633a39be
commit 3d5de1dc3e
84 changed files with 24229 additions and 0 deletions
@@ -0,0 +1,58 @@
---
version: 2
models:
- name: int_player_performance
description: "Unified model containing basic and advanced stats for each player in every game. Grain: one row per player per game."
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns:
- game_id
- player_id
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: int_team_performance
description: "Unified model containing basic and advanced stats for each team in every game. Grain: one row per team per game."
tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns:
- game_id
- team
columns:
- name: game_id
tests:
- not_null
- name: team
tests:
- not_null
- name: int_games_enriched
description: "Game-level model enriched with detailed performance stats for both the home and visitor teams. Grain: one row per game."
columns:
- name: game_id
tests:
- unique
- not_null
- name: int_player_shots_enriched
description: >
Unified shot-level dataset combining field goal attempts (from shot charts)
with free throw attempts (derived from stg_player_game_basic_stats).
Grain: one row per shot attempt (FG or FT).
columns:
- name: shot_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: shot_source
tests:
- not_null
- accepted_values:
values: ['shot_chart', 'box_score_ft']
@@ -0,0 +1,81 @@
{{
config(
materialized='table',
schema='intermediate',
tags=["intermediate"]
)
}}
WITH games AS (
SELECT
g.*,
home_map.team_abbr AS home_team_abbr,
visitor_map.team_abbr AS visitor_team_abbr,
winning_map.team_abbr AS winning_team_abbr
FROM {{ ref('stg_games') }} AS g
LEFT JOIN {{ ref('team_maps') }} AS home_map ON g.home_team = home_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS visitor_map ON g.visitor_team = visitor_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS winning_map ON g.winning_team = winning_map.full_name
WHERE (g.season_start_year >= home_map.start_year AND g.season_start_year < home_map.end_year)
AND (g.season_start_year >= visitor_map.start_year AND g.season_start_year < visitor_map.end_year)
AND (g.season_start_year >= winning_map.start_year AND g.season_start_year < winning_map.end_year)
),
arena_locations AS (
SELECT arena_name, arena_city
FROM (
SELECT
arena_name,
city AS arena_city,
ROW_NUMBER() OVER (PARTITION BY arena_name ORDER BY city) as rn
FROM {{ ref('arena_maps') }}
) AS sub
WHERE rn = 1
),
team_performance AS (
SELECT * FROM {{ ref('int_team_performance') }}
),
final AS (
SELECT
g.game_id,
g.game_date,
g.season_start_year,
g.is_playoff,
g.arena,
al.arena_city,
g.home_team_abbr AS home_team,
g.visitor_team_abbr AS visitor_team,
g.winning_team_abbr AS winning_team,
g.home_points,
g.visitor_points,
g.point_differential,
g.total_points,
g.is_overtime,
home_stats.offensive_rating AS home_offensive_rating,
home_stats.defensive_rating AS home_defensive_rating,
home_stats.net_rating AS home_net_rating,
home_stats.pace AS home_pace,
home_stats.effective_fg_pct AS home_effective_fg_pct,
home_stats.turnover_rate AS home_turnover_rate,
home_stats.offensive_tier AS home_offensive_tier,
home_stats.defensive_tier AS home_defensive_tier,
visitor_stats.offensive_rating AS visitor_offensive_rating,
visitor_stats.defensive_rating AS visitor_defensive_rating,
visitor_stats.net_rating AS visitor_net_rating,
visitor_stats.pace AS visitor_pace,
visitor_stats.effective_fg_pct AS visitor_effective_fg_pct,
visitor_stats.turnover_rate AS visitor_turnover_rate,
visitor_stats.offensive_tier AS visitor_offensive_tier,
visitor_stats.defensive_tier AS visitor_defensive_tier,
(home_stats.pace + visitor_stats.pace) / 2 AS matchup_pace
FROM games g
LEFT JOIN arena_locations al ON g.arena = al.arena_name
LEFT JOIN team_performance AS home_stats
ON g.game_id = home_stats.game_id AND g.home_team_abbr = home_stats.team
LEFT JOIN team_performance AS visitor_stats
ON g.game_id = visitor_stats.game_id AND g.visitor_team_abbr = visitor_stats.team
)
SELECT * FROM final
@@ -0,0 +1,97 @@
{{
config(
materialized='table',
schema='intermediate',
tags=["intermediate"]
)
}}
WITH basic_stats AS (
SELECT
s.*,
map.team_abbr AS team_conformed
FROM {{ ref('stg_player_game_basic_stats') }} AS s
LEFT JOIN {{ ref('team_maps') }} AS map
ON s.team = map.team_abbr
LEFT JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE did_play = TRUE
AND (g.season_start_year >= map.start_year AND g.season_start_year < map.end_year)
),
adv_stats AS (
SELECT
s.*,
map.team_abbr AS team_conformed
FROM {{ ref('stg_player_game_adv_stats_extended') }} AS s
LEFT JOIN {{ ref('team_maps') }} AS map
ON s.team = map.team_abbr
LEFT JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE (g.season_start_year >= map.start_year AND g.season_start_year < map.end_year)
),
games AS (
SELECT
g.game_id,
g.game_date,
g.season_start_year,
g.is_playoff,
home_map.team_abbr AS home_team_abbr,
winning_map.team_abbr AS winning_team_abbr
FROM {{ ref('stg_games') }} g
LEFT JOIN {{ ref('team_maps') }} AS home_map ON g.home_team = home_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS winning_map ON g.winning_team = winning_map.full_name
WHERE (g.season_start_year >= home_map.start_year AND g.season_start_year < home_map.end_year)
AND (g.season_start_year >= winning_map.start_year AND g.season_start_year < winning_map.end_year)
),
final AS (
SELECT
b.game_id,
b.player_id,
b.player_name,
b.team_conformed AS team,
g.game_date,
g.season_start_year,
g.is_playoff,
CASE WHEN b.team_conformed = g.winning_team_abbr THEN 'W' ELSE 'L' END AS game_result,
CASE WHEN b.team_conformed = g.home_team_abbr THEN 'HOME' ELSE 'AWAY' END AS team_location,
b.minutes_played,
b.points,
b.assists,
b.total_rebounds,
b.steals,
b.blocks,
b.turnovers,
b.plus_minus,
a.net_rating,
a.box_plus_minus,
b.field_goals_made,
b.field_goals_attempted,
b.field_goal_pct,
b.three_pointers_made,
b.three_pointers_attempted,
b.three_point_pct,
a.true_shooting_pct,
a.effective_fg_pct,
a.usage_pct,
a.offensive_rating,
a.defensive_rating,
a.usage_tier,
a.impact_tier,
a.shooting_efficiency_tier,
a.minutes_based_role,
b.is_double_double,
b.is_triple_double,
a.is_versatile,
a.is_defensive_specialist,
a.is_three_and_d
FROM basic_stats AS b
LEFT JOIN adv_stats AS a
ON b.game_id = a.game_id AND b.player_id = a.player_id
LEFT JOIN games AS g
ON b.game_id = g.game_id
)
SELECT * FROM final
@@ -0,0 +1,214 @@
{{
config(
materialized='table',
schema='intermediate',
tags=["intermediate"]
)
}}
WITH shot_charts AS (
SELECT * FROM {{ ref('stg_player_shot_charts') }}
),
games AS (
SELECT
g.game_id,
g.game_date,
g.season_start_year,
g.is_playoff,
g.arena,
g.home_points,
g.visitor_points,
g.point_differential,
g.is_overtime,
home_map.team_abbr AS home_team_abbr,
visitor_map.team_abbr AS visitor_team_abbr,
winning_map.team_abbr AS winning_team_abbr
FROM {{ ref('stg_games') }} AS g
LEFT JOIN {{ ref('team_maps') }} AS home_map ON g.home_team = home_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS visitor_map ON g.visitor_team = visitor_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS winning_map ON g.winning_team = winning_map.full_name
WHERE (g.season_start_year >= home_map.start_year AND g.season_start_year < home_map.end_year)
AND (g.season_start_year >= visitor_map.start_year AND g.season_start_year < visitor_map.end_year)
AND (g.season_start_year >= winning_map.start_year AND g.season_start_year < winning_map.end_year)
),
shots_conformed AS (
SELECT
sc.*,
COALESCE(tm.team_abbr, sc.team_abbr_raw) AS team_conformed,
COALESCE(opp.team_abbr, sc.opponent_abbr_raw) AS opponent_conformed
FROM shot_charts AS sc
LEFT JOIN {{ ref('team_maps') }} AS tm
ON sc.team_abbr_raw = tm.team_abbr
AND sc.season_start_year >= tm.start_year
AND sc.season_start_year < tm.end_year
LEFT JOIN {{ ref('team_maps') }} AS opp
ON sc.opponent_abbr_raw = opp.team_abbr
AND sc.season_start_year >= opp.start_year
AND sc.season_start_year < opp.end_year
),
shots_with_game AS (
SELECT
sc.*,
g.game_id,
g.is_playoff,
g.arena,
g.is_overtime AS game_had_overtime,
CASE
WHEN sc.team_conformed = g.home_team_abbr THEN 'HOME'
WHEN sc.team_conformed = g.visitor_team_abbr THEN 'AWAY'
ELSE NULL
END AS team_location,
CASE
WHEN sc.team_conformed = g.winning_team_abbr THEN 'W'
ELSE 'L'
END AS game_result
FROM shots_conformed AS sc
LEFT JOIN games AS g
ON CAST(sc.game_date AS DATE) = CAST(g.game_date AS DATE)
AND (
(sc.team_conformed = g.home_team_abbr AND sc.opponent_conformed = g.visitor_team_abbr)
OR
(sc.team_conformed = g.visitor_team_abbr AND sc.opponent_conformed = g.home_team_abbr)
)
),
fg_shots AS (
SELECT
shot_id,
game_id,
player_id,
team_conformed AS team,
opponent_conformed AS opponent,
game_date,
game_date_raw,
season_start_year,
is_playoff,
team_location,
game_result,
game_had_overtime,
quarter_raw,
quarter_number,
is_overtime_shot,
time_remaining_raw,
seconds_remaining_in_quarter,
shot_x_coordinate,
shot_y_coordinate,
is_made,
shot_made_flag,
shot_missed_flag,
shot_type_raw,
shot_point_value,
is_three_pointer,
is_free_throw,
distance_ft,
shot_distance_zone,
points_generated,
team_had_lead,
team_score_at_shot,
opponent_score_at_shot,
score_margin_at_shot,
is_clutch_shot,
CASE WHEN game_id IS NOT NULL THEN TRUE ELSE FALSE END AS has_game_match,
'shot_chart' AS shot_source,
created_at,
updated_at,
dbt_loaded_at
FROM shots_with_game
),
-- Free throw expansion using DuckDB's generate_series + UNNEST
matched_player_games AS (
SELECT DISTINCT
game_id, player_id, team, opponent, game_date, game_date_raw,
season_start_year, is_playoff, team_location, game_result, game_had_overtime
FROM fg_shots
WHERE has_game_match = TRUE
),
ft_source AS (
SELECT
mpg.*,
bs.free_throws_made,
bs.free_throws_attempted
FROM matched_player_games AS mpg
INNER JOIN {{ ref('stg_player_game_basic_stats') }} AS bs
ON mpg.game_id = bs.game_id AND mpg.player_id = bs.player_id
WHERE bs.did_play = TRUE AND bs.free_throws_attempted > 0
),
ft_made_expanded AS (
SELECT ft.*, n.n AS ft_seq, TRUE AS ft_is_made
FROM ft_source AS ft
CROSS JOIN LATERAL (SELECT UNNEST(generate_series(1, GREATEST(ft.free_throws_made, 0))) AS n) AS n
WHERE ft.free_throws_made > 0
),
ft_missed_expanded AS (
SELECT ft.*, n.n AS ft_seq, FALSE AS ft_is_made
FROM ft_source AS ft
CROSS JOIN LATERAL (SELECT UNNEST(generate_series(1, GREATEST(ft.free_throws_attempted - ft.free_throws_made, 0))) AS n) AS n
WHERE ft.free_throws_attempted > ft.free_throws_made
),
ft_shots AS (
SELECT
-(ABS(hash(
ft.game_id || '|' || ft.player_id || '|FT_'
|| CASE WHEN ft.ft_is_made THEN 'MADE' ELSE 'MISS' END
|| '|' || CAST(ft.ft_seq AS TEXT)
)) % 9223372036854775807 + 1) AS shot_id,
ft.game_id,
ft.player_id,
ft.team,
ft.opponent,
ft.game_date,
ft.game_date_raw,
ft.season_start_year,
ft.is_playoff,
ft.team_location,
ft.game_result,
ft.game_had_overtime,
'Free Throw' AS quarter_raw,
0 AS quarter_number,
FALSE AS is_overtime_shot,
NULL AS time_remaining_raw,
NULL::INT AS seconds_remaining_in_quarter,
NULL::BIGINT AS shot_x_coordinate,
NULL::BIGINT AS shot_y_coordinate,
ft.ft_is_made AS is_made,
CASE WHEN ft.ft_is_made THEN 1 ELSE 0 END AS shot_made_flag,
CASE WHEN ft.ft_is_made THEN 0 ELSE 1 END AS shot_missed_flag,
'free-throw' AS shot_type_raw,
1 AS shot_point_value,
FALSE AS is_three_pointer,
TRUE AS is_free_throw,
15 AS distance_ft,
'Free Throw (15 ft)' AS shot_distance_zone,
CASE WHEN ft.ft_is_made THEN 1 ELSE 0 END AS points_generated,
NULL::BOOLEAN AS team_had_lead,
NULL::BIGINT AS team_score_at_shot,
NULL::BIGINT AS opponent_score_at_shot,
NULL::INT AS score_margin_at_shot,
FALSE AS is_clutch_shot,
TRUE AS has_game_match,
'box_score_ft' AS shot_source,
CURRENT_TIMESTAMP AS created_at,
CURRENT_TIMESTAMP AS updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM (
SELECT * FROM ft_made_expanded
UNION ALL
SELECT * FROM ft_missed_expanded
) AS ft
),
final AS (
SELECT * FROM fg_shots
UNION ALL
SELECT * FROM ft_shots
)
SELECT * FROM final
@@ -0,0 +1,85 @@
{{
config(
materialized='table',
schema='intermediate',
tags=["intermediate"]
)
}}
WITH basic_stats AS (
SELECT
s.*,
map.team_abbr AS team_conformed
FROM {{ ref('stg_team_game_basic_stats') }} AS s
LEFT JOIN {{ ref('team_maps') }} AS map
ON s.team = map.team_abbr
LEFT JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE (g.season_start_year >= map.start_year AND g.season_start_year < map.end_year)
),
adv_stats AS (
SELECT
s.*,
map.team_abbr AS team_conformed
FROM {{ ref('stg_team_game_adv_stats') }} AS s
LEFT JOIN {{ ref('team_maps') }} AS map
ON s.team = map.team_abbr
LEFT JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE (g.season_start_year >= map.start_year AND g.season_start_year < map.end_year)
),
games AS (
SELECT
g.game_id,
g.game_date,
g.season_start_year,
g.is_playoff,
home_map.team_abbr AS home_team_abbr,
visitor_map.team_abbr AS visitor_team_abbr,
winning_map.team_abbr AS winning_team_abbr
FROM {{ ref('stg_games') }} g
LEFT JOIN {{ ref('team_maps') }} AS home_map ON g.home_team = home_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS visitor_map ON g.visitor_team = visitor_map.full_name
LEFT JOIN {{ ref('team_maps') }} AS winning_map ON g.winning_team = winning_map.full_name
WHERE (g.season_start_year >= home_map.start_year AND g.season_start_year < home_map.end_year)
AND (g.season_start_year >= visitor_map.start_year AND g.season_start_year < visitor_map.end_year)
AND (g.season_start_year >= winning_map.start_year AND g.season_start_year < winning_map.end_year)
),
final AS (
SELECT
b.game_id,
b.team_conformed AS team,
g.game_date,
g.season_start_year,
g.is_playoff,
CASE WHEN b.team_conformed = g.winning_team_abbr THEN 'W' ELSE 'L' END AS game_result,
CASE
WHEN b.team_conformed = g.home_team_abbr THEN g.visitor_team_abbr
ELSE g.home_team_abbr
END AS opponent_team,
b.points,
a.offensive_rating,
a.defensive_rating,
a.net_rating,
b.pace,
b.effective_fg_pct,
b.turnover_rate,
b.offensive_rebound_rate,
b.free_throw_rate,
a.offensive_tier,
a.defensive_tier,
a.shot_selection_style,
a.ball_movement_style,
a.ball_security_tier,
a.defensive_activity
FROM basic_stats AS b
LEFT JOIN adv_stats AS a
ON b.game_id = a.game_id AND b.team_conformed = a.team_conformed
LEFT JOIN games AS g
ON b.game_id = g.game_id
)
SELECT * FROM final
@@ -0,0 +1,21 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH source AS (
SELECT arena_name, city, state_or_country, location
FROM {{ ref('arena_mappings') }}
)
SELECT
{{ dbt_utils.generate_surrogate_key(['arena_name', 'city']) }} AS arena_key,
arena_name,
city AS arena_city,
state_or_country AS arena_state_or_country,
location AS arena_location
FROM source
ORDER BY arena_name, arena_city
@@ -0,0 +1,44 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
{% set start_date_query %}
select min(game_date)::date from {{ ref('stg_games') }}
{% endset %}
{% set start_date = dbt_utils.get_single_value(start_date_query) %}
{% set end_date_query %}
select max(game_date)::date from {{ ref('stg_games') }}
{% endset %}
{% set end_date = dbt_utils.get_single_value(end_date_query) %}
WITH date_spine AS (
SELECT UNNEST(generate_series(
'{{ start_date }}'::date,
'{{ end_date }}'::date,
INTERVAL '1 day'
))::date AS date_day
)
SELECT
CAST(strftime(date_day, '%Y%m%d') AS INTEGER) AS date_key,
date_day AS full_date,
EXTRACT(YEAR FROM date_day)::int AS year,
EXTRACT(QUARTER FROM date_day)::int AS quarter_of_year,
EXTRACT(MONTH FROM date_day)::int AS month_of_year,
strftime(date_day, '%B') AS month_name,
EXTRACT(DAY FROM date_day)::int AS day_of_month,
EXTRACT(ISODOW FROM date_day)::int AS day_of_week,
strftime(date_day, '%A') AS day_of_week_name,
EXTRACT(DOY FROM date_day)::int AS day_of_year,
EXTRACT(WEEK FROM date_day)::int AS week_of_year,
CASE
WHEN EXTRACT(ISODOW FROM date_day) IN (6, 7) THEN true
ELSE false
END AS is_weekend
FROM date_spine
ORDER BY full_date
@@ -0,0 +1,36 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH distinct_archetypes AS (
SELECT DISTINCT
usage_tier,
impact_tier,
shooting_efficiency_tier,
minutes_based_role,
is_double_double,
is_triple_double,
is_versatile,
is_defensive_specialist,
is_three_and_d
FROM {{ ref('int_player_performance') }}
)
SELECT
{{ dbt_utils.generate_surrogate_key([
'usage_tier',
'impact_tier',
'shooting_efficiency_tier',
'minutes_based_role',
'is_double_double',
'is_triple_double',
'is_versatile',
'is_defensive_specialist',
'is_three_and_d'
]) }} AS archetype_key,
*
FROM distinct_archetypes
@@ -0,0 +1,26 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH player_game_stats AS (
SELECT
stats.player_id,
stats.player_name,
games.game_date,
ROW_NUMBER() OVER (PARTITION BY stats.player_id ORDER BY games.game_date DESC) as rn
FROM {{ ref('stg_player_game_basic_stats') }} AS stats
LEFT JOIN {{ ref('stg_games') }} AS games
ON stats.game_id = games.game_id
)
SELECT
{{ dbt_utils.generate_surrogate_key(['player_id']) }} AS player_key,
player_id,
player_name
FROM player_game_stats
WHERE rn = 1
ORDER BY player_name
@@ -0,0 +1,19 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH all_seasons AS (
SELECT DISTINCT season_start_year
FROM {{ ref('int_games_enriched') }}
)
SELECT
{{ dbt_utils.generate_surrogate_key(['season_start_year']) }} AS season_key,
season_start_year,
season_start_year || '-' || SUBSTR(CAST(season_start_year + 1 AS VARCHAR), 3, 2) AS season_display
FROM all_seasons
ORDER BY season_start_year
@@ -0,0 +1,32 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH zones AS (
SELECT * FROM (
VALUES
('At Rim (0-3 ft)', 0, 3, 1, 'Paint', 'Interior', 'Field Goal'),
('Short Range (4-10 ft)', 4, 10, 2, 'Paint', 'Interior', 'Field Goal'),
('Mid Range (11-16 ft)', 11, 16, 3, 'Mid Range', 'Mid Range', 'Field Goal'),
('Long Mid Range (17-23 ft)', 17, 23, 4, 'Mid Range', 'Mid Range', 'Field Goal'),
('Three Point (24-27 ft)', 24, 27, 5, 'Perimeter', 'Three Point', 'Field Goal'),
('Deep Three (28+ ft)', 28, 50, 6, 'Perimeter', 'Three Point', 'Field Goal'),
('Free Throw (15 ft)', 15, 15, 7, 'Free Throw', 'Free Throw', 'Free Throw')
) AS t(shot_distance_zone, min_distance_ft, max_distance_ft, zone_order, zone_group, zone_category, shot_class)
)
SELECT
{{ dbt_utils.generate_surrogate_key(['shot_distance_zone']) }} AS shot_zone_key,
shot_distance_zone,
min_distance_ft,
max_distance_ft,
zone_order,
zone_group,
zone_category,
shot_class
FROM zones
ORDER BY zone_order
@@ -0,0 +1,19 @@
{{
config(
materialized='table',
schema='marts',
tags=["dimension"]
)
}}
WITH team_mappings AS (
SELECT team_abbr, full_name
FROM {{ ref('team_maps') }}
)
SELECT
{{ dbt_utils.generate_surrogate_key(['team_abbr']) }} AS team_key,
team_abbr,
full_name AS team_full_name
FROM team_mappings
ORDER BY team_abbr
@@ -0,0 +1,50 @@
{{
config(
materialized='incremental',
schema='marts',
unique_key='game_key',
tags=["fact"]
)
}}
WITH games_enriched AS (
SELECT *
FROM {{ ref('int_games_enriched') }}
{% if is_incremental() %}
WHERE game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
)
SELECT
{{ dbt_utils.generate_surrogate_key(['ge.game_id']) }} AS game_key,
d.date_key,
s.season_key,
a.arena_key,
home_team.team_key AS home_team_key,
visitor_team.team_key AS visitor_team_key,
winning_team.team_key AS winning_team_key,
ge.game_id,
ge.home_points,
ge.visitor_points,
ge.point_differential,
ge.total_points,
ge.is_playoff,
ge.is_overtime,
CASE WHEN ge.home_points > ge.visitor_points THEN TRUE ELSE FALSE END AS is_home_team_winner,
ge.home_net_rating,
ge.visitor_net_rating,
ge.matchup_pace,
CASE
WHEN ge.point_differential <= 5 THEN 'Clutch Game'
WHEN ge.point_differential <= 10 THEN 'Competitive'
WHEN ge.point_differential <= 20 THEN 'Decisive'
ELSE 'Blowout'
END AS game_competitiveness_tier,
CAST(ge.game_date AS DATE) AS game_date
FROM games_enriched AS ge
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(ge.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON ge.season_start_year = s.season_start_year
LEFT JOIN {{ ref('dim_arenas') }} AS a ON ge.arena = a.arena_name AND ge.arena_city = a.arena_city
LEFT JOIN {{ ref('dim_teams') }} AS home_team ON ge.home_team = home_team.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS visitor_team ON ge.visitor_team = visitor_team.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS winning_team ON ge.winning_team = winning_team.team_abbr
@@ -0,0 +1,175 @@
{{
config(
materialized='incremental',
schema='marts',
unique_key='player_game_shooting_key',
tags=["fact"]
)
}}
WITH shots AS (
SELECT *
FROM {{ ref('int_player_shots_enriched') }}
WHERE has_game_match = TRUE
{% if is_incremental() %}
AND game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
),
game_agg AS (
SELECT
game_id, player_id, team, opponent,
MIN(game_date) AS game_date,
MIN(season_start_year) AS season_start_year,
BOOL_OR(is_playoff) AS is_playoff,
MIN(team_location) AS team_location,
MIN(game_result) AS game_result,
SUM(points_generated) AS total_points,
COUNT(*) AS total_shot_attempts,
SUM(shot_made_flag) AS total_shots_made,
COUNT(*) FILTER (WHERE shot_source = 'shot_chart') AS fg_attempts,
SUM(shot_made_flag) FILTER (WHERE shot_source = 'shot_chart') AS fg_makes,
CASE WHEN COUNT(*) FILTER (WHERE shot_source = 'shot_chart') > 0
THEN SUM(shot_made_flag) FILTER (WHERE shot_source = 'shot_chart')::DECIMAL
/ COUNT(*) FILTER (WHERE shot_source = 'shot_chart')::DECIMAL
ELSE 0
END AS fg_pct,
SUM(points_generated) FILTER (WHERE shot_source = 'shot_chart') AS fg_points,
COUNT(*) FILTER (WHERE shot_source = 'box_score_ft') AS ft_attempts,
SUM(shot_made_flag) FILTER (WHERE shot_source = 'box_score_ft') AS ft_makes,
CASE WHEN COUNT(*) FILTER (WHERE shot_source = 'box_score_ft') > 0
THEN SUM(shot_made_flag) FILTER (WHERE shot_source = 'box_score_ft')::DECIMAL
/ COUNT(*) FILTER (WHERE shot_source = 'box_score_ft')::DECIMAL
ELSE 0
END AS ft_pct,
SUM(points_generated) FILTER (WHERE shot_source = 'box_score_ft') AS ft_points,
COUNT(*) FILTER (WHERE is_three_pointer = FALSE AND shot_source = 'shot_chart') AS two_point_attempts,
SUM(shot_made_flag) FILTER (WHERE is_three_pointer = FALSE AND shot_source = 'shot_chart') AS two_point_makes,
CASE WHEN COUNT(*) FILTER (WHERE is_three_pointer = FALSE AND shot_source = 'shot_chart') > 0
THEN SUM(shot_made_flag) FILTER (WHERE is_three_pointer = FALSE AND shot_source = 'shot_chart')::DECIMAL
/ COUNT(*) FILTER (WHERE is_three_pointer = FALSE AND shot_source = 'shot_chart')::DECIMAL
ELSE 0
END AS two_point_fg_pct,
COUNT(*) FILTER (WHERE is_three_pointer = TRUE) AS three_point_attempts,
SUM(shot_made_flag) FILTER (WHERE is_three_pointer = TRUE) AS three_point_makes,
CASE WHEN COUNT(*) FILTER (WHERE is_three_pointer = TRUE) > 0
THEN SUM(shot_made_flag) FILTER (WHERE is_three_pointer = TRUE)::DECIMAL
/ COUNT(*) FILTER (WHERE is_three_pointer = TRUE)::DECIMAL
ELSE 0
END AS three_point_fg_pct,
COUNT(*) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)') AS at_rim_attempts,
SUM(shot_made_flag) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)') AS at_rim_makes,
CASE WHEN COUNT(*) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)') > 0
THEN SUM(shot_made_flag) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)')::DECIMAL
/ COUNT(*) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)')::DECIMAL
ELSE 0
END AS at_rim_fg_pct,
COUNT(*) FILTER (WHERE is_clutch_shot = TRUE) AS clutch_fg_attempts,
SUM(shot_made_flag) FILTER (WHERE is_clutch_shot = TRUE) AS clutch_fg_makes,
CASE WHEN COUNT(*) FILTER (WHERE is_clutch_shot = TRUE) > 0
THEN SUM(shot_made_flag) FILTER (WHERE is_clutch_shot = TRUE)::DECIMAL
/ COUNT(*) FILTER (WHERE is_clutch_shot = TRUE)::DECIMAL
ELSE 0
END AS clutch_fg_pct,
CASE WHEN COUNT(*) FILTER (WHERE shot_source = 'shot_chart') > 0
THEN COUNT(*) FILTER (WHERE is_three_pointer = TRUE)::DECIMAL
/ COUNT(*) FILTER (WHERE shot_source = 'shot_chart')::DECIMAL
ELSE 0
END AS three_point_rate,
CASE WHEN COUNT(*) FILTER (WHERE shot_source = 'shot_chart') > 0
THEN COUNT(*) FILTER (WHERE shot_distance_zone = 'At Rim (0-3 ft)')::DECIMAL
/ COUNT(*) FILTER (WHERE shot_source = 'shot_chart')::DECIMAL
ELSE 0
END AS at_rim_rate,
CASE WHEN COUNT(*) FILTER (WHERE shot_source = 'shot_chart') > 0
THEN COUNT(*) FILTER (WHERE shot_distance_zone IN ('Mid Range (11-16 ft)', 'Long Mid Range (17-23 ft)'))::DECIMAL
/ COUNT(*) FILTER (WHERE shot_source = 'shot_chart')::DECIMAL
ELSE 0
END AS mid_range_rate,
AVG(distance_ft) FILTER (WHERE shot_source = 'shot_chart') AS avg_fg_distance_ft,
CASE WHEN (
COUNT(*) FILTER (WHERE shot_source = 'shot_chart')
+ 0.44 * COUNT(*) FILTER (WHERE shot_source = 'box_score_ft')
) > 0
THEN SUM(points_generated)::DECIMAL / (
2.0 * (
COUNT(*) FILTER (WHERE shot_source = 'shot_chart')
+ 0.44 * COUNT(*) FILTER (WHERE shot_source = 'box_score_ft')
)
)
ELSE 0
END AS true_shooting_pct
FROM shots
GROUP BY game_id, player_id, team, opponent
),
final AS (
SELECT
{{ dbt_utils.generate_surrogate_key(['ga.game_id', 'ga.player_id']) }} AS player_game_shooting_key,
p.player_key,
t.team_key,
opp_t.team_key AS opponent_key,
d.date_key,
s.season_key,
ga.game_id,
ga.is_playoff,
ga.team_location,
ga.game_result,
ga.total_points,
ga.total_shot_attempts,
ga.total_shots_made,
ga.fg_attempts,
ga.fg_makes,
ga.fg_pct,
ga.fg_points,
ga.ft_attempts,
ga.ft_makes,
ga.ft_pct,
ga.ft_points,
ga.true_shooting_pct,
ga.two_point_attempts,
ga.two_point_makes,
ga.two_point_fg_pct,
ga.three_point_attempts,
ga.three_point_makes,
ga.three_point_fg_pct,
ga.at_rim_attempts,
ga.at_rim_makes,
ga.at_rim_fg_pct,
ga.clutch_fg_attempts,
ga.clutch_fg_makes,
ga.clutch_fg_pct,
ga.three_point_rate,
ga.at_rim_rate,
ga.mid_range_rate,
ga.avg_fg_distance_ft,
CASE
WHEN ga.three_point_rate >= 0.50 THEN 'Perimeter Heavy'
WHEN ga.at_rim_rate >= 0.50 THEN 'Rim Attacker'
WHEN ga.mid_range_rate >= 0.40 THEN 'Mid Range Heavy'
WHEN ga.three_point_rate >= 0.35 AND ga.at_rim_rate >= 0.30 THEN 'Modern (Rim & Three)'
ELSE 'Balanced'
END AS shot_profile_type,
CAST(ga.game_date AS DATE) AS game_date
FROM game_agg AS ga
LEFT JOIN {{ ref('dim_players') }} AS p ON ga.player_id = p.player_id
LEFT JOIN {{ ref('dim_teams') }} AS t ON ga.team = t.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS opp_t ON ga.opponent = opp_t.team_abbr
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(ga.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON ga.season_start_year = s.season_start_year
)
SELECT * FROM final
@@ -0,0 +1,71 @@
{{
config(
materialized='incremental',
schema='marts',
unique_key='player_game_key',
tags=["fact"]
)
}}
WITH player_performance AS (
SELECT * FROM {{ ref('int_player_performance') }}
),
game_details AS (
SELECT game_id, arena AS arena_name, arena_city
FROM {{ ref('int_games_enriched') }}
),
final AS (
SELECT
{{ dbt_utils.generate_surrogate_key(['pp.game_id', 'pp.player_id']) }} AS player_game_key,
p.player_key,
t.team_key,
d.date_key,
s.season_key,
a.arena_key,
arch.archetype_key,
pp.game_id,
pp.minutes_played,
pp.points,
pp.assists,
pp.total_rebounds,
pp.steals,
pp.blocks,
pp.turnovers,
pp.plus_minus,
pp.net_rating,
pp.box_plus_minus,
pp.field_goals_made,
pp.field_goals_attempted,
pp.three_pointers_made,
pp.three_pointers_attempted,
pp.true_shooting_pct,
pp.effective_fg_pct,
pp.usage_pct,
pp.offensive_rating,
pp.defensive_rating,
CAST(pp.game_date AS DATE) AS game_date
FROM player_performance AS pp
LEFT JOIN game_details AS gd ON pp.game_id = gd.game_id
LEFT JOIN {{ ref('dim_player_game_archetypes') }} AS arch
ON pp.usage_tier = arch.usage_tier
AND pp.impact_tier = arch.impact_tier
AND pp.shooting_efficiency_tier = arch.shooting_efficiency_tier
AND pp.minutes_based_role = arch.minutes_based_role
AND pp.is_double_double = arch.is_double_double
AND pp.is_triple_double = arch.is_triple_double
AND pp.is_versatile = arch.is_versatile
AND pp.is_defensive_specialist = arch.is_defensive_specialist
AND pp.is_three_and_d = arch.is_three_and_d
LEFT JOIN {{ ref('dim_players') }} AS p ON pp.player_id = p.player_id
LEFT JOIN {{ ref('dim_teams') }} AS t ON pp.team = t.team_abbr
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(pp.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON pp.season_start_year = s.season_start_year
LEFT JOIN {{ ref('dim_arenas') }} AS a ON gd.arena_name = a.arena_name AND gd.arena_city = a.arena_city
{% if is_incremental() %}
WHERE pp.game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
)
SELECT * FROM final
@@ -0,0 +1,62 @@
{{
config(
materialized='incremental',
schema='marts',
unique_key='shot_key',
tags=["fact"]
)
}}
WITH shots_enriched AS (
SELECT *
FROM {{ ref('int_player_shots_enriched') }}
WHERE has_game_match = TRUE
{% if is_incremental() %}
AND game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
),
final AS (
SELECT
{{ dbt_utils.generate_surrogate_key(['se.shot_id', 'se.shot_source']) }} AS shot_key,
p.player_key,
t.team_key,
opp_t.team_key AS opponent_key,
d.date_key,
s.season_key,
se.game_id,
se.shot_id,
se.shot_source,
se.is_playoff,
se.team_location,
se.game_result,
se.quarter_number,
se.is_overtime_shot,
se.seconds_remaining_in_quarter,
se.shot_x_coordinate,
se.shot_y_coordinate,
se.is_made,
se.shot_made_flag,
se.shot_missed_flag,
se.shot_type_raw AS shot_type,
se.shot_point_value,
se.is_three_pointer,
se.is_free_throw,
se.distance_ft,
se.shot_distance_zone,
se.points_generated,
se.team_had_lead,
se.team_score_at_shot,
se.opponent_score_at_shot,
se.score_margin_at_shot,
se.is_clutch_shot,
CAST(se.game_date AS DATE) AS game_date
FROM shots_enriched AS se
LEFT JOIN {{ ref('dim_players') }} AS p ON se.player_id = p.player_id
LEFT JOIN {{ ref('dim_teams') }} AS t ON se.team = t.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS opp_t ON se.opponent = opp_t.team_abbr
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(se.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON se.season_start_year = s.season_start_year
)
SELECT * FROM final
@@ -0,0 +1,57 @@
{{
config(
materialized='incremental',
schema='marts',
unique_key='quarter_scoring_key',
tags=["fact"]
)
}}
WITH games AS (
SELECT game_id, game_date, season_start_year
FROM {{ ref('int_games_enriched') }}
{% if is_incremental() %}
WHERE game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
),
unpivoted_scores AS (
{{ dbt_utils.unpivot(
relation=ref('stg_line_scores'),
cast_to='INTEGER',
exclude=['game_id', 'team', 'total_points', 'first_half_points', 'second_half_points', 'regulation_points', 'overtime_points', 'had_ot1', 'had_ot2', 'had_ot3', 'q2_momentum', 'q3_momentum', 'q4_momentum', 'max_quarter_score', 'min_quarter_score', 'best_quarter', 'created_at', 'updated_at', 'dbt_loaded_at'],
field_name='period_name',
value_name='points_scored'
) }}
WHERE game_id IN (SELECT game_id FROM games)
),
final AS (
SELECT
{{ dbt_utils.generate_surrogate_key(['team_scores.game_id', 'team_map.team_abbr', 'team_scores.period_name']) }} AS quarter_scoring_key,
d.date_key,
s.season_key,
t.team_key,
opp.team_key AS opponent_key,
team_scores.game_id,
REPLACE(UPPER(team_scores.period_name), '_POINTS', '') AS period,
team_scores.points_scored,
opponent_scores.points_scored AS opponent_points_scored,
(team_scores.points_scored - opponent_scores.points_scored) AS period_point_differential,
CAST(g.game_date AS DATE) AS game_date
FROM unpivoted_scores AS team_scores
INNER JOIN unpivoted_scores AS opponent_scores
ON team_scores.game_id = opponent_scores.game_id
AND team_scores.period_name = opponent_scores.period_name
AND team_scores.team != opponent_scores.team
INNER JOIN games AS g ON team_scores.game_id = g.game_id
LEFT JOIN {{ ref('team_maps') }} AS team_map ON team_scores.team = team_map.team_abbr
LEFT JOIN {{ ref('team_maps') }} AS opponent_map ON opponent_scores.team = opponent_map.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS t ON team_map.team_abbr = t.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS opp ON opponent_map.team_abbr = opp.team_abbr
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(g.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON g.season_start_year = s.season_start_year
WHERE team_scores.points_scored IS NOT NULL
)
SELECT * FROM final
@@ -0,0 +1,54 @@
{{
config(
materialized='incremental',
schema='marts',
tags=["fact"],
unique_key='team_game_key'
)
}}
WITH team_performance AS (
SELECT * FROM {{ ref('int_team_performance') }}
),
game_details AS (
SELECT
game_id, game_date, season_start_year,
home_team, visitor_team, arena AS arena_name, arena_city
FROM {{ ref('int_games_enriched') }}
),
final AS (
SELECT
{{ dbt_utils.generate_surrogate_key(['tp.game_id', 'tp.team']) }} AS team_game_key,
t.team_key,
d.date_key,
s.season_key,
a.arena_key,
opp.team_key AS opponent_key,
tp.game_id,
(tp.team = gd.home_team) AS is_home_team,
tp.points,
tp.offensive_rating,
tp.defensive_rating,
tp.net_rating,
tp.pace,
tp.effective_fg_pct,
tp.turnover_rate,
tp.offensive_tier,
tp.defensive_tier,
CAST(gd.game_date AS DATE) AS game_date
FROM team_performance AS tp
LEFT JOIN game_details AS gd ON tp.game_id = gd.game_id
LEFT JOIN {{ ref('dim_teams') }} AS t ON tp.team = t.team_abbr
LEFT JOIN {{ ref('dim_teams') }} AS opp
ON CASE WHEN tp.team = gd.home_team THEN gd.visitor_team ELSE gd.home_team END = opp.team_abbr
LEFT JOIN {{ ref('dim_dates') }} AS d ON CAST(gd.game_date AS DATE) = d.full_date
LEFT JOIN {{ ref('dim_seasons') }} AS s ON gd.season_start_year = s.season_start_year
LEFT JOIN {{ ref('dim_arenas') }} AS a ON gd.arena_name = a.arena_name AND gd.arena_city = a.arena_city
{% if is_incremental() %}
WHERE gd.game_date >= (SELECT MAX(game_date) FROM {{ this }}) - INTERVAL '30 days'
{% endif %}
)
SELECT * FROM final
+129
View File
@@ -0,0 +1,129 @@
version: 2
sources:
- name: raw_nba
description: Raw NBA data in DuckDB
schema: main
tables:
- name: games
description: Core game information including scores, teams, and venues
columns:
- name: game_id
tests:
- unique
- not_null
- name: line_scores
description: Quarter-by-quarter scoring breakdown
columns:
- name: game_id
tests:
- not_null
- name: player_game_basic_stats
description: Basic player statistics for each game
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: player_game_adv_stats
description: Advanced player statistics for each game
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: team_game_basic_stats
description: Basic team statistics for each game
columns:
- name: game_id
tests:
- not_null
- name: team
tests:
- not_null
- name: team_game_adv_stats
description: Advanced team statistics for each game
columns:
- name: game_id
tests:
- not_null
- name: team
tests:
- not_null
- name: player_shot_charts
description: Individual shot-level data from NBA player shot charts
columns:
- name: id
tests:
- unique
- not_null
models:
- name: stg_games
description: Staged games data with cleaned fields and derived metrics
columns:
- name: game_id
tests:
- unique
- not_null
- name: stg_line_scores
description: Staged quarter-by-quarter scoring with momentum metrics
columns:
- name: game_id
tests:
- not_null
- name: stg_player_game_basic_stats
description: Staged player basic stats with calculated metrics
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: stg_player_game_adv_stats
description: Staged player advanced stats with performance tiers
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: stg_player_game_adv_stats_extended
description: Extended player advanced stats with dynamic per-season tiering
columns:
- name: game_id
tests:
- not_null
- name: player_id
tests:
- not_null
- name: stg_team_game_basic_stats
description: Staged team basic stats with four factors
columns:
- name: game_id
tests:
- not_null
- name: team
tests:
- not_null
- name: stg_team_game_adv_stats
description: Staged team advanced stats with play style indicators
columns:
- name: game_id
tests:
- not_null
- name: team
tests:
- not_null
+82
View File
@@ -0,0 +1,82 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH overtime_games AS (
SELECT
game_id,
MAX(CASE WHEN overtime_points > 0 THEN 1 ELSE 0 END) AS had_overtime
FROM {{ ref('stg_line_scores') }}
GROUP BY 1
),
source_data AS (
SELECT * FROM {{ source('raw_nba', 'games') }}
WHERE deleted_at IS NULL
),
cleaned AS (
SELECT
g.game_id,
g.home_team,
g.visitor_team,
g.date AS game_date,
EXTRACT(YEAR FROM g.date) AS season_year,
CASE
WHEN EXTRACT(MONTH FROM g.date) >= 10 THEN EXTRACT(YEAR FROM g.date)
ELSE EXTRACT(YEAR FROM g.date) - 1
END AS season_start_year,
EXTRACT(MONTH FROM g.date) AS game_month,
EXTRACT(DAY FROM g.date) AS game_day,
strftime(g.date, '%A') AS game_day_of_week,
g.is_playoff,
g.start_time_et,
CASE
WHEN g.start_time_et LIKE '%7:%p%' OR g.start_time_et LIKE '%8:%p%' THEN 'Prime Time'
WHEN g.start_time_et LIKE '%12:%p%' OR g.start_time_et LIKE '%1:%p%'
OR g.start_time_et LIKE '%2:%p%' OR g.start_time_et LIKE '%3:%p%' THEN 'Afternoon'
ELSE 'Late Night'
END AS game_time_slot,
g.arena,
g.home_pts AS home_points,
g.visitor_pts AS visitor_points,
CASE
WHEN g.home_pts > g.visitor_pts THEN g.home_team
WHEN g.visitor_pts > g.home_pts THEN g.visitor_team
ELSE NULL
END AS winning_team,
CASE
WHEN g.home_pts < g.visitor_pts THEN g.home_team
WHEN g.visitor_pts < g.home_pts THEN g.visitor_team
ELSE NULL
END AS losing_team,
CASE
WHEN g.home_pts > g.visitor_pts THEN 'HOME'
WHEN g.visitor_pts > g.home_pts THEN 'AWAY'
ELSE NULL
END AS winner_location,
ABS(g.home_pts - g.visitor_pts) AS point_differential,
g.home_pts + g.visitor_pts AS total_points,
g.game_duration,
CASE
WHEN ot.had_overtime = 1 THEN TRUE
ELSE FALSE
END AS is_overtime,
g.box_score_url,
g.created_at,
g.updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data AS g
LEFT JOIN overtime_games AS ot
ON g.game_id = ot.game_id
WHERE
g.game_id IS NOT NULL
AND g.date IS NOT NULL
AND g.home_team IS NOT NULL
AND g.visitor_team IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,51 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH source_data AS (
SELECT *
FROM {{ source('raw_nba', 'line_scores') }}
WHERE deleted_at IS NULL
),
cleaned_and_transformed AS (
SELECT
game_id,
team,
COALESCE(q1, 0) AS q1_points,
COALESCE(q2, 0) AS q2_points,
COALESCE(q3, 0) AS q3_points,
COALESCE(q4, 0) AS q4_points,
COALESCE(ot1, 0) AS ot1_points,
COALESCE(ot2, 0) AS ot2_points,
COALESCE(ot3, 0) AS ot3_points,
total AS total_points,
COALESCE(q1, 0) + COALESCE(q2, 0) AS first_half_points,
COALESCE(q3, 0) + COALESCE(q4, 0) AS second_half_points,
COALESCE(q1, 0) + COALESCE(q2, 0) + COALESCE(q3, 0) + COALESCE(q4, 0) AS regulation_points,
COALESCE(ot1, 0) + COALESCE(ot2, 0) + COALESCE(ot3, 0) AS overtime_points,
COALESCE(ot1, 0) > 0 AS had_ot1,
COALESCE(ot2, 0) > 0 AS had_ot2,
COALESCE(ot3, 0) > 0 AS had_ot3,
COALESCE(q2, 0) - COALESCE(q1, 0) AS q2_momentum,
COALESCE(q3, 0) - COALESCE(q2, 0) AS q3_momentum,
COALESCE(q4, 0) - COALESCE(q3, 0) AS q4_momentum,
GREATEST(COALESCE(q1, 0), COALESCE(q2, 0), COALESCE(q3, 0), COALESCE(q4, 0)) AS max_quarter_score,
LEAST(COALESCE(q1, 0), COALESCE(q2, 0), COALESCE(q3, 0), COALESCE(q4, 0)) AS min_quarter_score,
CASE
WHEN GREATEST(COALESCE(q1, 0), COALESCE(q2, 0), COALESCE(q3, 0), COALESCE(q4, 0)) = COALESCE(q1, 0) THEN 'Q1'
WHEN GREATEST(COALESCE(q1, 0), COALESCE(q2, 0), COALESCE(q3, 0), COALESCE(q4, 0)) = COALESCE(q2, 0) THEN 'Q2'
WHEN GREATEST(COALESCE(q1, 0), COALESCE(q2, 0), COALESCE(q3, 0), COALESCE(q4, 0)) = COALESCE(q3, 0) THEN 'Q3'
ELSE 'Q4'
END AS best_quarter,
created_at,
updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data
WHERE game_id IS NOT NULL AND team IS NOT NULL
)
SELECT * FROM cleaned_and_transformed
@@ -0,0 +1,106 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH source_data AS (
SELECT * FROM {{ source('raw_nba', 'player_game_adv_stats') }}
WHERE deleted_at IS NULL
),
with_minutes AS (
SELECT
*,
CASE
WHEN mp IS NOT NULL AND mp != '' AND mp LIKE '%:%' THEN
CAST(SPLIT_PART(mp, ':', 1) AS DECIMAL) +
(CAST(SPLIT_PART(mp, ':', 2) AS DECIMAL) / 60.0)
WHEN mp IS NOT NULL AND mp != '' THEN CAST(mp AS DECIMAL)
ELSE 0
END AS minutes_played_calc
FROM source_data
),
cleaned AS (
SELECT
game_id,
player_id,
team,
player_name,
mp AS minutes_played_str,
minutes_played_calc AS minutes_played,
COALESCE(ts_percent, 0) AS true_shooting_pct,
COALESCE(efg_percent, 0) AS effective_fg_pct,
COALESCE(three_p_ar, 0) AS three_point_attempt_rate,
COALESCE(f_tr, 0) AS free_throw_rate,
COALESCE(orb_percent, 0) AS offensive_rebound_pct,
COALESCE(drb_percent, 0) AS defensive_rebound_pct,
COALESCE(trb_percent, 0) AS total_rebound_pct,
COALESCE(ast_percent, 0) AS assist_pct,
COALESCE(stl_percent, 0) AS steal_pct,
COALESCE(blk_percent, 0) AS block_pct,
COALESCE(tov_percent, 0) AS turnover_pct,
COALESCE(usg_percent, 0) AS usage_pct,
COALESCE(o_rtg, 0) AS offensive_rating,
COALESCE(d_rtg, 0) AS defensive_rating,
COALESCE(bpm, 0) AS box_plus_minus,
COALESCE(o_rtg, 0) - COALESCE(d_rtg, 0) AS net_rating,
CASE
WHEN minutes_played_calc < 5 THEN 'Insufficient Minutes'
WHEN COALESCE(ts_percent, 0) >= 0.60 THEN 'Elite'
WHEN COALESCE(ts_percent, 0) >= 0.55 THEN 'Good'
WHEN COALESCE(ts_percent, 0) >= 0.50 THEN 'Average'
ELSE 'Below Average'
END AS shooting_efficiency_tier,
CASE
WHEN minutes_played_calc < 5 THEN 'Garbage Time'
WHEN minutes_played_calc < 15 THEN 'Limited Minutes'
WHEN (minutes_played_calc >= 30 AND COALESCE(usg_percent, 0) >= 22) OR
(minutes_played_calc >= 25 AND COALESCE(usg_percent, 0) >= 25) THEN 'Primary Option'
WHEN minutes_played_calc >= 20 AND COALESCE(usg_percent, 0) >= 20 THEN 'Secondary Option'
WHEN minutes_played_calc >= 15 THEN 'Role Player'
ELSE 'Limited Minutes'
END AS usage_tier,
CASE
WHEN minutes_played_calc < 5 THEN 'Insufficient Minutes'
WHEN COALESCE(bpm, 0) >= 10 THEN 'Elite Impact'
WHEN COALESCE(bpm, 0) >= 5 THEN 'High Impact'
WHEN COALESCE(bpm, 0) >= 0 THEN 'Positive Impact'
WHEN COALESCE(bpm, 0) >= -5 THEN 'Negative Impact'
ELSE 'Very Negative Impact'
END AS impact_tier,
CASE
WHEN minutes_played_calc >= 32 THEN 'Starter/Key Player'
WHEN minutes_played_calc >= 20 THEN 'Rotation Player'
WHEN minutes_played_calc >= 10 THEN 'Bench Player'
WHEN minutes_played_calc >= 5 THEN 'Deep Bench'
ELSE 'Garbage Time'
END AS minutes_based_role,
CASE
WHEN minutes_played_calc >= 15
AND COALESCE(ast_percent, 0) >= 20
AND COALESCE(trb_percent, 0) >= 15 THEN TRUE
ELSE FALSE
END AS is_versatile,
CASE
WHEN minutes_played_calc >= 15
AND (COALESCE(stl_percent, 0) >= 2.5 OR COALESCE(blk_percent, 0) >= 4)
AND COALESCE(d_rtg, 0) < 105 THEN TRUE
ELSE FALSE
END AS is_defensive_specialist,
CASE
WHEN minutes_played_calc >= 15
AND COALESCE(three_p_ar, 0) >= 0.4
AND COALESCE(d_rtg, 0) < 110 THEN TRUE
ELSE FALSE
END AS is_three_and_d,
created_at,
updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM with_minutes
WHERE game_id IS NOT NULL AND player_id IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,169 @@
{{
config(
materialized='table',
schema='staging',
tags=["staging"]
)
}}
{% set minutes_insufficient = 5 %}
WITH source_data AS (
SELECT * FROM {{ source('raw_nba', 'player_game_adv_stats') }}
WHERE deleted_at IS NULL
),
games AS (
SELECT game_id, season_start_year
FROM {{ ref('stg_games') }}
),
with_minutes AS (
SELECT
sd.*,
g.season_start_year,
CASE
WHEN sd.mp IS NOT NULL AND sd.mp != '' AND sd.mp LIKE '%:%' THEN
CAST(SPLIT_PART(sd.mp, ':', 1) AS DECIMAL)
+ (CAST(SPLIT_PART(sd.mp, ':', 2) AS DECIMAL) / 60.0)
WHEN sd.mp IS NOT NULL AND sd.mp != '' AND regexp_matches(sd.mp, '^[0-9\.]+$') THEN
CAST(sd.mp AS DECIMAL)
ELSE 0
END AS minutes_played
FROM source_data AS sd
LEFT JOIN games AS g ON sd.game_id = g.game_id
),
season_thresholds AS (
SELECT * FROM {{ ref('stg_season_thresholds') }}
),
cleaned AS (
SELECT
wm.game_id,
wm.player_id,
wm.team,
wm.player_name,
wm.mp AS minutes_played_str,
wm.minutes_played,
COALESCE(wm.ts_percent, 0) AS true_shooting_pct,
COALESCE(wm.efg_percent, 0) AS effective_fg_pct,
COALESCE(wm.three_p_ar, 0) AS three_point_attempt_rate,
COALESCE(wm.f_tr, 0) AS free_throw_rate,
COALESCE(wm.orb_percent, 0) AS offensive_rebound_pct,
COALESCE(wm.drb_percent, 0) AS defensive_rebound_pct,
COALESCE(wm.trb_percent, 0) AS total_rebound_pct,
COALESCE(wm.ast_percent, 0) AS assist_pct,
COALESCE(wm.stl_percent, 0) AS steal_pct,
COALESCE(wm.blk_percent, 0) AS block_pct,
COALESCE(wm.tov_percent, 0) AS turnover_pct,
COALESCE(wm.usg_percent, 0) AS usage_pct,
COALESCE(wm.o_rtg, 0) AS offensive_rating,
COALESCE(wm.d_rtg, 0) AS defensive_rating,
COALESCE(wm.bpm, 0) AS box_plus_minus,
COALESCE(wm.o_rtg, 0) - COALESCE(wm.d_rtg, 0) AS net_rating,
-- Minutes-based role
CASE
WHEN wm.minutes_played >= t.min_p95 THEN 'Elite Minutes (Top 5%)'
WHEN wm.minutes_played >= t.min_p90 THEN 'Elite Minutes (Top 10%)'
WHEN wm.minutes_played >= t.min_q3 THEN 'Starter'
WHEN wm.minutes_played >= t.min_median THEN 'Key Rotation'
WHEN wm.minutes_played >= t.min_q1 THEN 'Regular Rotation'
WHEN wm.minutes_played >= t.min_p5 THEN 'Deep Bench'
WHEN wm.minutes_played >= {{ minutes_insufficient }} THEN 'Garbage Time'
ELSE 'Insufficient Minutes'
END AS minutes_based_role,
CASE
WHEN wm.minutes_played < {{ minutes_insufficient }} THEN 'Insufficient'
WHEN wm.minutes_played < t.min_q1 THEN 'Q1'
WHEN wm.minutes_played < t.min_median THEN 'Q2'
WHEN wm.minutes_played < t.min_q3 THEN 'Q3'
ELSE 'Q4'
END AS minutes_quartile,
-- Usage Tier
CASE
WHEN wm.minutes_played < {{ minutes_insufficient }} THEN 'Insufficient Minutes'
WHEN COALESCE(wm.usg_percent, 0) >= t.usg_p90
AND wm.minutes_played >= t.min_q1 THEN 'Heliocentric Option'
WHEN COALESCE(wm.usg_percent, 0) >= t.usg_q3
AND wm.minutes_played >= t.min_q3 THEN 'Primary Option'
WHEN COALESCE(wm.usg_percent, 0) >= t.usg_median
AND wm.minutes_played >= t.min_median THEN 'Secondary Option'
WHEN COALESCE(wm.usg_percent, 0) >= t.usg_q1
AND wm.minutes_played >= t.min_q1 THEN 'Role Player'
WHEN wm.minutes_played >= t.min_q1
AND COALESCE(wm.usg_percent, 0) >= t.usg_p5 THEN 'Connector/Specialist'
WHEN wm.minutes_played >= t.min_p5 THEN 'Low Usage Player'
ELSE 'Limited Role'
END AS usage_tier,
CASE
WHEN COALESCE(wm.usg_percent, 0) < t.usg_q1 THEN 'Q1'
WHEN COALESCE(wm.usg_percent, 0) < t.usg_median THEN 'Q2'
WHEN COALESCE(wm.usg_percent, 0) < t.usg_q3 THEN 'Q3'
ELSE 'Q4'
END AS usage_quartile,
-- Impact Tier
CASE
WHEN wm.minutes_played < {{ minutes_insufficient }} THEN 'Insufficient Minutes'
WHEN COALESCE(wm.bpm, 0) >= t.bpm_p90 THEN 'Elite Impact'
WHEN COALESCE(wm.bpm, 0) >= t.bpm_q3 THEN 'High Impact'
WHEN COALESCE(wm.bpm, 0) >= t.bpm_median THEN 'Positive Impact'
WHEN COALESCE(wm.bpm, 0) >= t.bpm_q1 THEN 'Neutral Impact'
WHEN COALESCE(wm.bpm, 0) >= t.bpm_p5 THEN 'Negative Impact'
ELSE 'Very Negative Impact'
END AS impact_tier,
CASE
WHEN COALESCE(wm.bpm, 0) < t.bpm_q1 THEN 'Q1'
WHEN COALESCE(wm.bpm, 0) < t.bpm_median THEN 'Q2'
WHEN COALESCE(wm.bpm, 0) < t.bpm_q3 THEN 'Q3'
ELSE 'Q4'
END AS bpm_quartile,
-- Shooting efficiency tier
CASE
WHEN wm.minutes_played < {{ minutes_insufficient }} THEN 'Insufficient Minutes'
WHEN COALESCE(wm.ts_percent, 0) >= t.ts_q3 THEN 'Elite'
WHEN COALESCE(wm.ts_percent, 0) >= t.ts_median THEN 'Good'
WHEN COALESCE(wm.ts_percent, 0) >= t.ts_q1 THEN 'Average'
ELSE 'Below Average'
END AS shooting_efficiency_tier,
-- Boolean flags
wm.minutes_played >= t.min_p90 AS is_extreme_minutes,
wm.minutes_played >= t.min_q3 AS is_starter_minutes,
wm.minutes_played >= t.min_q1 AS is_meaningful_minutes,
wm.minutes_played >= t.min_q1
AND COALESCE(wm.ast_percent, 0) >= 20
AND COALESCE(wm.trb_percent, 0) >= 13 AS is_versatile,
wm.minutes_played >= t.min_q1
AND (COALESCE(wm.stl_percent, 0) >= 2.5 OR COALESCE(wm.blk_percent, 0) >= 3)
AND COALESCE(wm.d_rtg, 0) <= 110 AS is_defensive_specialist,
wm.minutes_played >= t.min_q1
AND COALESCE(wm.three_p_ar, 0) >= 0.4
AND COALESCE(wm.d_rtg, 0) <= 110 AS is_three_and_d,
COALESCE(wm.usg_percent, 0) >= t.usg_p90 AS is_extreme_usage,
COALESCE(wm.usg_percent, 0) >= t.usg_q3 AS is_high_usage,
COALESCE(wm.bpm, 0) >= t.bpm_p90 AS is_elite_impact,
COALESCE(wm.bpm, 0) >= t.bpm_q3 AS is_positive_impact,
wm.created_at,
wm.updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM with_minutes AS wm
LEFT JOIN season_thresholds AS t
ON wm.season_start_year = t.season_start_year
WHERE wm.game_id IS NOT NULL AND wm.player_id IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,93 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH source_data AS (
SELECT * FROM {{ source('raw_nba', 'player_game_basic_stats') }}
WHERE deleted_at IS NULL
),
cleaned AS (
SELECT
game_id,
player_id,
team,
player_name,
COALESCE(status, 'Unknown') AS player_status,
CASE WHEN status = 'Played' THEN TRUE ELSE FALSE END AS did_play,
mp AS minutes_played_str,
CAST(
CASE
WHEN mp IS NOT NULL AND mp != '' AND mp LIKE '%:%' THEN
CAST(SPLIT_PART(mp, ':', 1) AS DECIMAL) +
(CAST(SPLIT_PART(mp, ':', 2) AS DECIMAL) / 60.0)
WHEN mp IS NOT NULL AND mp != '' THEN CAST(mp AS DECIMAL)
ELSE 0
END AS DECIMAL(10, 2)) AS minutes_played,
COALESCE(fg, 0) AS field_goals_made,
COALESCE(fga, 0) AS field_goals_attempted,
COALESCE(fg_percent, 0) AS field_goal_pct,
COALESCE(three_p, 0) AS three_pointers_made,
COALESCE(three_pa, 0) AS three_pointers_attempted,
COALESCE(three_p_percent, 0) AS three_point_pct,
COALESCE(ft, 0) AS free_throws_made,
COALESCE(fta, 0) AS free_throws_attempted,
COALESCE(ft_percent, 0) AS free_throw_pct,
COALESCE(orb, 0) AS offensive_rebounds,
COALESCE(drb, 0) AS defensive_rebounds,
COALESCE(trb, 0) AS total_rebounds,
COALESCE(ast, 0) AS assists,
COALESCE(stl, 0) AS steals,
COALESCE(blk, 0) AS blocks,
COALESCE(tov, 0) AS turnovers,
COALESCE(pf, 0) AS personal_fouls,
COALESCE(pts, 0) AS points,
COALESCE(gm_sc, 0) AS game_score,
COALESCE(plus_minus, 0) AS plus_minus,
COALESCE(fg, 0) - COALESCE(three_p, 0) AS two_pointers_made,
COALESCE(fga, 0) - COALESCE(three_pa, 0) AS two_pointers_attempted,
CASE
WHEN (COALESCE(fga, 0) - COALESCE(three_pa, 0)) > 0 THEN
CAST(COALESCE(fg, 0) - COALESCE(three_p, 0) AS DECIMAL) /
CAST(COALESCE(fga, 0) - COALESCE(three_pa, 0) AS DECIMAL)
ELSE 0
END AS two_point_pct,
CASE
WHEN COALESCE(fga, 0) > 0 THEN
CAST(COALESCE(pts, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) AS DECIMAL)
ELSE 0
END AS points_per_shot,
CASE
WHEN (
(CASE WHEN COALESCE(pts, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(trb, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(ast, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(stl, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(blk, 0) >= 10 THEN 1 ELSE 0 END)
) >= 2 THEN TRUE ELSE FALSE
END AS is_double_double,
CASE
WHEN (
(CASE WHEN COALESCE(pts, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(trb, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(ast, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(stl, 0) >= 10 THEN 1 ELSE 0 END) +
(CASE WHEN COALESCE(blk, 0) >= 10 THEN 1 ELSE 0 END)
) >= 3 THEN TRUE ELSE FALSE
END AS is_triple_double,
CASE
WHEN mp IS NOT NULL AND mp LIKE '%:%' AND
CAST(SPLIT_PART(mp, ':', 1) AS DECIMAL) >= 20 THEN TRUE
ELSE FALSE
END AS likely_starter,
created_at,
updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data
WHERE game_id IS NOT NULL AND player_id IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,105 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH source_data AS (
SELECT *
FROM {{ source('raw_nba', 'player_shot_charts') }}
WHERE deleted_at IS NULL
),
cleaned AS (
SELECT
id AS shot_id,
player_id,
season AS season_year,
CASE
WHEN date IS NOT NULL AND date != '' THEN
strptime(TRIM(date), '%b %d,%Y')::DATE
ELSE NULL
END AS game_date,
date AS game_date_raw,
CASE
WHEN date IS NOT NULL AND date != '' THEN
CASE
WHEN EXTRACT(MONTH FROM strptime(TRIM(date), '%b %d,%Y')::DATE) >= 10
THEN EXTRACT(YEAR FROM strptime(TRIM(date), '%b %d,%Y')::DATE)::INT
ELSE EXTRACT(YEAR FROM strptime(TRIM(date), '%b %d,%Y')::DATE)::INT - 1
END
ELSE NULL
END AS season_start_year,
qtr AS quarter_raw,
CASE
WHEN qtr LIKE '1st%' THEN 1
WHEN qtr LIKE '2nd%' THEN 2
WHEN qtr LIKE '3rd%' THEN 3
WHEN qtr LIKE '4th%' THEN 4
WHEN qtr ILIKE '%OT%' THEN 5
ELSE NULL
END AS quarter_number,
CASE WHEN qtr ILIKE '%OT%' THEN TRUE ELSE FALSE END AS is_overtime_shot,
time_remaining AS time_remaining_raw,
CASE
WHEN time_remaining IS NOT NULL AND time_remaining LIKE '%:%' THEN
CAST(SPLIT_PART(time_remaining, ':', 1) AS INT) * 60
+ CAST(SPLIT_PART(time_remaining, ':', 2) AS INT)
ELSE NULL
END AS seconds_remaining_in_quarter,
top AS shot_y_coordinate,
"left" AS shot_x_coordinate,
COALESCE(result, FALSE) AS is_made,
CASE WHEN COALESCE(result, FALSE) = TRUE THEN 1 ELSE 0 END AS shot_made_flag,
CASE WHEN COALESCE(result, FALSE) = FALSE THEN 1 ELSE 0 END AS shot_missed_flag,
shot_type AS shot_type_raw,
CASE
WHEN shot_type = '3-pointer' THEN 3
WHEN shot_type = '2-pointer' THEN 2
ELSE NULL
END AS shot_point_value,
CASE WHEN shot_type = '3-pointer' THEN TRUE ELSE FALSE END AS is_three_pointer,
FALSE AS is_free_throw,
COALESCE(distance_ft, 0) AS distance_ft,
CASE
WHEN COALESCE(distance_ft, 0) <= 3 THEN 'At Rim (0-3 ft)'
WHEN COALESCE(distance_ft, 0) <= 10 THEN 'Short Range (4-10 ft)'
WHEN COALESCE(distance_ft, 0) <= 16 THEN 'Mid Range (11-16 ft)'
WHEN COALESCE(distance_ft, 0) <= 23 THEN 'Long Mid Range (17-23 ft)'
WHEN COALESCE(distance_ft, 0) <= 27 THEN 'Three Point (24-27 ft)'
ELSE 'Deep Three (28+ ft)'
END AS shot_distance_zone,
COALESCE(lead, FALSE) AS team_had_lead,
COALESCE(team_score, 0) AS team_score_at_shot,
COALESCE(opponent_team_score, 0) AS opponent_score_at_shot,
COALESCE(team_score, 0) - COALESCE(opponent_team_score, 0) AS score_margin_at_shot,
CASE
WHEN qtr LIKE '4th%'
AND time_remaining IS NOT NULL
AND time_remaining LIKE '%:%'
AND (CAST(SPLIT_PART(time_remaining, ':', 1) AS INT) * 60
+ CAST(SPLIT_PART(time_remaining, ':', 2) AS INT)) <= 300
AND ABS(COALESCE(team_score, 0) - COALESCE(opponent_team_score, 0)) <= 5
THEN TRUE
ELSE FALSE
END AS is_clutch_shot,
team AS team_abbr_raw,
opponent AS opponent_abbr_raw,
CASE
WHEN COALESCE(result, FALSE) = TRUE THEN
CASE
WHEN shot_type = '3-pointer' THEN 3
WHEN shot_type = '2-pointer' THEN 2
ELSE 0
END
ELSE 0
END AS points_generated,
created_at,
updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data
WHERE id IS NOT NULL AND player_id IS NOT NULL AND date IS NOT NULL AND team IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,59 @@
{{
config(
materialized='table',
schema='staging',
tags=["staging"]
)
}}
WITH player_stats AS (
SELECT
g.season_start_year,
CASE
WHEN s.mp IS NOT NULL AND s.mp != '' AND s.mp LIKE '%:%' THEN
CAST(SPLIT_PART(s.mp, ':', 1) AS DECIMAL)
+ (CAST(SPLIT_PART(s.mp, ':', 2) AS DECIMAL) / 60.0)
WHEN s.mp IS NOT NULL AND s.mp != '' AND regexp_matches(s.mp, '^[0-9\.]+$') THEN
CAST(s.mp AS DECIMAL)
ELSE 0
END AS minutes_played,
COALESCE(s.usg_percent, 0) AS usage_pct,
COALESCE(s.bpm, 0) AS bpm,
COALESCE(s.ts_percent, 0) AS ts_pct
FROM {{ source('raw_nba', 'player_game_adv_stats') }} AS s
INNER JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE s.deleted_at IS NULL
AND s.game_id IS NOT NULL
AND s.player_id IS NOT NULL
)
SELECT
season_start_year,
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY minutes_played) AS min_p5,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY minutes_played) AS min_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY minutes_played) AS min_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY minutes_played) AS min_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY minutes_played) AS min_p90,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY minutes_played) AS min_p95,
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY usage_pct) AS usg_p5,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY usage_pct) AS usg_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY usage_pct) AS usg_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY usage_pct) AS usg_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY usage_pct) AS usg_p90,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY usage_pct) AS usg_p95,
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY bpm) AS bpm_p5,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY bpm) AS bpm_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY bpm) AS bpm_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY bpm) AS bpm_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY bpm) AS bpm_p90,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY bpm) AS bpm_p95,
PERCENTILE_CONT(0.05) WITHIN GROUP (ORDER BY ts_pct) AS ts_p5,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY ts_pct) AS ts_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY ts_pct) AS ts_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY ts_pct) AS ts_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY ts_pct) AS ts_p90,
PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY ts_pct) AS ts_p95
FROM player_stats
GROUP BY season_start_year
ORDER BY season_start_year
@@ -0,0 +1,102 @@
{{
config(
materialized='table',
schema='staging',
tags=["staging"]
)
}}
WITH source_data AS (
SELECT * FROM {{ source('raw_nba', 'team_game_adv_stats') }}
WHERE deleted_at IS NULL
),
games AS (
SELECT game_id, season_start_year
FROM {{ ref('stg_games') }}
),
team_season_thresholds AS (
SELECT * FROM {{ ref('stg_team_season_thresholds') }}
),
cleaned AS (
SELECT
sd.game_id,
sd.team,
COALESCE(sd.mp, 240) AS minutes_played,
COALESCE(sd.ts_percent, 0) AS true_shooting_pct,
COALESCE(sd.efg_percent, 0) AS effective_fg_pct,
COALESCE(sd.three_p_ar, 0) AS three_point_attempt_rate,
COALESCE(sd.f_tr, 0) AS free_throw_rate,
COALESCE(sd.orb_percent, 0) AS offensive_rebound_pct,
COALESCE(sd.drb_percent, 0) AS defensive_rebound_pct,
COALESCE(sd.trb_percent, 0) AS total_rebound_pct,
COALESCE(sd.ast_percent, 0) AS assist_pct,
COALESCE(sd.stl_percent, 0) AS steal_pct,
COALESCE(sd.blk_percent, 0) AS block_pct,
COALESCE(sd.tov_percent, 0) AS turnover_pct,
COALESCE(sd.usg_percent, 100) AS usage_pct,
COALESCE(sd.o_rtg, 0) AS offensive_rating,
COALESCE(sd.d_rtg, 0) AS defensive_rating,
COALESCE(sd.o_rtg, 0) - COALESCE(sd.d_rtg, 0) AS net_rating,
CASE
WHEN COALESCE(sd.o_rtg, 0) >= t.ortg_p90 THEN 'Elite Offense'
WHEN COALESCE(sd.o_rtg, 0) >= t.ortg_q3 THEN 'Above Average Offense'
WHEN COALESCE(sd.o_rtg, 0) >= t.ortg_q1 THEN 'Average Offense'
WHEN COALESCE(sd.o_rtg, 0) >= t.ortg_p10 THEN 'Below Average Offense'
ELSE 'Poor Offense'
END AS offensive_tier,
CASE
WHEN COALESCE(sd.d_rtg, 0) <= t.drtg_p10 THEN 'Elite Defense'
WHEN COALESCE(sd.d_rtg, 0) <= t.drtg_q1 THEN 'Above Average Defense'
WHEN COALESCE(sd.d_rtg, 0) <= t.drtg_q3 THEN 'Average Defense'
WHEN COALESCE(sd.d_rtg, 0) <= t.drtg_p90 THEN 'Below Average Defense'
ELSE 'Poor Defense'
END AS defensive_tier,
CASE
WHEN COALESCE(sd.three_p_ar, 0) >= t.tpar_p90 THEN 'Three Point Heavy'
WHEN COALESCE(sd.three_p_ar, 0) >= t.tpar_q3 THEN 'Three Point Leaning'
WHEN COALESCE(sd.three_p_ar, 0) >= t.tpar_q1 THEN 'Balanced'
WHEN COALESCE(sd.three_p_ar, 0) >= t.tpar_p10 THEN 'Inside Leaning'
ELSE 'Inside Focused'
END AS shot_selection_style,
CASE
WHEN COALESCE(sd.ast_percent, 0) >= t.ast_p90 THEN 'Elite Ball Movement'
WHEN COALESCE(sd.ast_percent, 0) >= t.ast_q3 THEN 'High Ball Movement'
WHEN COALESCE(sd.ast_percent, 0) >= t.ast_q1 THEN 'Average Ball Movement'
WHEN COALESCE(sd.ast_percent, 0) >= t.ast_p10 THEN 'Low Ball Movement'
ELSE 'Isolation Heavy'
END AS ball_movement_style,
CASE
WHEN COALESCE(sd.tov_percent, 0) <= t.tov_p10 THEN 'Elite Ball Security'
WHEN COALESCE(sd.tov_percent, 0) <= t.tov_q1 THEN 'Above Average Ball Security'
WHEN COALESCE(sd.tov_percent, 0) <= t.tov_q3 THEN 'Average Ball Security'
WHEN COALESCE(sd.tov_percent, 0) <= t.tov_p90 THEN 'Below Average Ball Security'
ELSE 'Poor Ball Security'
END AS ball_security_tier,
CASE
WHEN (COALESCE(sd.stl_percent, 0) + COALESCE(sd.blk_percent, 0)) >= t.stl_blk_p90 THEN 'Elite Defensive Activity'
WHEN (COALESCE(sd.stl_percent, 0) + COALESCE(sd.blk_percent, 0)) >= t.stl_blk_q3 THEN 'High Defensive Activity'
WHEN (COALESCE(sd.stl_percent, 0) + COALESCE(sd.blk_percent, 0)) >= t.stl_blk_q1 THEN 'Average Defensive Activity'
WHEN (COALESCE(sd.stl_percent, 0) + COALESCE(sd.blk_percent, 0)) >= t.stl_blk_p10 THEN 'Low Defensive Activity'
ELSE 'Passive Defense'
END AS defensive_activity,
sd.created_at,
sd.updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data AS sd
LEFT JOIN games AS g ON sd.game_id = g.game_id
LEFT JOIN team_season_thresholds AS t ON g.season_start_year = t.season_start_year
WHERE sd.game_id IS NOT NULL AND sd.team IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,92 @@
{{
config(
materialized='view',
schema='staging'
)
}}
WITH source_data AS (
SELECT * FROM {{ source('raw_nba', 'team_game_basic_stats') }}
WHERE deleted_at IS NULL
),
cleaned AS (
SELECT
game_id,
team,
COALESCE(mp, 240) AS minutes_played,
COALESCE(fg, 0) AS field_goals_made,
COALESCE(fga, 0) AS field_goals_attempted,
COALESCE(fg_percent, 0) AS field_goal_pct,
COALESCE(three_p, 0) AS three_pointers_made,
COALESCE(three_pa, 0) AS three_pointers_attempted,
COALESCE(three_p_percent, 0) AS three_point_pct,
COALESCE(ft, 0) AS free_throws_made,
COALESCE(fta, 0) AS free_throws_attempted,
COALESCE(ft_percent, 0) AS free_throw_pct,
COALESCE(orb, 0) AS offensive_rebounds,
COALESCE(drb, 0) AS defensive_rebounds,
COALESCE(trb, 0) AS total_rebounds,
COALESCE(ast, 0) AS assists,
COALESCE(stl, 0) AS steals,
COALESCE(blk, 0) AS blocks,
COALESCE(tov, 0) AS turnovers,
COALESCE(pf, 0) AS personal_fouls,
COALESCE(pts, 0) AS points,
COALESCE(fg, 0) - COALESCE(three_p, 0) AS two_pointers_made,
COALESCE(fga, 0) - COALESCE(three_pa, 0) AS two_pointers_attempted,
CASE
WHEN (COALESCE(fga, 0) - COALESCE(three_pa, 0)) > 0 THEN
CAST(COALESCE(fg, 0) - COALESCE(three_p, 0) AS DECIMAL) /
CAST(COALESCE(fga, 0) - COALESCE(three_pa, 0) AS DECIMAL)
ELSE 0
END AS two_point_pct,
COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) - COALESCE(orb, 0) + COALESCE(tov, 0) AS possessions_estimate,
CASE
WHEN COALESCE(mp, 240) > 0 THEN
(COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) - COALESCE(orb, 0) + COALESCE(tov, 0)) * 48.0 / (COALESCE(mp, 240) / 5.0)
ELSE 0
END AS pace,
CASE
WHEN COALESCE(fga, 0) > 0 THEN
CAST(COALESCE(fg, 0) + 0.5 * COALESCE(three_p, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) AS DECIMAL)
ELSE 0
END AS effective_fg_pct,
CASE
WHEN (COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) + COALESCE(tov, 0)) > 0 THEN
CAST(COALESCE(tov, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) + COALESCE(tov, 0) AS DECIMAL)
ELSE 0
END AS turnover_rate,
CASE
WHEN (COALESCE(orb, 0) + COALESCE(drb, 0)) > 0 THEN
CAST(COALESCE(orb, 0) AS DECIMAL) / CAST(COALESCE(orb, 0) + COALESCE(drb, 0) AS DECIMAL)
ELSE 0
END AS offensive_rebound_rate,
CASE
WHEN COALESCE(fga, 0) > 0 THEN
CAST(COALESCE(fta, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) AS DECIMAL)
ELSE 0
END AS free_throw_rate,
CASE
WHEN COALESCE(tov, 0) > 0 THEN
CAST(COALESCE(ast, 0) AS DECIMAL) / CAST(COALESCE(tov, 0) AS DECIMAL)
ELSE 0
END AS ast_to_tov_ratio,
CASE
WHEN (COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) - COALESCE(orb, 0) + COALESCE(tov, 0)) > 0 THEN
CAST(COALESCE(pts, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) + 0.44 * COALESCE(fta, 0) - COALESCE(orb, 0) + COALESCE(tov, 0) AS DECIMAL)
ELSE 0
END AS points_per_possession,
CASE
WHEN COALESCE(fga, 0) > 0 THEN
CAST(COALESCE(three_pa, 0) AS DECIMAL) / CAST(COALESCE(fga, 0) AS DECIMAL)
ELSE 0
END AS three_point_rate,
created_at,
updated_at,
CURRENT_TIMESTAMP AS dbt_loaded_at
FROM source_data
WHERE game_id IS NOT NULL AND team IS NOT NULL
)
SELECT * FROM cleaned
@@ -0,0 +1,84 @@
{{
config(
materialized='table',
schema='staging',
tags=["staging"]
)
}}
WITH team_stats AS (
SELECT
g.season_start_year,
COALESCE(s.o_rtg, 0) AS offensive_rating,
COALESCE(s.d_rtg, 0) AS defensive_rating,
COALESCE(s.three_p_ar, 0) AS three_point_attempt_rate,
COALESCE(s.ast_percent, 0) AS assist_pct,
COALESCE(s.orb_percent, 0) AS offensive_rebound_pct,
COALESCE(s.drb_percent, 0) AS defensive_rebound_pct,
COALESCE(s.tov_percent, 0) AS turnover_pct,
COALESCE(s.stl_percent, 0) + COALESCE(s.blk_percent, 0) AS stl_blk_combined,
COALESCE(s.ts_percent, 0) AS true_shooting_pct,
COALESCE(s.f_tr, 0) AS free_throw_rate
FROM {{ source('raw_nba', 'team_game_adv_stats') }} AS s
INNER JOIN {{ ref('stg_games') }} AS g
ON s.game_id = g.game_id
WHERE s.deleted_at IS NULL
AND s.game_id IS NOT NULL
AND s.team IS NOT NULL
)
SELECT
season_start_year,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY offensive_rating) AS ortg_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY offensive_rating) AS ortg_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY offensive_rating) AS ortg_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY offensive_rating) AS ortg_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY offensive_rating) AS ortg_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY defensive_rating) AS drtg_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY defensive_rating) AS drtg_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY defensive_rating) AS drtg_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY defensive_rating) AS drtg_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY defensive_rating) AS drtg_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY three_point_attempt_rate) AS tpar_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY three_point_attempt_rate) AS tpar_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY three_point_attempt_rate) AS tpar_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY three_point_attempt_rate) AS tpar_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY three_point_attempt_rate) AS tpar_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY assist_pct) AS ast_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY assist_pct) AS ast_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY assist_pct) AS ast_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY assist_pct) AS ast_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY assist_pct) AS ast_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY offensive_rebound_pct) AS orb_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY offensive_rebound_pct) AS orb_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY offensive_rebound_pct) AS orb_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY offensive_rebound_pct) AS orb_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY offensive_rebound_pct) AS orb_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY defensive_rebound_pct) AS drb_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY defensive_rebound_pct) AS drb_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY defensive_rebound_pct) AS drb_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY defensive_rebound_pct) AS drb_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY defensive_rebound_pct) AS drb_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY turnover_pct) AS tov_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY turnover_pct) AS tov_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY turnover_pct) AS tov_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY turnover_pct) AS tov_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY turnover_pct) AS tov_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY stl_blk_combined) AS stl_blk_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY stl_blk_combined) AS stl_blk_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY stl_blk_combined) AS stl_blk_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY stl_blk_combined) AS stl_blk_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY stl_blk_combined) AS stl_blk_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY true_shooting_pct) AS ts_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY true_shooting_pct) AS ts_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY true_shooting_pct) AS ts_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY true_shooting_pct) AS ts_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY true_shooting_pct) AS ts_p90,
PERCENTILE_CONT(0.10) WITHIN GROUP (ORDER BY free_throw_rate) AS ftr_p10,
PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY free_throw_rate) AS ftr_q1,
PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY free_throw_rate) AS ftr_median,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY free_throw_rate) AS ftr_q3,
PERCENTILE_CONT(0.90) WITHIN GROUP (ORDER BY free_throw_rate) AS ftr_p90
FROM team_stats
GROUP BY season_start_year
ORDER BY season_start_year