Files
sonr/internal/migrations/012_crypto_coin_price_data.sql
Prad NukalaandGitHub 13e6c3e84d Master (#1262)
* clear

* feat: Add everything

* fix: Commenht
2025-10-03 14:45:52 -04:00

507 lines
16 KiB
PL/PgSQL

-- +goose Up
-- +goose StatementBegin
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Enable pg_cron extension for scheduled jobs
CREATE EXTENSION IF NOT EXISTS pg_cron;
-- Grant usage on pg_cron schema to postgres user
GRANT USAGE ON SCHEMA cron TO postgres;
-- Create crypto_coin_price table for storing historical price data
CREATE TABLE IF NOT EXISTS crypto_coin_price (
id SERIAL,
coin_id VARCHAR(255) NOT NULL REFERENCES crypto_coins(id) ON DELETE CASCADE,
time_open TIMESTAMPTZ NOT NULL,
time_close TIMESTAMPTZ NOT NULL,
open DECIMAL(20, 8),
high DECIMAL(20, 8),
low DECIMAL(20, 8),
close DECIMAL(20, 8),
volume DECIMAL(30, 2),
market_cap DECIMAL(30, 2),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(coin_id, time_open, time_close)
);
-- Convert to hypertable partitioned by time_close
SELECT create_hypertable('crypto_coin_price', 'time_close',
chunk_time_interval => INTERVAL '7 days',
if_not_exists => TRUE
);
-- Create indexes optimized for TimescaleDB
CREATE INDEX idx_crypto_coin_price_time_close_coin_id
ON crypto_coin_price (time_close DESC, coin_id)
WHERE time_close IS NOT NULL;
CREATE INDEX idx_crypto_coin_price_coin_id_time_close
ON crypto_coin_price (coin_id, time_close DESC)
WHERE coin_id IS NOT NULL;
-- Enable compression on older chunks
ALTER TABLE crypto_coin_price SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'coin_id',
timescaledb.compress_orderby = 'time_close DESC'
);
-- Create a policy to automatically compress chunks older than specified interval
-- Default is 30 days, but can be configured via app.timescaledb_compress_after_days
DO $$
DECLARE
compress_days INTEGER;
compress_interval INTERVAL;
BEGIN
-- Try to get compression days from config, default to 30 days
compress_days := COALESCE(
current_setting('app.timescaledb_compress_after_days', true)::INTEGER,
30
);
compress_interval := compress_days || ' days';
PERFORM add_compression_policy('crypto_coin_price', compress_interval);
RAISE NOTICE 'Compression policy set to compress chunks older than % days', compress_days;
END $$;
-- Add retention policy to drop chunks older than specified interval
-- Default is 1 year, but can be configured via app.timescaledb_retention_days
DO $$
DECLARE
retention_days INTEGER;
retention_interval INTERVAL;
BEGIN
-- Try to get retention days from config, default to 365 days (1 year)
retention_days := COALESCE(
current_setting('app.timescaledb_retention_days', true)::INTEGER,
365
);
retention_interval := retention_days || ' days';
PERFORM add_retention_policy('crypto_coin_price', retention_interval);
RAISE NOTICE 'Retention policy set to % days', retention_days;
END $$;
-- Create update trigger for updated_at
CREATE TRIGGER update_crypto_coin_price_updated_at
BEFORE UPDATE ON crypto_coin_price
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Create HTTP function to fetch OHLCV data from CoinPaprika
CREATE OR REPLACE FUNCTION http_get_coinpaprika_ohlcv(coin_id TEXT, start_date TEXT DEFAULT NULL, end_date TEXT DEFAULT NULL)
RETURNS TABLE (
time_open TIMESTAMPTZ,
time_close TIMESTAMPTZ,
open NUMERIC,
high NUMERIC,
low NUMERIC,
close NUMERIC,
volume NUMERIC,
market_cap NUMERIC
) AS $$
DECLARE
api_url TEXT;
response JSON;
api_key TEXT;
BEGIN
-- Get API key from environment or use default
api_key := COALESCE(current_setting('app.coinpaprika_api_key', true), 'dummy-key-for-free-tier');
-- Build URL based on parameters
IF start_date IS NULL AND end_date IS NULL THEN
-- Get today's data
api_url := 'https://api.coinpaprika.com/v1/coins/' || coin_id || '/ohlcv/today';
ELSIF start_date IS NOT NULL AND end_date IS NOT NULL THEN
-- Get historical data for date range
api_url := 'https://api.coinpaprika.com/v1/coins/' || coin_id || '/ohlcv/historical?start=' || start_date || '&end=' || end_date;
ELSE
-- Get latest data (last 24 hours)
api_url := 'https://api.coinpaprika.com/v1/coins/' || coin_id || '/ohlcv/latest';
END IF;
-- Make HTTP request
response := http_get(
api_url,
JSONB_BUILD_OBJECT(
'User-Agent', 'PostgreSQL/HTTP',
'Accept', 'application/json'
)
)::JSON;
-- Parse and return the response
RETURN QUERY
SELECT
(item->>'time_open')::TIMESTAMPTZ,
(item->>'time_close')::TIMESTAMPTZ,
(item->>'open')::NUMERIC,
(item->>'high')::NUMERIC,
(item->>'low')::NUMERIC,
(item->>'close')::NUMERIC,
(item->>'volume')::NUMERIC,
(item->>'market_cap')::NUMERIC
FROM json_array_elements(response) AS item;
END;
$$ LANGUAGE plpgsql;
-- Function to fetch and store today's price data for a coin
CREATE OR REPLACE FUNCTION refresh_coin_price_today(p_coin_id TEXT)
RETURNS VOID AS $$
BEGIN
-- Insert or update today's price data
INSERT INTO crypto_coin_price (
coin_id, time_open, time_close, open, high, low, close, volume, market_cap
)
SELECT
p_coin_id, time_open, time_close, open, high, low, close, volume, market_cap
FROM http_get_coinpaprika_ohlcv(p_coin_id)
ON CONFLICT (coin_id, time_open, time_close)
DO UPDATE SET
open = EXCLUDED.open,
high = EXCLUDED.high,
low = EXCLUDED.low,
close = EXCLUDED.close,
volume = EXCLUDED.volume,
market_cap = EXCLUDED.market_cap,
updated_at = NOW();
END;
$$ LANGUAGE plpgsql;
-- Create continuous aggregates for different time intervals
-- 1 hour aggregates
CREATE MATERIALIZED VIEW crypto_coin_price_1h
WITH (timescaledb.continuous) AS
SELECT
coin_id,
time_bucket('1 hour', time_close) AS bucket,
FIRST(open, time_open) AS open,
MAX(high) AS high,
MIN(low) AS low,
LAST(close, time_close) AS close,
SUM(volume) AS volume,
AVG(market_cap) AS market_cap,
COUNT(*) AS num_records
FROM crypto_coin_price
GROUP BY coin_id, bucket
WITH NO DATA;
-- 4 hour aggregates
CREATE MATERIALIZED VIEW crypto_coin_price_4h
WITH (timescaledb.continuous) AS
SELECT
coin_id,
time_bucket('4 hours', time_close) AS bucket,
FIRST(open, time_open) AS open,
MAX(high) AS high,
MIN(low) AS low,
LAST(close, time_close) AS close,
SUM(volume) AS volume,
AVG(market_cap) AS market_cap,
COUNT(*) AS num_records
FROM crypto_coin_price
GROUP BY coin_id, bucket
WITH NO DATA;
-- 1 day aggregates
CREATE MATERIALIZED VIEW crypto_coin_price_1d
WITH (timescaledb.continuous) AS
SELECT
coin_id,
time_bucket('1 day', time_close) AS bucket,
FIRST(open, time_open) AS open,
MAX(high) AS high,
MIN(low) AS low,
LAST(close, time_close) AS close,
SUM(volume) AS volume,
AVG(market_cap) AS market_cap,
COUNT(*) AS num_records
FROM crypto_coin_price
GROUP BY coin_id, bucket
WITH NO DATA;
-- 1 week aggregates
CREATE MATERIALIZED VIEW crypto_coin_price_1w
WITH (timescaledb.continuous) AS
SELECT
coin_id,
time_bucket('1 week', time_close) AS bucket,
FIRST(open, time_open) AS open,
MAX(high) AS high,
MIN(low) AS low,
LAST(close, time_close) AS close,
SUM(volume) AS volume,
AVG(market_cap) AS market_cap,
COUNT(*) AS num_records
FROM crypto_coin_price
GROUP BY coin_id, bucket
WITH NO DATA;
-- Create refresh policies for continuous aggregates
-- The refresh window must be larger than the bucket interval
-- For 1h buckets: window must be > 1 hour
SELECT add_continuous_aggregate_policy('crypto_coin_price_1h',
start_offset => INTERVAL '4 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '30 minutes');
-- For 4h buckets: window must be > 4 hours
SELECT add_continuous_aggregate_policy('crypto_coin_price_4h',
start_offset => INTERVAL '24 hours',
end_offset => INTERVAL '4 hours',
schedule_interval => INTERVAL '2 hours');
-- For 1d buckets: window must be > 1 day
SELECT add_continuous_aggregate_policy('crypto_coin_price_1d',
start_offset => INTERVAL '7 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '6 hours');
-- For 1w buckets: window must be > 1 week
SELECT add_continuous_aggregate_policy('crypto_coin_price_1w',
start_offset => INTERVAL '4 weeks',
end_offset => INTERVAL '1 week',
schedule_interval => INTERVAL '1 day');
-- Create indexes on continuous aggregates
CREATE INDEX idx_crypto_coin_price_1h_coin_bucket ON crypto_coin_price_1h(coin_id, bucket DESC);
CREATE INDEX idx_crypto_coin_price_4h_coin_bucket ON crypto_coin_price_4h(coin_id, bucket DESC);
CREATE INDEX idx_crypto_coin_price_1d_coin_bucket ON crypto_coin_price_1d(coin_id, bucket DESC);
CREATE INDEX idx_crypto_coin_price_1w_coin_bucket ON crypto_coin_price_1w(coin_id, bucket DESC);
-- Function to get latest price for a coin
CREATE OR REPLACE FUNCTION get_latest_coin_price(p_coin_id TEXT)
RETURNS TABLE (
coin_id VARCHAR(255),
price DECIMAL(20, 8),
volume DECIMAL(30, 2),
market_cap DECIMAL(30, 2),
high_24h DECIMAL(20, 8),
low_24h DECIMAL(20, 8),
change_24h DECIMAL(20, 8),
last_updated TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
cp.coin_id,
cp.close as price,
cp.volume,
cp.market_cap,
cp.high as high_24h,
cp.low as low_24h,
CASE
WHEN cp.open > 0 THEN ((cp.close - cp.open) / cp.open * 100)
ELSE 0
END as change_24h,
cp.time_close as last_updated
FROM crypto_coin_price cp
WHERE cp.coin_id = p_coin_id
ORDER BY cp.time_close DESC
LIMIT 1;
END;
$$ LANGUAGE plpgsql;
-- Function to update TimescaleDB policies dynamically
CREATE OR REPLACE FUNCTION update_timescaledb_policies(
p_retention_days INTEGER DEFAULT NULL,
p_compress_after_days INTEGER DEFAULT NULL
)
RETURNS TABLE (
policy_type TEXT,
old_value TEXT,
new_value TEXT,
status TEXT
) AS $$
DECLARE
current_retention INTERVAL;
current_compression INTERVAL;
new_retention INTERVAL;
new_compression INTERVAL;
BEGIN
-- Update retention policy if provided
IF p_retention_days IS NOT NULL THEN
-- Get current retention policy
SELECT schedule_interval INTO current_retention
FROM timescaledb_information.policies
WHERE hypertable = 'crypto_coin_price'::regclass
AND policy_name LIKE '%retention%'
LIMIT 1;
new_retention := p_retention_days || ' days';
-- Remove old policy and add new one
PERFORM remove_retention_policy('crypto_coin_price', if_exists => TRUE);
PERFORM add_retention_policy('crypto_coin_price', new_retention);
RETURN QUERY SELECT
'retention'::TEXT,
COALESCE(current_retention::TEXT, 'none'),
new_retention::TEXT,
'updated'::TEXT;
END IF;
-- Update compression policy if provided
IF p_compress_after_days IS NOT NULL THEN
-- Get current compression policy
SELECT schedule_interval INTO current_compression
FROM timescaledb_information.policies
WHERE hypertable = 'crypto_coin_price'::regclass
AND policy_name LIKE '%compress%'
LIMIT 1;
new_compression := p_compress_after_days || ' days';
-- Remove old policy and add new one
PERFORM remove_compression_policy('crypto_coin_price', if_exists => TRUE);
PERFORM add_compression_policy('crypto_coin_price', new_compression);
RETURN QUERY SELECT
'compression'::TEXT,
COALESCE(current_compression::TEXT, 'none'),
new_compression::TEXT,
'updated'::TEXT;
END IF;
-- Save configuration to database settings
IF p_retention_days IS NOT NULL THEN
PERFORM set_config('app.timescaledb_retention_days', p_retention_days::TEXT, FALSE);
END IF;
IF p_compress_after_days IS NOT NULL THEN
PERFORM set_config('app.timescaledb_compress_after_days', p_compress_after_days::TEXT, FALSE);
END IF;
END;
$$ LANGUAGE plpgsql;
-- Create function to refresh price data for all active coins
CREATE OR REPLACE FUNCTION refresh_all_coin_prices()
RETURNS VOID AS $$
DECLARE
coin RECORD;
success_count INT := 0;
error_count INT := 0;
BEGIN
-- Iterate through all coins that have been linked to Cosmos assets
FOR coin IN
SELECT DISTINCT cc.id as coin_id
FROM crypto_coins cc
JOIN asset_symbol_links asl ON asl.crypto_coin_id = cc.id
WHERE cc.is_active = TRUE
LIMIT 100 -- Process in batches to avoid overwhelming the API
LOOP
BEGIN
PERFORM refresh_coin_price_today(coin.coin_id);
success_count := success_count + 1;
EXCEPTION WHEN OTHERS THEN
error_count := error_count + 1;
RAISE NOTICE 'Error refreshing price for coin %: %', coin.coin_id, SQLERRM;
END;
END LOOP;
RAISE NOTICE 'Price refresh completed. Success: %, Errors: %', success_count, error_count;
END;
$$ LANGUAGE plpgsql;
-- Create pg_cron jobs for automated data fetching
-- Refresh prices every 5 minutes during market hours
SELECT cron.schedule(
'refresh-coin-prices-frequent',
'*/5 * * * *', -- Every 5 minutes
$$SELECT refresh_all_coin_prices();$$
);
-- Refresh continuous aggregates every hour
SELECT cron.schedule(
'refresh-continuous-aggregates-1h',
'0 * * * *', -- Every hour at minute 0
$$CALL refresh_continuous_aggregate('crypto_coin_price_1h', NULL, NULL);$$
);
-- Function to get cosmos asset price through symbol linking
CREATE OR REPLACE FUNCTION get_cosmos_asset_price(p_chain_name TEXT, p_base_denom TEXT)
RETURNS TABLE (
chain_name TEXT,
base_denom TEXT,
symbol TEXT,
coin_id VARCHAR(255),
price DECIMAL(20, 8),
volume DECIMAL(30, 2),
market_cap DECIMAL(30, 2),
high_24h DECIMAL(20, 8),
low_24h DECIMAL(20, 8),
change_24h DECIMAL(20, 8),
last_updated TIMESTAMPTZ
) AS $$
BEGIN
RETURN QUERY
SELECT
ca.chain_id as chain_name,
ca.base as base_denom,
asl.symbol,
lcp.coin_id,
lcp.price,
lcp.volume,
lcp.market_cap,
lcp.high_24h,
lcp.low_24h,
lcp.change_24h,
lcp.last_updated
FROM asset_symbol_links asl
JOIN chain_assets ca ON asl.chain_asset_id = ca.id
JOIN LATERAL get_latest_coin_price(asl.crypto_coin_id) lcp ON TRUE
WHERE ca.chain_id = p_chain_name
AND ca.base = p_base_denom;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
-- Drop cron jobs
SELECT cron.unschedule('refresh-coin-prices-frequent');
SELECT cron.unschedule('refresh-continuous-aggregates-1h');
-- Drop functions
DROP FUNCTION IF EXISTS get_cosmos_asset_price(TEXT, TEXT);
DROP FUNCTION IF EXISTS get_latest_coin_price(TEXT);
DROP FUNCTION IF EXISTS update_timescaledb_policies(INTEGER, INTEGER);
DROP FUNCTION IF EXISTS refresh_all_coin_prices();
DROP FUNCTION IF EXISTS refresh_coin_price_today(TEXT);
DROP FUNCTION IF EXISTS http_get_coinpaprika_ohlcv(TEXT, TEXT, TEXT);
-- Drop continuous aggregate policies
SELECT remove_continuous_aggregate_policy('crypto_coin_price_1h', if_exists => TRUE);
SELECT remove_continuous_aggregate_policy('crypto_coin_price_4h', if_exists => TRUE);
SELECT remove_continuous_aggregate_policy('crypto_coin_price_1d', if_exists => TRUE);
SELECT remove_continuous_aggregate_policy('crypto_coin_price_1w', if_exists => TRUE);
-- Drop continuous aggregates
DROP MATERIALIZED VIEW IF EXISTS crypto_coin_price_1h CASCADE;
DROP MATERIALIZED VIEW IF EXISTS crypto_coin_price_4h CASCADE;
DROP MATERIALIZED VIEW IF EXISTS crypto_coin_price_1d CASCADE;
DROP MATERIALIZED VIEW IF EXISTS crypto_coin_price_1w CASCADE;
-- Drop TimescaleDB policies
SELECT remove_retention_policy('crypto_coin_price', if_exists => TRUE);
SELECT remove_compression_policy('crypto_coin_price', if_exists => TRUE);
-- Drop triggers
DROP TRIGGER IF EXISTS update_crypto_coin_price_updated_at ON crypto_coin_price;
-- Drop hypertable (this will drop all associated objects)
DROP TABLE IF EXISTS crypto_coin_price CASCADE;
-- Drop extensions
DROP EXTENSION IF EXISTS pg_cron CASCADE;
DROP EXTENSION IF EXISTS timescaledb CASCADE;
-- +goose StatementEnd