Skip to content

🥇 Tutorial 03: Gold Layer

Last Updated: 2026-04-15 | Version: 2.0 Status: ✅ Final | Maintainer: Documentation Team

🏠 Home > 📖 Tutorials > 🥇 Gold Layer


Difficulty Duration Prerequisites


🥇 Tutorial 03: Gold Layer - Business-Ready Aggregations

Difficulty ⭐⭐ Intermediate
Time ⏱️ 60-90 minutes
Layer 🥇 Gold (Business Ready)

📍 Progress Tracker

Tutorial Name Status Duration Difficulty
00 ⚙️ Environment Setup Complete 45-60 min ⭐ Beginner
01 🥉 Bronze Layer Complete 60-90 min ⭐ Beginner
02 🥈 Silver Layer Complete 60-90 min ⭐⭐ Intermediate
03 👉 🥇 Gold Layer Current 90-120 min ⭐⭐ Intermediate
04 ⚡ Real-Time Analytics Todo 90-120 min ⭐⭐⭐ Advanced
05 📊 Direct Lake & Power BI Todo 60-90 min ⭐⭐ Intermediate
06 🔄 Data Pipelines Todo 60-90 min ⭐⭐ Intermediate
07 🛡️ Governance & Purview Todo 60-90 min ⭐⭐ Intermediate
08 🔄 Database Mirroring Todo 60-90 min ⭐⭐ Intermediate
09 🤖 Advanced AI/ML Todo 90-120 min ⭐⭐⭐ Advanced

💡 Tip: Click any tutorial name to jump directly to it


Navigation
⬅️ Previous 02-Silver Layer
➡️ Next 04-Real-Time Analytics

📖 Overview

This tutorial covers implementing the Gold layer - the crown jewel of the medallion architecture. The Gold layer provides business-ready aggregations, pre-computed KPIs, and star schema structures optimized for analytics and reporting.

Medallion Architecture in Microsoft Fabric

The Gold layer represents the business-ready, aggregated data tier in the medallion lakehouse architecture:

Medallion Architecture

Source: Implement medallion lakehouse architecture in Fabric

flowchart LR
    subgraph Silver["🥈 Silver Layer"]
        S1[Cleansed Slots]
        S2[Player Master]
        S3[Financial Txns]
        S4[Table Games]
    end

    subgraph Gold["🥇 Gold Layer"]
        direction TB
        G1[Slot Performance<br/>Daily KPIs]
        G2[Player 360<br/>Complete View]
        G3[Compliance<br/>Reports]
        G4[Dimensions<br/>Date, Machine]
    end

    subgraph Analytics["📊 Analytics"]
        PBI[Power BI<br/>Direct Lake]
    end

    S1 --> G1
    S2 --> G2
    S3 --> G2
    S4 --> G2
    S3 --> G3

    G1 --> PBI
    G2 --> PBI
    G3 --> PBI
    G4 --> PBI

    style Gold fill:#FFD700,stroke:#B8860B,stroke-width:2px

📊 Visual Overview

The following ERD illustrates the Gold layer star schema design for casino analytics, showing fact tables and their relationships with dimension tables optimized for Power BI Direct Lake mode.

%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#FFD700','primaryTextColor':'#000','primaryBorderColor':'#B8860B','lineColor':'#4682B4','secondaryColor':'#FFF8DC','tertiaryColor':'#fff'}}}%%
erDiagram
    %% Dimension Tables
    DIM_DATE {
        int date_key PK
        date calendar_date
        int year
        int quarter
        int month
        int day
        string month_name
        string day_name
        int fiscal_year
        int fiscal_quarter
        boolean is_weekend
        boolean is_holiday
    }

    DIM_MACHINE {
        int machine_key PK
        string machine_id UK
        string machine_type
        string manufacturer
        string model
        string denomination
        string floor_section
        int floor_position
        decimal theoretical_hold_pct
        date install_date
        date last_maintenance_date
        string status
    }

    DIM_PLAYER {
        int player_key PK
        string player_id UK
        string tier_status
        date enrollment_date
        date tier_effective_date
        string preferred_game_type
        int lifetime_visits
        decimal lifetime_coin_in
        string risk_category
        string current_status
        date last_visit_date
    }

    DIM_GAME {
        int game_key PK
        string game_id UK
        string game_name
        string game_theme
        string game_category
        string vendor
        decimal min_bet
        decimal max_bet
        string volatility_rating
        decimal rtp_percentage
    }

    DIM_LOCATION {
        int location_key PK
        string floor_section
        string zone_name
        int floor_number
        string casino_area
        int total_machines
        decimal square_footage
    }

    %% Fact Tables
    FACT_SLOT_PERFORMANCE {
        int perf_key PK
        int date_key FK
        int machine_key FK
        int game_key FK
        int location_key FK
        decimal total_coin_in
        decimal total_coin_out
        decimal total_jackpots
        decimal net_win
        decimal theoretical_win
        decimal variance
        int total_spins
        int total_sessions
        decimal avg_bet_amount
        decimal hold_percentage
        decimal player_count
        decimal peak_hour_utilization
    }

    FACT_PLAYER_ACTIVITY {
        int activity_key PK
        int date_key FK
        int player_key FK
        int machine_key FK
        int game_key FK
        timestamp session_start
        timestamp session_end
        int session_duration_min
        decimal total_coin_in
        decimal total_coin_out
        decimal net_win_loss
        int total_spins
        decimal avg_bet
        decimal max_bet
        int jackpot_count
        decimal comp_points_earned
        decimal theo_value
    }

    FACT_FINANCIAL_TXN {
        int txn_key PK
        int date_key FK
        int player_key FK
        string transaction_id UK
        string transaction_type
        decimal amount
        string payment_method
        timestamp transaction_timestamp
        string status
        string location_code
        string processed_by
        boolean compliance_flagged
    }

    FACT_DAILY_KPI {
        int kpi_key PK
        int date_key FK
        int location_key FK
        decimal total_revenue
        decimal total_coin_in
        decimal total_coin_out
        decimal total_jackpots
        decimal hold_percentage
        int active_machines
        int active_players
        int new_enrollments
        decimal avg_session_length
        decimal player_reinvestment_rate
        decimal compliance_score
    }

    %% Relationships - Fact_Slot_Performance
    FACT_SLOT_PERFORMANCE ||--o{ DIM_DATE : "occurs_on"
    FACT_SLOT_PERFORMANCE ||--o{ DIM_MACHINE : "played_on"
    FACT_SLOT_PERFORMANCE ||--o{ DIM_GAME : "features"
    FACT_SLOT_PERFORMANCE ||--o{ DIM_LOCATION : "located_in"

    %% Relationships - Fact_Player_Activity
    FACT_PLAYER_ACTIVITY ||--o{ DIM_DATE : "occurs_on"
    FACT_PLAYER_ACTIVITY ||--o{ DIM_PLAYER : "performed_by"
    FACT_PLAYER_ACTIVITY ||--o{ DIM_MACHINE : "played_on"
    FACT_PLAYER_ACTIVITY ||--o{ DIM_GAME : "features"

    %% Relationships - Fact_Financial_Txn
    FACT_FINANCIAL_TXN ||--o{ DIM_DATE : "occurs_on"
    FACT_FINANCIAL_TXN ||--o{ DIM_PLAYER : "made_by"

    %% Relationships - Fact_Daily_KPI
    FACT_DAILY_KPI ||--o{ DIM_DATE : "summarizes"
    FACT_DAILY_KPI ||--o{ DIM_LOCATION : "for_location"

Star Schema Benefits: - Optimized Queries: Denormalized dimensions for fast aggregations - Direct Lake Ready: Designed for Power BI Direct Lake mode - Business-Aligned: Dimensions match business reporting needs - Pre-Aggregated: Fact tables contain computed KPIs for performance

Key Design Patterns: - Conformed Dimensions: DIM_DATE and DIM_PLAYER shared across all facts - Gaming KPIs: Coin-in, coin-out, hold percentage, theoretical win - Surrogate Keys: Integer keys (_key) for optimal join performance - **SCD Type 2*: Player and Machine dimensions track historical changes - Grain Definition: Daily aggregations in FACT_SLOT_PERFORMANCE, session-level in FACT_PLAYER_ACTIVITY

Casino-Specific Metrics: - Hold Percentage: (Coin-In - Coin-Out) / Coin-In × 100 - Theoretical Win: Expected casino win based on game math - Variance: Actual win vs. theoretical win - Player Reinvestment Rate: Percentage of winnings replayed


🎯 Learning Objectives

By the end of this tutorial, you will be able to:

  • Understand Gold layer principles and business alignment
  • Create slot machine performance metrics with gaming KPIs
  • Build a comprehensive Player 360 view with churn scoring
  • Implement compliance reporting tables
  • Design star schema with dimension tables
  • Optimize for Direct Lake mode in Power BI

🥇 Gold Layer Principles

The Gold layer provides business-ready, aggregated data optimized for consumption:

Principle Description Example
Business-Aligned Organized by business domain Slots, Players, Compliance
Aggregated Pre-computed KPIs and metrics Daily Theo Win, Hold %
Optimized Designed for query performance Partitioned, Z-ordered
Star Schema Fact and dimension tables Fact_SlotPerformance + Dim_Date
Direct Lake Ready Optimized for Power BI Proper data types, column ordering

Gaming Industry KPIs

mindmap
  root((Casino KPIs))
    Revenue
      Coin-In
      Coin-Out
      Net Win
      Theo Win
    Performance
      Hold Percentage
      Actual vs Theoretical
      Games Played
    Player
      ADT Average Daily Theo
      Total Visits
      Churn Risk
    Compliance
      CTR Count
      SAR Count
      W2G Amount

📋 Prerequisites

Before starting this tutorial, ensure you have:

  • Completed Tutorial 02: Silver Layer
  • All Silver tables populated and verified
  • Access to lh_gold Lakehouse
  • Understanding of dimensional modeling concepts

💡 Tip: Run the Silver layer verification notebook before starting to ensure all source tables are ready.


🛠️ Step 1: Slot Performance Metrics

Understanding Gaming KPIs

Before building the Gold table, let's understand key gaming metrics:

KPI Formula Description
Coin-In Sum of all wagers Total money wagered
Coin-Out Sum of all payouts Total money paid out
Net Win Coin-In - Coin-Out Casino's actual profit
Theo Win Coin-In × Theoretical Hold % Expected casino profit
Hold % Net Win / Coin-In × 100 Actual hold percentage
Hold Variance Actual Hold % - Theoretical Hold % Performance vs expectation

Create Notebook: 01_gold_slot_performance

# Cell 1: Configuration
from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from pyspark.sql.types import *
from datetime import datetime

SILVER_TABLE = "lh_silver.silver_slot_cleansed"
GOLD_TABLE = "gold_slot_performance"

print("=" * 60)
print("🥇 GOLD LAYER - Slot Machine Performance Metrics")
print("=" * 60)
print(f"Source: {SILVER_TABLE}")
print(f"Target: {GOLD_TABLE}")
# Cell 2: Read Silver Data
df_silver = spark.table(SILVER_TABLE)

print(f"\n📊 Silver Layer Statistics:")
print(f"  Total Records: {df_silver.count():,}")
print(f"  Date Range: {df_silver.agg(min('event_date'), max('event_date')).first()}")
print(f"  Unique Machines: {df_silver.select('machine_id').distinct().count():,}")
# Cell 3: Calculate Daily Machine Performance Aggregations
print("\n📈 Building Daily Aggregations...")

df_daily = df_silver \
    .filter(col("event_type") == "GAME_PLAY") \
    .groupBy(
        col("machine_id"),
        col("event_date").alias("business_date"),
        col("zone"),
        col("machine_type"),
        col("denomination"),
        col("manufacturer"),
        col("game_theme")
    ) \
    .agg(
        # Volume Metrics
        count("*").alias("total_games"),
        countDistinct("player_id").alias("unique_players"),
        countDistinct("session_id").alias("total_sessions"),

        # Financial Metrics (in cents, will convert to dollars)
        sum("coin_in").alias("total_coin_in"),
        sum("coin_out").alias("total_coin_out"),
        sum("jackpot_amount").alias("total_jackpots"),

        # Performance Metrics
        avg("coin_in").alias("avg_bet"),
        max("coin_in").alias("max_bet"),
        avg("theoretical_hold").alias("avg_theoretical_hold"),

        # Time Metrics
        min("event_timestamp").alias("first_play"),
        max("event_timestamp").alias("last_play")
    )

print(f"  Daily aggregations created: {df_daily.count():,} rows")

KPI Calculations

# Cell 4: Calculate Gaming KPIs
print("\n🎰 Calculating Gaming KPIs...")

df_kpis = df_daily \
    .withColumn("net_win",
        col("total_coin_in") - col("total_coin_out")) \
    .withColumn("actual_hold_pct",
        when(col("total_coin_in") > 0,
             round((col("total_coin_in") - col("total_coin_out")) / col("total_coin_in") * 100, 2))
        .otherwise(0)) \
    .withColumn("theo_win",
        round(col("total_coin_in") * col("avg_theoretical_hold"), 2)) \
    .withColumn("hold_variance",
        round(col("actual_hold_pct") - (col("avg_theoretical_hold") * 100), 2)) \
    .withColumn("hold_performance",
        when(col("hold_variance") > 2, "OVER_PERFORMING")
        .when(col("hold_variance") < -2, "UNDER_PERFORMING")
        .otherwise("ON_TARGET")) \
    .withColumn("avg_session_length_games",
        when(col("total_sessions") > 0,
             round(col("total_games") / col("total_sessions"), 1))
        .otherwise(0)) \
    .withColumn("games_per_player",
        when(col("unique_players") > 0,
             round(col("total_games") / col("unique_players"), 1))
        .otherwise(0)) \
    .withColumn("jackpot_hit_rate",
        when(col("total_games") > 0,
             round(col("total_jackpots") / col("total_games"), 4))
        .otherwise(0)) \
    .withColumn("revenue_per_player",
        when(col("unique_players") > 0,
             round(col("net_win") / col("unique_players"), 2))
        .otherwise(0))

print("  KPIs calculated:")
print("    - net_win, actual_hold_pct, theo_win")
print("    - hold_variance, hold_performance")
print("    - avg_session_length_games, games_per_player")
print("    - jackpot_hit_rate, revenue_per_player")

💡 Tip: Hold variance is a critical metric for slot operations. Machines consistently outside the -2% to +2% range may need investigation.

# Cell 5: Add Gold Metadata and Finalize Schema
print("\n✨ Finalizing Gold Table Schema...")

df_gold = df_kpis \
    .withColumn("_gold_processed_at", current_timestamp()) \
    .withColumn("_gold_batch_id", lit(datetime.now().strftime("%Y%m%d")))

# Reorder columns for clarity and Direct Lake optimization
column_order = [
    # Keys (frequently filtered - put first for Direct Lake)
    "business_date", "machine_id", "zone", "machine_type", "denomination",
    "manufacturer", "game_theme",

    # Volume Metrics
    "total_games", "unique_players", "total_sessions",

    # Financial Metrics
    "total_coin_in", "total_coin_out", "net_win", "total_jackpots",

    # Performance KPIs
    "actual_hold_pct", "avg_theoretical_hold", "theo_win", "hold_variance",
    "hold_performance",

    # Player Metrics
    "avg_bet", "max_bet", "avg_session_length_games", "games_per_player",
    "jackpot_hit_rate", "revenue_per_player",

    # Time
    "first_play", "last_play",

    # Metadata
    "_gold_processed_at", "_gold_batch_id"
]

df_gold = df_gold.select(column_order)
# Cell 6: Write to Gold Table with Optimization
print("\n💾 Writing to Gold Table...")

df_gold.write \
    .format("delta") \
    .mode("overwrite") \
    .partitionBy("business_date") \
    .saveAsTable(GOLD_TABLE)

# Optimize for query performance
spark.sql(f"OPTIMIZE {GOLD_TABLE} ZORDER BY (machine_id, zone)")

print(f"✅ Wrote {df_gold.count():,} records to {GOLD_TABLE}")
print("✅ Applied Z-ORDER optimization on (machine_id, zone)")
# Cell 7: Verify and Analyze
df_verify = spark.table(GOLD_TABLE)

print("\n" + "=" * 60)
print("✅ SLOT PERFORMANCE VERIFICATION")
print("=" * 60)

print(f"\n📊 Gold Table Statistics:")
print(f"  Total Records: {df_verify.count():,}")
date_range = df_verify.agg(min('business_date'), max('business_date')).first()
print(f"  Date Range: {date_range[0]} to {date_range[1]}")

print("\n🏆 Top 10 Machines by Net Win:")
display(
    df_verify
    .groupBy("machine_id", "machine_type", "zone")
    .agg(
        sum("net_win").alias("total_net_win"),
        avg("actual_hold_pct").alias("avg_hold_pct"),
        sum("total_games").alias("total_games")
    )
    .orderBy(col("total_net_win").desc())
    .limit(10)
)

print("\n📉 Machines with Hold Variance Issues:")
display(
    df_verify
    .filter(abs(col("hold_variance")) > 3)
    .groupBy("machine_id", "zone", "hold_performance")
    .agg(
        avg("hold_variance").alias("avg_variance"),
        count("*").alias("days_with_variance")
    )
    .orderBy(col("days_with_variance").desc())
    .limit(10)
)

🛠️ Step 2: Player 360 View

Understanding Player 360

The Player 360 view combines data from multiple sources to create a complete picture of each player:

flowchart TB
    subgraph Sources["Data Sources"]
        PM[Player Master<br/>Demographics, Tier]
        SLOT[Slot Activity<br/>Games, Coin-in]
        TBL[Table Activity<br/>Wagered, Win/Loss]
        FIN[Financial<br/>Cash-in, Cash-out]
    end

    subgraph P360["Player 360 View"]
        PROFILE[Profile Data]
        GAMING[Gaming Summary]
        VALUE[Value Scores]
        RISK[Risk Indicators]
    end

    PM --> PROFILE
    SLOT --> GAMING
    TBL --> GAMING
    FIN --> VALUE
    GAMING --> VALUE
    VALUE --> RISK

Create Notebook: 02_gold_player_360

# Cell 1: Configuration
from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from datetime import datetime

SILVER_PLAYER = "lh_silver.silver_player_master"
SILVER_SLOTS = "lh_silver.silver_slot_cleansed"
SILVER_TABLES = "lh_silver.silver_table_enriched"
SILVER_FINANCIAL = "lh_silver.silver_financial_reconciled"
GOLD_TABLE = "gold_player_360"

print("=" * 60)
print("🥇 GOLD LAYER - Player 360 View")
print("=" * 60)
# Cell 2: Get Current Player Profile Data
print("\n👤 Loading Player Profile Data...")

df_player = spark.table(SILVER_PLAYER) \
    .filter(col("_is_current") == True) \
    .select(
        "player_id", "loyalty_number", "loyalty_tier",
        "enrollment_date", "total_visits", "total_theo",
        "preferred_game", "vip_flag", "self_excluded", "account_status",
        "date_of_birth", "city", "state"
    )

# Calculate age
df_player = df_player.withColumn("age",
    floor(datediff(current_date(), col("date_of_birth")) / 365.25))

print(f"  Active Players: {df_player.count():,}")
print(f"  VIP Players: {df_player.filter(col('vip_flag')).count():,}")
# Cell 3: Calculate Slot Gaming Activity
print("\n🎰 Aggregating Slot Activity...")

df_slot_activity = spark.table(SILVER_SLOTS) \
    .filter(col("player_id").isNotNull()) \
    .filter(col("event_type") == "GAME_PLAY") \
    .groupBy("player_id") \
    .agg(
        count("*").alias("slot_games_played"),
        sum("coin_in").alias("slot_coin_in"),
        sum("coin_out").alias("slot_coin_out"),
        countDistinct("machine_id").alias("unique_machines"),
        countDistinct("event_date").alias("slot_visit_days"),
        max("event_timestamp").alias("last_slot_play"),
        avg("coin_in").alias("avg_slot_bet"),
        max("coin_in").alias("max_slot_bet"),
        # Favorite denomination
        mode("denomination").alias("preferred_denomination")
    )

print(f"  Players with slot activity: {df_slot_activity.count():,}")
# Cell 4: Calculate Table Gaming Activity
print("\n🃏 Aggregating Table Activity...")

df_table_activity = spark.table(SILVER_TABLES) \
    .filter(col("player_id").isNotNull()) \
    .groupBy("player_id") \
    .agg(
        sum("average_bet").alias("table_total_wagered"),
        sum("theoretical_win").alias("table_theo_win"),
        sum("actual_win_loss").alias("table_actual_wl"),
        sum("hours_played").alias("table_hours_played"),
        countDistinct("event_date").alias("table_visit_days"),
        max("event_timestamp").alias("last_table_play"),
        avg("average_bet").alias("avg_table_bet"),
        mode("game_type").alias("preferred_table_game")
    )

print(f"  Players with table activity: {df_table_activity.count():,}")
# Cell 5: Calculate Financial Activity
print("\n💰 Aggregating Financial Activity...")

df_financial = spark.table(SILVER_FINANCIAL) \
    .filter(col("player_id").isNotNull()) \
    .groupBy("player_id") \
    .agg(
        sum(when(col("transaction_type") == "CASH_IN", col("amount"))).alias("total_cash_in"),
        sum(when(col("transaction_type") == "CASH_OUT", col("amount"))).alias("total_cash_out"),
        sum(when(col("transaction_type") == "MARKER_ISSUE", col("amount"))).alias("total_markers_issued"),
        sum(when(col("transaction_type") == "MARKER_PAYMENT", col("amount"))).alias("total_markers_paid"),
        count("*").alias("total_transactions"),
        countDistinct("transaction_date").alias("financial_visit_days")
    )

print(f"  Players with financial activity: {df_financial.count():,}")
# Cell 6: Join All Sources
print("\n🔗 Creating Unified Player View...")

df_360 = df_player \
    .join(df_slot_activity, "player_id", "left") \
    .join(df_table_activity, "player_id", "left") \
    .join(df_financial, "player_id", "left")

# Fill nulls for numeric columns
numeric_cols = [
    "slot_games_played", "slot_coin_in", "slot_coin_out", "unique_machines",
    "slot_visit_days", "avg_slot_bet", "max_slot_bet",
    "table_total_wagered", "table_theo_win", "table_actual_wl", "table_hours_played",
    "table_visit_days", "avg_table_bet",
    "total_cash_in", "total_cash_out", "total_markers_issued", "total_markers_paid",
    "total_transactions", "financial_visit_days"
]
df_360 = df_360.fillna(0, subset=numeric_cols)

print(f"  Combined Player Records: {df_360.count():,}")

Player Value and Risk Scoring

# Cell 7: Calculate Player KPIs and Scores
print("\n📊 Calculating Player KPIs...")

df_gold = df_360 \
    .withColumn("total_gaming_activity",
        col("slot_coin_in") + col("table_total_wagered")) \
    .withColumn("slot_theo_win",
        col("slot_coin_in") * 0.08) \
    .withColumn("total_theo_win",
        col("slot_theo_win") + coalesce(col("table_theo_win"), lit(0))) \
    .withColumn("slot_net_win",
        col("slot_coin_in") - col("slot_coin_out")) \
    .withColumn("total_net_win",
        col("slot_net_win") + coalesce(col("table_actual_wl"), lit(0))) \
    .withColumn("net_cash_position",
        coalesce(col("total_cash_in"), lit(0)) - coalesce(col("total_cash_out"), lit(0))) \
    .withColumn("outstanding_markers",
        coalesce(col("total_markers_issued"), lit(0)) - coalesce(col("total_markers_paid"), lit(0))) \
    .withColumn("last_activity_date",
        greatest(col("last_slot_play"), col("last_table_play"))) \
    .withColumn("days_since_visit",
        datediff(current_date(), col("last_activity_date"))) \
    .withColumn("total_visit_days",
        col("slot_visit_days") + col("table_visit_days"))
# Cell 8: Calculate Risk and Value Indicators
print("\n⚠️ Calculating Risk Indicators...")

df_gold = df_gold \
    .withColumn("churn_risk",
        when(col("days_since_visit") > 90, "HIGH")
        .when(col("days_since_visit") > 60, "MEDIUM_HIGH")
        .when(col("days_since_visit") > 30, "MEDIUM")
        .when(col("days_since_visit") > 14, "LOW")
        .otherwise("ACTIVE")) \
    .withColumn("player_value_tier",
        when(col("total_theo_win") >= 10000, "WHALE")
        .when(col("total_theo_win") >= 5000, "HIGH_VALUE")
        .when(col("total_theo_win") >= 1000, "MEDIUM_VALUE")
        .when(col("total_theo_win") >= 100, "LOW_VALUE")
        .otherwise("MINIMAL")) \
    .withColumn("player_value_score",
        # Composite score: Theo Win + Visit Frequency + Loyalty Tier bonus
        (col("total_theo_win") / 100) +
        (col("total_visit_days") * 10) +
        when(col("loyalty_tier") == "Diamond", 500)
        .when(col("loyalty_tier") == "Platinum", 200)
        .when(col("loyalty_tier") == "Gold", 100)
        .when(col("loyalty_tier") == "Silver", 50)
        .otherwise(10)) \
    .withColumn("engagement_score",
        # Based on recency and frequency
        when(col("days_since_visit") <= 7, 100)
        .when(col("days_since_visit") <= 14, 80)
        .when(col("days_since_visit") <= 30, 60)
        .when(col("days_since_visit") <= 60, 40)
        .when(col("days_since_visit") <= 90, 20)
        .otherwise(0) +
        least(col("total_visit_days"), lit(100))) \
    .withColumn("credit_risk",
        when(col("outstanding_markers") > 10000, "HIGH")
        .when(col("outstanding_markers") > 5000, "MEDIUM")
        .when(col("outstanding_markers") > 0, "LOW")
        .otherwise("NONE")) \
    .withColumn("_gold_processed_at", current_timestamp())

⚠️ Warning: Player value scores and risk indicators should be reviewed by marketing and compliance teams before use in campaigns.

# Cell 9: Write Player 360 Gold Table
print("\n💾 Writing Player 360 Gold Table...")

df_gold.write \
    .format("delta") \
    .mode("overwrite") \
    .saveAsTable(GOLD_TABLE)

# No partitioning for dimension-like table, but optimize
spark.sql(f"OPTIMIZE {GOLD_TABLE} ZORDER BY (loyalty_tier, churn_risk)")

print(f"✅ Wrote {df_gold.count():,} player 360 records")
# Cell 10: Verify and Analyze
print("\n" + "=" * 60)
print("✅ PLAYER 360 VERIFICATION")
print("=" * 60)

df_verify = spark.table(GOLD_TABLE)

print("\n📊 Player Distribution by Value Tier:")
display(
    df_verify
    .groupBy("player_value_tier")
    .agg(
        count("*").alias("player_count"),
        round(avg("total_theo_win"), 2).alias("avg_theo_win"),
        round(avg("total_visit_days"), 1).alias("avg_visits")
    )
    .orderBy(col("avg_theo_win").desc())
)

print("\n⚠️ Churn Risk Analysis:")
display(
    df_verify
    .groupBy("churn_risk", "loyalty_tier")
    .agg(
        count("*").alias("player_count"),
        round(sum("total_theo_win"), 2).alias("total_theo_at_risk")
    )
    .orderBy("churn_risk", col("total_theo_at_risk").desc())
)

print("\n🏆 Top 20 Players by Value Score:")
display(
    df_verify
    .select(
        "player_id", "loyalty_tier", "player_value_tier",
        "total_theo_win", "total_visit_days", "churn_risk",
        "player_value_score", "engagement_score"
    )
    .orderBy(col("player_value_score").desc())
    .limit(20)
)

🛠️ Step 3: Compliance Reporting

Create Notebook: 03_gold_compliance_reporting

# Cell 1: Configuration
from pyspark.sql import SparkSession
from pyspark.sql.functions import *
from datetime import datetime

SILVER_TABLE = "lh_silver.silver_compliance_validated"
GOLD_TABLE = "gold_compliance_reporting"

print("=" * 60)
print("🥇 GOLD LAYER - Compliance Reporting")
print("=" * 60)
# Cell 2: Read and Aggregate Daily Compliance Summary
df_silver = spark.table(SILVER_TABLE)

print("\n📋 Building Daily Compliance Summary...")

df_daily = df_silver \
    .withColumn("report_date", to_date(col("filing_date"))) \
    .groupBy("report_date", "report_type", "status") \
    .agg(
        count("*").alias("report_count"),
        sum("transaction_amount").alias("total_amount"),
        avg("transaction_amount").alias("avg_amount"),
        countDistinct("player_id").alias("unique_patrons"),
        countDistinct("transaction_id").alias("unique_transactions")
    )

print(f"  Daily summaries: {df_daily.count():,}")
# Cell 3: Pivot by Report Type for Dashboard View
print("\n📊 Creating Pivoted Report View...")

df_pivot = df_silver \
    .withColumn("report_date", to_date(col("filing_date"))) \
    .groupBy("report_date") \
    .pivot("report_type", ["CTR", "SAR", "W2G", "MTLAP", "CTRC"]) \
    .agg(
        count("*").alias("count"),
        sum("transaction_amount").alias("amount")
    )

# Clean up column names
for col_name in df_pivot.columns:
    if col_name != "report_date":
        new_name = col_name.lower().replace(" ", "_")
        df_pivot = df_pivot.withColumnRenamed(col_name, new_name)

df_pivot = df_pivot.fillna(0)
# Cell 4: Add Compliance KPIs and Alert Levels
print("\n⚠️ Calculating Compliance KPIs...")

df_gold = df_pivot \
    .withColumn("total_filings",
        coalesce(col("ctr_count"), lit(0)) +
        coalesce(col("sar_count"), lit(0)) +
        coalesce(col("w2g_count"), lit(0)) +
        coalesce(col("mtlap_count"), lit(0)) +
        coalesce(col("ctrc_count"), lit(0))) \
    .withColumn("total_reportable_amount",
        coalesce(col("ctr_amount"), lit(0)) +
        coalesce(col("sar_amount"), lit(0)) +
        coalesce(col("w2g_amount"), lit(0))) \
    .withColumn("ctr_compliance_status",
        when(col("ctr_count") > 0, "FILINGS_PRESENT")
        .otherwise("NO_FILINGS")) \
    .withColumn("sar_alert_level",
        when(col("sar_count") > 10, "CRITICAL")
        .when(col("sar_count") > 5, "HIGH")
        .when(col("sar_count") > 2, "ELEVATED")
        .when(col("sar_count") > 0, "NORMAL")
        .otherwise("NONE")) \
    .withColumn("w2g_jackpot_activity",
        when(col("w2g_amount") > 100000, "VERY_HIGH")
        .when(col("w2g_amount") > 50000, "HIGH")
        .when(col("w2g_amount") > 10000, "MODERATE")
        .otherwise("NORMAL")) \
    .withColumn("daily_compliance_score",
        # Higher score = better compliance posture
        100 -
        (when(col("sar_alert_level") == "CRITICAL", 30)
         .when(col("sar_alert_level") == "HIGH", 20)
         .when(col("sar_alert_level") == "ELEVATED", 10)
         .otherwise(0))) \
    .withColumn("_gold_processed_at", current_timestamp())

⚠️ Warning: SAR (Suspicious Activity Report) counts at "CRITICAL" level require immediate review by the compliance department.

# Cell 5: Write Compliance Gold Table
print("\n💾 Writing Compliance Reporting Gold Table...")

df_gold.write \
    .format("delta") \
    .mode("overwrite") \
    .partitionBy("report_date") \
    .saveAsTable(GOLD_TABLE)

print(f"✅ Wrote {df_gold.count():,} compliance summary records")
# Cell 6: Verification
print("\n" + "=" * 60)
print("✅ COMPLIANCE REPORTING VERIFICATION")
print("=" * 60)

df_verify = spark.table(GOLD_TABLE)

print("\n📊 Filing Summary by Type:")
display(
    df_verify
    .agg(
        sum("ctr_count").alias("total_ctr"),
        sum("sar_count").alias("total_sar"),
        sum("w2g_count").alias("total_w2g"),
        round(sum("ctr_amount"), 2).alias("ctr_amount"),
        round(sum("w2g_amount"), 2).alias("w2g_amount")
    )
)

print("\n⚠️ Days with Elevated SAR Activity:")
display(
    df_verify
    .filter(col("sar_alert_level").isin(["CRITICAL", "HIGH", "ELEVATED"]))
    .select("report_date", "sar_count", "sar_alert_level", "daily_compliance_score")
    .orderBy(col("sar_count").desc())
    .limit(10)
)

🛠️ Step 4: Create Dimension Tables

Star Schema Design

A proper star schema requires dimension tables for efficient filtering and slicing:

erDiagram
    FACT_SLOT_PERFORMANCE ||--o{ DIM_DATE : business_date
    FACT_SLOT_PERFORMANCE ||--o{ DIM_MACHINE : machine_id
    FACT_SLOT_PERFORMANCE ||--o{ DIM_ZONE : zone_id
    GOLD_PLAYER_360 ||--o{ DIM_DATE : enrollment_date
    GOLD_COMPLIANCE ||--o{ DIM_DATE : report_date

    DIM_DATE {
        date date_key PK
        int year
        int quarter
        int month
        string month_name
        int week
        int day_of_week
        string day_name
        boolean is_weekend
        boolean is_holiday
        int fiscal_year
        int fiscal_quarter
    }

    DIM_MACHINE {
        string machine_id PK
        string asset_number
        string zone
        string machine_type
        string manufacturer
        decimal denomination
        string game_theme
    }

    FACT_SLOT_PERFORMANCE {
        date business_date FK
        string machine_id FK
        int total_games
        decimal coin_in
        decimal net_win
    }

Date Dimension

📦 Shortcut — use the packaged notebook. Both dim_date and dim_machine are packaged in notebooks/gold/00_gold_dim_tables.py. Import that single notebook into your Fabric workspace and run it once; it creates both dimensions in lh_gold.dbo.dim_date and lh_gold.dbo.dim_machine. The code blocks below explain what the notebook does — you do not need to copy-paste them if you imported the notebook.

# Create Date Dimension
from pyspark.sql.functions import *

print("📅 Creating Date Dimension...")

# Generate date range (2 years of history + 1 year future)
start_date = "2023-01-01"
end_date = "2026-12-31"

df_dates = spark.sql(f"""
    SELECT explode(sequence(to_date('{start_date}'), to_date('{end_date}'))) as date_key
""")

df_dim_date = df_dates \
    .withColumn("year", year("date_key")) \
    .withColumn("quarter", quarter("date_key")) \
    .withColumn("month", month("date_key")) \
    .withColumn("month_name", date_format("date_key", "MMMM")) \
    .withColumn("month_abbr", date_format("date_key", "MMM")) \
    .withColumn("week", weekofyear("date_key")) \
    .withColumn("day_of_month", dayofmonth("date_key")) \
    .withColumn("day_of_week", dayofweek("date_key")) \
    .withColumn("day_name", date_format("date_key", "EEEE")) \
    .withColumn("day_abbr", date_format("date_key", "EEE")) \
    .withColumn("is_weekend", dayofweek("date_key").isin([1, 7])) \
    .withColumn("is_weekday", ~dayofweek("date_key").isin([1, 7])) \
    .withColumn("year_month", date_format("date_key", "yyyy-MM")) \
    .withColumn("year_quarter", concat(year("date_key"), lit("-Q"), quarter("date_key"))) \
    .withColumn("fiscal_year",
        when(month("date_key") >= 10, year("date_key") + 1)
        .otherwise(year("date_key"))) \
    .withColumn("fiscal_quarter",
        when(quarter("date_key") >= 4, quarter("date_key") - 3)
        .otherwise(quarter("date_key") + 1)) \
    .withColumn("fiscal_month",
        when(month("date_key") >= 10, month("date_key") - 9)
        .otherwise(month("date_key") + 3))

df_dim_date.write.format("delta").mode("overwrite").saveAsTable("dim_date")
print(f"✅ Created dim_date with {df_dim_date.count():,} dates")

Machine Dimension

# Create Machine Dimension
print("🎰 Creating Machine Dimension...")

df_dim_machine = spark.table("lh_silver.silver_slot_cleansed") \
    .select(
        "machine_id", "asset_number", "location_id", "zone",
        "machine_type", "manufacturer", "game_theme", "denomination",
        "par_percentage", "max_bet", "progressive_flag"
    ) \
    .distinct() \
    .withColumn("machine_key", monotonically_increasing_id()) \
    .withColumn("denomination_display",
        concat(lit("$"), format_number(col("denomination"), 2))) \
    .withColumn("machine_category",
        when(col("machine_type").contains("VIDEO"), "Video")
        .when(col("machine_type").contains("REEL"), "Reel")
        .when(col("progressive_flag"), "Progressive")
        .otherwise("Standard"))

df_dim_machine.write.format("delta").mode("overwrite").saveAsTable("dim_machine")
print(f"✅ Created dim_machine with {df_dim_machine.count():,} machines")

Zone Dimension

# Create Zone Dimension
print("📍 Creating Zone Dimension...")

df_dim_zone = spark.table("lh_silver.silver_slot_cleansed") \
    .select("zone", "location_id") \
    .distinct() \
    .withColumn("zone_key", monotonically_increasing_id()) \
    .withColumn("zone_type",
        when(col("zone").contains("VIP"), "VIP")
        .when(col("zone").contains("HIGH"), "High Limit")
        .when(col("zone").contains("SMOKE"), "Smoking")
        .otherwise("General")) \
    .withColumn("floor_level",
        when(col("zone").contains("LEVEL_1"), 1)
        .when(col("zone").contains("LEVEL_2"), 2)
        .when(col("zone").contains("LEVEL_3"), 3)
        .otherwise(1))

df_dim_zone.write.format("delta").mode("overwrite").saveAsTable("dim_zone")
print(f"✅ Created dim_zone with {df_dim_zone.count():,} zones")

🛠️ Step 5: Gold Layer Verification

# Gold Layer Complete Verification
from pyspark.sql.functions import *
from datetime import datetime

# All Gold tables
gold_tables = [
    ("gold_slot_performance", "Fact"),
    ("gold_player_360", "Aggregate"),
    ("gold_compliance_reporting", "Aggregate"),
    ("gold_table_analytics", "Fact"),
    ("gold_financial_summary", "Aggregate"),
    ("dim_date", "Dimension"),
    ("dim_machine", "Dimension"),
    ("dim_zone", "Dimension")
]

print("=" * 70)
print("🥇 GOLD LAYER VERIFICATION REPORT")
print("=" * 70)
print(f"Timestamp: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print("-" * 70)

total_records = 0
passed = 0
failed = 0

for table, table_type in gold_tables:
    try:
        df = spark.table(table)
        count = df.count()
        total_records += count

        # Check for Gold metadata
        has_gold_meta = "_gold_processed_at" in df.columns or table.startswith("dim_")
        status = "✅" if has_gold_meta else "⚠️"
        passed += 1

        print(f"{status} [{table_type:10}] {table:30} {count:>12,} records")

    except Exception as e:
        print(f"❌ [{table_type:10}] {table:30} NOT FOUND")
        failed += 1

print("-" * 70)
print(f"{'SUMMARY':45} {total_records:>12,} total records")
print(f"{'Tables Passed:':45} {passed}/{len(gold_tables)}")
if failed > 0:
    print(f"{'Tables Failed:':45} {failed}/{len(gold_tables)}")
print("=" * 70)

⚡ Direct Lake Optimization

Direct Lake mode connects Power BI directly to Delta tables in OneLake for the best of both import and DirectQuery modes:

Direct Lake Overview

Source: Direct Lake overview

For optimal Power BI Direct Lake performance, follow these guidelines:

1. Column Ordering

Place frequently filtered columns first in the table schema:

# Good: Keys and filter columns first
df.select(
    "business_date",    # Primary filter
    "machine_id",       # Common filter
    "zone",             # Common filter
    "total_coin_in",    # Measure
    "net_win",          # Measure
    ...
)

2. Data Types

Use appropriate types that Direct Lake handles efficiently:

df = df \
    .withColumn("amount", col("amount").cast("decimal(18,2)")) \
    .withColumn("percentage", col("percentage").cast("decimal(5,2)")) \
    .withColumn("is_active", col("is_active").cast("boolean")) \
    .withColumn("count", col("count").cast("integer"))

3. Partitioning Strategy

Partition fact tables by date for time-series queries:

# Fact tables: Partition by date
df.write \
    .partitionBy("business_date") \
    .saveAsTable("gold_slot_performance")

# Dimension tables: No partitioning (small, frequently joined)
df.write \
    .saveAsTable("dim_machine")

4. Table Optimization

Run optimization after writes:

# Z-ORDER by commonly filtered columns
spark.sql(f"OPTIMIZE gold_slot_performance ZORDER BY (machine_id, zone)")

# VACUUM to remove old files (retain 7 days for time travel)
spark.sql(f"VACUUM gold_slot_performance RETAIN 168 HOURS")

5. V-Order (Fabric-Specific)

Enable V-Order for maximum Direct Lake performance:

# V-Order is automatically applied in Fabric
# Verify with:
spark.sql("DESCRIBE DETAIL gold_slot_performance").show()

✅ Validation Checklist

Before moving to the next tutorial, verify:

  • All Gold Tables Created - Fact and dimension tables exist in lh_gold
  • Star Schema Correct - Relationships between facts and dimensions verified
  • Aggregations Work - KPIs calculating correctly (hold %, theo win, etc.)
  • Dimension Tables Populated - Date, machine, zone dimensions complete
  • Partitioning Applied - Date-based partitions on fact tables
  • Optimization Complete - Z-ORDER and VACUUM executed successfully
🔍 How to verify each item ### All Gold Tables Created
# List all Gold tables
tables = spark.sql("SHOW TABLES IN lh_gold").collect()
table_names = [row.tableName for row in tables]

expected_tables = [
    "gold_slot_performance",
    "gold_player_360",
    "gold_compliance_reporting",
    "dim_date",
    "dim_machine",
    "dim_zone"
]

for table in expected_tables:
    status = "✅" if table in table_names else "❌"
    count = spark.table(f"lh_gold.{table}").count() if table in table_names else 0
    print(f"{status} {table:30} {count:>10,} rows")
### Star Schema Correct
-- Verify fact table can join to all dimensions
SELECT 
    f.business_date,
    d.date_name,
    m.machine_name,
    z.zone_name,
    f.total_coin_in,
    f.net_win
FROM lh_gold.gold_slot_performance f
JOIN lh_gold.dim_date d ON f.business_date = d.date_key
JOIN lh_gold.dim_machine m ON f.machine_id = m.machine_id
JOIN lh_gold.dim_zone z ON m.zone_id = z.zone_id
LIMIT 10;

-- Should return results without errors
### Aggregations Work
# Verify KPI calculations
df = spark.table("lh_gold.gold_slot_performance")

# Check hold percentage calculation
df.select(
    "machine_id",
    "business_date",
    "total_coin_in",
    "total_coin_out",
    "net_win",
    "actual_hold_pct",
    ((col("total_coin_in") - col("total_coin_out")) / col("total_coin_in") * 100).alias("calculated_hold_pct")
).show(5)

# Verify actual_hold_pct matches calculated_hold_pct
### Dimension Tables Populated
# Check date dimension coverage
df_date = spark.table("lh_gold.dim_date")
print(f"Date dimension rows: {df_date.count():,}")
print(f"Date range: {df_date.agg({'date_key': 'min'}).collect()[0][0]} to {df_date.agg({'date_key': 'max'}).collect()[0][0]}")

# Check machine dimension
df_machine = spark.table("lh_gold.dim_machine")
print(f"\nMachine dimension rows: {df_machine.count():,}")
df_machine.groupBy("zone_id").count().show()

# Check zone dimension
df_zone = spark.table("lh_gold.dim_zone")
print(f"\nZone dimension rows: {df_zone.count():,}")
df_zone.show()
### Partitioning Applied
# Check if tables are partitioned
from delta.tables import DeltaTable

table_name = "lh_gold.gold_slot_performance"
delta_table = DeltaTable.forName(spark, table_name)

# Get table details
details = delta_table.detail().select("partitionColumns").collect()[0]
print(f"Partition columns: {details['partitionColumns']}")

# Should show ['business_date'] or similar date partition
### Optimization Complete
# Check when tables were last optimized
from delta.tables import DeltaTable

table_name = "lh_gold.gold_slot_performance"
delta_table = DeltaTable.forName(spark, table_name)

# Check history for OPTIMIZE operations
history = delta_table.history(10)
optimize_ops = history.filter(col("operation") == "OPTIMIZE")

print("Recent optimization history:")
optimize_ops.select("timestamp", "operation", "operationMetrics").show(truncate=False)

# Also check for VACUUM operations
vacuum_ops = history.filter(col("operation") == "VACUUM END")
print("\nRecent vacuum history:")
vacuum_ops.select("timestamp", "operation", "operationMetrics").show(truncate=False)
### KPI Validation
-- Verify key metrics are calculating correctly
SELECT 
    business_date,
    COUNT(DISTINCT machine_id) as machine_count,
    SUM(total_coin_in) as total_coin_in,
    SUM(total_coin_out) as total_coin_out,
    SUM(net_win) as net_win,
    AVG(actual_hold_pct) as avg_hold_pct,
    SUM(theoretical_win) as total_theo
FROM lh_gold.gold_slot_performance
WHERE business_date >= DATEADD(day, -7, GETDATE())
GROUP BY business_date
ORDER BY business_date DESC;

-- Metrics should be positive and hold % between 5-15%

🎉 Summary

Congratulations! You have successfully implemented the Gold layer:

Achievement Description
✅ Slot Performance Created daily machine KPIs with gaming-specific metrics
✅ Player 360 Built comprehensive player view with value and risk scoring
✅ Compliance Reporting Implemented regulatory reporting summaries
✅ Star Schema Created dimension tables for efficient analytics
✅ Direct Lake Ready Optimized tables for Power BI consumption

➡️ Next Steps

Continue to Tutorial 04: Real-Time Analytics to learn:

  • Implementing Eventstream for real-time data ingestion
  • Building KQL queries for streaming analytics
  • Creating real-time dashboards
  • Alerting on anomalies

📚 Resources

Resource Description
Gold Layer Best Practices Microsoft Fabric medallion patterns
Direct Lake Mode Power BI Direct Lake optimization
Star Schema Design Dimensional modeling guidance
Delta Lake Optimization OPTIMIZE and Z-ORDER
Gaming Analytics KPIs Industry standard metrics

⬅️ Previous ⬆️ Up ➡️ Next
02-Silver Layer Tutorials Index 04-Real-Time Analytics

Questions or issues? Open an issue in the GitHub repository.


🥇 Gold Layer - Business-ready insights at your fingertips


⬆️ Back to Top | 📚 Tutorials | 🏠 Home