0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スタースキーマがわからなくて実際に作ってみた

Posted at

最近分析のためのデータ構造としてスタースキーマというものがあることを知った。

データをディメンションとファクトで分けて、クエリーをシンプルに書けるようにするため使うとのこと。ディメンショナルモデルともいうらしい。

元のデータを使いやすいようにいろんなテーブルに分けていくイメージだった。

以下の記事がわかりやすくまとめてくれていた。

概念はわかったけど...

これでスタースキーマがどんなものかはわかったけど、結局これでどういうSQLが書けて、どういうAPIの設計をすればいいかがピンと来なかった。

こういう時は実際ものを作ることが一番早いので、株のデータを用いてスタースキーマを作ることにした。

データの準備

株データの取得は以下の記事で行なった。Redshiftはコストが結構かかるので今回はSupabaseで作っている。

Supabaseはデータウェアハウスではないので本格的に開発するときにはあまり向いていなさそう。

株APIから取得したデータは以下のようである。このデータをStockテーブルに入れている。

date symbol open high low close volume
2024-09-13 GOOGL 155.4300 158.3800 155.2100 157.4600 27509172
2024-09-12 GOOGL 153.8000 154.8200 152.6500 154.6900 29695048
2024-09-11 GOOGL 149.9200 151.5000 147.5200 151.1600 29607693
2024-09-10 GOOGL 150.4500 151.2700 148.3400 148.6600 31118765
2024-09-09 GOOGL 152.5100 153.4000 147.2150 148.7100 39260451
2024-09-06 GOOGL 157.3000 157.8300 150.5500 150.9200 37912130

ディメンションとファクトテーブルを作る

とりあえずわかりやすい軸として、日付と銘柄のディメンションを作った。
それに紐づくファクトとして株価テーブルも作った。

-- 日付ディメンションテーブル
CREATE TABLE dim_date (
    date_key SERIAL PRIMARY KEY,
    date DATE NOT NULL,
    year INT NOT NULL,
    month INT NOT NULL,
    day INT NOT NULL,
    day_of_week INT NOT NULL,
    quarter INT NOT NULL
);

-- 銘柄(シンボル)ディメンションテーブル
CREATE TABLE dim_symbol (
    symbol_key SERIAL PRIMARY KEY,
    symbol VARCHAR(10) NOT NULL,
    company_name VARCHAR(100) NOT NULL
);

-- 株価ファクトテーブル
CREATE TABLE fact_stock_price (
    date_key INT NOT NULL,
    symbol_key INT NOT NULL,
    open_price DECIMAL(10, 4) NOT NULL,
    high_price DECIMAL(10, 4) NOT NULL,
    low_price DECIMAL(10, 4) NOT NULL,
    close_price DECIMAL(10, 4) NOT NULL,
    volume BIGINT NOT NULL,
    FOREIGN KEY (date_key) REFERENCES dim_date(date_key),
    FOREIGN KEY (symbol_key) REFERENCES dim_symbol(symbol_key)
);

-- インデックスの作成(パフォーマンス向上のため)
CREATE INDEX idx_fact_stock_price_date ON fact_stock_price(date_key);
CREATE INDEX idx_fact_stock_price_symbol ON fact_stock_price(symbol_key);

作ったテーブルにデータを入れていく

BEがある場合だとBatchタスクで書けるところでもあるが、今回はSupabaseを使っているため、SQLで書いてみた。

PostgresのEXTRACTを初めて使ってみたけど結構便利だった。

-- 1. dim_dateテーブルにデータを挿入(date列をDATE型に変換)
INSERT INTO dim_date (date, year, month, day, day_of_week, quarter)
SELECT DISTINCT
    date::DATE,
    EXTRACT(YEAR FROM date::DATE) AS year,
    EXTRACT(MONTH FROM date::DATE) AS month,
    EXTRACT(DAY FROM date::DATE) AS day,
    EXTRACT(DOW FROM date::DATE) AS day_of_week,
    EXTRACT(QUARTER FROM date::DATE) AS quarter
FROM stock;

-- 2. dim_symbolテーブルにデータを挿入(変更なし)
INSERT INTO dim_symbol (symbol, company_name)
SELECT DISTINCT symbol, 
       CASE 
           WHEN symbol = 'GOOGL' THEN 'Alphabet Inc.'
           ELSE 'Unknown' -- 他の銘柄がある場合は、ここで適切な会社名を設定
       END AS company_name
FROM stock;

-- 3. fact_stock_priceテーブルにデータを挿入(date列をDATE型に変換)
INSERT INTO fact_stock_price (date_key, symbol_key, open_price, high_price, low_price, close_price, volume)
SELECT 
    d.date_key,
    s.symbol_key,
    st.open::DECIMAL(10, 4),
    st.high::DECIMAL(10, 4),
    st.low::DECIMAL(10, 4),
    st.close::DECIMAL(10, 4),
    st.volume::BIGINT
FROM 
    stock st
JOIN 
    dim_date d ON st.date::DATE = d.date
JOIN 
    dim_symbol s ON st.symbol = s.symbol;

-- 4. データ移行の確認
SELECT COUNT(*) FROM fact_stock_price;

-- 5. インデックスの再構築(オプション、大量データの挿入後にパフォーマンスを向上させるため)
REINDEX TABLE fact_stock_price;
ANALYZE fact_stock_price;

ビューを作る

これでスタースキーマの準備ができたので、ダッシュボードを作れるようになった。

しかし、今の状態でAPIを叩いたらフロントエンドでデータの整理が必要になる。
BE側で細かな処理を書けないSupabaseのめんどくさいところ...今回はSupabaseのDBに追加でビューを作って、そのデータを取得することにした。

-- 月次平均株価ビューの作成
CREATE VIEW monthly_avg_price AS
SELECT 
    d.year,
    d.month,
    s.symbol,
    s.company_name,
    AVG(f.close_price) as avg_price
FROM 
    fact_stock_price f
JOIN 
    dim_date d ON f.date_key = d.date_key
JOIN 
    dim_symbol s ON f.symbol_key = s.symbol_key
GROUP BY 
    d.year, d.month, s.symbol, s.company_name
ORDER BY 
    d.year, d.month, s.symbol;

これで使いやすいデータができた。取得するだけでグラフ化できるようになっている。

year month symbol company_name avg_price
1999 11 AAPL Apple 90.00714285714285714
1999 11 AMZN Amazon 76.24190476190476190
1999 11 MSFT Microsoft 89.46238095238095238
1999 12 AAPL Apple 103.40000000000000000
1999 12 AMZN Amazon 91.02863636363636363
1999 12 MSFT Microsoft 106.18954545454545454
2000 1 AAPL Apple 103.35950000000000000
2000 1 AMZN Amazon 68.15050000000000000

ダッシュボードを作る

vue環境上でEChartライブラリーを使ったら短いコードでリッチなダッシュボードが作れた。
まだちょっと甘いところはあるが...

スクリーンショット 2024-09-28 23.05.32.png

おわりに

分析というには浅いところしか見ていないが、スタースキーマがどんなものかは少しわかったような気がする。

多数のテーブルを巻き込むとクエリーが必要以上に複雑になってしまうから。使うところだけをまとめたテーブルを作ってしまおう、ということだろう。

今回書いたコード

<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { LineChart } from 'echarts/charts';
import {
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
} from 'echarts/components';
import VChart from 'vue-echarts';
import { supabase } from "@/utils/supabase";


use([
  CanvasRenderer,
  LineChart,
  TitleComponent,
  TooltipComponent,
  LegendComponent,
  GridComponent,
]);

const stockData = ref([]);
const selectedStocks = ref(['AAPL', 'AMZN', 'MSFT', 'GOOGL']);
const yearRange = ref({ start: 2023, end: 2024 });
const availableStocks = ['AAPL', 'AMZN', 'MSFT', 'GOOGL'];

const fetchMonthlyAvgPrice = async () => {
  const { data, error } = await supabase
      .from('monthly_avg_price')
      .select('*')
      .in('symbol', selectedStocks.value)
      .gte('year', yearRange.value.start)
      .lte('year', yearRange.value.end)
      .order('year')
      .order('month');
  if (error) {
    console.error('Error fetching monthly average price data:', error);
  } else {
    stockData.value = data;
  }
};

const handleStockSelection = (symbol) => {
  if (selectedStocks.value.includes(symbol)) {
    selectedStocks.value = selectedStocks.value.filter(s => s !== symbol);
  } else {
    selectedStocks.value.push(symbol);
  }
};

const handleYearChange = (type, value) => {
  yearRange.value[type] = parseInt(value);
};

const chartOption = computed(() => ({
  title: {
    text: 'Monthly Average Stock Price Comparison'
  },
  tooltip: {
    trigger: 'axis',
    axisPointer: {
      type: 'cross'
    }
  },
  legend: {
    data: selectedStocks.value
  },
  xAxis: {
    type: 'category',
    data: Array.from(new Set(stockData.value.map(item => `${item.year}-${item.month.toString().padStart(2, '0')}`))).sort(),
    axisLabel: {
      formatter: (value) => {
        const [year, month] = value.split('-');
        return `${year}-${month}`;
      }
    }
  },
  yAxis: {
    type: 'value',
    name: 'Average Price',
    axisLabel: {
      formatter: '${value}'
    }
  },
  series: selectedStocks.value.map(symbol => ({
    name: symbol,
    type: 'line',
    data: stockData.value
        .filter(item => item.symbol === symbol)
        .map(item => [
          `${item.year}-${item.month.toString().padStart(2, '0')}`,
          item.avg_price
        ])
  }))
}));

onMounted(fetchMonthlyAvgPrice);

watch([selectedStocks, yearRange], fetchMonthlyAvgPrice);
</script>

<template>
  <div class="monthly-avg-price-dashboard">
    <h1>Monthly Average Stock Price Dashboard</h1>
    <div class="controls">
      <div class="stock-selector">
        <label v-for="symbol in availableStocks" :key="symbol">
          <input
              type="checkbox"
              :checked="selectedStocks.includes(symbol)"
              @change="handleStockSelection(symbol)"
          />
          {{ symbol }}
        </label>
      </div>
      <div class="year-range-selector">
        <input
            type="number"
            v-model="yearRange.start"
            @input="handleYearChange('start', $event.target.value)"
            min="2000"
            max="2024"
        />
        <input
            type="number"
            v-model="yearRange.end"
            @input="handleYearChange('end', $event.target.value)"
            min="2000"
            max="2024"
        />
      </div>
    </div>
    <v-chart class="chart" :option="chartOption" autoresize />
  </div>
</template>

<style scoped>
.monthly-avg-price-dashboard {
  font-family: Arial, sans-serif;
}
.controls {
  margin-bottom: 20px;
}
.stock-selector label {
  margin-right: 10px;
}
.year-range-selector input {
  width: 70px;
  margin-right: 10px;
}
.chart {
  height: 600px;
}
</style>

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?