[DDL] feat: Sprint 1 - Add 12 tables for users, admin, notifications, market_data
## New Tables Created (Sprint 1 - DDL Roadmap Q1-2026) ### users schema (4 tables): - profiles: Extended user profile information - user_settings: User preferences and configurations - kyc_verifications: KYC/AML verification records - risk_profiles: Trading risk assessment profiles ### admin schema (3 tables): - admin_roles: Platform administrative roles - platform_analytics: Aggregated platform metrics - api_keys: Programmatic API access keys ### notifications schema (1 table): - notifications: Multi-channel notification system ### market_data schema (4 tables): - tickers: Financial instruments catalog - ohlcv_5m: 5-minute OHLCV price data - technical_indicators: Pre-calculated TA indicators - ohlcv_5m_staging: Staging table for data ingestion ## Features: - Multi-tenancy with RLS policies - Comprehensive indexes for query optimization - Triggers for computed fields and timestamps - Helper functions for common operations - Views for dashboard and reporting - Full GRANTS configuration Roadmap: orchestration/planes/ROADMAP-IMPLEMENTACION-DDL-2026-Q1.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e520268348
commit
b86dfa2e06
243
ddl/schemas/admin/tables/001_admin_roles.sql
Normal file
243
ddl/schemas/admin/tables/001_admin_roles.sql
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: admin
|
||||||
|
-- TABLE: admin_roles
|
||||||
|
-- DESCRIPTION: Roles administrativos de la plataforma
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Crear schema si no existe
|
||||||
|
CREATE SCHEMA IF NOT EXISTS admin;
|
||||||
|
|
||||||
|
-- Grant usage
|
||||||
|
GRANT USAGE ON SCHEMA admin TO trading_app;
|
||||||
|
GRANT USAGE ON SCHEMA admin TO trading_readonly;
|
||||||
|
|
||||||
|
-- Enum para nivel de acceso administrativo
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE admin.admin_level AS ENUM (
|
||||||
|
'super_admin', -- Acceso total a la plataforma
|
||||||
|
'platform_admin', -- Administracion de plataforma (sin acceso a codigo)
|
||||||
|
'tenant_admin', -- Administrador de tenant
|
||||||
|
'support', -- Soporte al cliente
|
||||||
|
'analyst', -- Solo lectura para analisis
|
||||||
|
'auditor' -- Solo lectura para auditorias
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Roles Administrativos
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.admin_roles (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Nivel administrativo
|
||||||
|
level admin.admin_level NOT NULL,
|
||||||
|
|
||||||
|
-- Scope del rol
|
||||||
|
is_global BOOLEAN NOT NULL DEFAULT FALSE, -- Aplica a todos los tenants
|
||||||
|
tenant_id UUID REFERENCES tenants.tenants(id), -- Tenant especifico (NULL si global)
|
||||||
|
|
||||||
|
-- Permisos especificos (override del nivel)
|
||||||
|
permissions JSONB DEFAULT '{}'::JSONB,
|
||||||
|
denied_permissions JSONB DEFAULT '[]'::JSONB, -- Permisos explicitamente denegados
|
||||||
|
|
||||||
|
-- Configuracion
|
||||||
|
can_impersonate BOOLEAN NOT NULL DEFAULT FALSE, -- Puede actuar como otro usuario
|
||||||
|
can_view_pii BOOLEAN NOT NULL DEFAULT FALSE, -- Puede ver datos personales
|
||||||
|
can_export_data BOOLEAN NOT NULL DEFAULT FALSE, -- Puede exportar datos masivos
|
||||||
|
can_modify_config BOOLEAN NOT NULL DEFAULT FALSE, -- Puede modificar configuracion
|
||||||
|
can_manage_admins BOOLEAN NOT NULL DEFAULT FALSE, -- Puede gestionar otros admins
|
||||||
|
|
||||||
|
-- Restricciones de IP
|
||||||
|
allowed_ips INET[], -- IPs permitidas (NULL = todas)
|
||||||
|
|
||||||
|
-- Restricciones de horario
|
||||||
|
allowed_hours_start TIME, -- Hora inicio permitida
|
||||||
|
allowed_hours_end TIME, -- Hora fin permitida
|
||||||
|
allowed_days INTEGER[], -- Dias permitidos (0=Dom, 6=Sab)
|
||||||
|
|
||||||
|
-- Activacion
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
deactivated_at TIMESTAMPTZ,
|
||||||
|
deactivation_reason TEXT,
|
||||||
|
|
||||||
|
-- Expiracion
|
||||||
|
expires_at TIMESTAMPTZ, -- Rol temporal
|
||||||
|
|
||||||
|
-- Auditoria de asignacion
|
||||||
|
assigned_by UUID REFERENCES users.users(id),
|
||||||
|
assigned_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
assignment_reason TEXT,
|
||||||
|
|
||||||
|
-- Ultima actividad administrativa
|
||||||
|
last_admin_action_at TIMESTAMPTZ,
|
||||||
|
total_admin_actions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT admin_roles_unique_user_tenant UNIQUE (user_id, tenant_id),
|
||||||
|
CONSTRAINT admin_roles_tenant_check CHECK (
|
||||||
|
(is_global = TRUE AND tenant_id IS NULL) OR
|
||||||
|
(is_global = FALSE AND tenant_id IS NOT NULL)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE admin.admin_roles IS
|
||||||
|
'Roles administrativos asignados a usuarios de la plataforma';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.admin_roles.level IS
|
||||||
|
'Nivel de acceso: super_admin, platform_admin, tenant_admin, support, analyst, auditor';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.admin_roles.is_global IS
|
||||||
|
'TRUE si el rol aplica a todos los tenants (solo para super_admin/platform_admin)';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_user_id
|
||||||
|
ON admin.admin_roles(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_tenant_id
|
||||||
|
ON admin.admin_roles(tenant_id)
|
||||||
|
WHERE tenant_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_level
|
||||||
|
ON admin.admin_roles(level);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_active
|
||||||
|
ON admin.admin_roles(is_active)
|
||||||
|
WHERE is_active = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_global
|
||||||
|
ON admin.admin_roles(is_global)
|
||||||
|
WHERE is_global = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_admin_roles_expires
|
||||||
|
ON admin.admin_roles(expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION admin.update_admin_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS admin_role_updated_at ON admin.admin_roles;
|
||||||
|
CREATE TRIGGER admin_role_updated_at
|
||||||
|
BEFORE UPDATE ON admin.admin_roles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION admin.update_admin_timestamp();
|
||||||
|
|
||||||
|
-- Funcion para verificar acceso admin
|
||||||
|
CREATE OR REPLACE FUNCTION admin.check_admin_access(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_tenant_id UUID DEFAULT NULL,
|
||||||
|
p_required_level admin.admin_level DEFAULT 'support'
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
DECLARE
|
||||||
|
v_role RECORD;
|
||||||
|
v_level_order INTEGER;
|
||||||
|
v_required_order INTEGER;
|
||||||
|
v_current_time TIME;
|
||||||
|
v_current_day INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Orden de niveles (mayor = mas permisos)
|
||||||
|
SELECT CASE p_required_level
|
||||||
|
WHEN 'super_admin' THEN 6
|
||||||
|
WHEN 'platform_admin' THEN 5
|
||||||
|
WHEN 'tenant_admin' THEN 4
|
||||||
|
WHEN 'support' THEN 3
|
||||||
|
WHEN 'analyst' THEN 2
|
||||||
|
WHEN 'auditor' THEN 1
|
||||||
|
END INTO v_required_order;
|
||||||
|
|
||||||
|
-- Buscar rol activo
|
||||||
|
SELECT * INTO v_role
|
||||||
|
FROM admin.admin_roles
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND is_active = TRUE
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
AND (is_global = TRUE OR tenant_id = p_tenant_id)
|
||||||
|
ORDER BY is_global DESC -- Preferir roles globales
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_role IS NULL THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar nivel
|
||||||
|
SELECT CASE v_role.level
|
||||||
|
WHEN 'super_admin' THEN 6
|
||||||
|
WHEN 'platform_admin' THEN 5
|
||||||
|
WHEN 'tenant_admin' THEN 4
|
||||||
|
WHEN 'support' THEN 3
|
||||||
|
WHEN 'analyst' THEN 2
|
||||||
|
WHEN 'auditor' THEN 1
|
||||||
|
END INTO v_level_order;
|
||||||
|
|
||||||
|
IF v_level_order < v_required_order THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar restricciones de horario si existen
|
||||||
|
IF v_role.allowed_hours_start IS NOT NULL AND v_role.allowed_hours_end IS NOT NULL THEN
|
||||||
|
v_current_time := CURRENT_TIME;
|
||||||
|
IF v_current_time < v_role.allowed_hours_start OR v_current_time > v_role.allowed_hours_end THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar restricciones de dias si existen
|
||||||
|
IF v_role.allowed_days IS NOT NULL AND array_length(v_role.allowed_days, 1) > 0 THEN
|
||||||
|
v_current_day := EXTRACT(DOW FROM CURRENT_DATE)::INTEGER;
|
||||||
|
IF NOT (v_current_day = ANY(v_role.allowed_days)) THEN
|
||||||
|
RETURN FALSE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN TRUE;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Vista de administradores activos
|
||||||
|
CREATE OR REPLACE VIEW admin.v_active_admins AS
|
||||||
|
SELECT
|
||||||
|
ar.id,
|
||||||
|
ar.user_id,
|
||||||
|
u.email,
|
||||||
|
u.first_name,
|
||||||
|
u.last_name,
|
||||||
|
ar.level,
|
||||||
|
ar.is_global,
|
||||||
|
ar.tenant_id,
|
||||||
|
t.name AS tenant_name,
|
||||||
|
ar.can_impersonate,
|
||||||
|
ar.can_view_pii,
|
||||||
|
ar.expires_at,
|
||||||
|
ar.last_admin_action_at,
|
||||||
|
ar.total_admin_actions
|
||||||
|
FROM admin.admin_roles ar
|
||||||
|
JOIN users.users u ON ar.user_id = u.id
|
||||||
|
LEFT JOIN tenants.tenants t ON ar.tenant_id = t.id
|
||||||
|
WHERE ar.is_active = TRUE
|
||||||
|
AND (ar.expires_at IS NULL OR ar.expires_at > NOW())
|
||||||
|
ORDER BY ar.level, ar.created_at;
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON admin.admin_roles TO trading_app;
|
||||||
|
GRANT SELECT ON admin.admin_roles TO trading_readonly;
|
||||||
|
GRANT SELECT ON admin.v_active_admins TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION admin.check_admin_access TO trading_app;
|
||||||
276
ddl/schemas/admin/tables/002_platform_analytics.sql
Normal file
276
ddl/schemas/admin/tables/002_platform_analytics.sql
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: admin
|
||||||
|
-- TABLE: platform_analytics
|
||||||
|
-- DESCRIPTION: Metricas agregadas de la plataforma
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enum para tipo de metrica
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE admin.metric_type AS ENUM (
|
||||||
|
'counter', -- Contador simple
|
||||||
|
'gauge', -- Valor actual
|
||||||
|
'histogram', -- Distribucion
|
||||||
|
'summary', -- Resumen estadistico
|
||||||
|
'rate' -- Tasa por periodo
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para granularidad
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE admin.time_granularity AS ENUM (
|
||||||
|
'minute',
|
||||||
|
'hour',
|
||||||
|
'day',
|
||||||
|
'week',
|
||||||
|
'month',
|
||||||
|
'quarter',
|
||||||
|
'year'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Metricas de Plataforma
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.platform_analytics (
|
||||||
|
-- Identificadores
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Scope
|
||||||
|
tenant_id UUID REFERENCES tenants.tenants(id), -- NULL = plataforma global
|
||||||
|
|
||||||
|
-- Metrica
|
||||||
|
metric_name VARCHAR(100) NOT NULL,
|
||||||
|
metric_type admin.metric_type NOT NULL DEFAULT 'gauge',
|
||||||
|
category VARCHAR(50) NOT NULL, -- 'users', 'trading', 'revenue', 'performance'
|
||||||
|
|
||||||
|
-- Periodo
|
||||||
|
period_start TIMESTAMPTZ NOT NULL,
|
||||||
|
period_end TIMESTAMPTZ NOT NULL,
|
||||||
|
granularity admin.time_granularity NOT NULL DEFAULT 'day',
|
||||||
|
|
||||||
|
-- Valores
|
||||||
|
value DECIMAL(20, 6) NOT NULL,
|
||||||
|
previous_value DECIMAL(20, 6), -- Valor periodo anterior (para calcular delta)
|
||||||
|
delta_value DECIMAL(20, 6), -- Cambio vs periodo anterior
|
||||||
|
delta_percent DECIMAL(10, 4), -- Cambio porcentual
|
||||||
|
|
||||||
|
-- Estadisticas (para histogramas/summaries)
|
||||||
|
min_value DECIMAL(20, 6),
|
||||||
|
max_value DECIMAL(20, 6),
|
||||||
|
avg_value DECIMAL(20, 6),
|
||||||
|
median_value DECIMAL(20, 6),
|
||||||
|
p95_value DECIMAL(20, 6),
|
||||||
|
p99_value DECIMAL(20, 6),
|
||||||
|
stddev_value DECIMAL(20, 6),
|
||||||
|
sample_count BIGINT,
|
||||||
|
|
||||||
|
-- Dimensiones adicionales
|
||||||
|
dimensions JSONB DEFAULT '{}'::JSONB, -- Dimensiones para desglose
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT 'system', -- Origen del dato
|
||||||
|
is_estimated BOOLEAN NOT NULL DEFAULT FALSE, -- Dato estimado vs real
|
||||||
|
confidence_level DECIMAL(5, 4), -- Nivel de confianza (0-1)
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
collected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT analytics_period_check CHECK (period_end > period_start),
|
||||||
|
CONSTRAINT analytics_unique_metric UNIQUE (tenant_id, metric_name, granularity, period_start)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE admin.platform_analytics IS
|
||||||
|
'Metricas agregadas de la plataforma para dashboards y reportes';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.platform_analytics.tenant_id IS
|
||||||
|
'NULL para metricas globales de plataforma, UUID para metricas por tenant';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.platform_analytics.dimensions IS
|
||||||
|
'Dimensiones adicionales como {"country": "MX", "plan": "premium"}';
|
||||||
|
|
||||||
|
-- Particionamiento por fecha (recomendado para produccion)
|
||||||
|
-- CREATE TABLE admin.platform_analytics_partitioned (
|
||||||
|
-- LIKE admin.platform_analytics INCLUDING ALL
|
||||||
|
-- ) PARTITION BY RANGE (period_start);
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_metric_period
|
||||||
|
ON admin.platform_analytics(metric_name, period_start DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_tenant_metric
|
||||||
|
ON admin.platform_analytics(tenant_id, metric_name, period_start DESC)
|
||||||
|
WHERE tenant_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_category
|
||||||
|
ON admin.platform_analytics(category, period_start DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_granularity
|
||||||
|
ON admin.platform_analytics(granularity, period_start DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_global
|
||||||
|
ON admin.platform_analytics(metric_name, granularity, period_start DESC)
|
||||||
|
WHERE tenant_id IS NULL;
|
||||||
|
|
||||||
|
-- GIN index para dimensiones
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_analytics_dimensions_gin
|
||||||
|
ON admin.platform_analytics USING GIN (dimensions);
|
||||||
|
|
||||||
|
-- Trigger para calcular deltas automaticamente
|
||||||
|
CREATE OR REPLACE FUNCTION admin.calculate_analytics_delta()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_prev RECORD;
|
||||||
|
v_period_interval INTERVAL;
|
||||||
|
BEGIN
|
||||||
|
-- Calcular intervalo basado en granularidad
|
||||||
|
CASE NEW.granularity
|
||||||
|
WHEN 'minute' THEN v_period_interval := INTERVAL '1 minute';
|
||||||
|
WHEN 'hour' THEN v_period_interval := INTERVAL '1 hour';
|
||||||
|
WHEN 'day' THEN v_period_interval := INTERVAL '1 day';
|
||||||
|
WHEN 'week' THEN v_period_interval := INTERVAL '1 week';
|
||||||
|
WHEN 'month' THEN v_period_interval := INTERVAL '1 month';
|
||||||
|
WHEN 'quarter' THEN v_period_interval := INTERVAL '3 months';
|
||||||
|
WHEN 'year' THEN v_period_interval := INTERVAL '1 year';
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Buscar valor anterior
|
||||||
|
SELECT value INTO v_prev
|
||||||
|
FROM admin.platform_analytics
|
||||||
|
WHERE metric_name = NEW.metric_name
|
||||||
|
AND granularity = NEW.granularity
|
||||||
|
AND (tenant_id = NEW.tenant_id OR (tenant_id IS NULL AND NEW.tenant_id IS NULL))
|
||||||
|
AND period_start = NEW.period_start - v_period_interval
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_prev IS NOT NULL THEN
|
||||||
|
NEW.previous_value := v_prev.value;
|
||||||
|
NEW.delta_value := NEW.value - v_prev.value;
|
||||||
|
IF v_prev.value != 0 THEN
|
||||||
|
NEW.delta_percent := ((NEW.value - v_prev.value) / v_prev.value) * 100;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS analytics_calc_delta ON admin.platform_analytics;
|
||||||
|
CREATE TRIGGER analytics_calc_delta
|
||||||
|
BEFORE INSERT ON admin.platform_analytics
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION admin.calculate_analytics_delta();
|
||||||
|
|
||||||
|
-- Funcion para insertar o actualizar metrica
|
||||||
|
CREATE OR REPLACE FUNCTION admin.upsert_metric(
|
||||||
|
p_metric_name VARCHAR(100),
|
||||||
|
p_category VARCHAR(50),
|
||||||
|
p_value DECIMAL(20, 6),
|
||||||
|
p_granularity admin.time_granularity DEFAULT 'day',
|
||||||
|
p_tenant_id UUID DEFAULT NULL,
|
||||||
|
p_metric_type admin.metric_type DEFAULT 'gauge',
|
||||||
|
p_dimensions JSONB DEFAULT '{}'::JSONB
|
||||||
|
)
|
||||||
|
RETURNS BIGINT AS $$
|
||||||
|
DECLARE
|
||||||
|
v_period_start TIMESTAMPTZ;
|
||||||
|
v_period_end TIMESTAMPTZ;
|
||||||
|
v_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Calcular periodo basado en granularidad
|
||||||
|
CASE p_granularity
|
||||||
|
WHEN 'minute' THEN
|
||||||
|
v_period_start := DATE_TRUNC('minute', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 minute';
|
||||||
|
WHEN 'hour' THEN
|
||||||
|
v_period_start := DATE_TRUNC('hour', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 hour';
|
||||||
|
WHEN 'day' THEN
|
||||||
|
v_period_start := DATE_TRUNC('day', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 day';
|
||||||
|
WHEN 'week' THEN
|
||||||
|
v_period_start := DATE_TRUNC('week', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 week';
|
||||||
|
WHEN 'month' THEN
|
||||||
|
v_period_start := DATE_TRUNC('month', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 month';
|
||||||
|
WHEN 'quarter' THEN
|
||||||
|
v_period_start := DATE_TRUNC('quarter', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '3 months';
|
||||||
|
WHEN 'year' THEN
|
||||||
|
v_period_start := DATE_TRUNC('year', NOW());
|
||||||
|
v_period_end := v_period_start + INTERVAL '1 year';
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
INSERT INTO admin.platform_analytics (
|
||||||
|
tenant_id, metric_name, metric_type, category,
|
||||||
|
period_start, period_end, granularity,
|
||||||
|
value, dimensions
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_metric_name, p_metric_type, p_category,
|
||||||
|
v_period_start, v_period_end, p_granularity,
|
||||||
|
p_value, p_dimensions
|
||||||
|
)
|
||||||
|
ON CONFLICT (tenant_id, metric_name, granularity, period_start)
|
||||||
|
DO UPDATE SET
|
||||||
|
value = EXCLUDED.value,
|
||||||
|
dimensions = EXCLUDED.dimensions,
|
||||||
|
collected_at = NOW()
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
|
||||||
|
RETURN v_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Vista de metricas recientes
|
||||||
|
CREATE OR REPLACE VIEW admin.v_recent_metrics AS
|
||||||
|
SELECT
|
||||||
|
metric_name,
|
||||||
|
category,
|
||||||
|
granularity,
|
||||||
|
tenant_id,
|
||||||
|
value,
|
||||||
|
delta_percent,
|
||||||
|
period_start,
|
||||||
|
collected_at
|
||||||
|
FROM admin.platform_analytics
|
||||||
|
WHERE collected_at > NOW() - INTERVAL '24 hours'
|
||||||
|
ORDER BY metric_name, period_start DESC;
|
||||||
|
|
||||||
|
-- Vista de KPIs principales
|
||||||
|
CREATE OR REPLACE VIEW admin.v_platform_kpis AS
|
||||||
|
SELECT
|
||||||
|
metric_name,
|
||||||
|
value AS current_value,
|
||||||
|
previous_value,
|
||||||
|
delta_percent AS change_percent,
|
||||||
|
period_start,
|
||||||
|
granularity
|
||||||
|
FROM admin.platform_analytics
|
||||||
|
WHERE tenant_id IS NULL -- Solo metricas globales
|
||||||
|
AND granularity = 'day'
|
||||||
|
AND metric_name IN (
|
||||||
|
'total_users',
|
||||||
|
'active_users_daily',
|
||||||
|
'total_trades',
|
||||||
|
'total_volume_usd',
|
||||||
|
'revenue_usd',
|
||||||
|
'new_signups',
|
||||||
|
'churn_rate'
|
||||||
|
)
|
||||||
|
AND period_start = DATE_TRUNC('day', NOW())
|
||||||
|
ORDER BY metric_name;
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON admin.platform_analytics TO trading_app;
|
||||||
|
GRANT SELECT ON admin.platform_analytics TO trading_readonly;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE admin.platform_analytics_id_seq TO trading_app;
|
||||||
|
GRANT SELECT ON admin.v_recent_metrics TO trading_app;
|
||||||
|
GRANT SELECT ON admin.v_platform_kpis TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION admin.upsert_metric TO trading_app;
|
||||||
358
ddl/schemas/admin/tables/003_api_keys.sql
Normal file
358
ddl/schemas/admin/tables/003_api_keys.sql
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: admin
|
||||||
|
-- TABLE: api_keys
|
||||||
|
-- DESCRIPTION: API Keys para acceso programatico
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enum para tipo de API key
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE admin.api_key_type AS ENUM (
|
||||||
|
'public', -- Solo lectura publica
|
||||||
|
'private', -- Acceso completo a recursos propios
|
||||||
|
'admin', -- Acceso administrativo
|
||||||
|
'service', -- Servicio a servicio
|
||||||
|
'webhook', -- Solo para recibir webhooks
|
||||||
|
'readonly' -- Solo lectura
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para estado de API key
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE admin.api_key_status AS ENUM (
|
||||||
|
'active', -- Activa y funcional
|
||||||
|
'suspended', -- Suspendida temporalmente
|
||||||
|
'revoked', -- Revocada permanentemente
|
||||||
|
'expired' -- Expirada
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de API Keys
|
||||||
|
CREATE TABLE IF NOT EXISTS admin.api_keys (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Identificacion de la key
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
|
||||||
|
-- Key values (solo se almacena hash del secret)
|
||||||
|
key_prefix VARCHAR(8) NOT NULL, -- Primeros 8 caracteres para identificacion
|
||||||
|
key_hash VARCHAR(255) NOT NULL, -- Hash bcrypt del secret completo
|
||||||
|
key_hint VARCHAR(4), -- Ultimos 4 caracteres para referencia
|
||||||
|
|
||||||
|
-- Tipo y estado
|
||||||
|
type admin.api_key_type NOT NULL DEFAULT 'private',
|
||||||
|
status admin.api_key_status NOT NULL DEFAULT 'active',
|
||||||
|
|
||||||
|
-- Permisos
|
||||||
|
scopes JSONB NOT NULL DEFAULT '["read"]'::JSONB, -- Array de scopes permitidos
|
||||||
|
permissions JSONB DEFAULT '{}'::JSONB, -- Permisos granulares
|
||||||
|
|
||||||
|
-- Restricciones
|
||||||
|
allowed_ips INET[], -- IPs permitidas (NULL = todas)
|
||||||
|
allowed_origins TEXT[], -- Origenes CORS permitidos
|
||||||
|
allowed_user_agents TEXT[], -- User agents permitidos
|
||||||
|
|
||||||
|
-- Rate limiting
|
||||||
|
rate_limit_per_minute INTEGER DEFAULT 60,
|
||||||
|
rate_limit_per_hour INTEGER DEFAULT 1000,
|
||||||
|
rate_limit_per_day INTEGER DEFAULT 10000,
|
||||||
|
|
||||||
|
-- Uso
|
||||||
|
last_used_at TIMESTAMPTZ,
|
||||||
|
last_used_ip INET,
|
||||||
|
last_used_user_agent TEXT,
|
||||||
|
total_requests BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_errors BIGINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Tracking de uso diario
|
||||||
|
requests_today INTEGER NOT NULL DEFAULT 0,
|
||||||
|
requests_today_date DATE DEFAULT CURRENT_DATE,
|
||||||
|
|
||||||
|
-- Expiracion
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Rotacion
|
||||||
|
previous_key_hash VARCHAR(255), -- Hash anterior durante rotacion
|
||||||
|
previous_key_valid_until TIMESTAMPTZ, -- Validez del key anterior
|
||||||
|
rotated_at TIMESTAMPTZ,
|
||||||
|
rotation_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
-- Auditoria
|
||||||
|
created_by UUID REFERENCES users.users(id),
|
||||||
|
revoked_by UUID REFERENCES users.users(id),
|
||||||
|
revoked_at TIMESTAMPTZ,
|
||||||
|
revocation_reason TEXT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
environment VARCHAR(20) DEFAULT 'production', -- 'development', 'staging', 'production'
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT api_keys_unique_name_per_user UNIQUE (user_id, name),
|
||||||
|
CONSTRAINT api_keys_unique_prefix UNIQUE (key_prefix)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE admin.api_keys IS
|
||||||
|
'API Keys para acceso programatico a la plataforma';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.api_keys.key_prefix IS
|
||||||
|
'Primeros 8 caracteres de la key para identificacion sin exponer el secret';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.api_keys.key_hash IS
|
||||||
|
'Hash bcrypt del secret completo (el secret solo se muestra una vez al crear)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN admin.api_keys.scopes IS
|
||||||
|
'Array de scopes: ["read", "write", "trade", "admin"]';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_tenant
|
||||||
|
ON admin.api_keys(tenant_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_user
|
||||||
|
ON admin.api_keys(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_prefix
|
||||||
|
ON admin.api_keys(key_prefix);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_status_active
|
||||||
|
ON admin.api_keys(status)
|
||||||
|
WHERE status = 'active';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_type
|
||||||
|
ON admin.api_keys(type);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_expires
|
||||||
|
ON admin.api_keys(expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL AND status = 'active';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_keys_last_used
|
||||||
|
ON admin.api_keys(last_used_at DESC)
|
||||||
|
WHERE status = 'active';
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
DROP TRIGGER IF EXISTS api_key_updated_at ON admin.api_keys;
|
||||||
|
CREATE TRIGGER api_key_updated_at
|
||||||
|
BEFORE UPDATE ON admin.api_keys
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION admin.update_admin_timestamp();
|
||||||
|
|
||||||
|
-- Trigger para resetear contador diario
|
||||||
|
CREATE OR REPLACE FUNCTION admin.reset_daily_requests()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.requests_today_date < CURRENT_DATE THEN
|
||||||
|
NEW.requests_today := 0;
|
||||||
|
NEW.requests_today_date := CURRENT_DATE;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS api_key_reset_daily ON admin.api_keys;
|
||||||
|
CREATE TRIGGER api_key_reset_daily
|
||||||
|
BEFORE UPDATE ON admin.api_keys
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION admin.reset_daily_requests();
|
||||||
|
|
||||||
|
-- Funcion para validar API key
|
||||||
|
CREATE OR REPLACE FUNCTION admin.validate_api_key(
|
||||||
|
p_key_prefix VARCHAR(8),
|
||||||
|
p_client_ip INET DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
is_valid BOOLEAN,
|
||||||
|
key_id UUID,
|
||||||
|
user_id UUID,
|
||||||
|
tenant_id UUID,
|
||||||
|
key_type admin.api_key_type,
|
||||||
|
scopes JSONB,
|
||||||
|
rate_limit_remaining INTEGER,
|
||||||
|
error_message TEXT
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_key RECORD;
|
||||||
|
v_rate_remaining INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Buscar key por prefijo
|
||||||
|
SELECT * INTO v_key
|
||||||
|
FROM admin.api_keys ak
|
||||||
|
WHERE ak.key_prefix = p_key_prefix
|
||||||
|
AND ak.status = 'active'
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_key IS NULL THEN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
FALSE, NULL::UUID, NULL::UUID, NULL::UUID,
|
||||||
|
NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER,
|
||||||
|
'API key not found or inactive'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar expiracion
|
||||||
|
IF v_key.expires_at IS NOT NULL AND v_key.expires_at < NOW() THEN
|
||||||
|
-- Marcar como expirada
|
||||||
|
UPDATE admin.api_keys SET status = 'expired' WHERE id = v_key.id;
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
FALSE, NULL::UUID, NULL::UUID, NULL::UUID,
|
||||||
|
NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER,
|
||||||
|
'API key has expired'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar IP si hay restricciones
|
||||||
|
IF v_key.allowed_ips IS NOT NULL AND array_length(v_key.allowed_ips, 1) > 0 THEN
|
||||||
|
IF p_client_ip IS NULL OR NOT (p_client_ip = ANY(v_key.allowed_ips)) THEN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
FALSE, NULL::UUID, NULL::UUID, NULL::UUID,
|
||||||
|
NULL::admin.api_key_type, NULL::JSONB, NULL::INTEGER,
|
||||||
|
'IP address not allowed'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verificar rate limit diario
|
||||||
|
v_rate_remaining := v_key.rate_limit_per_day - v_key.requests_today;
|
||||||
|
IF v_rate_remaining <= 0 THEN
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
FALSE, v_key.id, v_key.user_id, v_key.tenant_id,
|
||||||
|
v_key.type, v_key.scopes, 0,
|
||||||
|
'Daily rate limit exceeded'::TEXT;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Key valida - actualizar uso
|
||||||
|
UPDATE admin.api_keys
|
||||||
|
SET
|
||||||
|
last_used_at = NOW(),
|
||||||
|
last_used_ip = p_client_ip,
|
||||||
|
total_requests = total_requests + 1,
|
||||||
|
requests_today = requests_today + 1
|
||||||
|
WHERE id = v_key.id;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT
|
||||||
|
TRUE, v_key.id, v_key.user_id, v_key.tenant_id,
|
||||||
|
v_key.type, v_key.scopes, v_rate_remaining - 1,
|
||||||
|
NULL::TEXT;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para revocar API key
|
||||||
|
CREATE OR REPLACE FUNCTION admin.revoke_api_key(
|
||||||
|
p_key_id UUID,
|
||||||
|
p_revoked_by UUID,
|
||||||
|
p_reason TEXT DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE admin.api_keys
|
||||||
|
SET
|
||||||
|
status = 'revoked',
|
||||||
|
revoked_by = p_revoked_by,
|
||||||
|
revoked_at = NOW(),
|
||||||
|
revocation_reason = p_reason
|
||||||
|
WHERE id = p_key_id
|
||||||
|
AND status = 'active';
|
||||||
|
|
||||||
|
RETURN FOUND;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para rotar API key (genera nuevo secret, mantiene el anterior temporalmente)
|
||||||
|
CREATE OR REPLACE FUNCTION admin.rotate_api_key(
|
||||||
|
p_key_id UUID,
|
||||||
|
p_new_key_hash VARCHAR(255),
|
||||||
|
p_grace_period_hours INTEGER DEFAULT 24
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE admin.api_keys
|
||||||
|
SET
|
||||||
|
previous_key_hash = key_hash,
|
||||||
|
previous_key_valid_until = NOW() + (p_grace_period_hours || ' hours')::INTERVAL,
|
||||||
|
key_hash = p_new_key_hash,
|
||||||
|
rotated_at = NOW(),
|
||||||
|
rotation_count = rotation_count + 1
|
||||||
|
WHERE id = p_key_id
|
||||||
|
AND status = 'active';
|
||||||
|
|
||||||
|
RETURN FOUND;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Vista de API keys activas
|
||||||
|
CREATE OR REPLACE VIEW admin.v_active_api_keys AS
|
||||||
|
SELECT
|
||||||
|
ak.id,
|
||||||
|
ak.tenant_id,
|
||||||
|
ak.user_id,
|
||||||
|
u.email AS user_email,
|
||||||
|
ak.name,
|
||||||
|
ak.key_prefix,
|
||||||
|
ak.key_hint,
|
||||||
|
ak.type,
|
||||||
|
ak.scopes,
|
||||||
|
ak.rate_limit_per_day,
|
||||||
|
ak.requests_today,
|
||||||
|
ak.total_requests,
|
||||||
|
ak.last_used_at,
|
||||||
|
ak.expires_at,
|
||||||
|
ak.created_at
|
||||||
|
FROM admin.api_keys ak
|
||||||
|
JOIN users.users u ON ak.user_id = u.id
|
||||||
|
WHERE ak.status = 'active'
|
||||||
|
ORDER BY ak.last_used_at DESC NULLS LAST;
|
||||||
|
|
||||||
|
-- Vista de uso de API keys por dia
|
||||||
|
CREATE OR REPLACE VIEW admin.v_api_key_usage AS
|
||||||
|
SELECT
|
||||||
|
ak.id,
|
||||||
|
ak.name,
|
||||||
|
ak.key_prefix,
|
||||||
|
ak.type,
|
||||||
|
ak.total_requests,
|
||||||
|
ak.total_errors,
|
||||||
|
ak.requests_today,
|
||||||
|
CASE WHEN ak.rate_limit_per_day > 0
|
||||||
|
THEN (ak.requests_today::DECIMAL / ak.rate_limit_per_day * 100)::DECIMAL(5,2)
|
||||||
|
ELSE 0
|
||||||
|
END AS usage_percent,
|
||||||
|
ak.last_used_at,
|
||||||
|
ak.last_used_ip
|
||||||
|
FROM admin.api_keys ak
|
||||||
|
WHERE ak.status = 'active'
|
||||||
|
ORDER BY ak.total_requests DESC;
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE admin.api_keys ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY api_keys_tenant_isolation ON admin.api_keys
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Los usuarios solo pueden ver sus propias API keys
|
||||||
|
CREATE POLICY api_keys_user_isolation ON admin.api_keys
|
||||||
|
FOR SELECT
|
||||||
|
USING (user_id = current_setting('app.current_user_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON admin.api_keys TO trading_app;
|
||||||
|
GRANT SELECT ON admin.api_keys TO trading_readonly;
|
||||||
|
GRANT SELECT ON admin.v_active_api_keys TO trading_app;
|
||||||
|
GRANT SELECT ON admin.v_api_key_usage TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION admin.validate_api_key TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION admin.revoke_api_key TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION admin.rotate_api_key TO trading_app;
|
||||||
274
ddl/schemas/market_data/tables/001_tickers.sql
Normal file
274
ddl/schemas/market_data/tables/001_tickers.sql
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: market_data
|
||||||
|
-- TABLE: tickers
|
||||||
|
-- DESCRIPTION: Instrumentos financieros disponibles para trading
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Crear schema si no existe
|
||||||
|
CREATE SCHEMA IF NOT EXISTS market_data;
|
||||||
|
|
||||||
|
-- Grant usage
|
||||||
|
GRANT USAGE ON SCHEMA market_data TO trading_app;
|
||||||
|
GRANT USAGE ON SCHEMA market_data TO trading_readonly;
|
||||||
|
|
||||||
|
-- Enum para tipo de instrumento
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE market_data.instrument_type AS ENUM (
|
||||||
|
'forex_major', -- Pares mayores: EURUSD, GBPUSD, etc.
|
||||||
|
'forex_minor', -- Pares menores: EURGBP, AUDNZD, etc.
|
||||||
|
'forex_exotic', -- Pares exoticos: USDMXN, USDZAR, etc.
|
||||||
|
'commodity', -- Commodities: XAUUSD, XAGUSD, USOIL
|
||||||
|
'index', -- Indices: US30, US500, NAS100
|
||||||
|
'stock', -- Acciones individuales
|
||||||
|
'crypto', -- Criptomonedas
|
||||||
|
'bond', -- Bonos
|
||||||
|
'etf' -- ETFs
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para estado del ticker
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE market_data.ticker_status AS ENUM (
|
||||||
|
'active', -- Activo y tradeable
|
||||||
|
'inactive', -- Inactivo temporalmente
|
||||||
|
'halted', -- Trading suspendido
|
||||||
|
'delisted', -- Eliminado del mercado
|
||||||
|
'pending' -- Pendiente de activacion
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Tickers (Instrumentos)
|
||||||
|
CREATE TABLE IF NOT EXISTS market_data.tickers (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
symbol VARCHAR(20) NOT NULL UNIQUE, -- EURUSD, XAUUSD, US30
|
||||||
|
name VARCHAR(200) NOT NULL, -- "Euro / US Dollar"
|
||||||
|
|
||||||
|
-- Clasificacion
|
||||||
|
type market_data.instrument_type NOT NULL,
|
||||||
|
category VARCHAR(50), -- Sub-categoria libre
|
||||||
|
sector VARCHAR(100), -- Para stocks
|
||||||
|
industry VARCHAR(100), -- Para stocks
|
||||||
|
|
||||||
|
-- Monedas (para forex)
|
||||||
|
base_currency VARCHAR(3), -- EUR en EURUSD
|
||||||
|
quote_currency VARCHAR(3), -- USD en EURUSD
|
||||||
|
|
||||||
|
-- Exchange / Broker
|
||||||
|
exchange VARCHAR(50), -- NYSE, NASDAQ, FOREX
|
||||||
|
broker_symbol VARCHAR(50), -- Simbolo en el broker
|
||||||
|
polygon_ticker VARCHAR(50), -- Ticker en Polygon.io
|
||||||
|
|
||||||
|
-- Especificaciones de trading
|
||||||
|
contract_size DECIMAL(15, 6) DEFAULT 100000, -- Tamaño del contrato
|
||||||
|
tick_size DECIMAL(15, 8) NOT NULL DEFAULT 0.00001,
|
||||||
|
tick_value DECIMAL(15, 6), -- Valor por tick en USD
|
||||||
|
pip_size DECIMAL(15, 8) DEFAULT 0.0001, -- Tamaño del pip
|
||||||
|
min_lot_size DECIMAL(10, 4) DEFAULT 0.01,
|
||||||
|
max_lot_size DECIMAL(10, 4) DEFAULT 100,
|
||||||
|
lot_step DECIMAL(10, 4) DEFAULT 0.01,
|
||||||
|
|
||||||
|
-- Margen y apalancamiento
|
||||||
|
margin_required DECIMAL(10, 4) DEFAULT 1.00, -- % margen requerido
|
||||||
|
max_leverage INTEGER DEFAULT 100,
|
||||||
|
margin_mode VARCHAR(20) DEFAULT 'percentage', -- 'percentage', 'fixed'
|
||||||
|
|
||||||
|
-- Spread tipico
|
||||||
|
typical_spread_pips DECIMAL(10, 4),
|
||||||
|
min_spread_pips DECIMAL(10, 4),
|
||||||
|
max_spread_pips DECIMAL(10, 4),
|
||||||
|
|
||||||
|
-- Horarios de trading
|
||||||
|
trading_hours JSONB DEFAULT '{
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"sessions": [
|
||||||
|
{"day": 0, "open": "17:00", "close": "17:00", "next_day": true},
|
||||||
|
{"day": 1, "open": "00:00", "close": "17:00"},
|
||||||
|
{"day": 2, "open": "17:00", "close": "17:00", "next_day": true},
|
||||||
|
{"day": 3, "open": "00:00", "close": "17:00"},
|
||||||
|
{"day": 4, "open": "17:00", "close": "17:00", "next_day": true},
|
||||||
|
{"day": 5, "open": "00:00", "close": "17:00"}
|
||||||
|
]
|
||||||
|
}'::JSONB,
|
||||||
|
is_tradeable_now BOOLEAN DEFAULT FALSE, -- Cache de estado actual
|
||||||
|
|
||||||
|
-- Precio actual (cache)
|
||||||
|
current_bid DECIMAL(15, 8),
|
||||||
|
current_ask DECIMAL(15, 8),
|
||||||
|
current_spread DECIMAL(15, 8),
|
||||||
|
price_updated_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Estadisticas diarias
|
||||||
|
daily_open DECIMAL(15, 8),
|
||||||
|
daily_high DECIMAL(15, 8),
|
||||||
|
daily_low DECIMAL(15, 8),
|
||||||
|
daily_close DECIMAL(15, 8),
|
||||||
|
daily_change DECIMAL(15, 8),
|
||||||
|
daily_change_percent DECIMAL(10, 4),
|
||||||
|
daily_volume BIGINT,
|
||||||
|
|
||||||
|
-- Volatilidad
|
||||||
|
atr_14d DECIMAL(15, 8), -- ATR de 14 dias
|
||||||
|
volatility_percentile INTEGER, -- Percentil de volatilidad (0-100)
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
status market_data.ticker_status NOT NULL DEFAULT 'active',
|
||||||
|
is_featured BOOLEAN NOT NULL DEFAULT FALSE, -- Destacado en UI
|
||||||
|
display_order INTEGER DEFAULT 999,
|
||||||
|
|
||||||
|
-- Integraciones
|
||||||
|
enabled_for_predictions BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
enabled_for_signals BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
enabled_for_bots BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
tags VARCHAR(50)[],
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE market_data.tickers IS
|
||||||
|
'Catalogo de instrumentos financieros disponibles para trading';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.tickers.symbol IS
|
||||||
|
'Simbolo estandar del instrumento (ej: EURUSD, XAUUSD, US30)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.tickers.pip_size IS
|
||||||
|
'Tamaño de un pip: 0.0001 para forex, 0.01 para JPY pairs';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_symbol
|
||||||
|
ON market_data.tickers(symbol);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_type
|
||||||
|
ON market_data.tickers(type);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_status_active
|
||||||
|
ON market_data.tickers(status, type)
|
||||||
|
WHERE status = 'active';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_featured
|
||||||
|
ON market_data.tickers(is_featured, display_order)
|
||||||
|
WHERE is_featured = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_predictions
|
||||||
|
ON market_data.tickers(symbol)
|
||||||
|
WHERE enabled_for_predictions = TRUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_polygon
|
||||||
|
ON market_data.tickers(polygon_ticker)
|
||||||
|
WHERE polygon_ticker IS NOT NULL;
|
||||||
|
|
||||||
|
-- GIN index para tags
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tickers_tags_gin
|
||||||
|
ON market_data.tickers USING GIN (tags);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.update_ticker_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ticker_updated_at ON market_data.tickers;
|
||||||
|
CREATE TRIGGER ticker_updated_at
|
||||||
|
BEFORE UPDATE ON market_data.tickers
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION market_data.update_ticker_timestamp();
|
||||||
|
|
||||||
|
-- Funcion para actualizar precio
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.update_ticker_price(
|
||||||
|
p_symbol VARCHAR(20),
|
||||||
|
p_bid DECIMAL(15, 8),
|
||||||
|
p_ask DECIMAL(15, 8)
|
||||||
|
)
|
||||||
|
RETURNS BOOLEAN AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE market_data.tickers
|
||||||
|
SET
|
||||||
|
current_bid = p_bid,
|
||||||
|
current_ask = p_ask,
|
||||||
|
current_spread = p_ask - p_bid,
|
||||||
|
price_updated_at = NOW()
|
||||||
|
WHERE symbol = p_symbol;
|
||||||
|
|
||||||
|
RETURN FOUND;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Vista de tickers activos para trading
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_active_tickers AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
symbol,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
base_currency,
|
||||||
|
quote_currency,
|
||||||
|
pip_size,
|
||||||
|
typical_spread_pips,
|
||||||
|
current_bid,
|
||||||
|
current_ask,
|
||||||
|
daily_change_percent,
|
||||||
|
is_tradeable_now,
|
||||||
|
enabled_for_predictions,
|
||||||
|
enabled_for_signals
|
||||||
|
FROM market_data.tickers
|
||||||
|
WHERE status = 'active'
|
||||||
|
ORDER BY
|
||||||
|
is_featured DESC,
|
||||||
|
display_order ASC,
|
||||||
|
symbol ASC;
|
||||||
|
|
||||||
|
-- Vista de tickers para ML predictions
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_prediction_tickers AS
|
||||||
|
SELECT
|
||||||
|
symbol,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
pip_size,
|
||||||
|
atr_14d,
|
||||||
|
volatility_percentile
|
||||||
|
FROM market_data.tickers
|
||||||
|
WHERE status = 'active'
|
||||||
|
AND enabled_for_predictions = TRUE
|
||||||
|
ORDER BY symbol;
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON market_data.tickers TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.tickers TO trading_readonly;
|
||||||
|
GRANT SELECT ON market_data.v_active_tickers TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_prediction_tickers TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.update_ticker_price TO trading_app;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- DATOS INICIALES - Tickers principales
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
INSERT INTO market_data.tickers (symbol, name, type, base_currency, quote_currency, pip_size, is_featured, enabled_for_predictions, enabled_for_signals, display_order)
|
||||||
|
VALUES
|
||||||
|
('EURUSD', 'Euro / US Dollar', 'forex_major', 'EUR', 'USD', 0.0001, TRUE, TRUE, TRUE, 1),
|
||||||
|
('GBPUSD', 'British Pound / US Dollar', 'forex_major', 'GBP', 'USD', 0.0001, TRUE, TRUE, TRUE, 2),
|
||||||
|
('USDJPY', 'US Dollar / Japanese Yen', 'forex_major', 'USD', 'JPY', 0.01, TRUE, TRUE, TRUE, 3),
|
||||||
|
('USDCHF', 'US Dollar / Swiss Franc', 'forex_major', 'USD', 'CHF', 0.0001, TRUE, TRUE, TRUE, 4),
|
||||||
|
('AUDUSD', 'Australian Dollar / US Dollar', 'forex_major', 'AUD', 'USD', 0.0001, TRUE, TRUE, TRUE, 5),
|
||||||
|
('USDCAD', 'US Dollar / Canadian Dollar', 'forex_major', 'USD', 'CAD', 0.0001, TRUE, TRUE, TRUE, 6),
|
||||||
|
('NZDUSD', 'New Zealand Dollar / US Dollar', 'forex_major', 'NZD', 'USD', 0.0001, FALSE, TRUE, TRUE, 7),
|
||||||
|
('XAUUSD', 'Gold / US Dollar', 'commodity', 'XAU', 'USD', 0.01, TRUE, TRUE, TRUE, 10),
|
||||||
|
('XAGUSD', 'Silver / US Dollar', 'commodity', 'XAG', 'USD', 0.001, FALSE, TRUE, TRUE, 11),
|
||||||
|
('US30', 'Dow Jones Industrial Average', 'index', NULL, 'USD', 1.0, TRUE, TRUE, TRUE, 20),
|
||||||
|
('US500', 'S&P 500 Index', 'index', NULL, 'USD', 0.1, TRUE, TRUE, TRUE, 21),
|
||||||
|
('NAS100', 'NASDAQ 100 Index', 'index', NULL, 'USD', 0.1, TRUE, TRUE, TRUE, 22)
|
||||||
|
ON CONFLICT (symbol) DO NOTHING;
|
||||||
303
ddl/schemas/market_data/tables/002_ohlcv_5m.sql
Normal file
303
ddl/schemas/market_data/tables/002_ohlcv_5m.sql
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: market_data
|
||||||
|
-- TABLE: ohlcv_5m
|
||||||
|
-- DESCRIPTION: Datos OHLCV de 5 minutos para analisis tecnico y ML
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Tabla de datos OHLCV (5 minutos)
|
||||||
|
-- NOTA: Esta tabla puede crecer significativamente. Considerar particionamiento por fecha.
|
||||||
|
CREATE TABLE IF NOT EXISTS market_data.ohlcv_5m (
|
||||||
|
-- Identificadores
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ticker_id UUID NOT NULL REFERENCES market_data.tickers(id) ON DELETE CASCADE,
|
||||||
|
symbol VARCHAR(20) NOT NULL, -- Denormalizado para performance
|
||||||
|
|
||||||
|
-- Timestamp de la vela
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
timestamp_unix BIGINT NOT NULL, -- Unix timestamp para queries rapidos
|
||||||
|
|
||||||
|
-- Precios OHLC
|
||||||
|
open DECIMAL(15, 8) NOT NULL,
|
||||||
|
high DECIMAL(15, 8) NOT NULL,
|
||||||
|
low DECIMAL(15, 8) NOT NULL,
|
||||||
|
close DECIMAL(15, 8) NOT NULL,
|
||||||
|
|
||||||
|
-- Volumen
|
||||||
|
volume BIGINT DEFAULT 0,
|
||||||
|
volume_weighted_price DECIMAL(15, 8), -- VWAP del periodo
|
||||||
|
|
||||||
|
-- Estadisticas derivadas
|
||||||
|
range_high_low DECIMAL(15, 8), -- high - low
|
||||||
|
range_percent DECIMAL(10, 6), -- (high - low) / open * 100
|
||||||
|
body_size DECIMAL(15, 8), -- |close - open|
|
||||||
|
body_percent DECIMAL(10, 6), -- body / range * 100
|
||||||
|
is_bullish BOOLEAN, -- close > open
|
||||||
|
|
||||||
|
-- Gaps
|
||||||
|
gap_from_previous DECIMAL(15, 8), -- open - previous_close
|
||||||
|
gap_percent DECIMAL(10, 6),
|
||||||
|
|
||||||
|
-- Fuente de datos
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'polygon', -- 'polygon', 'broker', 'manual'
|
||||||
|
is_complete BOOLEAN NOT NULL DEFAULT TRUE, -- Vela completa vs en progreso
|
||||||
|
|
||||||
|
-- Calidad de datos
|
||||||
|
data_quality VARCHAR(20) DEFAULT 'good', -- 'good', 'interpolated', 'missing'
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT ohlcv_5m_unique UNIQUE (ticker_id, timestamp),
|
||||||
|
CONSTRAINT ohlcv_5m_price_check CHECK (high >= low AND high >= open AND high >= close AND low <= open AND low <= close)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE market_data.ohlcv_5m IS
|
||||||
|
'Datos OHLCV de 5 minutos para todos los tickers activos';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.ohlcv_5m.symbol IS
|
||||||
|
'Simbolo denormalizado para evitar JOINs en queries frecuentes';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.ohlcv_5m.volume_weighted_price IS
|
||||||
|
'Precio promedio ponderado por volumen (VWAP) del periodo';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- PARTICIONAMIENTO (para produccion)
|
||||||
|
-- Descomentar para habilitar particionamiento mensual
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- CREATE TABLE market_data.ohlcv_5m_partitioned (
|
||||||
|
-- LIKE market_data.ohlcv_5m INCLUDING ALL
|
||||||
|
-- ) PARTITION BY RANGE (timestamp);
|
||||||
|
|
||||||
|
-- CREATE TABLE market_data.ohlcv_5m_2026_01 PARTITION OF market_data.ohlcv_5m_partitioned
|
||||||
|
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDICES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Indice principal para queries por simbolo y tiempo
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ohlcv_5m_symbol_time
|
||||||
|
ON market_data.ohlcv_5m(symbol, timestamp DESC);
|
||||||
|
|
||||||
|
-- Indice para ticker_id
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ohlcv_5m_ticker_time
|
||||||
|
ON market_data.ohlcv_5m(ticker_id, timestamp DESC);
|
||||||
|
|
||||||
|
-- Indice para timestamp solo (para queries de rango)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ohlcv_5m_timestamp
|
||||||
|
ON market_data.ohlcv_5m(timestamp DESC);
|
||||||
|
|
||||||
|
-- Indice BRIN para timestamp (eficiente en tablas grandes ordenadas)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ohlcv_5m_timestamp_brin
|
||||||
|
ON market_data.ohlcv_5m USING BRIN (timestamp);
|
||||||
|
|
||||||
|
-- Indice para velas incompletas
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ohlcv_5m_incomplete
|
||||||
|
ON market_data.ohlcv_5m(symbol, timestamp)
|
||||||
|
WHERE is_complete = FALSE;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Trigger para calcular campos derivados
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.calculate_ohlcv_derived()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_prev_close DECIMAL(15, 8);
|
||||||
|
BEGIN
|
||||||
|
-- Calcular rango
|
||||||
|
NEW.range_high_low := NEW.high - NEW.low;
|
||||||
|
IF NEW.open > 0 THEN
|
||||||
|
NEW.range_percent := (NEW.range_high_low / NEW.open) * 100;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular body
|
||||||
|
NEW.body_size := ABS(NEW.close - NEW.open);
|
||||||
|
IF NEW.range_high_low > 0 THEN
|
||||||
|
NEW.body_percent := (NEW.body_size / NEW.range_high_low) * 100;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Determinar direccion
|
||||||
|
NEW.is_bullish := NEW.close > NEW.open;
|
||||||
|
|
||||||
|
-- Calcular gap (buscar vela anterior)
|
||||||
|
SELECT close INTO v_prev_close
|
||||||
|
FROM market_data.ohlcv_5m
|
||||||
|
WHERE symbol = NEW.symbol
|
||||||
|
AND timestamp < NEW.timestamp
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
IF v_prev_close IS NOT NULL THEN
|
||||||
|
NEW.gap_from_previous := NEW.open - v_prev_close;
|
||||||
|
IF v_prev_close > 0 THEN
|
||||||
|
NEW.gap_percent := (NEW.gap_from_previous / v_prev_close) * 100;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Generar timestamp_unix si no viene
|
||||||
|
IF NEW.timestamp_unix IS NULL THEN
|
||||||
|
NEW.timestamp_unix := EXTRACT(EPOCH FROM NEW.timestamp)::BIGINT;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ohlcv_5m_derived ON market_data.ohlcv_5m;
|
||||||
|
CREATE TRIGGER ohlcv_5m_derived
|
||||||
|
BEFORE INSERT OR UPDATE ON market_data.ohlcv_5m
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION market_data.calculate_ohlcv_derived();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCIONES HELPER
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Funcion para obtener ultimas N velas
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.get_latest_candles(
|
||||||
|
p_symbol VARCHAR(20),
|
||||||
|
p_count INTEGER DEFAULT 100
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
timestamp TIMESTAMPTZ,
|
||||||
|
open DECIMAL(15, 8),
|
||||||
|
high DECIMAL(15, 8),
|
||||||
|
low DECIMAL(15, 8),
|
||||||
|
close DECIMAL(15, 8),
|
||||||
|
volume BIGINT,
|
||||||
|
is_bullish BOOLEAN
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
o.timestamp,
|
||||||
|
o.open,
|
||||||
|
o.high,
|
||||||
|
o.low,
|
||||||
|
o.close,
|
||||||
|
o.volume,
|
||||||
|
o.is_bullish
|
||||||
|
FROM market_data.ohlcv_5m o
|
||||||
|
WHERE o.symbol = p_symbol
|
||||||
|
AND o.is_complete = TRUE
|
||||||
|
ORDER BY o.timestamp DESC
|
||||||
|
LIMIT p_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para insertar/actualizar vela
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.upsert_candle(
|
||||||
|
p_symbol VARCHAR(20),
|
||||||
|
p_timestamp TIMESTAMPTZ,
|
||||||
|
p_open DECIMAL(15, 8),
|
||||||
|
p_high DECIMAL(15, 8),
|
||||||
|
p_low DECIMAL(15, 8),
|
||||||
|
p_close DECIMAL(15, 8),
|
||||||
|
p_volume BIGINT DEFAULT 0,
|
||||||
|
p_source VARCHAR(20) DEFAULT 'polygon',
|
||||||
|
p_is_complete BOOLEAN DEFAULT TRUE
|
||||||
|
)
|
||||||
|
RETURNS BIGINT AS $$
|
||||||
|
DECLARE
|
||||||
|
v_ticker_id UUID;
|
||||||
|
v_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
-- Obtener ticker_id
|
||||||
|
SELECT id INTO v_ticker_id FROM market_data.tickers WHERE symbol = p_symbol;
|
||||||
|
|
||||||
|
IF v_ticker_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Ticker % not found', p_symbol;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO market_data.ohlcv_5m (
|
||||||
|
ticker_id, symbol, timestamp,
|
||||||
|
open, high, low, close, volume,
|
||||||
|
source, is_complete
|
||||||
|
) VALUES (
|
||||||
|
v_ticker_id, p_symbol, p_timestamp,
|
||||||
|
p_open, p_high, p_low, p_close, p_volume,
|
||||||
|
p_source, p_is_complete
|
||||||
|
)
|
||||||
|
ON CONFLICT (ticker_id, timestamp)
|
||||||
|
DO UPDATE SET
|
||||||
|
high = GREATEST(market_data.ohlcv_5m.high, EXCLUDED.high),
|
||||||
|
low = LEAST(market_data.ohlcv_5m.low, EXCLUDED.low),
|
||||||
|
close = EXCLUDED.close,
|
||||||
|
volume = EXCLUDED.volume,
|
||||||
|
is_complete = EXCLUDED.is_complete
|
||||||
|
RETURNING id INTO v_id;
|
||||||
|
|
||||||
|
RETURN v_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar datos antiguos
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.cleanup_old_ohlcv(
|
||||||
|
p_days_to_keep INTEGER DEFAULT 365
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM market_data.ohlcv_5m
|
||||||
|
WHERE timestamp < NOW() - (p_days_to_keep || ' days')::INTERVAL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VISTAS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Vista de ultimas velas por simbolo
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_latest_candles AS
|
||||||
|
SELECT DISTINCT ON (symbol)
|
||||||
|
symbol,
|
||||||
|
timestamp,
|
||||||
|
open,
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
close,
|
||||||
|
volume,
|
||||||
|
is_bullish,
|
||||||
|
range_percent,
|
||||||
|
is_complete
|
||||||
|
FROM market_data.ohlcv_5m
|
||||||
|
ORDER BY symbol, timestamp DESC;
|
||||||
|
|
||||||
|
-- Vista de estadisticas por simbolo (ultimas 24h)
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_symbol_stats_24h AS
|
||||||
|
SELECT
|
||||||
|
symbol,
|
||||||
|
COUNT(*) AS candle_count,
|
||||||
|
MIN(low) AS period_low,
|
||||||
|
MAX(high) AS period_high,
|
||||||
|
SUM(volume) AS total_volume,
|
||||||
|
AVG(range_percent) AS avg_range_percent,
|
||||||
|
SUM(CASE WHEN is_bullish THEN 1 ELSE 0 END)::DECIMAL / COUNT(*) * 100 AS bullish_percent
|
||||||
|
FROM market_data.ohlcv_5m
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '24 hours'
|
||||||
|
AND is_complete = TRUE
|
||||||
|
GROUP BY symbol;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- GRANTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON market_data.ohlcv_5m TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.ohlcv_5m TO trading_readonly;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE market_data.ohlcv_5m_id_seq TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_latest_candles TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_symbol_stats_24h TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.get_latest_candles TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.upsert_candle TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.cleanup_old_ohlcv TO trading_app;
|
||||||
287
ddl/schemas/market_data/tables/003_technical_indicators.sql
Normal file
287
ddl/schemas/market_data/tables/003_technical_indicators.sql
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: market_data
|
||||||
|
-- TABLE: technical_indicators
|
||||||
|
-- DESCRIPTION: Indicadores tecnicos pre-calculados para ML y analisis
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Tabla de Indicadores Tecnicos pre-calculados
|
||||||
|
CREATE TABLE IF NOT EXISTS market_data.technical_indicators (
|
||||||
|
-- Identificadores
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
ticker_id UUID NOT NULL REFERENCES market_data.tickers(id) ON DELETE CASCADE,
|
||||||
|
symbol VARCHAR(20) NOT NULL, -- Denormalizado para performance
|
||||||
|
ohlcv_id BIGINT REFERENCES market_data.ohlcv_5m(id),
|
||||||
|
|
||||||
|
-- Timestamp de referencia
|
||||||
|
timestamp TIMESTAMPTZ NOT NULL,
|
||||||
|
timeframe VARCHAR(10) NOT NULL DEFAULT '5m', -- '5m', '15m', '1h', '4h', '1d'
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- MOVING AVERAGES
|
||||||
|
-- ============================================
|
||||||
|
sma_5 DECIMAL(15, 8),
|
||||||
|
sma_10 DECIMAL(15, 8),
|
||||||
|
sma_20 DECIMAL(15, 8),
|
||||||
|
sma_50 DECIMAL(15, 8),
|
||||||
|
sma_100 DECIMAL(15, 8),
|
||||||
|
sma_200 DECIMAL(15, 8),
|
||||||
|
|
||||||
|
ema_5 DECIMAL(15, 8),
|
||||||
|
ema_10 DECIMAL(15, 8),
|
||||||
|
ema_20 DECIMAL(15, 8),
|
||||||
|
ema_50 DECIMAL(15, 8),
|
||||||
|
ema_100 DECIMAL(15, 8),
|
||||||
|
ema_200 DECIMAL(15, 8),
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- MOMENTUM INDICATORS
|
||||||
|
-- ============================================
|
||||||
|
rsi_14 DECIMAL(10, 6), -- Relative Strength Index
|
||||||
|
rsi_7 DECIMAL(10, 6),
|
||||||
|
rsi_21 DECIMAL(10, 6),
|
||||||
|
|
||||||
|
stoch_k DECIMAL(10, 6), -- Stochastic %K
|
||||||
|
stoch_d DECIMAL(10, 6), -- Stochastic %D
|
||||||
|
|
||||||
|
cci_14 DECIMAL(15, 6), -- Commodity Channel Index
|
||||||
|
cci_20 DECIMAL(15, 6),
|
||||||
|
|
||||||
|
williams_r DECIMAL(10, 6), -- Williams %R
|
||||||
|
|
||||||
|
roc_10 DECIMAL(15, 8), -- Rate of Change
|
||||||
|
momentum_10 DECIMAL(15, 8),
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VOLATILITY INDICATORS
|
||||||
|
-- ============================================
|
||||||
|
atr_14 DECIMAL(15, 8), -- Average True Range
|
||||||
|
atr_7 DECIMAL(15, 8),
|
||||||
|
atr_21 DECIMAL(15, 8),
|
||||||
|
|
||||||
|
-- Bollinger Bands
|
||||||
|
bb_upper DECIMAL(15, 8),
|
||||||
|
bb_middle DECIMAL(15, 8),
|
||||||
|
bb_lower DECIMAL(15, 8),
|
||||||
|
bb_width DECIMAL(15, 8),
|
||||||
|
bb_percent DECIMAL(10, 6), -- Position within bands
|
||||||
|
|
||||||
|
-- Keltner Channels
|
||||||
|
kc_upper DECIMAL(15, 8),
|
||||||
|
kc_middle DECIMAL(15, 8),
|
||||||
|
kc_lower DECIMAL(15, 8),
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- TREND INDICATORS
|
||||||
|
-- ============================================
|
||||||
|
macd_line DECIMAL(15, 8), -- MACD Line
|
||||||
|
macd_signal DECIMAL(15, 8), -- Signal Line
|
||||||
|
macd_histogram DECIMAL(15, 8), -- Histogram
|
||||||
|
|
||||||
|
adx_14 DECIMAL(10, 6), -- Average Directional Index
|
||||||
|
plus_di DECIMAL(10, 6), -- +DI
|
||||||
|
minus_di DECIMAL(10, 6), -- -DI
|
||||||
|
|
||||||
|
aroon_up DECIMAL(10, 6),
|
||||||
|
aroon_down DECIMAL(10, 6),
|
||||||
|
aroon_oscillator DECIMAL(10, 6),
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- VOLUME INDICATORS
|
||||||
|
-- ============================================
|
||||||
|
obv BIGINT, -- On-Balance Volume
|
||||||
|
vwap DECIMAL(15, 8), -- Volume Weighted Average Price
|
||||||
|
mfi_14 DECIMAL(10, 6), -- Money Flow Index
|
||||||
|
ad_line DECIMAL(20, 8), -- Accumulation/Distribution
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SUPPORT/RESISTANCE
|
||||||
|
-- ============================================
|
||||||
|
pivot_point DECIMAL(15, 8),
|
||||||
|
pivot_r1 DECIMAL(15, 8),
|
||||||
|
pivot_r2 DECIMAL(15, 8),
|
||||||
|
pivot_r3 DECIMAL(15, 8),
|
||||||
|
pivot_s1 DECIMAL(15, 8),
|
||||||
|
pivot_s2 DECIMAL(15, 8),
|
||||||
|
pivot_s3 DECIMAL(15, 8),
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ICT/SMC CONCEPTS
|
||||||
|
-- ============================================
|
||||||
|
fair_value_gap_high DECIMAL(15, 8), -- FVG upper
|
||||||
|
fair_value_gap_low DECIMAL(15, 8), -- FVG lower
|
||||||
|
order_block_high DECIMAL(15, 8), -- OB upper
|
||||||
|
order_block_low DECIMAL(15, 8), -- OB lower
|
||||||
|
liquidity_level DECIMAL(15, 8), -- Identified liquidity
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- SIGNALS AND PATTERNS
|
||||||
|
-- ============================================
|
||||||
|
trend_direction VARCHAR(10), -- 'bullish', 'bearish', 'neutral'
|
||||||
|
trend_strength DECIMAL(10, 6), -- 0-100
|
||||||
|
|
||||||
|
-- Crossover signals
|
||||||
|
ma_cross_signal VARCHAR(20), -- 'golden_cross', 'death_cross', 'none'
|
||||||
|
macd_cross_signal VARCHAR(20), -- 'bullish', 'bearish', 'none'
|
||||||
|
rsi_signal VARCHAR(20), -- 'overbought', 'oversold', 'neutral'
|
||||||
|
bb_signal VARCHAR(20), -- 'upper_touch', 'lower_touch', 'squeeze', 'none'
|
||||||
|
|
||||||
|
-- Pattern detection (JSON for flexibility)
|
||||||
|
detected_patterns JSONB DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ML FEATURES
|
||||||
|
-- ============================================
|
||||||
|
ml_features JSONB DEFAULT '{}'::JSONB, -- Pre-computed ML features
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- METADATA
|
||||||
|
-- ============================================
|
||||||
|
calculation_version VARCHAR(20) DEFAULT '1.0',
|
||||||
|
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT ti_unique UNIQUE (ticker_id, timestamp, timeframe)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE market_data.technical_indicators IS
|
||||||
|
'Indicadores tecnicos pre-calculados por vela para analisis y ML';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.technical_indicators.ml_features IS
|
||||||
|
'Features adicionales pre-calculadas para modelos ML';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDICES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_symbol_time
|
||||||
|
ON market_data.technical_indicators(symbol, timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_ticker_timeframe
|
||||||
|
ON market_data.technical_indicators(ticker_id, timeframe, timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_timestamp
|
||||||
|
ON market_data.technical_indicators(timestamp DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_trend
|
||||||
|
ON market_data.technical_indicators(symbol, trend_direction)
|
||||||
|
WHERE trend_direction IS NOT NULL;
|
||||||
|
|
||||||
|
-- BRIN index para timestamp (eficiente en tablas grandes)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_timestamp_brin
|
||||||
|
ON market_data.technical_indicators USING BRIN (timestamp);
|
||||||
|
|
||||||
|
-- GIN index para patterns
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ti_patterns_gin
|
||||||
|
ON market_data.technical_indicators USING GIN (detected_patterns);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCIONES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Funcion para obtener indicadores mas recientes
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.get_latest_indicators(
|
||||||
|
p_symbol VARCHAR(20),
|
||||||
|
p_timeframe VARCHAR(10) DEFAULT '5m'
|
||||||
|
)
|
||||||
|
RETURNS SETOF market_data.technical_indicators AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT *
|
||||||
|
FROM market_data.technical_indicators
|
||||||
|
WHERE symbol = p_symbol
|
||||||
|
AND timeframe = p_timeframe
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para obtener resumen de indicadores
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.get_indicator_summary(
|
||||||
|
p_symbol VARCHAR(20),
|
||||||
|
p_timeframe VARCHAR(10) DEFAULT '5m'
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
symbol VARCHAR(20),
|
||||||
|
timestamp TIMESTAMPTZ,
|
||||||
|
trend_direction VARCHAR(10),
|
||||||
|
rsi_14 DECIMAL(10, 6),
|
||||||
|
macd_histogram DECIMAL(15, 8),
|
||||||
|
bb_percent DECIMAL(10, 6),
|
||||||
|
adx_14 DECIMAL(10, 6),
|
||||||
|
atr_14 DECIMAL(15, 8)
|
||||||
|
) AS $$
|
||||||
|
BEGIN
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
ti.symbol,
|
||||||
|
ti.timestamp,
|
||||||
|
ti.trend_direction,
|
||||||
|
ti.rsi_14,
|
||||||
|
ti.macd_histogram,
|
||||||
|
ti.bb_percent,
|
||||||
|
ti.adx_14,
|
||||||
|
ti.atr_14
|
||||||
|
FROM market_data.technical_indicators ti
|
||||||
|
WHERE ti.symbol = p_symbol
|
||||||
|
AND ti.timeframe = p_timeframe
|
||||||
|
ORDER BY ti.timestamp DESC
|
||||||
|
LIMIT 1;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VISTAS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Vista de indicadores mas recientes por simbolo
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_latest_indicators AS
|
||||||
|
SELECT DISTINCT ON (symbol, timeframe)
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
timestamp,
|
||||||
|
trend_direction,
|
||||||
|
trend_strength,
|
||||||
|
rsi_14,
|
||||||
|
macd_histogram,
|
||||||
|
adx_14,
|
||||||
|
bb_percent,
|
||||||
|
rsi_signal,
|
||||||
|
macd_cross_signal
|
||||||
|
FROM market_data.technical_indicators
|
||||||
|
ORDER BY symbol, timeframe, timestamp DESC;
|
||||||
|
|
||||||
|
-- Vista de senales activas
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_active_signals AS
|
||||||
|
SELECT
|
||||||
|
symbol,
|
||||||
|
timeframe,
|
||||||
|
timestamp,
|
||||||
|
trend_direction,
|
||||||
|
rsi_signal,
|
||||||
|
macd_cross_signal,
|
||||||
|
ma_cross_signal,
|
||||||
|
bb_signal
|
||||||
|
FROM market_data.technical_indicators
|
||||||
|
WHERE timestamp > NOW() - INTERVAL '1 hour'
|
||||||
|
AND (
|
||||||
|
rsi_signal IN ('overbought', 'oversold')
|
||||||
|
OR macd_cross_signal IN ('bullish', 'bearish')
|
||||||
|
OR ma_cross_signal IN ('golden_cross', 'death_cross')
|
||||||
|
)
|
||||||
|
ORDER BY timestamp DESC;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- GRANTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON market_data.technical_indicators TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.technical_indicators TO trading_readonly;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE market_data.technical_indicators_id_seq TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_latest_indicators TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_active_signals TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.get_latest_indicators TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.get_indicator_summary TO trading_app;
|
||||||
391
ddl/schemas/market_data/tables/004_ohlcv_5m_staging.sql
Normal file
391
ddl/schemas/market_data/tables/004_ohlcv_5m_staging.sql
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: market_data
|
||||||
|
-- TABLE: ohlcv_5m_staging
|
||||||
|
-- DESCRIPTION: Tabla staging para ingesta de datos OHLCV desde APIs externas
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enum para estado de procesamiento
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE market_data.staging_status AS ENUM (
|
||||||
|
'pending', -- Pendiente de procesar
|
||||||
|
'processing', -- En proceso
|
||||||
|
'processed', -- Procesado exitosamente
|
||||||
|
'error', -- Error en procesamiento
|
||||||
|
'duplicate', -- Duplicado detectado
|
||||||
|
'invalid' -- Datos invalidos
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Staging para OHLCV
|
||||||
|
-- Esta tabla recibe datos crudos de APIs externas (Polygon, brokers, etc)
|
||||||
|
-- y los procesa/valida antes de insertar en ohlcv_5m
|
||||||
|
CREATE TABLE IF NOT EXISTS market_data.ohlcv_5m_staging (
|
||||||
|
-- Identificadores
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
batch_id UUID NOT NULL DEFAULT gen_random_uuid(), -- ID del lote de importacion
|
||||||
|
|
||||||
|
-- Datos crudos
|
||||||
|
raw_symbol VARCHAR(50) NOT NULL, -- Simbolo como viene del proveedor
|
||||||
|
normalized_symbol VARCHAR(20), -- Simbolo normalizado (EURUSD)
|
||||||
|
ticker_id UUID, -- FK a tickers (NULL hasta normalizar)
|
||||||
|
|
||||||
|
-- Timestamp raw
|
||||||
|
raw_timestamp VARCHAR(50), -- Timestamp como viene del proveedor
|
||||||
|
parsed_timestamp TIMESTAMPTZ, -- Timestamp parseado
|
||||||
|
|
||||||
|
-- Precios OHLCV (raw - pueden venir como strings)
|
||||||
|
raw_open VARCHAR(50),
|
||||||
|
raw_high VARCHAR(50),
|
||||||
|
raw_low VARCHAR(50),
|
||||||
|
raw_close VARCHAR(50),
|
||||||
|
raw_volume VARCHAR(50),
|
||||||
|
|
||||||
|
-- Precios parseados
|
||||||
|
open DECIMAL(15, 8),
|
||||||
|
high DECIMAL(15, 8),
|
||||||
|
low DECIMAL(15, 8),
|
||||||
|
close DECIMAL(15, 8),
|
||||||
|
volume BIGINT,
|
||||||
|
|
||||||
|
-- Metadata del proveedor
|
||||||
|
source VARCHAR(20) NOT NULL, -- 'polygon', 'broker', 'csv', 'manual'
|
||||||
|
source_id VARCHAR(100), -- ID en el sistema origen
|
||||||
|
api_response_id VARCHAR(100), -- ID de respuesta de API
|
||||||
|
|
||||||
|
-- Estado de procesamiento
|
||||||
|
status market_data.staging_status NOT NULL DEFAULT 'pending',
|
||||||
|
error_message TEXT,
|
||||||
|
validation_errors JSONB DEFAULT '[]'::JSONB,
|
||||||
|
|
||||||
|
-- Procesamiento
|
||||||
|
processed_at TIMESTAMPTZ,
|
||||||
|
target_ohlcv_id BIGINT, -- ID del registro creado en ohlcv_5m
|
||||||
|
|
||||||
|
-- Deduplicacion
|
||||||
|
data_hash VARCHAR(64), -- Hash para detectar duplicados
|
||||||
|
is_duplicate BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
duplicate_of_id BIGINT,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
raw_data JSONB, -- Datos originales completos
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE market_data.ohlcv_5m_staging IS
|
||||||
|
'Tabla staging para ingesta y validacion de datos OHLCV antes de insertar en ohlcv_5m';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.ohlcv_5m_staging.batch_id IS
|
||||||
|
'ID del lote de importacion para agrupar registros del mismo request';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN market_data.ohlcv_5m_staging.data_hash IS
|
||||||
|
'Hash SHA256 de symbol+timestamp+ohlcv para detectar duplicados';
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- INDICES
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_batch
|
||||||
|
ON market_data.ohlcv_5m_staging(batch_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_status
|
||||||
|
ON market_data.ohlcv_5m_staging(status)
|
||||||
|
WHERE status IN ('pending', 'processing', 'error');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_symbol
|
||||||
|
ON market_data.ohlcv_5m_staging(normalized_symbol, parsed_timestamp);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_received
|
||||||
|
ON market_data.ohlcv_5m_staging(received_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_hash
|
||||||
|
ON market_data.ohlcv_5m_staging(data_hash)
|
||||||
|
WHERE data_hash IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_staging_source
|
||||||
|
ON market_data.ohlcv_5m_staging(source, status);
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- TRIGGERS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Trigger para normalizar y validar datos al insertar
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.normalize_staging_data()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_ticker_id UUID;
|
||||||
|
v_hash TEXT;
|
||||||
|
BEGIN
|
||||||
|
-- Normalizar simbolo (remover prefijos/sufijos de broker)
|
||||||
|
NEW.normalized_symbol := UPPER(TRIM(
|
||||||
|
REGEXP_REPLACE(NEW.raw_symbol, '^(C:|oanda:)', '', 'i')
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Buscar ticker_id
|
||||||
|
SELECT id INTO v_ticker_id
|
||||||
|
FROM market_data.tickers
|
||||||
|
WHERE symbol = NEW.normalized_symbol
|
||||||
|
OR broker_symbol = NEW.raw_symbol
|
||||||
|
OR polygon_ticker = NEW.raw_symbol
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
NEW.ticker_id := v_ticker_id;
|
||||||
|
|
||||||
|
-- Parsear timestamp
|
||||||
|
BEGIN
|
||||||
|
IF NEW.raw_timestamp ~ '^\d+$' THEN
|
||||||
|
-- Unix timestamp (milliseconds o seconds)
|
||||||
|
IF LENGTH(NEW.raw_timestamp) > 10 THEN
|
||||||
|
NEW.parsed_timestamp := TO_TIMESTAMP(NEW.raw_timestamp::BIGINT / 1000.0);
|
||||||
|
ELSE
|
||||||
|
NEW.parsed_timestamp := TO_TIMESTAMP(NEW.raw_timestamp::BIGINT);
|
||||||
|
END IF;
|
||||||
|
ELSE
|
||||||
|
NEW.parsed_timestamp := NEW.raw_timestamp::TIMESTAMPTZ;
|
||||||
|
END IF;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NEW.validation_errors := NEW.validation_errors || jsonb_build_object(
|
||||||
|
'field', 'timestamp',
|
||||||
|
'error', 'Invalid timestamp format',
|
||||||
|
'value', NEW.raw_timestamp
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Parsear precios
|
||||||
|
BEGIN
|
||||||
|
NEW.open := NEW.raw_open::DECIMAL(15, 8);
|
||||||
|
NEW.high := NEW.raw_high::DECIMAL(15, 8);
|
||||||
|
NEW.low := NEW.raw_low::DECIMAL(15, 8);
|
||||||
|
NEW.close := NEW.raw_close::DECIMAL(15, 8);
|
||||||
|
NEW.volume := COALESCE(NEW.raw_volume::BIGINT, 0);
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
NEW.validation_errors := NEW.validation_errors || jsonb_build_object(
|
||||||
|
'field', 'prices',
|
||||||
|
'error', 'Invalid price format'
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Validar precios
|
||||||
|
IF NEW.open IS NOT NULL AND NEW.high IS NOT NULL AND NEW.low IS NOT NULL AND NEW.close IS NOT NULL THEN
|
||||||
|
IF NEW.high < NEW.low OR NEW.high < NEW.open OR NEW.high < NEW.close
|
||||||
|
OR NEW.low > NEW.open OR NEW.low > NEW.close THEN
|
||||||
|
NEW.validation_errors := NEW.validation_errors || jsonb_build_object(
|
||||||
|
'field', 'ohlc_consistency',
|
||||||
|
'error', 'OHLC values are inconsistent'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calcular hash para deduplicacion
|
||||||
|
v_hash := encode(
|
||||||
|
sha256(
|
||||||
|
(COALESCE(NEW.normalized_symbol, '') ||
|
||||||
|
COALESCE(NEW.parsed_timestamp::TEXT, '') ||
|
||||||
|
COALESCE(NEW.open::TEXT, '') ||
|
||||||
|
COALESCE(NEW.high::TEXT, '') ||
|
||||||
|
COALESCE(NEW.low::TEXT, '') ||
|
||||||
|
COALESCE(NEW.close::TEXT, ''))::bytea
|
||||||
|
),
|
||||||
|
'hex'
|
||||||
|
);
|
||||||
|
NEW.data_hash := v_hash;
|
||||||
|
|
||||||
|
-- Verificar si es duplicado
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM market_data.ohlcv_5m_staging
|
||||||
|
WHERE data_hash = v_hash AND id != NEW.id
|
||||||
|
) THEN
|
||||||
|
NEW.is_duplicate := TRUE;
|
||||||
|
NEW.status := 'duplicate';
|
||||||
|
SELECT id INTO NEW.duplicate_of_id
|
||||||
|
FROM market_data.ohlcv_5m_staging
|
||||||
|
WHERE data_hash = v_hash
|
||||||
|
LIMIT 1;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Marcar como invalido si hay errores
|
||||||
|
IF jsonb_array_length(NEW.validation_errors) > 0 THEN
|
||||||
|
NEW.status := 'invalid';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Marcar como invalido si no se encontro ticker
|
||||||
|
IF NEW.ticker_id IS NULL AND NEW.status = 'pending' THEN
|
||||||
|
NEW.validation_errors := NEW.validation_errors || jsonb_build_object(
|
||||||
|
'field', 'symbol',
|
||||||
|
'error', 'Unknown symbol',
|
||||||
|
'value', NEW.raw_symbol
|
||||||
|
);
|
||||||
|
NEW.status := 'invalid';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS staging_normalize ON market_data.ohlcv_5m_staging;
|
||||||
|
CREATE TRIGGER staging_normalize
|
||||||
|
BEFORE INSERT ON market_data.ohlcv_5m_staging
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION market_data.normalize_staging_data();
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- FUNCIONES DE PROCESAMIENTO
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Funcion para procesar registros pendientes en staging
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.process_staging_batch(
|
||||||
|
p_batch_id UUID DEFAULT NULL,
|
||||||
|
p_limit INTEGER DEFAULT 1000
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
processed INTEGER,
|
||||||
|
errors INTEGER,
|
||||||
|
duplicates INTEGER
|
||||||
|
) AS $$
|
||||||
|
DECLARE
|
||||||
|
v_staging RECORD;
|
||||||
|
v_processed INTEGER := 0;
|
||||||
|
v_errors INTEGER := 0;
|
||||||
|
v_duplicates INTEGER := 0;
|
||||||
|
v_ohlcv_id BIGINT;
|
||||||
|
BEGIN
|
||||||
|
FOR v_staging IN
|
||||||
|
SELECT * FROM market_data.ohlcv_5m_staging
|
||||||
|
WHERE status = 'pending'
|
||||||
|
AND (p_batch_id IS NULL OR batch_id = p_batch_id)
|
||||||
|
ORDER BY received_at
|
||||||
|
LIMIT p_limit
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
LOOP
|
||||||
|
BEGIN
|
||||||
|
-- Marcar como procesando
|
||||||
|
UPDATE market_data.ohlcv_5m_staging
|
||||||
|
SET status = 'processing'
|
||||||
|
WHERE id = v_staging.id;
|
||||||
|
|
||||||
|
-- Verificar duplicado en tabla principal
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM market_data.ohlcv_5m
|
||||||
|
WHERE ticker_id = v_staging.ticker_id
|
||||||
|
AND timestamp = v_staging.parsed_timestamp
|
||||||
|
) THEN
|
||||||
|
UPDATE market_data.ohlcv_5m_staging
|
||||||
|
SET status = 'duplicate',
|
||||||
|
processed_at = NOW()
|
||||||
|
WHERE id = v_staging.id;
|
||||||
|
v_duplicates := v_duplicates + 1;
|
||||||
|
CONTINUE;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Insertar en tabla principal
|
||||||
|
INSERT INTO market_data.ohlcv_5m (
|
||||||
|
ticker_id, symbol, timestamp,
|
||||||
|
open, high, low, close, volume,
|
||||||
|
source, is_complete
|
||||||
|
) VALUES (
|
||||||
|
v_staging.ticker_id,
|
||||||
|
v_staging.normalized_symbol,
|
||||||
|
v_staging.parsed_timestamp,
|
||||||
|
v_staging.open,
|
||||||
|
v_staging.high,
|
||||||
|
v_staging.low,
|
||||||
|
v_staging.close,
|
||||||
|
v_staging.volume,
|
||||||
|
v_staging.source,
|
||||||
|
TRUE
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_ohlcv_id;
|
||||||
|
|
||||||
|
-- Marcar como procesado
|
||||||
|
UPDATE market_data.ohlcv_5m_staging
|
||||||
|
SET status = 'processed',
|
||||||
|
processed_at = NOW(),
|
||||||
|
target_ohlcv_id = v_ohlcv_id
|
||||||
|
WHERE id = v_staging.id;
|
||||||
|
|
||||||
|
v_processed := v_processed + 1;
|
||||||
|
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
UPDATE market_data.ohlcv_5m_staging
|
||||||
|
SET status = 'error',
|
||||||
|
error_message = SQLERRM,
|
||||||
|
processed_at = NOW()
|
||||||
|
WHERE id = v_staging.id;
|
||||||
|
|
||||||
|
v_errors := v_errors + 1;
|
||||||
|
END;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN QUERY SELECT v_processed, v_errors, v_duplicates;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar staging procesado
|
||||||
|
CREATE OR REPLACE FUNCTION market_data.cleanup_staging(
|
||||||
|
p_older_than_hours INTEGER DEFAULT 24
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM market_data.ohlcv_5m_staging
|
||||||
|
WHERE status IN ('processed', 'duplicate')
|
||||||
|
AND processed_at < NOW() - (p_older_than_hours || ' hours')::INTERVAL;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- VISTAS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Vista de estado de staging
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_staging_status AS
|
||||||
|
SELECT
|
||||||
|
status,
|
||||||
|
source,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
MIN(received_at) AS oldest,
|
||||||
|
MAX(received_at) AS newest
|
||||||
|
FROM market_data.ohlcv_5m_staging
|
||||||
|
GROUP BY status, source
|
||||||
|
ORDER BY status, source;
|
||||||
|
|
||||||
|
-- Vista de errores recientes
|
||||||
|
CREATE OR REPLACE VIEW market_data.v_staging_errors AS
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
batch_id,
|
||||||
|
raw_symbol,
|
||||||
|
normalized_symbol,
|
||||||
|
source,
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
validation_errors,
|
||||||
|
received_at
|
||||||
|
FROM market_data.ohlcv_5m_staging
|
||||||
|
WHERE status IN ('error', 'invalid')
|
||||||
|
ORDER BY received_at DESC
|
||||||
|
LIMIT 100;
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- GRANTS
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON market_data.ohlcv_5m_staging TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.ohlcv_5m_staging TO trading_readonly;
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCE market_data.ohlcv_5m_staging_id_seq TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_staging_status TO trading_app;
|
||||||
|
GRANT SELECT ON market_data.v_staging_errors TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.process_staging_batch TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION market_data.cleanup_staging TO trading_app;
|
||||||
355
ddl/schemas/notifications/tables/001_notifications.sql
Normal file
355
ddl/schemas/notifications/tables/001_notifications.sql
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: notifications
|
||||||
|
-- TABLE: notifications
|
||||||
|
-- DESCRIPTION: Sistema de notificaciones del sistema
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Crear schema si no existe
|
||||||
|
CREATE SCHEMA IF NOT EXISTS notifications;
|
||||||
|
|
||||||
|
-- Grant usage
|
||||||
|
GRANT USAGE ON SCHEMA notifications TO trading_app;
|
||||||
|
GRANT USAGE ON SCHEMA notifications TO trading_readonly;
|
||||||
|
|
||||||
|
-- Enum para tipo de notificacion
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE notifications.notification_type AS ENUM (
|
||||||
|
'info', -- Informativa general
|
||||||
|
'success', -- Accion exitosa
|
||||||
|
'warning', -- Advertencia
|
||||||
|
'error', -- Error
|
||||||
|
'alert', -- Alerta importante
|
||||||
|
'trade', -- Relacionada a trading
|
||||||
|
'signal', -- Senal de trading
|
||||||
|
'payment', -- Pago/transaccion
|
||||||
|
'account', -- Cuenta de usuario
|
||||||
|
'security', -- Seguridad
|
||||||
|
'system', -- Sistema/mantenimiento
|
||||||
|
'promotion' -- Promocional
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para prioridad
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE notifications.notification_priority AS ENUM (
|
||||||
|
'low',
|
||||||
|
'normal',
|
||||||
|
'high',
|
||||||
|
'critical'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para canal de entrega
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE notifications.delivery_channel AS ENUM (
|
||||||
|
'in_app', -- Notificacion en app
|
||||||
|
'email', -- Email
|
||||||
|
'push', -- Push notification
|
||||||
|
'sms', -- SMS
|
||||||
|
'webhook' -- Webhook externo
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla principal de Notificaciones
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications.notifications (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Clasificacion
|
||||||
|
type notifications.notification_type NOT NULL DEFAULT 'info',
|
||||||
|
priority notifications.notification_priority NOT NULL DEFAULT 'normal',
|
||||||
|
category VARCHAR(50), -- Subcategoria libre
|
||||||
|
|
||||||
|
-- Contenido
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
message_html TEXT, -- Version HTML del mensaje
|
||||||
|
|
||||||
|
-- Datos estructurados
|
||||||
|
data JSONB DEFAULT '{}'::JSONB, -- Datos adicionales para la UI
|
||||||
|
action_url TEXT, -- URL de accion principal
|
||||||
|
action_label VARCHAR(100), -- Texto del boton de accion
|
||||||
|
actions JSONB, -- Array de acciones multiples
|
||||||
|
|
||||||
|
-- Estado
|
||||||
|
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
archived_at TIMESTAMPTZ,
|
||||||
|
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
deleted_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Entrega multicanal
|
||||||
|
channels notifications.delivery_channel[] NOT NULL DEFAULT ARRAY['in_app']::notifications.delivery_channel[],
|
||||||
|
delivery_status JSONB DEFAULT '{}'::JSONB, -- Estado por canal
|
||||||
|
|
||||||
|
-- Tracking de emails
|
||||||
|
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_sent_at TIMESTAMPTZ,
|
||||||
|
email_opened BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_opened_at TIMESTAMPTZ,
|
||||||
|
email_clicked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_clicked_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Push notifications
|
||||||
|
push_sent BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
push_sent_at TIMESTAMPTZ,
|
||||||
|
push_delivered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
push_delivered_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Agrupacion
|
||||||
|
group_key VARCHAR(100), -- Agrupar notificaciones similares
|
||||||
|
group_count INTEGER DEFAULT 1, -- Contador de agrupacion
|
||||||
|
|
||||||
|
-- Programacion
|
||||||
|
scheduled_for TIMESTAMPTZ, -- Envio programado
|
||||||
|
expires_at TIMESTAMPTZ, -- Expiracion automatica
|
||||||
|
|
||||||
|
-- Origen
|
||||||
|
source VARCHAR(50) NOT NULL DEFAULT 'system', -- 'system', 'user', 'bot', 'webhook'
|
||||||
|
source_id VARCHAR(255), -- ID del recurso origen
|
||||||
|
source_type VARCHAR(50), -- Tipo de recurso origen
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE notifications.notifications IS
|
||||||
|
'Notificaciones del sistema para usuarios, soporta multicanal';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN notifications.notifications.channels IS
|
||||||
|
'Canales de entrega: in_app, email, push, sms, webhook';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN notifications.notifications.group_key IS
|
||||||
|
'Clave para agrupar notificaciones similares y evitar spam';
|
||||||
|
|
||||||
|
-- Indices para queries comunes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread
|
||||||
|
ON notifications.notifications(user_id, created_at DESC)
|
||||||
|
WHERE is_read = FALSE AND is_deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_all
|
||||||
|
ON notifications.notifications(user_id, created_at DESC)
|
||||||
|
WHERE is_deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_tenant
|
||||||
|
ON notifications.notifications(tenant_id, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_type
|
||||||
|
ON notifications.notifications(type);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_priority
|
||||||
|
ON notifications.notifications(priority)
|
||||||
|
WHERE priority IN ('high', 'critical');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_scheduled
|
||||||
|
ON notifications.notifications(scheduled_for)
|
||||||
|
WHERE scheduled_for IS NOT NULL AND scheduled_for > NOW();
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_expires
|
||||||
|
ON notifications.notifications(expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL AND is_deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_group
|
||||||
|
ON notifications.notifications(user_id, group_key, created_at DESC)
|
||||||
|
WHERE group_key IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_source
|
||||||
|
ON notifications.notifications(source, source_type, source_id);
|
||||||
|
|
||||||
|
-- GIN index para busqueda en datos JSONB
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_data_gin
|
||||||
|
ON notifications.notifications USING GIN (data);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.update_notification_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notification_updated_at ON notifications.notifications;
|
||||||
|
CREATE TRIGGER notification_updated_at
|
||||||
|
BEFORE UPDATE ON notifications.notifications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notifications.update_notification_timestamp();
|
||||||
|
|
||||||
|
-- Trigger para marcar read_at cuando is_read cambia a true
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.set_read_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.is_read = TRUE AND OLD.is_read = FALSE THEN
|
||||||
|
NEW.read_at = NOW();
|
||||||
|
END IF;
|
||||||
|
IF NEW.is_archived = TRUE AND OLD.is_archived = FALSE THEN
|
||||||
|
NEW.archived_at = NOW();
|
||||||
|
END IF;
|
||||||
|
IF NEW.is_deleted = TRUE AND OLD.is_deleted = FALSE THEN
|
||||||
|
NEW.deleted_at = NOW();
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS notification_status_timestamps ON notifications.notifications;
|
||||||
|
CREATE TRIGGER notification_status_timestamps
|
||||||
|
BEFORE UPDATE ON notifications.notifications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notifications.set_read_timestamp();
|
||||||
|
|
||||||
|
-- Funcion para crear notificacion
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.create_notification(
|
||||||
|
p_tenant_id UUID,
|
||||||
|
p_user_id UUID,
|
||||||
|
p_type notifications.notification_type,
|
||||||
|
p_title VARCHAR(255),
|
||||||
|
p_message TEXT,
|
||||||
|
p_priority notifications.notification_priority DEFAULT 'normal',
|
||||||
|
p_channels notifications.delivery_channel[] DEFAULT ARRAY['in_app']::notifications.delivery_channel[],
|
||||||
|
p_data JSONB DEFAULT '{}'::JSONB,
|
||||||
|
p_action_url TEXT DEFAULT NULL,
|
||||||
|
p_source VARCHAR(50) DEFAULT 'system',
|
||||||
|
p_source_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
p_source_id VARCHAR(255) DEFAULT NULL,
|
||||||
|
p_group_key VARCHAR(100) DEFAULT NULL,
|
||||||
|
p_scheduled_for TIMESTAMPTZ DEFAULT NULL,
|
||||||
|
p_expires_at TIMESTAMPTZ DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS UUID AS $$
|
||||||
|
DECLARE
|
||||||
|
v_notification_id UUID;
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO notifications.notifications (
|
||||||
|
tenant_id, user_id, type, title, message, priority,
|
||||||
|
channels, data, action_url, source, source_type, source_id,
|
||||||
|
group_key, scheduled_for, expires_at
|
||||||
|
) VALUES (
|
||||||
|
p_tenant_id, p_user_id, p_type, p_title, p_message, p_priority,
|
||||||
|
p_channels, p_data, p_action_url, p_source, p_source_type, p_source_id,
|
||||||
|
p_group_key, p_scheduled_for, p_expires_at
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_notification_id;
|
||||||
|
|
||||||
|
RETURN v_notification_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para marcar como leidas por lotes
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.mark_all_read(
|
||||||
|
p_user_id UUID,
|
||||||
|
p_before TIMESTAMPTZ DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE notifications.notifications
|
||||||
|
SET is_read = TRUE,
|
||||||
|
read_at = NOW()
|
||||||
|
WHERE user_id = p_user_id
|
||||||
|
AND is_read = FALSE
|
||||||
|
AND is_deleted = FALSE
|
||||||
|
AND (p_before IS NULL OR created_at <= p_before);
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Funcion para limpiar notificaciones expiradas
|
||||||
|
CREATE OR REPLACE FUNCTION notifications.cleanup_expired()
|
||||||
|
RETURNS INTEGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_count INTEGER;
|
||||||
|
BEGIN
|
||||||
|
UPDATE notifications.notifications
|
||||||
|
SET is_deleted = TRUE,
|
||||||
|
deleted_at = NOW()
|
||||||
|
WHERE expires_at IS NOT NULL
|
||||||
|
AND expires_at < NOW()
|
||||||
|
AND is_deleted = FALSE;
|
||||||
|
|
||||||
|
GET DIAGNOSTICS v_count = ROW_COUNT;
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Vista de notificaciones no leidas
|
||||||
|
CREATE OR REPLACE VIEW notifications.v_unread_notifications AS
|
||||||
|
SELECT
|
||||||
|
n.id,
|
||||||
|
n.tenant_id,
|
||||||
|
n.user_id,
|
||||||
|
n.type,
|
||||||
|
n.priority,
|
||||||
|
n.title,
|
||||||
|
n.message,
|
||||||
|
n.data,
|
||||||
|
n.action_url,
|
||||||
|
n.source,
|
||||||
|
n.created_at
|
||||||
|
FROM notifications.notifications n
|
||||||
|
WHERE n.is_read = FALSE
|
||||||
|
AND n.is_deleted = FALSE
|
||||||
|
AND (n.expires_at IS NULL OR n.expires_at > NOW())
|
||||||
|
AND (n.scheduled_for IS NULL OR n.scheduled_for <= NOW())
|
||||||
|
ORDER BY
|
||||||
|
CASE n.priority
|
||||||
|
WHEN 'critical' THEN 1
|
||||||
|
WHEN 'high' THEN 2
|
||||||
|
WHEN 'normal' THEN 3
|
||||||
|
WHEN 'low' THEN 4
|
||||||
|
END,
|
||||||
|
n.created_at DESC;
|
||||||
|
|
||||||
|
-- Vista de conteo por tipo para dashboard
|
||||||
|
CREATE OR REPLACE VIEW notifications.v_notification_counts AS
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
COUNT(*) FILTER (WHERE is_read = FALSE) AS unread_count,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'critical' AND is_read = FALSE) AS critical_count,
|
||||||
|
COUNT(*) FILTER (WHERE priority = 'high' AND is_read = FALSE) AS high_priority_count,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'trade' AND is_read = FALSE) AS trade_count,
|
||||||
|
COUNT(*) FILTER (WHERE type = 'signal' AND is_read = FALSE) AS signal_count
|
||||||
|
FROM notifications.notifications
|
||||||
|
WHERE is_deleted = FALSE
|
||||||
|
AND (expires_at IS NULL OR expires_at > NOW())
|
||||||
|
GROUP BY user_id;
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE notifications.notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY notifications_tenant_isolation ON notifications.notifications
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Los usuarios solo pueden ver sus propias notificaciones
|
||||||
|
CREATE POLICY notifications_user_isolation ON notifications.notifications
|
||||||
|
FOR SELECT
|
||||||
|
USING (user_id = current_setting('app.current_user_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON notifications.notifications TO trading_app;
|
||||||
|
GRANT SELECT ON notifications.notifications TO trading_readonly;
|
||||||
|
GRANT SELECT ON notifications.v_unread_notifications TO trading_app;
|
||||||
|
GRANT SELECT ON notifications.v_notification_counts TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION notifications.create_notification TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION notifications.mark_all_read TO trading_app;
|
||||||
|
GRANT EXECUTE ON FUNCTION notifications.cleanup_expired TO trading_app;
|
||||||
128
ddl/schemas/users/tables/002_profiles.sql
Normal file
128
ddl/schemas/users/tables/002_profiles.sql
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: users
|
||||||
|
-- TABLE: profiles
|
||||||
|
-- DESCRIPTION: Informacion extendida del perfil de usuario
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Tabla de Perfiles extendidos
|
||||||
|
CREATE TABLE IF NOT EXISTS users.profiles (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Informacion personal
|
||||||
|
bio TEXT,
|
||||||
|
date_of_birth DATE,
|
||||||
|
gender VARCHAR(20),
|
||||||
|
nationality VARCHAR(100),
|
||||||
|
country_of_residence VARCHAR(100),
|
||||||
|
city VARCHAR(100),
|
||||||
|
address TEXT,
|
||||||
|
postal_code VARCHAR(20),
|
||||||
|
|
||||||
|
-- Informacion profesional
|
||||||
|
occupation VARCHAR(100),
|
||||||
|
company_name VARCHAR(200),
|
||||||
|
annual_income_range VARCHAR(50), -- '0-25k', '25k-50k', '50k-100k', '100k-250k', '250k+'
|
||||||
|
source_of_funds VARCHAR(100), -- 'salary', 'business', 'investments', 'inheritance', 'other'
|
||||||
|
|
||||||
|
-- Documentacion
|
||||||
|
id_document_type VARCHAR(50), -- 'passport', 'national_id', 'drivers_license'
|
||||||
|
id_document_number VARCHAR(100),
|
||||||
|
id_document_expiry DATE,
|
||||||
|
id_document_country VARCHAR(100),
|
||||||
|
|
||||||
|
-- Redes sociales
|
||||||
|
social_links JSONB DEFAULT '{}'::JSONB, -- { "twitter": "@user", "linkedin": "..." }
|
||||||
|
|
||||||
|
-- Preferencias de comunicacion
|
||||||
|
preferred_contact_method VARCHAR(20) DEFAULT 'email', -- 'email', 'phone', 'sms'
|
||||||
|
timezone VARCHAR(50) DEFAULT 'America/New_York',
|
||||||
|
locale VARCHAR(10) DEFAULT 'es-MX',
|
||||||
|
|
||||||
|
-- Completitud del perfil
|
||||||
|
completion_percentage INTEGER NOT NULL DEFAULT 0 CHECK (completion_percentage BETWEEN 0 AND 100),
|
||||||
|
last_profile_update TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE users.profiles IS
|
||||||
|
'Informacion extendida del perfil de usuario, separada de credenciales';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.profiles.completion_percentage IS
|
||||||
|
'Porcentaje de completitud del perfil (0-100)';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.profiles.annual_income_range IS
|
||||||
|
'Rango de ingresos anuales para compliance KYC';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_user_id
|
||||||
|
ON users.profiles(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_tenant_id
|
||||||
|
ON users.profiles(tenant_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_completion
|
||||||
|
ON users.profiles(completion_percentage);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_profiles_country
|
||||||
|
ON users.profiles(country_of_residence);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
DROP TRIGGER IF EXISTS profile_updated_at ON users.profiles;
|
||||||
|
CREATE TRIGGER profile_updated_at
|
||||||
|
BEFORE UPDATE ON users.profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_user_timestamp();
|
||||||
|
|
||||||
|
-- Trigger para calcular completion_percentage
|
||||||
|
CREATE OR REPLACE FUNCTION users.calculate_profile_completion()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_total_fields INTEGER := 10;
|
||||||
|
v_filled_fields INTEGER := 0;
|
||||||
|
BEGIN
|
||||||
|
IF NEW.bio IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.date_of_birth IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.nationality IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.country_of_residence IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.city IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.occupation IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.annual_income_range IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.source_of_funds IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.id_document_type IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
IF NEW.timezone IS NOT NULL THEN v_filled_fields := v_filled_fields + 1; END IF;
|
||||||
|
|
||||||
|
NEW.completion_percentage := (v_filled_fields * 100) / v_total_fields;
|
||||||
|
NEW.last_profile_update := NOW();
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS profile_completion_calc ON users.profiles;
|
||||||
|
CREATE TRIGGER profile_completion_calc
|
||||||
|
BEFORE INSERT OR UPDATE ON users.profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.calculate_profile_completion();
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE users.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY profiles_tenant_isolation ON users.profiles
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON users.profiles TO trading_app;
|
||||||
|
GRANT SELECT ON users.profiles TO trading_readonly;
|
||||||
141
ddl/schemas/users/tables/003_user_settings.sql
Normal file
141
ddl/schemas/users/tables/003_user_settings.sql
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: users
|
||||||
|
-- TABLE: user_settings
|
||||||
|
-- DESCRIPTION: Configuraciones y preferencias de usuario
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Tabla de Configuraciones de Usuario
|
||||||
|
CREATE TABLE IF NOT EXISTS users.user_settings (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Configuraciones de UI
|
||||||
|
ui_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"theme": "dark",
|
||||||
|
"sidebar_collapsed": false,
|
||||||
|
"dashboard_layout": "default",
|
||||||
|
"charts_theme": "dark",
|
||||||
|
"compact_mode": false,
|
||||||
|
"animations_enabled": true
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Configuraciones de Trading
|
||||||
|
trading_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"default_leverage": 1,
|
||||||
|
"risk_per_trade_percent": 2,
|
||||||
|
"default_stop_loss_pips": 50,
|
||||||
|
"default_take_profit_pips": 100,
|
||||||
|
"confirmation_required": true,
|
||||||
|
"one_click_trading": false,
|
||||||
|
"sound_enabled": true
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Configuraciones de Notificaciones
|
||||||
|
notification_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"signals": true,
|
||||||
|
"trades": true,
|
||||||
|
"account": true,
|
||||||
|
"marketing": false,
|
||||||
|
"digest": "daily"
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"enabled": true,
|
||||||
|
"signals": true,
|
||||||
|
"trades": true,
|
||||||
|
"price_alerts": true
|
||||||
|
},
|
||||||
|
"sms": {
|
||||||
|
"enabled": false,
|
||||||
|
"critical_only": true
|
||||||
|
}
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Configuraciones de Privacidad
|
||||||
|
privacy_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"profile_visibility": "private",
|
||||||
|
"show_in_leaderboard": false,
|
||||||
|
"share_performance": false,
|
||||||
|
"allow_follow": false
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Configuraciones de Seguridad
|
||||||
|
security_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"session_timeout_minutes": 30,
|
||||||
|
"remember_devices": true,
|
||||||
|
"login_notifications": true,
|
||||||
|
"suspicious_activity_alerts": true
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Configuraciones de Datos de Mercado
|
||||||
|
market_data_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"default_symbols": ["EURUSD", "GBPUSD", "XAUUSD"],
|
||||||
|
"default_timeframe": "H1",
|
||||||
|
"price_decimals": 5,
|
||||||
|
"volume_display": "lots"
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Preferencias de reportes
|
||||||
|
report_settings JSONB NOT NULL DEFAULT '{
|
||||||
|
"default_period": "monthly",
|
||||||
|
"include_closed_trades": true,
|
||||||
|
"currency_display": "USD"
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE users.user_settings IS
|
||||||
|
'Configuraciones y preferencias personalizables del usuario';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.user_settings.ui_settings IS
|
||||||
|
'Configuraciones de interfaz: tema, layouts, preferencias visuales';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.user_settings.trading_settings IS
|
||||||
|
'Configuraciones de trading: leverage, risk, confirmaciones';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.user_settings.notification_settings IS
|
||||||
|
'Preferencias de notificaciones por canal';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id
|
||||||
|
ON users.user_settings(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_settings_tenant_id
|
||||||
|
ON users.user_settings(tenant_id);
|
||||||
|
|
||||||
|
-- GIN index para busquedas en JSONB
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_settings_ui_gin
|
||||||
|
ON users.user_settings USING GIN (ui_settings);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_settings_notif_gin
|
||||||
|
ON users.user_settings USING GIN (notification_settings);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
DROP TRIGGER IF EXISTS user_settings_updated_at ON users.user_settings;
|
||||||
|
CREATE TRIGGER user_settings_updated_at
|
||||||
|
BEFORE UPDATE ON users.user_settings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_user_timestamp();
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE users.user_settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY user_settings_tenant_isolation ON users.user_settings
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON users.user_settings TO trading_app;
|
||||||
|
GRANT SELECT ON users.user_settings TO trading_readonly;
|
||||||
230
ddl/schemas/users/tables/004_kyc_verifications.sql
Normal file
230
ddl/schemas/users/tables/004_kyc_verifications.sql
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: users
|
||||||
|
-- TABLE: kyc_verifications
|
||||||
|
-- DESCRIPTION: Verificacion de identidad KYC (Know Your Customer)
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enum para estado de verificacion KYC
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE users.kyc_status AS ENUM (
|
||||||
|
'not_started', -- No ha iniciado proceso
|
||||||
|
'pending', -- Documentos enviados, pendiente revision
|
||||||
|
'under_review', -- En proceso de revision manual
|
||||||
|
'approved', -- Aprobado completamente
|
||||||
|
'rejected', -- Rechazado (puede reintentar)
|
||||||
|
'expired', -- Verificacion expirada, requiere re-verificacion
|
||||||
|
'suspended' -- Suspendido por actividad sospechosa
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para nivel de verificacion
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE users.kyc_level AS ENUM (
|
||||||
|
'none', -- Sin verificacion
|
||||||
|
'basic', -- Email + telefono verificado
|
||||||
|
'standard', -- Documento ID verificado
|
||||||
|
'enhanced', -- ID + prueba de direccion
|
||||||
|
'full' -- Verificacion completa con video
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Verificaciones KYC
|
||||||
|
CREATE TABLE IF NOT EXISTS users.kyc_verifications (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Estado actual
|
||||||
|
status users.kyc_status NOT NULL DEFAULT 'not_started',
|
||||||
|
level users.kyc_level NOT NULL DEFAULT 'none',
|
||||||
|
|
||||||
|
-- Verificacion de Email
|
||||||
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
email_verified_at TIMESTAMPTZ,
|
||||||
|
email_verification_method VARCHAR(50), -- 'code', 'link'
|
||||||
|
|
||||||
|
-- Verificacion de Telefono
|
||||||
|
phone_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
phone_verified_at TIMESTAMPTZ,
|
||||||
|
phone_verification_method VARCHAR(50), -- 'sms', 'call'
|
||||||
|
|
||||||
|
-- Verificacion de Documento ID
|
||||||
|
id_document_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
id_document_verified_at TIMESTAMPTZ,
|
||||||
|
id_document_type VARCHAR(50), -- 'passport', 'national_id', 'drivers_license'
|
||||||
|
id_document_number_hash VARCHAR(255), -- Hash del numero para verificacion
|
||||||
|
id_document_country VARCHAR(100),
|
||||||
|
id_document_expiry DATE,
|
||||||
|
id_document_front_url TEXT, -- URL segura al documento (encriptado)
|
||||||
|
id_document_back_url TEXT,
|
||||||
|
id_selfie_url TEXT, -- Selfie con documento
|
||||||
|
|
||||||
|
-- Verificacion de Direccion
|
||||||
|
address_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
address_verified_at TIMESTAMPTZ,
|
||||||
|
address_document_type VARCHAR(50), -- 'utility_bill', 'bank_statement', 'tax_document'
|
||||||
|
address_document_url TEXT,
|
||||||
|
address_document_date DATE, -- Fecha del documento (max 3 meses)
|
||||||
|
|
||||||
|
-- Verificacion de Video (enhanced)
|
||||||
|
video_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
video_verified_at TIMESTAMPTZ,
|
||||||
|
video_url TEXT,
|
||||||
|
video_liveness_score DECIMAL(5, 4), -- 0.0000 - 1.0000
|
||||||
|
|
||||||
|
-- Proveedor de verificacion externo
|
||||||
|
external_provider VARCHAR(50), -- 'sumsub', 'onfido', 'jumio'
|
||||||
|
external_verification_id VARCHAR(255),
|
||||||
|
external_status VARCHAR(50),
|
||||||
|
external_risk_score DECIMAL(5, 4),
|
||||||
|
|
||||||
|
-- Informacion de revision
|
||||||
|
reviewer_id UUID,
|
||||||
|
reviewed_at TIMESTAMPTZ,
|
||||||
|
review_notes TEXT,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejection_codes JSONB, -- Array de codigos de rechazo
|
||||||
|
|
||||||
|
-- AML/PEP checks
|
||||||
|
aml_checked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
aml_checked_at TIMESTAMPTZ,
|
||||||
|
aml_result VARCHAR(50), -- 'clear', 'match', 'potential_match'
|
||||||
|
aml_details JSONB,
|
||||||
|
|
||||||
|
pep_checked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
pep_checked_at TIMESTAMPTZ,
|
||||||
|
pep_result VARCHAR(50),
|
||||||
|
pep_details JSONB,
|
||||||
|
|
||||||
|
sanctions_checked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
sanctions_checked_at TIMESTAMPTZ,
|
||||||
|
sanctions_result VARCHAR(50),
|
||||||
|
sanctions_details JSONB,
|
||||||
|
|
||||||
|
-- Expiracion
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
reminder_sent_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Intentos
|
||||||
|
verification_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||||
|
locked_until TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
ip_address INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
device_fingerprint VARCHAR(255),
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Constraints
|
||||||
|
CONSTRAINT kyc_unique_user UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE users.kyc_verifications IS
|
||||||
|
'Registro de verificacion KYC de usuarios para compliance regulatorio';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.kyc_verifications.level IS
|
||||||
|
'Nivel de verificacion alcanzado: none, basic, standard, enhanced, full';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.kyc_verifications.external_risk_score IS
|
||||||
|
'Score de riesgo del proveedor externo (0-1, menor es mejor)';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_user_id
|
||||||
|
ON users.kyc_verifications(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_tenant_id
|
||||||
|
ON users.kyc_verifications(tenant_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_status
|
||||||
|
ON users.kyc_verifications(status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_level
|
||||||
|
ON users.kyc_verifications(level);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_expires_at
|
||||||
|
ON users.kyc_verifications(expires_at)
|
||||||
|
WHERE expires_at IS NOT NULL AND status = 'approved';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_pending_review
|
||||||
|
ON users.kyc_verifications(created_at)
|
||||||
|
WHERE status IN ('pending', 'under_review');
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_kyc_aml_matches
|
||||||
|
ON users.kyc_verifications(tenant_id, aml_result)
|
||||||
|
WHERE aml_result IN ('match', 'potential_match');
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
DROP TRIGGER IF EXISTS kyc_updated_at ON users.kyc_verifications;
|
||||||
|
CREATE TRIGGER kyc_updated_at
|
||||||
|
BEFORE UPDATE ON users.kyc_verifications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_user_timestamp();
|
||||||
|
|
||||||
|
-- Funcion para actualizar nivel de KYC automaticamente
|
||||||
|
CREATE OR REPLACE FUNCTION users.update_kyc_level()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Calcular nivel basado en verificaciones completadas
|
||||||
|
IF NEW.video_verified AND NEW.address_verified AND NEW.id_document_verified AND NEW.phone_verified AND NEW.email_verified THEN
|
||||||
|
NEW.level := 'full';
|
||||||
|
ELSIF NEW.address_verified AND NEW.id_document_verified AND NEW.phone_verified AND NEW.email_verified THEN
|
||||||
|
NEW.level := 'enhanced';
|
||||||
|
ELSIF NEW.id_document_verified AND (NEW.phone_verified OR NEW.email_verified) THEN
|
||||||
|
NEW.level := 'standard';
|
||||||
|
ELSIF NEW.email_verified AND NEW.phone_verified THEN
|
||||||
|
NEW.level := 'basic';
|
||||||
|
ELSE
|
||||||
|
NEW.level := 'none';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Actualizar status si todas las verificaciones del nivel estan completas
|
||||||
|
IF NEW.level = 'full' AND OLD.status IN ('pending', 'under_review') THEN
|
||||||
|
NEW.status := 'approved';
|
||||||
|
NEW.completed_at := NOW();
|
||||||
|
-- Set expiration (typically 1-2 years)
|
||||||
|
NEW.expires_at := NOW() + INTERVAL '2 years';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS kyc_level_update ON users.kyc_verifications;
|
||||||
|
CREATE TRIGGER kyc_level_update
|
||||||
|
BEFORE UPDATE ON users.kyc_verifications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_kyc_level();
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE users.kyc_verifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY kyc_tenant_isolation ON users.kyc_verifications
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Policy especial para revisores (pueden ver todos los pendientes)
|
||||||
|
CREATE POLICY kyc_reviewer_access ON users.kyc_verifications
|
||||||
|
FOR SELECT
|
||||||
|
USING (
|
||||||
|
current_setting('app.is_kyc_reviewer', true)::boolean = true
|
||||||
|
AND status IN ('pending', 'under_review')
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON users.kyc_verifications TO trading_app;
|
||||||
|
GRANT SELECT ON users.kyc_verifications TO trading_readonly;
|
||||||
241
ddl/schemas/users/tables/005_risk_profiles.sql
Normal file
241
ddl/schemas/users/tables/005_risk_profiles.sql
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- SCHEMA: users
|
||||||
|
-- TABLE: risk_profiles
|
||||||
|
-- DESCRIPTION: Perfil de riesgo del usuario para trading
|
||||||
|
-- VERSION: 1.0.0
|
||||||
|
-- CREATED: 2026-01-16
|
||||||
|
-- SPRINT: Sprint 1 - DDL Implementation Roadmap Q1-2026
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Enum para perfil de riesgo
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE users.risk_tolerance AS ENUM (
|
||||||
|
'conservative', -- Bajo riesgo, preservacion de capital
|
||||||
|
'moderate', -- Riesgo moderado, balance crecimiento/seguridad
|
||||||
|
'aggressive', -- Alto riesgo, maximizar retornos
|
||||||
|
'speculative' -- Muy alto riesgo, trading activo
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Enum para experiencia de trading
|
||||||
|
DO $$ BEGIN
|
||||||
|
CREATE TYPE users.trading_experience AS ENUM (
|
||||||
|
'none', -- Sin experiencia
|
||||||
|
'beginner', -- Menos de 1 año
|
||||||
|
'intermediate', -- 1-3 años
|
||||||
|
'advanced', -- 3-5 años
|
||||||
|
'expert' -- 5+ años
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN null;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Tabla de Perfiles de Riesgo
|
||||||
|
CREATE TABLE IF NOT EXISTS users.risk_profiles (
|
||||||
|
-- Identificadores
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users.users(id) ON DELETE CASCADE,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Perfil de riesgo
|
||||||
|
risk_tolerance users.risk_tolerance NOT NULL DEFAULT 'moderate',
|
||||||
|
trading_experience users.trading_experience NOT NULL DEFAULT 'none',
|
||||||
|
|
||||||
|
-- Cuestionario de adecuacion (suitability)
|
||||||
|
questionnaire_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
questionnaire_completed_at TIMESTAMPTZ,
|
||||||
|
questionnaire_version VARCHAR(20),
|
||||||
|
questionnaire_responses JSONB,
|
||||||
|
questionnaire_score INTEGER CHECK (questionnaire_score BETWEEN 0 AND 100),
|
||||||
|
|
||||||
|
-- Objetivos de inversion
|
||||||
|
investment_objectives JSONB DEFAULT '{
|
||||||
|
"primary_goal": "growth",
|
||||||
|
"time_horizon": "medium_term",
|
||||||
|
"liquidity_needs": "moderate"
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Situacion financiera
|
||||||
|
financial_situation JSONB DEFAULT '{
|
||||||
|
"net_worth_range": "not_specified",
|
||||||
|
"annual_income_range": "not_specified",
|
||||||
|
"liquid_assets_range": "not_specified",
|
||||||
|
"investable_amount_range": "not_specified"
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Conocimiento de productos
|
||||||
|
product_knowledge JSONB DEFAULT '{
|
||||||
|
"forex": "none",
|
||||||
|
"stocks": "none",
|
||||||
|
"options": "none",
|
||||||
|
"futures": "none",
|
||||||
|
"crypto": "none",
|
||||||
|
"leverage_products": "none"
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Historial de trading declarado
|
||||||
|
trading_history JSONB DEFAULT '{
|
||||||
|
"years_trading": 0,
|
||||||
|
"average_trades_per_month": 0,
|
||||||
|
"largest_single_trade": 0,
|
||||||
|
"has_professional_experience": false
|
||||||
|
}'::JSONB,
|
||||||
|
|
||||||
|
-- Limites basados en perfil
|
||||||
|
max_position_size_percent DECIMAL(5, 2) NOT NULL DEFAULT 5.00, -- % del capital por posicion
|
||||||
|
max_daily_loss_percent DECIMAL(5, 2) NOT NULL DEFAULT 3.00, -- % perdida diaria maxima
|
||||||
|
max_total_exposure_percent DECIMAL(5, 2) NOT NULL DEFAULT 50.00, -- % exposicion total
|
||||||
|
max_leverage INTEGER NOT NULL DEFAULT 10,
|
||||||
|
allowed_instruments JSONB DEFAULT '["forex_majors", "indices"]'::JSONB,
|
||||||
|
|
||||||
|
-- Score de riesgo calculado
|
||||||
|
calculated_risk_score INTEGER CHECK (calculated_risk_score BETWEEN 1 AND 10),
|
||||||
|
risk_score_factors JSONB,
|
||||||
|
last_risk_assessment TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Warnings y restricciones
|
||||||
|
risk_warnings_acknowledged JSONB DEFAULT '[]'::JSONB,
|
||||||
|
trading_restrictions JSONB DEFAULT '[]'::JSONB,
|
||||||
|
requires_additional_disclosure BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Clasificacion regulatoria
|
||||||
|
regulatory_classification VARCHAR(50) DEFAULT 'retail', -- 'retail', 'professional', 'eligible_counterparty'
|
||||||
|
classification_request_status VARCHAR(50),
|
||||||
|
classification_approved_at TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Metadata
|
||||||
|
metadata JSONB DEFAULT '{}'::JSONB,
|
||||||
|
|
||||||
|
-- Timestamps
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE users.risk_profiles IS
|
||||||
|
'Perfil de riesgo del usuario basado en cuestionario de adecuacion';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.risk_profiles.risk_tolerance IS
|
||||||
|
'Tolerancia al riesgo declarada: conservative, moderate, aggressive, speculative';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.risk_profiles.calculated_risk_score IS
|
||||||
|
'Score de riesgo calculado (1-10), donde 10 es maximo riesgo permitido';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.risk_profiles.regulatory_classification IS
|
||||||
|
'Clasificacion segun MiFID II: retail, professional, eligible_counterparty';
|
||||||
|
|
||||||
|
-- Indices
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_user_id
|
||||||
|
ON users.risk_profiles(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_tenant_id
|
||||||
|
ON users.risk_profiles(tenant_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_tolerance
|
||||||
|
ON users.risk_profiles(risk_tolerance);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_experience
|
||||||
|
ON users.risk_profiles(trading_experience);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_classification
|
||||||
|
ON users.risk_profiles(regulatory_classification);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_pending_questionnaire
|
||||||
|
ON users.risk_profiles(tenant_id, created_at)
|
||||||
|
WHERE questionnaire_completed = FALSE;
|
||||||
|
|
||||||
|
-- GIN index para busquedas en JSONB
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_risk_profiles_knowledge_gin
|
||||||
|
ON users.risk_profiles USING GIN (product_knowledge);
|
||||||
|
|
||||||
|
-- Trigger para updated_at
|
||||||
|
DROP TRIGGER IF EXISTS risk_profile_updated_at ON users.risk_profiles;
|
||||||
|
CREATE TRIGGER risk_profile_updated_at
|
||||||
|
BEFORE UPDATE ON users.risk_profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.update_user_timestamp();
|
||||||
|
|
||||||
|
-- Funcion para calcular risk score automaticamente
|
||||||
|
CREATE OR REPLACE FUNCTION users.calculate_risk_score()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
DECLARE
|
||||||
|
v_score INTEGER := 5; -- Base score
|
||||||
|
v_experience_weight INTEGER;
|
||||||
|
v_tolerance_weight INTEGER;
|
||||||
|
BEGIN
|
||||||
|
-- Ajustar por experiencia
|
||||||
|
CASE NEW.trading_experience
|
||||||
|
WHEN 'none' THEN v_experience_weight := -2;
|
||||||
|
WHEN 'beginner' THEN v_experience_weight := -1;
|
||||||
|
WHEN 'intermediate' THEN v_experience_weight := 0;
|
||||||
|
WHEN 'advanced' THEN v_experience_weight := 1;
|
||||||
|
WHEN 'expert' THEN v_experience_weight := 2;
|
||||||
|
ELSE v_experience_weight := 0;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Ajustar por tolerancia al riesgo
|
||||||
|
CASE NEW.risk_tolerance
|
||||||
|
WHEN 'conservative' THEN v_tolerance_weight := -2;
|
||||||
|
WHEN 'moderate' THEN v_tolerance_weight := 0;
|
||||||
|
WHEN 'aggressive' THEN v_tolerance_weight := 2;
|
||||||
|
WHEN 'speculative' THEN v_tolerance_weight := 3;
|
||||||
|
ELSE v_tolerance_weight := 0;
|
||||||
|
END CASE;
|
||||||
|
|
||||||
|
-- Calcular score final
|
||||||
|
v_score := v_score + v_experience_weight + v_tolerance_weight;
|
||||||
|
|
||||||
|
-- Asegurar rango 1-10
|
||||||
|
v_score := GREATEST(1, LEAST(10, v_score));
|
||||||
|
|
||||||
|
NEW.calculated_risk_score := v_score;
|
||||||
|
NEW.last_risk_assessment := NOW();
|
||||||
|
|
||||||
|
-- Guardar factores del calculo
|
||||||
|
NEW.risk_score_factors := jsonb_build_object(
|
||||||
|
'base_score', 5,
|
||||||
|
'experience_weight', v_experience_weight,
|
||||||
|
'tolerance_weight', v_tolerance_weight,
|
||||||
|
'final_score', v_score
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ajustar limites basados en score
|
||||||
|
IF v_score <= 3 THEN
|
||||||
|
NEW.max_leverage := 5;
|
||||||
|
NEW.max_position_size_percent := 2.00;
|
||||||
|
NEW.max_daily_loss_percent := 1.00;
|
||||||
|
ELSIF v_score <= 5 THEN
|
||||||
|
NEW.max_leverage := 10;
|
||||||
|
NEW.max_position_size_percent := 5.00;
|
||||||
|
NEW.max_daily_loss_percent := 3.00;
|
||||||
|
ELSIF v_score <= 7 THEN
|
||||||
|
NEW.max_leverage := 20;
|
||||||
|
NEW.max_position_size_percent := 10.00;
|
||||||
|
NEW.max_daily_loss_percent := 5.00;
|
||||||
|
ELSE
|
||||||
|
NEW.max_leverage := 50;
|
||||||
|
NEW.max_position_size_percent := 15.00;
|
||||||
|
NEW.max_daily_loss_percent := 10.00;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS risk_score_calc ON users.risk_profiles;
|
||||||
|
CREATE TRIGGER risk_score_calc
|
||||||
|
BEFORE INSERT OR UPDATE OF risk_tolerance, trading_experience ON users.risk_profiles
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION users.calculate_risk_score();
|
||||||
|
|
||||||
|
-- RLS Policy para multi-tenancy
|
||||||
|
ALTER TABLE users.risk_profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY risk_profiles_tenant_isolation ON users.risk_profiles
|
||||||
|
FOR ALL
|
||||||
|
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
|
||||||
|
|
||||||
|
-- Grants
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON users.risk_profiles TO trading_app;
|
||||||
|
GRANT SELECT ON users.risk_profiles TO trading_readonly;
|
||||||
Loading…
Reference in New Issue
Block a user