mirror of
https://github.com/sonr-io/sonr.git
synced 2026-08-02 17:31:39 +00:00
507 lines
16 KiB
PL/PgSQL
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 |