commit ce9ae25a9b456ed10c184bbe23ee6641530470e1 Author: rckrdmrd Date: Sun Jan 4 07:05:19 2026 -0600 Initial commit - trading-platform-database diff --git a/DIRECTIVA-POLITICA-CARGA-LIMPIA.md b/DIRECTIVA-POLITICA-CARGA-LIMPIA.md new file mode 100644 index 0000000..cb090ad --- /dev/null +++ b/DIRECTIVA-POLITICA-CARGA-LIMPIA.md @@ -0,0 +1,259 @@ +# DIRECTIVA: Politica de Carga Limpia (DDL-First) + +**ID:** DIR-DB-001 +**Version:** 1.0.0 +**Fecha:** 2025-12-06 +**Estado:** ACTIVA +**Aplica a:** Todos los agentes que trabajen con base de datos + +--- + +## OBJETIVO + +Establecer una politica clara y obligatoria para la gestion del esquema de base de datos del proyecto Trading Platform (OrbiQuant IA), garantizando que la base de datos pueda ser creada o recreada completamente desde archivos DDL sin dependencia de migraciones incrementales. + +--- + +## PRINCIPIO FUNDAMENTAL + +> **La base de datos SIEMPRE debe poder ser creada desde cero ejecutando unicamente los archivos DDL.** + +Esto significa: +- NO migraciones incrementales +- NO archivos de "fix" o "patch" +- NO scripts de correccion +- NO ALTER TABLE en archivos separados + +--- + +## REGLAS OBLIGATORIAS + +### 1. Estructura de Archivos + +``` +apps/database/ +├── ddl/ +│ └── schemas/ +│ ├── {schema}/ +│ │ ├── 00-enums.sql # Tipos enumerados +│ │ ├── tables/ +│ │ │ ├── 01-{tabla}.sql # Una tabla por archivo +│ │ │ ├── 02-{tabla}.sql +│ │ │ └── ... +│ │ ├── functions/ +│ │ │ ├── 01-{funcion}.sql +│ │ │ └── ... +│ │ ├── triggers/ +│ │ │ └── ... +│ │ └── views/ +│ │ └── ... +├── seeds/ +│ ├── prod/ # Datos de produccion +│ └── dev/ # Datos de desarrollo +└── scripts/ + ├── create-database.sh # Crear BD + └── drop-and-recreate-database.sh # Recrear BD +``` + +### 2. Nomenclatura de Archivos + +| Tipo | Patron | Ejemplo | +|------|--------|---------| +| Enums | `00-enums.sql` | `00-enums.sql` | +| Tablas | `NN-{nombre}.sql` | `01-users.sql`, `02-profiles.sql` | +| Funciones | `NN-{nombre}.sql` | `01-update_updated_at.sql` | +| Triggers | `NN-{nombre}.sql` | `01-audit_trigger.sql` | +| Views | `NN-{nombre}.sql` | `01-user_summary.sql` | + +El numero `NN` indica el orden de ejecucion dentro de cada carpeta. + +### 3. Modificaciones al Schema + +**CORRECTO:** +```sql +-- Editar directamente el archivo DDL original +-- apps/database/ddl/schemas/auth/tables/01-users.sql + +CREATE TABLE IF NOT EXISTS auth.users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + -- Agregar nuevas columnas aqui + phone VARCHAR(20), -- <-- Nueva columna + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +**INCORRECTO:** +```sql +-- NO crear archivos de migracion +-- migrations/20251206_add_phone_to_users.sql <-- PROHIBIDO + +ALTER TABLE auth.users ADD COLUMN phone VARCHAR(20); +``` + +### 4. Cuando Necesitas Cambiar el Schema + +1. **Edita el archivo DDL original** de la tabla/funcion/trigger +2. **Ejecuta recreacion** en tu ambiente de desarrollo: + ```bash + ./scripts/drop-and-recreate-database.sh + ``` +3. **Verifica** que todo funcione correctamente +4. **Commit** los cambios al DDL + +### 5. Prohibiciones Explicitas + +| Accion | Permitido | Razon | +|--------|-----------|-------| +| Crear archivo `migrations/*.sql` | NO | Rompe carga limpia | +| Crear archivo `fix-*.sql` | NO | Rompe carga limpia | +| Crear archivo `patch-*.sql` | NO | Rompe carga limpia | +| Crear archivo `alter-*.sql` | NO | Rompe carga limpia | +| Usar `ALTER TABLE` en archivo separado | NO | Debe estar en DDL original | +| Modificar DDL original directamente | SI | Es la forma correcta | + +--- + +## ESTANDARES TECNICOS + +### Tipos de Datos + +| Uso | Tipo Correcto | Tipo Incorrecto | +|-----|---------------|-----------------| +| Timestamps | `TIMESTAMPTZ` | `TIMESTAMP` | +| UUIDs | `gen_random_uuid()` | `uuid_generate_v4()` | +| Moneda | `DECIMAL(20,8)` | `FLOAT`, `DOUBLE` | +| Texto variable | `VARCHAR(n)` | `CHAR(n)` para texto variable | + +### Convenciones SQL + +```sql +-- Nombres en snake_case +CREATE TABLE auth.user_profiles ( -- Correcto +CREATE TABLE auth.UserProfiles ( -- Incorrecto + +-- Siempre incluir schema +CREATE TABLE auth.users ( -- Correcto +CREATE TABLE users ( -- Incorrecto (usa public) + +-- IF NOT EXISTS en CREATE +CREATE TABLE IF NOT EXISTS auth.users ( +CREATE TYPE IF NOT EXISTS auth.user_status AS ENUM ( + +-- Indices con prefijo descriptivo +CREATE INDEX idx_users_email ON auth.users(email); +CREATE INDEX idx_users_created ON auth.users(created_at DESC); +``` + +### Foreign Keys + +```sql +-- Siempre referenciar con schema completo +user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + +-- Nunca asumir schema +user_id UUID NOT NULL REFERENCES users(id), -- INCORRECTO +``` + +--- + +## ORDEN DE CARGA + +El script `create-database.sh` ejecuta en este orden: + +1. **Extensiones** + - uuid-ossp + - pgcrypto + - pg_trgm + - btree_gin + - vector (si disponible) + +2. **Schemas** (en orden) + - auth + - education + - financial + - trading + - investment + - ml + - llm + - audit + +3. **Por cada schema:** + - 00-enums.sql (si existe) + - tables/*.sql (orden numerico) + - functions/*.sql (orden numerico) + - triggers/*.sql (orden numerico) + - views/*.sql (orden numerico) + +4. **Seeds** + - prod/ o dev/ segun ambiente + +--- + +## VALIDACION + +### Pre-commit Checklist + +Antes de hacer commit de cambios a DDL: + +- [ ] No existen archivos `migrations/`, `fix-*`, `patch-*`, `alter-*` +- [ ] Todos los cambios estan en archivos DDL originales +- [ ] Se puede ejecutar `drop-and-recreate-database.sh` sin errores +- [ ] Todas las FKs usan schema completo (`auth.users`, no `users`) +- [ ] Todos los timestamps son `TIMESTAMPTZ` +- [ ] Todos los UUIDs usan `gen_random_uuid()` + +### Script de Validacion + +```bash +# Verificar que no hay archivos prohibidos +find apps/database -name "fix-*.sql" -o -name "patch-*.sql" -o -name "alter-*.sql" +# Debe retornar vacio + +# Verificar que no hay carpeta migrations con contenido nuevo +ls apps/database/migrations/ +# Solo debe existir si hay migraciones legacy (a eliminar) +``` + +--- + +## EXCEPCIONES + +### Unica Excepcion: Datos de Produccion + +Si hay datos de produccion que NO pueden perderse: + +1. **Exportar datos** antes de recrear +2. **Recrear schema** con DDL limpio +3. **Importar datos** desde backup + +Esto NO es una migracion, es un proceso de backup/restore. + +--- + +## CONSECUENCIAS DE VIOLAR ESTA DIRECTIVA + +1. **Build fallara** - CI/CD rechazara archivos prohibidos +2. **PR sera rechazado** - Code review detectara violaciones +3. **Deuda tecnica** - Se acumularan inconsistencias + +--- + +## REFERENCIAS + +- [_MAP.md - Database Schemas](../apps/database/schemas/_MAP.md) +- [DECISIONES-ARQUITECTONICAS.md](../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md) +- [create-database.sh](../apps/database/scripts/create-database.sh) + +--- + +## HISTORIAL DE CAMBIOS + +| Version | Fecha | Cambio | +|---------|-------|--------| +| 1.0.0 | 2025-12-06 | Version inicial | + +--- + +*Directiva establecida por Requirements-Analyst Agent* +*OrbiQuant IA Trading Platform* diff --git a/ddl/00-extensions.sql b/ddl/00-extensions.sql new file mode 100644 index 0000000..6ed9a68 --- /dev/null +++ b/ddl/00-extensions.sql @@ -0,0 +1,26 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- File: 00-extensions.sql +-- Description: PostgreSQL extensions required globally +-- ============================================================================ + +-- UUID generation extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Cryptographic functions for password hashing and token generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Network address types and functions +CREATE EXTENSION IF NOT EXISTS "citext"; + +-- Full text search +CREATE EXTENSION IF NOT EXISTS "unaccent"; + +-- Trigram similarity for fuzzy text matching +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; + +COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; +COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; +COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; +COMMENT ON EXTENSION "unaccent" IS 'Text search dictionary that removes accents'; +COMMENT ON EXTENSION "pg_trgm" IS 'Trigram matching for similarity searches'; diff --git a/ddl/01-schemas.sql b/ddl/01-schemas.sql new file mode 100644 index 0000000..7fc1ea7 --- /dev/null +++ b/ddl/01-schemas.sql @@ -0,0 +1,37 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- File: 01-schemas.sql +-- Description: Database schemas creation +-- ============================================================================ + +-- Authentication and User Management +CREATE SCHEMA IF NOT EXISTS auth; +COMMENT ON SCHEMA auth IS 'Authentication, authorization, and user management'; + +-- Education and Learning +CREATE SCHEMA IF NOT EXISTS education; +COMMENT ON SCHEMA education IS 'Educational content, courses, and learning progress'; + +-- Trading Operations +CREATE SCHEMA IF NOT EXISTS trading; +COMMENT ON SCHEMA trading IS 'Trading bots, orders, positions, and market data'; + +-- Investment Management +CREATE SCHEMA IF NOT EXISTS investment; +COMMENT ON SCHEMA investment IS 'Investment products, accounts, and transactions'; + +-- Financial Operations +CREATE SCHEMA IF NOT EXISTS financial; +COMMENT ON SCHEMA financial IS 'Wallets, payments, subscriptions, and financial transactions'; + +-- Machine Learning +CREATE SCHEMA IF NOT EXISTS ml; +COMMENT ON SCHEMA ml IS 'ML models, predictions, and feature store'; + +-- Large Language Models +CREATE SCHEMA IF NOT EXISTS llm; +COMMENT ON SCHEMA llm IS 'LLM conversations, messages, and user preferences'; + +-- Audit and Compliance +CREATE SCHEMA IF NOT EXISTS audit; +COMMENT ON SCHEMA audit IS 'Audit logs, security events, and compliance tracking'; diff --git a/ddl/schemas/audit/00-enums.sql b/ddl/schemas/audit/00-enums.sql new file mode 100644 index 0000000..5356735 --- /dev/null +++ b/ddl/schemas/audit/00-enums.sql @@ -0,0 +1,63 @@ +-- ============================================================================ +-- AUDIT SCHEMA - ENUMs +-- ============================================================================ +-- OrbiQuant IA Trading Platform +-- Schema: audit +-- Propósito: Tipos enumerados para auditoría y logging +-- ============================================================================ + +-- Tipo de evento de auditoría +CREATE TYPE audit.audit_event_type AS ENUM ( + 'create', + 'read', + 'update', + 'delete', + 'login', + 'logout', + 'permission_change', + 'config_change', + 'export', + 'import' +); + +-- Severidad del evento +CREATE TYPE audit.event_severity AS ENUM ( + 'debug', + 'info', + 'warning', + 'error', + 'critical' +); + +-- Categoría del evento de seguridad +CREATE TYPE audit.security_event_category AS ENUM ( + 'authentication', + 'authorization', + 'data_access', + 'configuration', + 'suspicious_activity', + 'compliance' +); + +-- Estado del evento +CREATE TYPE audit.event_status AS ENUM ( + 'success', + 'failure', + 'blocked', + 'pending_review' +); + +-- Tipo de recurso auditado +CREATE TYPE audit.resource_type AS ENUM ( + 'user', + 'account', + 'transaction', + 'order', + 'position', + 'bot', + 'subscription', + 'payment', + 'course', + 'model', + 'system_config' +); diff --git a/ddl/schemas/audit/tables/01-audit_logs.sql b/ddl/schemas/audit/tables/01-audit_logs.sql new file mode 100644 index 0000000..545eda6 --- /dev/null +++ b/ddl/schemas/audit/tables/01-audit_logs.sql @@ -0,0 +1,54 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: audit_logs +-- ============================================================================ +-- Tabla principal de auditoría general +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información del evento + event_type audit.audit_event_type NOT NULL, + event_status audit.event_status NOT NULL DEFAULT 'success', + severity audit.event_severity NOT NULL DEFAULT 'info', + + -- Quién realizó la acción + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + session_id UUID, + ip_address INET, + user_agent TEXT, + + -- Qué se modificó + resource_type audit.resource_type NOT NULL, + resource_id UUID, + resource_name VARCHAR(255), + + -- Detalles del cambio + action VARCHAR(100) NOT NULL, + description TEXT, + old_values JSONB, + new_values JSONB, + metadata JSONB DEFAULT '{}', + + -- Contexto + request_id UUID, + correlation_id UUID, + service_name VARCHAR(50), + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices para consultas frecuentes +CREATE INDEX idx_audit_logs_user_id ON audit.audit_logs(user_id); +CREATE INDEX idx_audit_logs_resource ON audit.audit_logs(resource_type, resource_id); +CREATE INDEX idx_audit_logs_event_type ON audit.audit_logs(event_type); +CREATE INDEX idx_audit_logs_created_at ON audit.audit_logs(created_at DESC); +CREATE INDEX idx_audit_logs_severity ON audit.audit_logs(severity) WHERE severity IN ('error', 'critical'); +CREATE INDEX idx_audit_logs_correlation ON audit.audit_logs(correlation_id) WHERE correlation_id IS NOT NULL; + +-- Índice GIN para búsqueda en JSONB +CREATE INDEX idx_audit_logs_metadata ON audit.audit_logs USING GIN (metadata); + +COMMENT ON TABLE audit.audit_logs IS 'Log general de auditoría para todas las acciones del sistema'; +COMMENT ON COLUMN audit.audit_logs.correlation_id IS 'ID para correlacionar eventos relacionados en una misma operación'; diff --git a/ddl/schemas/audit/tables/02-security_events.sql b/ddl/schemas/audit/tables/02-security_events.sql new file mode 100644 index 0000000..7701dc5 --- /dev/null +++ b/ddl/schemas/audit/tables/02-security_events.sql @@ -0,0 +1,57 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: security_events +-- ============================================================================ +-- Eventos de seguridad específicos +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.security_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Categorización + category audit.security_event_category NOT NULL, + severity audit.event_severity NOT NULL, + event_status audit.event_status NOT NULL DEFAULT 'success', + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + ip_address INET NOT NULL, + user_agent TEXT, + geo_location JSONB, + + -- Detalles del evento + event_code VARCHAR(50) NOT NULL, + event_name VARCHAR(255) NOT NULL, + description TEXT, + + -- Contexto técnico + request_path VARCHAR(500), + request_method VARCHAR(10), + response_code INTEGER, + + -- Datos adicionales + risk_score DECIMAL(3, 2), + is_blocked BOOLEAN DEFAULT FALSE, + block_reason TEXT, + requires_review BOOLEAN DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + -- Metadata + raw_data JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_security_events_user ON audit.security_events(user_id); +CREATE INDEX idx_security_events_category ON audit.security_events(category); +CREATE INDEX idx_security_events_severity ON audit.security_events(severity); +CREATE INDEX idx_security_events_ip ON audit.security_events(ip_address); +CREATE INDEX idx_security_events_created ON audit.security_events(created_at DESC); +CREATE INDEX idx_security_events_blocked ON audit.security_events(is_blocked) WHERE is_blocked = TRUE; +CREATE INDEX idx_security_events_review ON audit.security_events(requires_review) WHERE requires_review = TRUE; + +COMMENT ON TABLE audit.security_events IS 'Eventos de seguridad para monitoreo y respuesta a incidentes'; +COMMENT ON COLUMN audit.security_events.risk_score IS 'Puntuación de riesgo calculada (0.00-1.00)'; diff --git a/ddl/schemas/audit/tables/03-system_events.sql b/ddl/schemas/audit/tables/03-system_events.sql new file mode 100644 index 0000000..03966c7 --- /dev/null +++ b/ddl/schemas/audit/tables/03-system_events.sql @@ -0,0 +1,47 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: system_events +-- ============================================================================ +-- Eventos del sistema (jobs, tareas programadas, errores) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.system_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Categorización + event_type VARCHAR(50) NOT NULL, + severity audit.event_severity NOT NULL DEFAULT 'info', + + -- Origen + service_name VARCHAR(100) NOT NULL, + component VARCHAR(100), + environment VARCHAR(20) NOT NULL DEFAULT 'production', + hostname VARCHAR(255), + + -- Detalles + event_name VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + stack_trace TEXT, + + -- Contexto + correlation_id UUID, + job_id VARCHAR(100), + duration_ms INTEGER, + + -- Metadata + metadata JSONB DEFAULT '{}', + tags TEXT[], + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_system_events_service ON audit.system_events(service_name); +CREATE INDEX idx_system_events_type ON audit.system_events(event_type); +CREATE INDEX idx_system_events_severity ON audit.system_events(severity); +CREATE INDEX idx_system_events_created ON audit.system_events(created_at DESC); +CREATE INDEX idx_system_events_correlation ON audit.system_events(correlation_id) WHERE correlation_id IS NOT NULL; +CREATE INDEX idx_system_events_job ON audit.system_events(job_id) WHERE job_id IS NOT NULL; +CREATE INDEX idx_system_events_tags ON audit.system_events USING GIN (tags); + +COMMENT ON TABLE audit.system_events IS 'Eventos del sistema para monitoreo de infraestructura y jobs'; diff --git a/ddl/schemas/audit/tables/04-trading_audit.sql b/ddl/schemas/audit/tables/04-trading_audit.sql new file mode 100644 index 0000000..2dc245e --- /dev/null +++ b/ddl/schemas/audit/tables/04-trading_audit.sql @@ -0,0 +1,57 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: trading_audit +-- ============================================================================ +-- Auditoría específica de operaciones de trading +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.trading_audit ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Actor + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID, + + -- Acción + action VARCHAR(50) NOT NULL, + action_status audit.event_status NOT NULL, + + -- Objeto de la acción + order_id UUID, + position_id UUID, + symbol VARCHAR(20) NOT NULL, + + -- Detalles de la operación + side VARCHAR(4) NOT NULL, -- 'buy' o 'sell' + order_type VARCHAR(20), + quantity DECIMAL(20, 8) NOT NULL, + price DECIMAL(20, 8), + executed_price DECIMAL(20, 8), + + -- Resultado + pnl DECIMAL(20, 8), + fees DECIMAL(20, 8), + + -- Contexto + strategy_id UUID, + signal_id UUID, + is_paper_trading BOOLEAN DEFAULT FALSE, + + -- Metadata + execution_time_ms INTEGER, + broker_response JSONB, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_trading_audit_user ON audit.trading_audit(user_id); +CREATE INDEX idx_trading_audit_bot ON audit.trading_audit(bot_id) WHERE bot_id IS NOT NULL; +CREATE INDEX idx_trading_audit_symbol ON audit.trading_audit(symbol); +CREATE INDEX idx_trading_audit_action ON audit.trading_audit(action); +CREATE INDEX idx_trading_audit_created ON audit.trading_audit(created_at DESC); +CREATE INDEX idx_trading_audit_order ON audit.trading_audit(order_id) WHERE order_id IS NOT NULL; +CREATE INDEX idx_trading_audit_position ON audit.trading_audit(position_id) WHERE position_id IS NOT NULL; + +COMMENT ON TABLE audit.trading_audit IS 'Auditoría detallada de todas las operaciones de trading'; diff --git a/ddl/schemas/audit/tables/05-api_request_logs.sql b/ddl/schemas/audit/tables/05-api_request_logs.sql new file mode 100644 index 0000000..3b83ff5 --- /dev/null +++ b/ddl/schemas/audit/tables/05-api_request_logs.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: api_request_logs +-- ============================================================================ +-- Log de requests a la API (para debugging y análisis) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.api_request_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Request + request_id UUID NOT NULL, + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + query_params JSONB, + headers JSONB, + body_size INTEGER, + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + api_key_id UUID, + ip_address INET NOT NULL, + user_agent TEXT, + + -- Response + status_code INTEGER NOT NULL, + response_size INTEGER, + response_time_ms INTEGER NOT NULL, + + -- Contexto + service_name VARCHAR(50), + version VARCHAR(20), + error_code VARCHAR(50), + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices optimizados para consultas de análisis +CREATE INDEX idx_api_logs_user ON audit.api_request_logs(user_id); +CREATE INDEX idx_api_logs_path ON audit.api_request_logs(path); +CREATE INDEX idx_api_logs_status ON audit.api_request_logs(status_code); +CREATE INDEX idx_api_logs_created ON audit.api_request_logs(created_at DESC); +CREATE INDEX idx_api_logs_ip ON audit.api_request_logs(ip_address); +CREATE INDEX idx_api_logs_slow ON audit.api_request_logs(response_time_ms) WHERE response_time_ms > 1000; +CREATE INDEX idx_api_logs_errors ON audit.api_request_logs(status_code) WHERE status_code >= 400; + +COMMENT ON TABLE audit.api_request_logs IS 'Log de requests HTTP para análisis y debugging'; +COMMENT ON COLUMN audit.api_request_logs.body_size IS 'Tamaño del body en bytes (no se guarda contenido por seguridad)'; diff --git a/ddl/schemas/audit/tables/06-data_access_logs.sql b/ddl/schemas/audit/tables/06-data_access_logs.sql new file mode 100644 index 0000000..dce4435 --- /dev/null +++ b/ddl/schemas/audit/tables/06-data_access_logs.sql @@ -0,0 +1,45 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: data_access_logs +-- ============================================================================ +-- Log de acceso a datos sensibles (cumplimiento regulatorio) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.data_access_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Quién accedió + accessor_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + accessor_role VARCHAR(50) NOT NULL, + + -- A qué datos se accedió + target_user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + data_category VARCHAR(50) NOT NULL, -- 'pii', 'financial', 'health', 'credentials' + data_fields TEXT[], -- campos específicos accedidos + + -- Cómo se accedió + access_type VARCHAR(20) NOT NULL, -- 'view', 'export', 'modify', 'delete' + access_reason TEXT, + + -- Contexto + request_id UUID, + ip_address INET, + user_agent TEXT, + + -- Compliance + consent_verified BOOLEAN DEFAULT FALSE, + legal_basis VARCHAR(100), + retention_days INTEGER, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_data_access_accessor ON audit.data_access_logs(accessor_user_id); +CREATE INDEX idx_data_access_target ON audit.data_access_logs(target_user_id); +CREATE INDEX idx_data_access_category ON audit.data_access_logs(data_category); +CREATE INDEX idx_data_access_type ON audit.data_access_logs(access_type); +CREATE INDEX idx_data_access_created ON audit.data_access_logs(created_at DESC); + +COMMENT ON TABLE audit.data_access_logs IS 'Registro de acceso a datos sensibles para cumplimiento GDPR/CCPA'; +COMMENT ON COLUMN audit.data_access_logs.legal_basis IS 'Base legal para el acceso (consentimiento, contrato, obligación legal, etc.)'; diff --git a/ddl/schemas/audit/tables/07-compliance_logs.sql b/ddl/schemas/audit/tables/07-compliance_logs.sql new file mode 100644 index 0000000..ff6cced --- /dev/null +++ b/ddl/schemas/audit/tables/07-compliance_logs.sql @@ -0,0 +1,52 @@ +-- ============================================================================ +-- AUDIT SCHEMA - Tabla: compliance_logs +-- ============================================================================ +-- Log de eventos de cumplimiento regulatorio +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit.compliance_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Regulación + regulation VARCHAR(50) NOT NULL, -- 'GDPR', 'CCPA', 'SOX', 'PCI-DSS', 'MiFID' + requirement VARCHAR(100) NOT NULL, + + -- Evento + event_type VARCHAR(50) NOT NULL, + event_description TEXT NOT NULL, + + -- Actor + user_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + system_initiated BOOLEAN DEFAULT FALSE, + + -- Estado + compliance_status VARCHAR(20) NOT NULL, -- 'compliant', 'non_compliant', 'remediation' + risk_level VARCHAR(20), -- 'low', 'medium', 'high', 'critical' + + -- Detalles + evidence JSONB, + remediation_required BOOLEAN DEFAULT FALSE, + remediation_deadline TIMESTAMPTZ, + remediation_notes TEXT, + + -- Revisión + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_compliance_regulation ON audit.compliance_logs(regulation); +CREATE INDEX idx_compliance_status ON audit.compliance_logs(compliance_status); +CREATE INDEX idx_compliance_risk ON audit.compliance_logs(risk_level); +CREATE INDEX idx_compliance_created ON audit.compliance_logs(created_at DESC); +CREATE INDEX idx_compliance_remediation ON audit.compliance_logs(remediation_required) + WHERE remediation_required = TRUE; + +COMMENT ON TABLE audit.compliance_logs IS 'Registro de cumplimiento regulatorio para auditorías'; diff --git a/ddl/schemas/auth/00-extensions.sql b/ddl/schemas/auth/00-extensions.sql new file mode 100644 index 0000000..2ccf481 --- /dev/null +++ b/ddl/schemas/auth/00-extensions.sql @@ -0,0 +1,19 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: 00-extensions.sql +-- Description: PostgreSQL extensions required for authentication schema +-- ============================================================================ + +-- UUID generation extension +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Cryptographic functions for password hashing and token generation +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- Network address types and functions +CREATE EXTENSION IF NOT EXISTS "citext"; + +COMMENT ON EXTENSION "uuid-ossp" IS 'UUID generation functions'; +COMMENT ON EXTENSION "pgcrypto" IS 'Cryptographic functions for secure password and token handling'; +COMMENT ON EXTENSION "citext" IS 'Case-insensitive text type for email addresses'; diff --git a/ddl/schemas/auth/01-enums.sql b/ddl/schemas/auth/01-enums.sql new file mode 100644 index 0000000..3fd7991 --- /dev/null +++ b/ddl/schemas/auth/01-enums.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: 01-enums.sql +-- Description: Enumerated types for authentication and authorization +-- ============================================================================ + +-- User account status +CREATE TYPE auth.user_status AS ENUM ( + 'pending_verification', + 'active', + 'suspended', + 'deactivated', + 'banned' +); + +COMMENT ON TYPE auth.user_status IS 'User account status lifecycle states'; + +-- User role for RBAC +CREATE TYPE auth.user_role AS ENUM ( + 'user', + 'trader', + 'analyst', + 'admin', + 'super_admin' +); + +COMMENT ON TYPE auth.user_role IS 'Role-based access control roles'; + +-- OAuth provider types +CREATE TYPE auth.oauth_provider AS ENUM ( + 'google', + 'facebook', + 'apple', + 'github', + 'microsoft', + 'twitter' +); + +COMMENT ON TYPE auth.oauth_provider IS 'Supported OAuth 2.0 providers'; + +-- Phone verification channel +CREATE TYPE auth.phone_channel AS ENUM ( + 'sms', + 'whatsapp' +); + +COMMENT ON TYPE auth.phone_channel IS 'Phone verification delivery channels'; + +-- Authentication event types for logging +CREATE TYPE auth.auth_event_type AS ENUM ( + 'login', + 'logout', + 'register', + 'password_change', + 'password_reset_request', + 'password_reset_complete', + 'email_verification', + 'phone_verification', + 'mfa_enabled', + 'mfa_disabled', + 'session_expired', + 'account_suspended', + 'account_reactivated', + 'failed_login', + 'oauth_linked', + 'oauth_unlinked' +); + +COMMENT ON TYPE auth.auth_event_type IS 'Types of authentication events for audit logging'; + +-- MFA method types +CREATE TYPE auth.mfa_method AS ENUM ( + 'none', + 'totp', + 'sms', + 'email' +); + +COMMENT ON TYPE auth.mfa_method IS 'Multi-factor authentication methods'; diff --git a/ddl/schemas/auth/functions/01-update_updated_at.sql b/ddl/schemas/auth/functions/01-update_updated_at.sql new file mode 100644 index 0000000..3cd3d22 --- /dev/null +++ b/ddl/schemas/auth/functions/01-update_updated_at.sql @@ -0,0 +1,48 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/01-update_updated_at.sql +-- Description: Trigger function to automatically update updated_at timestamp +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.update_updated_at() IS 'Trigger function to automatically update updated_at column on row modification'; + +-- Apply trigger to all tables with updated_at column + +-- Users table +CREATE TRIGGER trigger_update_users_updated_at + BEFORE UPDATE ON auth.users + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- User profiles table +CREATE TRIGGER trigger_update_user_profiles_updated_at + BEFORE UPDATE ON auth.user_profiles + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- OAuth accounts table +CREATE TRIGGER trigger_update_oauth_accounts_updated_at + BEFORE UPDATE ON auth.oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- Sessions table +CREATE TRIGGER trigger_update_sessions_updated_at + BEFORE UPDATE ON auth.sessions + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); + +-- Rate limiting config table +CREATE TRIGGER trigger_update_rate_limiting_config_updated_at + BEFORE UPDATE ON auth.rate_limiting_config + FOR EACH ROW + EXECUTE FUNCTION auth.update_updated_at(); diff --git a/ddl/schemas/auth/functions/02-log_auth_event.sql b/ddl/schemas/auth/functions/02-log_auth_event.sql new file mode 100644 index 0000000..6b91882 --- /dev/null +++ b/ddl/schemas/auth/functions/02-log_auth_event.sql @@ -0,0 +1,75 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/02-log_auth_event.sql +-- Description: Function to log authentication events to auth_logs table +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.log_auth_event( + p_event_type auth.auth_event_type, + p_user_id UUID DEFAULT NULL, + p_email CITEXT DEFAULT NULL, + p_ip_address INET DEFAULT NULL, + p_user_agent TEXT DEFAULT NULL, + p_session_id UUID DEFAULT NULL, + p_success BOOLEAN DEFAULT true, + p_failure_reason VARCHAR(255) DEFAULT NULL, + p_metadata JSONB DEFAULT '{}'::jsonb +) +RETURNS UUID AS $$ +DECLARE + v_log_id UUID; +BEGIN + INSERT INTO auth.auth_logs ( + event_type, + user_id, + email, + ip_address, + user_agent, + session_id, + success, + failure_reason, + metadata, + created_at + ) VALUES ( + p_event_type, + p_user_id, + p_email, + p_ip_address, + p_user_agent, + p_session_id, + p_success, + p_failure_reason, + p_metadata, + NOW() + ) + RETURNING id INTO v_log_id; + + RETURN v_log_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.log_auth_event( + auth.auth_event_type, + UUID, + CITEXT, + INET, + TEXT, + UUID, + BOOLEAN, + VARCHAR, + JSONB +) IS 'Logs authentication events to the auth_logs table with optional metadata'; + +-- Example usage: +-- SELECT auth.log_auth_event( +-- 'login'::auth.auth_event_type, +-- '123e4567-e89b-12d3-a456-426614174000'::UUID, +-- 'user@example.com'::CITEXT, +-- '192.168.1.1'::INET, +-- 'Mozilla/5.0...', +-- '123e4567-e89b-12d3-a456-426614174001'::UUID, +-- true, +-- NULL, +-- '{"device": "mobile"}'::JSONB +-- ); diff --git a/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql b/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql new file mode 100644 index 0000000..f8574b0 --- /dev/null +++ b/ddl/schemas/auth/functions/03-cleanup_expired_sessions.sql @@ -0,0 +1,58 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/03-cleanup_expired_sessions.sql +-- Description: Function to cleanup expired and inactive sessions +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.cleanup_expired_sessions( + p_batch_size INTEGER DEFAULT 1000 +) +RETURNS TABLE( + deleted_count INTEGER, + execution_time_ms NUMERIC +) AS $$ +DECLARE + v_start_time TIMESTAMPTZ; + v_deleted_count INTEGER; +BEGIN + v_start_time := clock_timestamp(); + + -- Delete expired sessions + WITH deleted AS ( + DELETE FROM auth.sessions + WHERE ( + -- Expired sessions + expires_at < NOW() OR + -- Inactive sessions older than 30 days + (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') + ) + AND id IN ( + SELECT id FROM auth.sessions + WHERE ( + expires_at < NOW() OR + (is_active = false AND invalidated_at < NOW() - INTERVAL '30 days') + ) + LIMIT p_batch_size + ) + RETURNING * + ) + SELECT COUNT(*)::INTEGER INTO v_deleted_count FROM deleted; + + RETURN QUERY + SELECT + v_deleted_count, + EXTRACT(EPOCH FROM (clock_timestamp() - v_start_time) * 1000)::NUMERIC; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.cleanup_expired_sessions(INTEGER) IS + 'Deletes expired and old inactive sessions in batches. Returns deleted count and execution time.'; + +-- Example usage: +-- SELECT * FROM auth.cleanup_expired_sessions(1000); + +-- Recommended: Schedule this function to run periodically via pg_cron or external scheduler +-- Example pg_cron schedule (runs daily at 2 AM): +-- SELECT cron.schedule('cleanup-expired-sessions', '0 2 * * *', +-- 'SELECT auth.cleanup_expired_sessions(1000);'); diff --git a/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql b/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql new file mode 100644 index 0000000..a01e6e9 --- /dev/null +++ b/ddl/schemas/auth/functions/04-create_user_profile_trigger.sql @@ -0,0 +1,46 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: functions/04-create_user_profile_trigger.sql +-- Description: Automatically create user profile when new user is created +-- ============================================================================ + +CREATE OR REPLACE FUNCTION auth.create_user_profile() +RETURNS TRIGGER AS $$ +BEGIN + -- Create a new user profile for the newly created user + INSERT INTO auth.user_profiles ( + user_id, + language, + timezone, + newsletter_subscribed, + marketing_emails_enabled, + notifications_enabled, + created_at, + updated_at + ) VALUES ( + NEW.id, + 'en', + 'UTC', + false, + false, + true, + NOW(), + NOW() + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION auth.create_user_profile() IS + 'Trigger function to automatically create a user profile when a new user is registered'; + +-- Create trigger on users table +CREATE TRIGGER trigger_create_user_profile + AFTER INSERT ON auth.users + FOR EACH ROW + EXECUTE FUNCTION auth.create_user_profile(); + +COMMENT ON TRIGGER trigger_create_user_profile ON auth.users IS + 'Automatically creates a user profile entry when a new user is inserted'; diff --git a/ddl/schemas/auth/tables/01-users.sql b/ddl/schemas/auth/tables/01-users.sql new file mode 100644 index 0000000..b8d1e2f --- /dev/null +++ b/ddl/schemas/auth/tables/01-users.sql @@ -0,0 +1,107 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/01-users.sql +-- Description: Core users table for authentication and user management +-- ============================================================================ + +CREATE TABLE auth.users ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Authentication Credentials + email CITEXT NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT false, + email_verified_at TIMESTAMPTZ, + password_hash VARCHAR(255), + + -- User Status and Role + status auth.user_status NOT NULL DEFAULT 'pending_verification', + role auth.user_role NOT NULL DEFAULT 'user', + + -- Multi-Factor Authentication + mfa_enabled BOOLEAN NOT NULL DEFAULT false, + mfa_method auth.mfa_method NOT NULL DEFAULT 'none', + mfa_secret VARCHAR(255), + backup_codes JSONB DEFAULT '[]', + + -- Phone Information + phone_number VARCHAR(20), + phone_verified BOOLEAN NOT NULL DEFAULT false, + phone_verified_at TIMESTAMPTZ, + + -- Security Settings + last_login_at TIMESTAMPTZ, + last_login_ip INET, + failed_login_attempts INTEGER NOT NULL DEFAULT 0, + locked_until TIMESTAMPTZ, + + -- Account Lifecycle + suspended_at TIMESTAMPTZ, + suspended_reason TEXT, + deactivated_at TIMESTAMPTZ, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_id UUID, + updated_by_id UUID, + + -- Constraints + CONSTRAINT valid_email CHECK (email ~* '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'), + CONSTRAINT password_or_oauth CHECK (password_hash IS NOT NULL OR EXISTS ( + SELECT 1 FROM auth.oauth_accounts WHERE user_id = users.id + )), + CONSTRAINT failed_attempts_non_negative CHECK (failed_login_attempts >= 0), + CONSTRAINT email_verified_at_consistency CHECK ( + (email_verified = true AND email_verified_at IS NOT NULL) OR + (email_verified = false AND email_verified_at IS NULL) + ), + CONSTRAINT phone_verified_at_consistency CHECK ( + (phone_verified = true AND phone_verified_at IS NOT NULL) OR + (phone_verified = false AND phone_verified_at IS NULL) + ), + CONSTRAINT mfa_secret_consistency CHECK ( + (mfa_enabled = true AND mfa_secret IS NOT NULL AND mfa_method != 'none') OR + (mfa_enabled = false) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_users_email ON auth.users(email); +CREATE INDEX idx_users_status ON auth.users(status); +CREATE INDEX idx_users_role ON auth.users(role); +CREATE INDEX idx_users_last_login ON auth.users(last_login_at DESC); +CREATE INDEX idx_users_created_at ON auth.users(created_at DESC); +CREATE INDEX idx_users_email_verified ON auth.users(email_verified) WHERE email_verified = false; +CREATE INDEX idx_users_locked ON auth.users(locked_until) WHERE locked_until IS NOT NULL; +CREATE INDEX idx_users_phone ON auth.users(phone_number) WHERE phone_number IS NOT NULL; + +-- Table Comments +COMMENT ON TABLE auth.users IS 'Core users table for authentication and user management'; + +-- Column Comments +COMMENT ON COLUMN auth.users.id IS 'Unique identifier for the user'; +COMMENT ON COLUMN auth.users.email IS 'User email address (case-insensitive, unique)'; +COMMENT ON COLUMN auth.users.email_verified IS 'Whether the email has been verified'; +COMMENT ON COLUMN auth.users.email_verified_at IS 'Timestamp when email was verified'; +COMMENT ON COLUMN auth.users.password_hash IS 'Bcrypt hashed password (null for OAuth-only users)'; +COMMENT ON COLUMN auth.users.status IS 'Current status of the user account'; +COMMENT ON COLUMN auth.users.role IS 'User role for role-based access control'; +COMMENT ON COLUMN auth.users.mfa_enabled IS 'Whether multi-factor authentication is enabled'; +COMMENT ON COLUMN auth.users.mfa_method IS 'MFA method used (totp, sms, email)'; +COMMENT ON COLUMN auth.users.mfa_secret IS 'Secret key for TOTP MFA'; +COMMENT ON COLUMN auth.users.phone_number IS 'User phone number for SMS verification'; +COMMENT ON COLUMN auth.users.phone_verified IS 'Whether the phone number has been verified'; +COMMENT ON COLUMN auth.users.phone_verified_at IS 'Timestamp when phone was verified'; +COMMENT ON COLUMN auth.users.last_login_at IS 'Timestamp of last successful login'; +COMMENT ON COLUMN auth.users.last_login_ip IS 'IP address of last successful login'; +COMMENT ON COLUMN auth.users.failed_login_attempts IS 'Counter for failed login attempts'; +COMMENT ON COLUMN auth.users.locked_until IS 'Account locked until this timestamp (null if not locked)'; +COMMENT ON COLUMN auth.users.suspended_at IS 'Timestamp when account was suspended'; +COMMENT ON COLUMN auth.users.suspended_reason IS 'Reason for account suspension'; +COMMENT ON COLUMN auth.users.deactivated_at IS 'Timestamp when account was deactivated'; +COMMENT ON COLUMN auth.users.created_at IS 'Timestamp when user was created'; +COMMENT ON COLUMN auth.users.updated_at IS 'Timestamp when user was last updated'; +COMMENT ON COLUMN auth.users.created_by_id IS 'ID of user who created this record'; +COMMENT ON COLUMN auth.users.updated_by_id IS 'ID of user who last updated this record'; diff --git a/ddl/schemas/auth/tables/02-user_profiles.sql b/ddl/schemas/auth/tables/02-user_profiles.sql new file mode 100644 index 0000000..2092a4a --- /dev/null +++ b/ddl/schemas/auth/tables/02-user_profiles.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/02-user_profiles.sql +-- Description: Extended user profile information +-- ============================================================================ + +CREATE TABLE auth.user_profiles ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL UNIQUE, + + -- Personal Information + first_name VARCHAR(100), + last_name VARCHAR(100), + display_name VARCHAR(200), + avatar_url TEXT, + bio TEXT, + + -- Localization + language VARCHAR(10) DEFAULT 'en', + timezone VARCHAR(50) DEFAULT 'UTC', + country_code VARCHAR(2), + + -- Preferences + newsletter_subscribed BOOLEAN NOT NULL DEFAULT false, + marketing_emails_enabled BOOLEAN NOT NULL DEFAULT false, + notifications_enabled BOOLEAN NOT NULL DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_user_profiles_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE +); + +-- Indexes for Performance +CREATE INDEX idx_user_profiles_user_id ON auth.user_profiles(user_id); +CREATE INDEX idx_user_profiles_display_name ON auth.user_profiles(display_name); +CREATE INDEX idx_user_profiles_country ON auth.user_profiles(country_code); +CREATE INDEX idx_user_profiles_metadata ON auth.user_profiles USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.user_profiles IS 'Extended user profile information and preferences'; + +-- Column Comments +COMMENT ON COLUMN auth.user_profiles.id IS 'Unique identifier for the profile'; +COMMENT ON COLUMN auth.user_profiles.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.user_profiles.first_name IS 'User first name'; +COMMENT ON COLUMN auth.user_profiles.last_name IS 'User last name'; +COMMENT ON COLUMN auth.user_profiles.display_name IS 'Public display name'; +COMMENT ON COLUMN auth.user_profiles.avatar_url IS 'URL to user avatar image'; +COMMENT ON COLUMN auth.user_profiles.bio IS 'User biography or description'; +COMMENT ON COLUMN auth.user_profiles.language IS 'Preferred language (ISO 639-1 code)'; +COMMENT ON COLUMN auth.user_profiles.timezone IS 'User timezone (IANA timezone database)'; +COMMENT ON COLUMN auth.user_profiles.country_code IS 'Country code (ISO 3166-1 alpha-2)'; +COMMENT ON COLUMN auth.user_profiles.newsletter_subscribed IS 'Newsletter subscription preference'; +COMMENT ON COLUMN auth.user_profiles.marketing_emails_enabled IS 'Marketing emails preference'; +COMMENT ON COLUMN auth.user_profiles.notifications_enabled IS 'In-app notifications preference'; +COMMENT ON COLUMN auth.user_profiles.metadata IS 'Additional profile metadata as JSON'; +COMMENT ON COLUMN auth.user_profiles.created_at IS 'Timestamp when profile was created'; +COMMENT ON COLUMN auth.user_profiles.updated_at IS 'Timestamp when profile was last updated'; diff --git a/ddl/schemas/auth/tables/03-oauth_accounts.sql b/ddl/schemas/auth/tables/03-oauth_accounts.sql new file mode 100644 index 0000000..75fdba4 --- /dev/null +++ b/ddl/schemas/auth/tables/03-oauth_accounts.sql @@ -0,0 +1,69 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/03-oauth_accounts.sql +-- Description: OAuth provider accounts linked to users +-- ============================================================================ + +CREATE TABLE auth.oauth_accounts ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- OAuth Provider Information + provider auth.oauth_provider NOT NULL, + provider_account_id VARCHAR(255) NOT NULL, + provider_email CITEXT, + + -- OAuth Tokens + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + + -- Provider Profile Data + profile_data JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + linked_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_oauth_accounts_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Unique Constraint: One provider account per user + CONSTRAINT unique_user_provider UNIQUE (user_id, provider), + + -- Unique Constraint: Provider account can only link to one user + CONSTRAINT unique_provider_account UNIQUE (provider, provider_account_id) +); + +-- Indexes for Performance +CREATE INDEX idx_oauth_accounts_user_id ON auth.oauth_accounts(user_id); +CREATE INDEX idx_oauth_accounts_provider ON auth.oauth_accounts(provider); +CREATE INDEX idx_oauth_accounts_provider_email ON auth.oauth_accounts(provider_email); +CREATE INDEX idx_oauth_accounts_last_used ON auth.oauth_accounts(last_used_at DESC); +CREATE INDEX idx_oauth_accounts_profile_data ON auth.oauth_accounts USING gin(profile_data); + +-- Table Comments +COMMENT ON TABLE auth.oauth_accounts IS 'OAuth provider accounts linked to users for social authentication'; + +-- Column Comments +COMMENT ON COLUMN auth.oauth_accounts.id IS 'Unique identifier for the OAuth account'; +COMMENT ON COLUMN auth.oauth_accounts.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.oauth_accounts.provider IS 'OAuth provider (google, facebook, etc.)'; +COMMENT ON COLUMN auth.oauth_accounts.provider_account_id IS 'User ID from the OAuth provider'; +COMMENT ON COLUMN auth.oauth_accounts.provider_email IS 'Email address from OAuth provider'; +COMMENT ON COLUMN auth.oauth_accounts.access_token IS 'OAuth access token (encrypted)'; +COMMENT ON COLUMN auth.oauth_accounts.refresh_token IS 'OAuth refresh token (encrypted)'; +COMMENT ON COLUMN auth.oauth_accounts.token_expires_at IS 'Access token expiration timestamp'; +COMMENT ON COLUMN auth.oauth_accounts.profile_data IS 'Profile data from OAuth provider as JSON'; +COMMENT ON COLUMN auth.oauth_accounts.linked_at IS 'Timestamp when account was linked'; +COMMENT ON COLUMN auth.oauth_accounts.last_used_at IS 'Timestamp when last used for authentication'; +COMMENT ON COLUMN auth.oauth_accounts.created_at IS 'Timestamp when record was created'; +COMMENT ON COLUMN auth.oauth_accounts.updated_at IS 'Timestamp when record was last updated'; diff --git a/ddl/schemas/auth/tables/04-sessions.sql b/ddl/schemas/auth/tables/04-sessions.sql new file mode 100644 index 0000000..b7d61de --- /dev/null +++ b/ddl/schemas/auth/tables/04-sessions.sql @@ -0,0 +1,87 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/04-sessions.sql +-- Description: User session management for authentication +-- ============================================================================ + +CREATE TABLE auth.sessions ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Session Token + session_token VARCHAR(255) NOT NULL UNIQUE, + + -- Session Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Session Metadata + ip_address INET, + user_agent TEXT, + device_type VARCHAR(50), + device_name VARCHAR(100), + browser VARCHAR(50), + os VARCHAR(50), + + -- Geolocation + country_code VARCHAR(2), + city VARCHAR(100), + + -- Security + last_activity_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + invalidated_at TIMESTAMPTZ, + invalidation_reason VARCHAR(100), + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_session_dates CHECK (expires_at > created_at), + CONSTRAINT invalidated_consistency CHECK ( + (is_active = false AND invalidated_at IS NOT NULL) OR + (is_active = true AND invalidated_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_sessions_user_id ON auth.sessions(user_id); +CREATE INDEX idx_sessions_token ON auth.sessions(session_token); +CREATE INDEX idx_sessions_expires_at ON auth.sessions(expires_at); +CREATE INDEX idx_sessions_active ON auth.sessions(is_active, expires_at) WHERE is_active = true; +CREATE INDEX idx_sessions_last_activity ON auth.sessions(last_activity_at DESC); +CREATE INDEX idx_sessions_ip_address ON auth.sessions(ip_address); +CREATE INDEX idx_sessions_user_active ON auth.sessions(user_id, is_active, expires_at) + WHERE is_active = true; + +-- Table Comments +COMMENT ON TABLE auth.sessions IS 'User session management for authentication and activity tracking'; + +-- Column Comments +COMMENT ON COLUMN auth.sessions.id IS 'Unique identifier for the session'; +COMMENT ON COLUMN auth.sessions.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.sessions.session_token IS 'Unique session token for authentication'; +COMMENT ON COLUMN auth.sessions.expires_at IS 'Session expiration timestamp'; +COMMENT ON COLUMN auth.sessions.is_active IS 'Whether the session is currently active'; +COMMENT ON COLUMN auth.sessions.ip_address IS 'IP address of the session'; +COMMENT ON COLUMN auth.sessions.user_agent IS 'User agent string from the browser'; +COMMENT ON COLUMN auth.sessions.device_type IS 'Device type (desktop, mobile, tablet)'; +COMMENT ON COLUMN auth.sessions.device_name IS 'Device name or model'; +COMMENT ON COLUMN auth.sessions.browser IS 'Browser name and version'; +COMMENT ON COLUMN auth.sessions.os IS 'Operating system name and version'; +COMMENT ON COLUMN auth.sessions.country_code IS 'Country code from IP geolocation'; +COMMENT ON COLUMN auth.sessions.city IS 'City from IP geolocation'; +COMMENT ON COLUMN auth.sessions.last_activity_at IS 'Timestamp of last session activity'; +COMMENT ON COLUMN auth.sessions.invalidated_at IS 'Timestamp when session was invalidated'; +COMMENT ON COLUMN auth.sessions.invalidation_reason IS 'Reason for session invalidation'; +COMMENT ON COLUMN auth.sessions.created_at IS 'Timestamp when session was created'; +COMMENT ON COLUMN auth.sessions.updated_at IS 'Timestamp when session was last updated'; diff --git a/ddl/schemas/auth/tables/05-email_verifications.sql b/ddl/schemas/auth/tables/05-email_verifications.sql new file mode 100644 index 0000000..8995898 --- /dev/null +++ b/ddl/schemas/auth/tables/05-email_verifications.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/05-email_verifications.sql +-- Description: Email verification tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.email_verifications ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Email and Token + email CITEXT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + is_verified BOOLEAN NOT NULL DEFAULT false, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_email_verifications_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT verified_consistency CHECK ( + (is_verified = true AND verified_at IS NOT NULL) OR + (is_verified = false AND verified_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_email_verifications_user_id ON auth.email_verifications(user_id); +CREATE INDEX idx_email_verifications_token ON auth.email_verifications(token); +CREATE INDEX idx_email_verifications_email ON auth.email_verifications(email); +CREATE INDEX idx_email_verifications_expires ON auth.email_verifications(expires_at); +CREATE INDEX idx_email_verifications_pending ON auth.email_verifications(user_id, is_verified, expires_at) + WHERE is_verified = false; + +-- Table Comments +COMMENT ON TABLE auth.email_verifications IS 'Email verification tokens and tracking for user email confirmation'; + +-- Column Comments +COMMENT ON COLUMN auth.email_verifications.id IS 'Unique identifier for the verification record'; +COMMENT ON COLUMN auth.email_verifications.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.email_verifications.email IS 'Email address to be verified'; +COMMENT ON COLUMN auth.email_verifications.token IS 'Unique verification token sent to email'; +COMMENT ON COLUMN auth.email_verifications.expires_at IS 'Token expiration timestamp'; +COMMENT ON COLUMN auth.email_verifications.verified_at IS 'Timestamp when email was verified'; +COMMENT ON COLUMN auth.email_verifications.is_verified IS 'Whether the email has been verified'; +COMMENT ON COLUMN auth.email_verifications.ip_address IS 'IP address when verification was requested'; +COMMENT ON COLUMN auth.email_verifications.user_agent IS 'User agent when verification was requested'; +COMMENT ON COLUMN auth.email_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/ddl/schemas/auth/tables/06-phone_verifications.sql b/ddl/schemas/auth/tables/06-phone_verifications.sql new file mode 100644 index 0000000..e809854 --- /dev/null +++ b/ddl/schemas/auth/tables/06-phone_verifications.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/06-phone_verifications.sql +-- Description: Phone number verification tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.phone_verifications ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Phone and Verification Code + phone_number VARCHAR(20) NOT NULL, + verification_code VARCHAR(10) NOT NULL, + channel auth.phone_channel NOT NULL DEFAULT 'sms', + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + is_verified BOOLEAN NOT NULL DEFAULT false, + + -- Attempt Tracking + send_attempts INTEGER NOT NULL DEFAULT 0, + verification_attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_phone_verifications_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT verified_consistency CHECK ( + (is_verified = true AND verified_at IS NOT NULL) OR + (is_verified = false AND verified_at IS NULL) + ), + CONSTRAINT valid_attempts CHECK ( + send_attempts >= 0 AND + verification_attempts >= 0 AND + verification_attempts <= max_attempts + ) +); + +-- Indexes for Performance +CREATE INDEX idx_phone_verifications_user_id ON auth.phone_verifications(user_id); +CREATE INDEX idx_phone_verifications_phone ON auth.phone_verifications(phone_number); +CREATE INDEX idx_phone_verifications_expires ON auth.phone_verifications(expires_at); +CREATE INDEX idx_phone_verifications_pending ON auth.phone_verifications(user_id, is_verified, expires_at) + WHERE is_verified = false; + +-- Table Comments +COMMENT ON TABLE auth.phone_verifications IS 'Phone number verification codes and tracking for user phone confirmation'; + +-- Column Comments +COMMENT ON COLUMN auth.phone_verifications.id IS 'Unique identifier for the verification record'; +COMMENT ON COLUMN auth.phone_verifications.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.phone_verifications.phone_number IS 'Phone number to be verified'; +COMMENT ON COLUMN auth.phone_verifications.verification_code IS 'Verification code sent via SMS'; +COMMENT ON COLUMN auth.phone_verifications.expires_at IS 'Code expiration timestamp'; +COMMENT ON COLUMN auth.phone_verifications.verified_at IS 'Timestamp when phone was verified'; +COMMENT ON COLUMN auth.phone_verifications.is_verified IS 'Whether the phone has been verified'; +COMMENT ON COLUMN auth.phone_verifications.send_attempts IS 'Number of times code was sent'; +COMMENT ON COLUMN auth.phone_verifications.verification_attempts IS 'Number of verification attempts'; +COMMENT ON COLUMN auth.phone_verifications.max_attempts IS 'Maximum allowed verification attempts'; +COMMENT ON COLUMN auth.phone_verifications.ip_address IS 'IP address when verification was requested'; +COMMENT ON COLUMN auth.phone_verifications.user_agent IS 'User agent when verification was requested'; +COMMENT ON COLUMN auth.phone_verifications.created_at IS 'Timestamp when verification was created'; diff --git a/ddl/schemas/auth/tables/07-password_reset_tokens.sql b/ddl/schemas/auth/tables/07-password_reset_tokens.sql new file mode 100644 index 0000000..b18b2cb --- /dev/null +++ b/ddl/schemas/auth/tables/07-password_reset_tokens.sql @@ -0,0 +1,65 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/07-password_reset_tokens.sql +-- Description: Password reset tokens and tracking +-- ============================================================================ + +CREATE TABLE auth.password_reset_tokens ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Foreign Key to Users + user_id UUID NOT NULL, + + -- Email and Token + email CITEXT NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + + -- Token Lifecycle + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + is_used BOOLEAN NOT NULL DEFAULT false, + + -- Metadata + ip_address INET, + user_agent TEXT, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Foreign Key Constraints + CONSTRAINT fk_password_reset_tokens_user FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE CASCADE, + + -- Check Constraints + CONSTRAINT valid_expiration CHECK (expires_at > created_at), + CONSTRAINT used_consistency CHECK ( + (is_used = true AND used_at IS NOT NULL) OR + (is_used = false AND used_at IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_password_reset_tokens_user_id ON auth.password_reset_tokens(user_id); +CREATE INDEX idx_password_reset_tokens_token ON auth.password_reset_tokens(token); +CREATE INDEX idx_password_reset_tokens_email ON auth.password_reset_tokens(email); +CREATE INDEX idx_password_reset_tokens_expires ON auth.password_reset_tokens(expires_at); +CREATE INDEX idx_password_reset_tokens_active ON auth.password_reset_tokens(user_id, is_used, expires_at) + WHERE is_used = false; + +-- Table Comments +COMMENT ON TABLE auth.password_reset_tokens IS 'Password reset tokens for secure password recovery'; + +-- Column Comments +COMMENT ON COLUMN auth.password_reset_tokens.id IS 'Unique identifier for the reset token'; +COMMENT ON COLUMN auth.password_reset_tokens.user_id IS 'Reference to the user account'; +COMMENT ON COLUMN auth.password_reset_tokens.email IS 'Email address for password reset'; +COMMENT ON COLUMN auth.password_reset_tokens.token IS 'Unique reset token sent to email'; +COMMENT ON COLUMN auth.password_reset_tokens.expires_at IS 'Token expiration timestamp'; +COMMENT ON COLUMN auth.password_reset_tokens.used_at IS 'Timestamp when token was used'; +COMMENT ON COLUMN auth.password_reset_tokens.is_used IS 'Whether the token has been used'; +COMMENT ON COLUMN auth.password_reset_tokens.ip_address IS 'IP address when reset was requested'; +COMMENT ON COLUMN auth.password_reset_tokens.user_agent IS 'User agent when reset was requested'; +COMMENT ON COLUMN auth.password_reset_tokens.created_at IS 'Timestamp when token was created'; diff --git a/ddl/schemas/auth/tables/08-auth_logs.sql b/ddl/schemas/auth/tables/08-auth_logs.sql new file mode 100644 index 0000000..810c96c --- /dev/null +++ b/ddl/schemas/auth/tables/08-auth_logs.sql @@ -0,0 +1,74 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/08-auth_logs.sql +-- Description: Authentication event audit logging with optional partitioning +-- ============================================================================ + +CREATE TABLE auth.auth_logs ( + -- Primary Key + id UUID DEFAULT gen_random_uuid(), + + -- Event Information + event_type auth.auth_event_type NOT NULL, + user_id UUID, + email CITEXT, + + -- Request Context + ip_address INET, + user_agent TEXT, + session_id UUID, + + -- Event Details + success BOOLEAN NOT NULL DEFAULT false, + failure_reason VARCHAR(255), + + -- Additional Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Timestamp (partition key) + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Primary Key includes partition key for partitioned tables + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); + +-- Create initial partitions for current and next month +-- These should be created dynamically by a maintenance job in production + +-- Current month partition +CREATE TABLE auth.auth_logs_current PARTITION OF auth.auth_logs + FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE)) + TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')); + +-- Next month partition +CREATE TABLE auth.auth_logs_next PARTITION OF auth.auth_logs + FOR VALUES FROM (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '1 month')) + TO (DATE_TRUNC('month', CURRENT_DATE + INTERVAL '2 months')); + +-- Indexes for Performance (will be inherited by partitions) +CREATE INDEX idx_auth_logs_user_id ON auth.auth_logs(user_id, created_at DESC); +CREATE INDEX idx_auth_logs_email ON auth.auth_logs(email, created_at DESC); +CREATE INDEX idx_auth_logs_event_type ON auth.auth_logs(event_type, created_at DESC); +CREATE INDEX idx_auth_logs_ip_address ON auth.auth_logs(ip_address, created_at DESC); +CREATE INDEX idx_auth_logs_session_id ON auth.auth_logs(session_id); +CREATE INDEX idx_auth_logs_created_at ON auth.auth_logs(created_at DESC); +CREATE INDEX idx_auth_logs_failures ON auth.auth_logs(user_id, created_at DESC) + WHERE success = false; +CREATE INDEX idx_auth_logs_metadata ON auth.auth_logs USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.auth_logs IS 'Authentication event audit logging with monthly partitioning for performance'; + +-- Column Comments +COMMENT ON COLUMN auth.auth_logs.id IS 'Unique identifier for the log entry'; +COMMENT ON COLUMN auth.auth_logs.event_type IS 'Type of authentication event'; +COMMENT ON COLUMN auth.auth_logs.user_id IS 'Reference to the user (null for failed logins)'; +COMMENT ON COLUMN auth.auth_logs.email IS 'Email address associated with the event'; +COMMENT ON COLUMN auth.auth_logs.ip_address IS 'IP address of the request'; +COMMENT ON COLUMN auth.auth_logs.user_agent IS 'User agent string from the request'; +COMMENT ON COLUMN auth.auth_logs.session_id IS 'Session ID if applicable'; +COMMENT ON COLUMN auth.auth_logs.success IS 'Whether the event was successful'; +COMMENT ON COLUMN auth.auth_logs.failure_reason IS 'Reason for failure if applicable'; +COMMENT ON COLUMN auth.auth_logs.metadata IS 'Additional event metadata as JSON'; +COMMENT ON COLUMN auth.auth_logs.created_at IS 'Timestamp when event occurred (partition key)'; diff --git a/ddl/schemas/auth/tables/09-login_attempts.sql b/ddl/schemas/auth/tables/09-login_attempts.sql new file mode 100644 index 0000000..bb4d9e3 --- /dev/null +++ b/ddl/schemas/auth/tables/09-login_attempts.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/09-login_attempts.sql +-- Description: Login attempt tracking for rate limiting and security monitoring +-- ============================================================================ + +CREATE TABLE auth.login_attempts ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Attempt Information + email CITEXT, + user_id UUID, + + -- Request Context + ip_address INET NOT NULL, + user_agent TEXT, + + -- Attempt Result + success BOOLEAN NOT NULL, + failure_reason VARCHAR(100), + + -- Additional Details + attempted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Check Constraints + CONSTRAINT login_attempt_has_identifier CHECK ( + email IS NOT NULL OR user_id IS NOT NULL + ), + CONSTRAINT failure_reason_consistency CHECK ( + (success = false AND failure_reason IS NOT NULL) OR + (success = true AND failure_reason IS NULL) + ) +); + +-- Indexes for Performance +CREATE INDEX idx_login_attempts_email ON auth.login_attempts(email, created_at DESC); +CREATE INDEX idx_login_attempts_user_id ON auth.login_attempts(user_id, created_at DESC); +CREATE INDEX idx_login_attempts_ip ON auth.login_attempts(ip_address, created_at DESC); +CREATE INDEX idx_login_attempts_created ON auth.login_attempts(created_at DESC); +CREATE INDEX idx_login_attempts_failures ON auth.login_attempts(email, ip_address, created_at DESC) + WHERE success = false; +CREATE INDEX idx_login_attempts_success ON auth.login_attempts(email, created_at DESC) + WHERE success = true; +CREATE INDEX idx_login_attempts_metadata ON auth.login_attempts USING gin(metadata); + +-- Table Comments +COMMENT ON TABLE auth.login_attempts IS 'Login attempt tracking for rate limiting, brute force detection, and security monitoring'; + +-- Column Comments +COMMENT ON COLUMN auth.login_attempts.id IS 'Unique identifier for the login attempt'; +COMMENT ON COLUMN auth.login_attempts.email IS 'Email address used in login attempt'; +COMMENT ON COLUMN auth.login_attempts.user_id IS 'User ID if resolved from email'; +COMMENT ON COLUMN auth.login_attempts.ip_address IS 'IP address of the login attempt'; +COMMENT ON COLUMN auth.login_attempts.user_agent IS 'User agent string from the request'; +COMMENT ON COLUMN auth.login_attempts.success IS 'Whether the login attempt was successful'; +COMMENT ON COLUMN auth.login_attempts.failure_reason IS 'Reason for login failure (invalid_password, account_locked, etc.)'; +COMMENT ON COLUMN auth.login_attempts.attempted_at IS 'Timestamp when login was attempted'; +COMMENT ON COLUMN auth.login_attempts.metadata IS 'Additional attempt metadata as JSON'; +COMMENT ON COLUMN auth.login_attempts.created_at IS 'Timestamp when record was created'; diff --git a/ddl/schemas/auth/tables/10-rate_limiting_config.sql b/ddl/schemas/auth/tables/10-rate_limiting_config.sql new file mode 100644 index 0000000..b70162c --- /dev/null +++ b/ddl/schemas/auth/tables/10-rate_limiting_config.sql @@ -0,0 +1,82 @@ +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Schema: auth +-- File: tables/10-rate_limiting_config.sql +-- Description: Rate limiting configuration for API endpoints and auth operations +-- ============================================================================ + +CREATE TABLE auth.rate_limiting_config ( + -- Primary Key + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Endpoint Configuration + endpoint VARCHAR(200) NOT NULL UNIQUE, + description TEXT, + + -- Rate Limiting Parameters + max_requests INTEGER NOT NULL DEFAULT 100, + window_seconds INTEGER NOT NULL DEFAULT 60, + block_duration_seconds INTEGER DEFAULT 300, + + -- Scope Configuration + scope VARCHAR(50) NOT NULL DEFAULT 'ip', + + -- Status + is_active BOOLEAN NOT NULL DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}'::jsonb, + + -- Audit Fields + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by_id UUID, + updated_by_id UUID, + + -- Check Constraints + CONSTRAINT valid_rate_limits CHECK ( + max_requests > 0 AND + window_seconds > 0 AND + (block_duration_seconds IS NULL OR block_duration_seconds > 0) + ), + CONSTRAINT valid_scope CHECK ( + scope IN ('ip', 'user', 'email', 'global') + ) +); + +-- Indexes for Performance +CREATE INDEX idx_rate_limiting_endpoint ON auth.rate_limiting_config(endpoint); +CREATE INDEX idx_rate_limiting_active ON auth.rate_limiting_config(is_active) WHERE is_active = true; +CREATE INDEX idx_rate_limiting_scope ON auth.rate_limiting_config(scope); +CREATE INDEX idx_rate_limiting_metadata ON auth.rate_limiting_config USING gin(metadata); + +-- Insert Default Rate Limiting Rules +INSERT INTO auth.rate_limiting_config (endpoint, description, max_requests, window_seconds, block_duration_seconds, scope) VALUES + ('/auth/login', 'Login endpoint rate limit', 5, 300, 900, 'ip'), + ('/auth/register', 'Registration endpoint rate limit', 3, 3600, 1800, 'ip'), + ('/auth/password-reset/request', 'Password reset request limit', 3, 3600, 1800, 'email'), + ('/auth/password-reset/verify', 'Password reset verification limit', 5, 300, 900, 'ip'), + ('/auth/verify-email', 'Email verification limit', 10, 3600, 1800, 'user'), + ('/auth/verify-phone', 'Phone verification limit', 5, 3600, 1800, 'user'), + ('/auth/refresh-token', 'Token refresh limit', 20, 300, 600, 'user'), + ('/auth/logout', 'Logout endpoint limit', 10, 60, NULL, 'user'), + ('/auth/mfa/enable', 'MFA enable limit', 5, 3600, NULL, 'user'), + ('/auth/mfa/verify', 'MFA verification limit', 5, 300, 900, 'user'); + +-- Table Comments +COMMENT ON TABLE auth.rate_limiting_config IS 'Rate limiting configuration for API endpoints to prevent abuse and brute force attacks'; + +-- Column Comments +COMMENT ON COLUMN auth.rate_limiting_config.id IS 'Unique identifier for the configuration'; +COMMENT ON COLUMN auth.rate_limiting_config.endpoint IS 'API endpoint path to rate limit'; +COMMENT ON COLUMN auth.rate_limiting_config.description IS 'Description of the rate limit purpose'; +COMMENT ON COLUMN auth.rate_limiting_config.max_requests IS 'Maximum requests allowed within the time window'; +COMMENT ON COLUMN auth.rate_limiting_config.window_seconds IS 'Time window in seconds for rate limiting'; +COMMENT ON COLUMN auth.rate_limiting_config.block_duration_seconds IS 'Duration to block after exceeding limit (null for no block)'; +COMMENT ON COLUMN auth.rate_limiting_config.scope IS 'Scope of rate limit (ip, user, email, global)'; +COMMENT ON COLUMN auth.rate_limiting_config.is_active IS 'Whether this rate limit is currently active'; +COMMENT ON COLUMN auth.rate_limiting_config.metadata IS 'Additional configuration metadata as JSON'; +COMMENT ON COLUMN auth.rate_limiting_config.created_at IS 'Timestamp when configuration was created'; +COMMENT ON COLUMN auth.rate_limiting_config.updated_at IS 'Timestamp when configuration was last updated'; +COMMENT ON COLUMN auth.rate_limiting_config.created_by_id IS 'ID of user who created this configuration'; +COMMENT ON COLUMN auth.rate_limiting_config.updated_by_id IS 'ID of user who last updated this configuration'; diff --git a/ddl/schemas/education/00-enums.sql b/ddl/schemas/education/00-enums.sql new file mode 100644 index 0000000..e235aa8 --- /dev/null +++ b/ddl/schemas/education/00-enums.sql @@ -0,0 +1,64 @@ +-- ===================================================== +-- ENUMS - Schema Education +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- PostgreSQL: 15+ +-- ===================================================== + +-- Nivel de dificultad +CREATE TYPE education.difficulty_level AS ENUM ( + 'beginner', + 'intermediate', + 'advanced', + 'expert' +); + +-- Estado de curso +CREATE TYPE education.course_status AS ENUM ( + 'draft', + 'published', + 'archived' +); + +-- Estado de enrollment +CREATE TYPE education.enrollment_status AS ENUM ( + 'active', + 'completed', + 'expired', + 'cancelled' +); + +-- Tipo de contenido de lección +CREATE TYPE education.lesson_content_type AS ENUM ( + 'video', + 'article', + 'interactive', + 'quiz' +); + +-- Tipo de pregunta de quiz +CREATE TYPE education.question_type AS ENUM ( + 'multiple_choice', + 'true_false', + 'multiple_select', + 'fill_blank', + 'code_challenge' +); + +-- Tipo de logro/badge +CREATE TYPE education.achievement_type AS ENUM ( + 'course_completion', + 'quiz_perfect_score', + 'streak_milestone', + 'level_up', + 'special_event' +); + +COMMENT ON TYPE education.difficulty_level IS 'Nivel de dificultad de cursos'; +COMMENT ON TYPE education.course_status IS 'Estado del curso (draft, published, archived)'; +COMMENT ON TYPE education.enrollment_status IS 'Estado de la inscripción del usuario'; +COMMENT ON TYPE education.lesson_content_type IS 'Tipo de contenido de la lección'; +COMMENT ON TYPE education.question_type IS 'Tipo de pregunta en quizzes'; +COMMENT ON TYPE education.achievement_type IS 'Tipo de logro/badge'; diff --git a/ddl/schemas/education/README.md b/ddl/schemas/education/README.md new file mode 100644 index 0000000..235a4cf --- /dev/null +++ b/ddl/schemas/education/README.md @@ -0,0 +1,353 @@ +# Schema: education + +**Proyecto:** OrbiQuant IA (Trading Platform) +**Módulo:** OQI-002 - Education +**Especificación:** ET-EDU-001-database.md +**PostgreSQL:** 15+ + +--- + +## Descripción + +Schema completo para el módulo educativo de OrbiQuant IA, implementando: +- Gestión de cursos, módulos y lecciones +- Sistema de enrollments y progreso de estudiantes +- Quizzes y evaluaciones +- Certificados de finalización +- Sistema de gamificación (XP, niveles, streaks, achievements) +- Reviews de cursos +- Activity logging + +--- + +## Estructura de Archivos + +``` +education/ +├── 00-enums.sql # Tipos ENUM +├── tables/ +│ ├── 01-categories.sql # Categorías de cursos +│ ├── 02-courses.sql # Cursos +│ ├── 03-modules.sql # Módulos del curso +│ ├── 04-lessons.sql # Lecciones +│ ├── 05-enrollments.sql # Inscripciones de usuarios +│ ├── 06-progress.sql # Progreso en lecciones +│ ├── 07-quizzes.sql # Quizzes/evaluaciones +│ ├── 08-quiz_questions.sql # Preguntas de quiz +│ ├── 09-quiz_attempts.sql # Intentos de quiz +│ ├── 10-certificates.sql # Certificados +│ ├── 11-user_achievements.sql # Logros/badges +│ ├── 12-user_gamification_profile.sql # Perfil de gamificación +│ ├── 13-user_activity_log.sql # Log de actividades +│ └── 14-course_reviews.sql # Reviews de cursos +└── functions/ + ├── 01-update_updated_at.sql # Trigger updated_at + ├── 02-update_enrollment_progress.sql # Actualizar progreso + ├── 03-auto_complete_enrollment.sql # Auto-completar enrollment + ├── 04-generate_certificate.sql # Generar certificados + ├── 05-update_course_stats.sql # Actualizar estadísticas + ├── 06-update_enrollment_count.sql # Contador de enrollments + ├── 07-update_gamification_profile.sql # Sistema de gamificación + └── 08-views.sql # Vistas útiles +``` + +--- + +## Orden de Ejecución + +Para crear el schema completo, ejecutar en este orden: + +```bash +# 1. ENUMs +psql -f 00-enums.sql + +# 2. Tablas (en orden de dependencias) +psql -f tables/01-categories.sql +psql -f tables/02-courses.sql +psql -f tables/03-modules.sql +psql -f tables/04-lessons.sql +psql -f tables/05-enrollments.sql +psql -f tables/06-progress.sql +psql -f tables/07-quizzes.sql +psql -f tables/08-quiz_questions.sql +psql -f tables/09-quiz_attempts.sql +psql -f tables/10-certificates.sql +psql -f tables/11-user_achievements.sql +psql -f tables/12-user_gamification_profile.sql +psql -f tables/13-user_activity_log.sql +psql -f tables/14-course_reviews.sql + +# 3. Funciones y Triggers +psql -f functions/01-update_updated_at.sql +psql -f functions/02-update_enrollment_progress.sql +psql -f functions/03-auto_complete_enrollment.sql +psql -f functions/04-generate_certificate.sql +psql -f functions/05-update_course_stats.sql +psql -f functions/06-update_enrollment_count.sql +psql -f functions/07-update_gamification_profile.sql +psql -f functions/08-views.sql +``` + +--- + +## Tablas + +### Principales + +| Tabla | Descripción | +|-------|-------------| +| `categories` | Categorías de cursos con soporte para jerarquía | +| `courses` | Cursos del módulo educativo | +| `modules` | Módulos que agrupan lecciones | +| `lessons` | Lecciones individuales (video, artículo, interactivo) | +| `enrollments` | Inscripciones de usuarios a cursos | +| `progress` | Progreso del usuario en cada lección | + +### Evaluación + +| Tabla | Descripción | +|-------|-------------| +| `quizzes` | Quizzes/evaluaciones | +| `quiz_questions` | Preguntas de los quizzes | +| `quiz_attempts` | Intentos de usuarios en quizzes | + +### Logros + +| Tabla | Descripción | +|-------|-------------| +| `certificates` | Certificados de finalización | +| `user_achievements` | Logros/badges obtenidos | + +### Gamificación + +| Tabla | Descripción | +|-------|-------------| +| `user_gamification_profile` | XP, niveles, streaks, estadísticas | +| `user_activity_log` | Log de todas las actividades | +| `course_reviews` | Reviews y calificaciones de cursos | + +--- + +## ENUMs + +- `difficulty_level`: beginner, intermediate, advanced, expert +- `course_status`: draft, published, archived +- `enrollment_status`: active, completed, expired, cancelled +- `lesson_content_type`: video, article, interactive, quiz +- `question_type`: multiple_choice, true_false, multiple_select, fill_blank, code_challenge +- `achievement_type`: course_completion, quiz_perfect_score, streak_milestone, level_up, special_event + +--- + +## Funciones Principales + +### Gamificación + +```sql +-- Actualizar XP del usuario +SELECT education.update_user_xp( + 'user-uuid', -- user_id + 100 -- xp_to_add +); + +-- Actualizar streak del usuario +SELECT education.update_user_streak('user-uuid'); +``` + +### Triggers Automáticos + +- `updated_at`: Se actualiza automáticamente en todas las tablas +- `update_enrollment_progress()`: Calcula progreso al completar lecciones +- `auto_complete_enrollment()`: Completa enrollment al alcanzar 100% +- `generate_certificate_number()`: Genera número único de certificado +- `update_course_rating_stats()`: Actualiza rating promedio del curso +- `update_enrollment_count()`: Actualiza contador de enrollments +- `update_streak_on_activity()`: Actualiza streak en cada actividad + +--- + +## Vistas + +| Vista | Descripción | +|-------|-------------| +| `v_courses_with_stats` | Cursos con estadísticas agregadas | +| `v_user_course_progress` | Progreso del usuario por curso | +| `v_leaderboard_weekly` | Top 100 usuarios por XP semanal | +| `v_leaderboard_monthly` | Top 100 usuarios por XP mensual | +| `v_leaderboard_alltime` | Top 100 usuarios por XP total | +| `v_user_statistics` | Estadísticas completas del usuario | +| `v_popular_courses` | Top 50 cursos más populares | + +--- + +## Dependencias + +### Schemas externos +- `auth.users` - Tabla de usuarios (requerida) + +### Extensions +- `gen_random_uuid()` - Built-in en PostgreSQL 13+ + +--- + +## Políticas de Seguridad (RLS) + +Para habilitar Row Level Security (implementar según necesidad): + +```sql +-- Habilitar RLS +ALTER TABLE education.enrollments ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.quiz_attempts ENABLE ROW LEVEL SECURITY; +ALTER TABLE education.certificates ENABLE ROW LEVEL SECURITY; + +-- Política: usuarios solo ven sus propios datos +CREATE POLICY user_own_data ON education.enrollments + FOR ALL + USING (user_id = current_setting('app.user_id')::UUID); +``` + +--- + +## Ejemplos de Uso + +### Enrollar usuario a un curso + +```sql +INSERT INTO education.enrollments (user_id, course_id) +VALUES ('user-uuid', 'course-uuid') +RETURNING *; +``` + +### Registrar progreso en lección + +```sql +INSERT INTO education.progress ( + user_id, + lesson_id, + enrollment_id, + is_completed, + watch_percentage +) VALUES ( + 'user-uuid', + 'lesson-uuid', + 'enrollment-uuid', + true, + 100.00 +); +-- Esto automáticamente actualizará el enrollment progress +``` + +### Completar quiz + +```sql +INSERT INTO education.quiz_attempts ( + user_id, + quiz_id, + enrollment_id, + is_completed, + is_passed, + user_answers, + score_percentage, + xp_earned +) VALUES ( + 'user-uuid', + 'quiz-uuid', + 'enrollment-uuid', + true, + true, + '[{"questionId": "q1", "answer": "A", "isCorrect": true}]'::jsonb, + 85.00, + 50 +); +``` + +### Emitir certificado + +```sql +INSERT INTO education.certificates ( + user_id, + course_id, + enrollment_id, + user_name, + course_title, + completion_date, + final_score +) VALUES ( + 'user-uuid', + 'course-uuid', + 'enrollment-uuid', + 'John Doe', + 'Introducción al Trading', + CURRENT_DATE, + 92.50 +); +-- El número de certificado y código de verificación se generan automáticamente +``` + +### Agregar review a curso + +```sql +INSERT INTO education.course_reviews ( + user_id, + course_id, + enrollment_id, + rating, + title, + content +) VALUES ( + 'user-uuid', + 'course-uuid', + 'enrollment-uuid', + 5, + 'Excelente curso', + 'Muy bien explicado y con ejemplos prácticos' +); +``` + +--- + +## Notas Importantes + +1. **Referencias**: Todas las FKs a usuarios usan `auth.users(id)` +2. **Cascadas**: Las eliminaciones en CASCADE están definidas donde corresponde +3. **Índices**: Creados para optimizar queries frecuentes +4. **Constraints**: Validaciones de lógica de negocio implementadas +5. **JSONB**: Usado para metadata flexible (attachments, user_answers, etc.) +6. **Denormalización**: Algunas estadísticas están denormalizadas para performance + +--- + +## Mantenimiento + +### Resetear XP semanal/mensual + +```sql +-- Resetear XP semanal (ejecutar cada lunes) +UPDATE education.user_gamification_profile SET weekly_xp = 0; + +-- Resetear XP mensual (ejecutar el 1ro de cada mes) +UPDATE education.user_gamification_profile SET monthly_xp = 0; +``` + +### Recalcular estadísticas de curso + +```sql +-- Recalcular total de módulos y lecciones +UPDATE education.courses c +SET + total_modules = (SELECT COUNT(*) FROM education.modules WHERE course_id = c.id), + total_lessons = ( + SELECT COUNT(*) + FROM education.lessons l + JOIN education.modules m ON l.module_id = m.id + WHERE m.course_id = c.id + ); +``` + +--- + +## Versión + +**Versión:** 1.0.0 +**Última actualización:** 2025-12-06 diff --git a/ddl/schemas/education/TECHNICAL.md b/ddl/schemas/education/TECHNICAL.md new file mode 100644 index 0000000..032c91c --- /dev/null +++ b/ddl/schemas/education/TECHNICAL.md @@ -0,0 +1,458 @@ +# Documentación Técnica - Schema Education + +**Proyecto:** OrbiQuant IA (Trading Platform) +**Schema:** education +**PostgreSQL:** 15+ +**Versión:** 1.0.0 + +--- + +## Estadísticas del Schema + +- **ENUMs:** 6 tipos +- **Tablas:** 14 tablas +- **Funciones:** 8 funciones +- **Triggers:** 15+ triggers +- **Vistas:** 7 vistas +- **Índices:** 60+ índices +- **Total líneas SQL:** ~1,350 líneas + +--- + +## Índices por Tabla + +### categories +- `idx_categories_parent` - parent_id +- `idx_categories_slug` - slug +- `idx_categories_active` - is_active (WHERE is_active = true) + +### courses +- `idx_courses_category` - category_id +- `idx_courses_slug` - slug +- `idx_courses_status` - status +- `idx_courses_difficulty` - difficulty_level +- `idx_courses_instructor` - instructor_id +- `idx_courses_published` - published_at (WHERE status = 'published') + +### modules +- `idx_modules_course` - course_id +- `idx_modules_order` - course_id, display_order + +### lessons +- `idx_lessons_module` - module_id +- `idx_lessons_order` - module_id, display_order +- `idx_lessons_type` - content_type +- `idx_lessons_preview` - is_preview (WHERE is_preview = true) + +### enrollments +- `idx_enrollments_user` - user_id +- `idx_enrollments_course` - course_id +- `idx_enrollments_status` - status +- `idx_enrollments_user_active` - user_id, status (WHERE status = 'active') + +### progress +- `idx_progress_user` - user_id +- `idx_progress_lesson` - lesson_id +- `idx_progress_enrollment` - enrollment_id +- `idx_progress_completed` - is_completed (WHERE is_completed = true) +- `idx_progress_user_enrollment` - user_id, enrollment_id + +### quizzes +- `idx_quizzes_module` - module_id +- `idx_quizzes_lesson` - lesson_id +- `idx_quizzes_active` - is_active (WHERE is_active = true) + +### quiz_questions +- `idx_quiz_questions_quiz` - quiz_id +- `idx_quiz_questions_order` - quiz_id, display_order + +### quiz_attempts +- `idx_quiz_attempts_user` - user_id +- `idx_quiz_attempts_quiz` - quiz_id +- `idx_quiz_attempts_enrollment` - enrollment_id +- `idx_quiz_attempts_user_quiz` - user_id, quiz_id +- `idx_quiz_attempts_completed` - is_completed, completed_at + +### certificates +- `idx_certificates_user` - user_id +- `idx_certificates_course` - course_id +- `idx_certificates_number` - certificate_number +- `idx_certificates_verification` - verification_code + +### user_achievements +- `idx_user_achievements_user` - user_id +- `idx_user_achievements_type` - achievement_type +- `idx_user_achievements_earned` - earned_at DESC +- `idx_user_achievements_course` - course_id + +### user_gamification_profile +- `idx_gamification_user` - user_id +- `idx_gamification_level` - current_level DESC +- `idx_gamification_xp` - total_xp DESC +- `idx_gamification_weekly` - weekly_xp DESC +- `idx_gamification_monthly` - monthly_xp DESC + +### user_activity_log +- `idx_activity_user` - user_id +- `idx_activity_type` - activity_type +- `idx_activity_created` - created_at DESC +- `idx_activity_user_date` - user_id, created_at DESC +- `idx_activity_course` - course_id (WHERE course_id IS NOT NULL) + +### course_reviews +- `idx_reviews_course` - course_id +- `idx_reviews_user` - user_id +- `idx_reviews_rating` - rating +- `idx_reviews_approved` - is_approved (WHERE is_approved = true) +- `idx_reviews_featured` - is_featured (WHERE is_featured = true) +- `idx_reviews_helpful` - helpful_votes DESC + +--- + +## Constraints + +### CHECK Constraints + +**categories:** +- `valid_color_format` - Color debe ser formato #RRGGBB + +**courses:** +- `valid_rating` - avg_rating >= 0 AND <= 5 +- `valid_price` - price_usd >= 0 + +**lessons:** +- `video_fields_required` - Si content_type='video', video_url y video_duration_seconds requeridos + +**enrollments:** +- `valid_progress` - progress_percentage >= 0 AND <= 100 +- `valid_completion` - Si status='completed', completed_at y progress=100 requeridos + +**progress:** +- `valid_watch_percentage` - watch_percentage >= 0 AND <= 100 +- `completion_requires_date` - Si is_completed=true, completed_at requerido + +**quizzes:** +- `valid_passing_score` - passing_score_percentage > 0 AND <= 100 +- `quiz_association` - Debe tener module_id O lesson_id (no ambos) + +**quiz_questions:** +- `valid_options` - Si question_type requiere options, options no puede ser NULL + +**quiz_attempts:** +- `valid_score_percentage` - score_percentage >= 0 AND <= 100 + +**user_gamification_profile:** +- `valid_level` - current_level >= 1 +- `valid_xp` - total_xp >= 0 +- `valid_streak` - current_streak_days >= 0 AND longest_streak_days >= 0 +- `valid_avg_score` - average_quiz_score >= 0 AND <= 100 + +**course_reviews:** +- `rating` - rating >= 1 AND <= 5 + +### UNIQUE Constraints + +- `categories.slug` - UNIQUE +- `courses.slug` - UNIQUE +- `modules.unique_course_order` - UNIQUE(course_id, display_order) +- `lessons.unique_module_order` - UNIQUE(module_id, display_order) +- `enrollments.unique_user_course` - UNIQUE(user_id, course_id) +- `progress.unique_user_lesson` - UNIQUE(user_id, lesson_id) +- `certificates.certificate_number` - UNIQUE +- `certificates.verification_code` - UNIQUE +- `certificates.unique_user_course_cert` - UNIQUE(user_id, course_id) +- `user_gamification_profile.unique_user_gamification` - UNIQUE(user_id) +- `course_reviews.unique_user_course_review` - UNIQUE(user_id, course_id) + +--- + +## Foreign Keys + +### Relaciones con auth.users +- `courses.instructor_id` → `auth.users(id)` ON DELETE RESTRICT +- `enrollments.user_id` → `auth.users(id)` ON DELETE CASCADE +- `progress.user_id` → `auth.users(id)` ON DELETE CASCADE +- `quiz_attempts.user_id` → `auth.users(id)` ON DELETE CASCADE +- `certificates.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_achievements.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_gamification_profile.user_id` → `auth.users(id)` ON DELETE CASCADE +- `user_activity_log.user_id` → `auth.users(id)` ON DELETE CASCADE +- `course_reviews.user_id` → `auth.users(id)` ON DELETE CASCADE +- `course_reviews.approved_by` → `auth.users(id)` + +### Relaciones internas +- `categories.parent_id` → `categories(id)` ON DELETE SET NULL +- `courses.category_id` → `categories(id)` ON DELETE RESTRICT +- `modules.course_id` → `courses(id)` ON DELETE CASCADE +- `modules.unlock_after_module_id` → `modules(id)` ON DELETE SET NULL +- `lessons.module_id` → `modules(id)` ON DELETE CASCADE +- `enrollments.course_id` → `courses(id)` ON DELETE RESTRICT +- `progress.lesson_id` → `lessons(id)` ON DELETE CASCADE +- `progress.enrollment_id` → `enrollments(id)` ON DELETE CASCADE +- `quizzes.module_id` → `modules(id)` ON DELETE CASCADE +- `quizzes.lesson_id` → `lessons(id)` ON DELETE CASCADE +- `quiz_questions.quiz_id` → `quizzes(id)` ON DELETE CASCADE +- `quiz_attempts.quiz_id` → `quizzes(id)` ON DELETE RESTRICT +- `quiz_attempts.enrollment_id` → `enrollments(id)` ON DELETE SET NULL +- `certificates.course_id` → `courses(id)` ON DELETE RESTRICT +- `certificates.enrollment_id` → `enrollments(id)` ON DELETE RESTRICT +- `user_achievements.course_id` → `courses(id)` ON DELETE SET NULL +- `user_achievements.quiz_id` → `quizzes(id)` ON DELETE SET NULL +- `user_activity_log.course_id` → `courses(id)` ON DELETE SET NULL +- `user_activity_log.lesson_id` → `lessons(id)` ON DELETE SET NULL +- `user_activity_log.quiz_id` → `quizzes(id)` ON DELETE SET NULL +- `course_reviews.course_id` → `courses(id)` ON DELETE CASCADE +- `course_reviews.enrollment_id` → `enrollments(id)` ON DELETE CASCADE + +--- + +## Triggers + +### Triggers de updated_at +Aplica a: categories, courses, modules, lessons, enrollments, progress, quizzes, quiz_questions, user_gamification_profile, course_reviews + +**Función:** `education.update_updated_at_column()` +**Trigger:** `update_{table}_updated_at` +**Evento:** BEFORE UPDATE +**Acción:** Actualiza `updated_at = NOW()` + +### Triggers de lógica de negocio + +**update_enrollment_on_progress** +- Tabla: progress +- Función: `education.update_enrollment_progress()` +- Evento: AFTER INSERT OR UPDATE +- Condición: WHEN (NEW.is_completed = true) +- Acción: Recalcula progreso del enrollment + +**auto_complete_enrollment_trigger** +- Tabla: enrollments +- Función: `education.auto_complete_enrollment()` +- Evento: BEFORE UPDATE +- Acción: Completa enrollment si progress >= 100% + +**generate_certificate_number_trigger** +- Tabla: certificates +- Función: `education.generate_certificate_number()` +- Evento: BEFORE INSERT +- Acción: Genera certificate_number y verification_code + +**update_course_rating_on_review_insert** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER INSERT +- Acción: Actualiza avg_rating del curso + +**update_course_rating_on_review_update** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER UPDATE +- Condición: rating o is_approved cambió +- Acción: Actualiza avg_rating del curso + +**update_course_rating_on_review_delete** +- Tabla: course_reviews +- Función: `education.update_course_rating_stats()` +- Evento: AFTER DELETE +- Acción: Actualiza avg_rating del curso + +**update_enrollment_count_on_insert** +- Tabla: enrollments +- Función: `education.update_enrollment_count()` +- Evento: AFTER INSERT +- Acción: Incrementa contador en courses + +**update_enrollment_count_on_delete** +- Tabla: enrollments +- Función: `education.update_enrollment_count()` +- Evento: AFTER DELETE +- Acción: Decrementa contador en courses + +**update_streak_on_activity** +- Tabla: user_activity_log +- Función: `education.trigger_update_streak()` +- Evento: AFTER INSERT +- Acción: Actualiza streak del usuario + +--- + +## Funciones Públicas + +### education.update_user_xp(user_id UUID, xp_to_add INTEGER) +Actualiza XP del usuario y recalcula nivel. + +**Parámetros:** +- `user_id`: UUID del usuario +- `xp_to_add`: Cantidad de XP a agregar + +**Lógica:** +- Suma XP al total +- Calcula nuevo nivel basado en fórmula cuadrática +- Actualiza weekly_xp y monthly_xp +- Crea achievement si subió de nivel + +**Ejemplo:** +```sql +SELECT education.update_user_xp( + '00000000-0000-0000-0000-000000000001', + 100 +); +``` + +### education.update_user_streak(user_id UUID) +Actualiza streak del usuario basado en actividad diaria. + +**Parámetros:** +- `user_id`: UUID del usuario + +**Lógica:** +- Verifica última actividad +- Incrementa streak si es día consecutivo +- Resetea streak si se rompió +- Crea achievement en milestones (7, 30, 100 días) + +**Ejemplo:** +```sql +SELECT education.update_user_streak( + '00000000-0000-0000-0000-000000000001' +); +``` + +--- + +## Vistas Materializadas Recomendadas + +Para mejorar performance en queries frecuentes: + +```sql +-- Top cursos por enrollments (actualizar diariamente) +CREATE MATERIALIZED VIEW education.mv_top_courses AS +SELECT * FROM education.v_popular_courses; + +CREATE UNIQUE INDEX ON education.mv_top_courses(id); + +-- Leaderboards (actualizar cada hora) +CREATE MATERIALIZED VIEW education.mv_leaderboard_weekly AS +SELECT * FROM education.v_leaderboard_weekly; + +CREATE UNIQUE INDEX ON education.mv_leaderboard_weekly(user_id); +``` + +--- + +## Optimizaciones Recomendadas + +### 1. Particionamiento de user_activity_log + +Para logs con alto volumen: + +```sql +-- Particionar por mes +CREATE TABLE education.user_activity_log_2025_12 + PARTITION OF education.user_activity_log + FOR VALUES FROM ('2025-12-01') TO ('2026-01-01'); +``` + +### 2. Índices adicionales según uso + +```sql +-- Si hay muchas búsquedas por título de curso +CREATE INDEX idx_courses_title_trgm ON education.courses + USING gin(title gin_trgm_ops); + +-- Requiere extension pg_trgm +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` + +### 3. Vacuum y Analyze automático + +```sql +-- Configurar autovacuum para tablas con alta escritura +ALTER TABLE education.user_activity_log + SET (autovacuum_vacuum_scale_factor = 0.01); + +ALTER TABLE education.progress + SET (autovacuum_vacuum_scale_factor = 0.02); +``` + +--- + +## Monitoreo + +### Queries útiles para monitoreo + +**Tamaño de tablas:** +```sql +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size +FROM pg_tables +WHERE schemaname = 'education' +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; +``` + +**Índices no usados:** +```sql +SELECT + schemaname, + tablename, + indexname, + idx_scan +FROM pg_stat_user_indexes +WHERE schemaname = 'education' AND idx_scan = 0; +``` + +**Actividad de enrollments hoy:** +```sql +SELECT COUNT(*) +FROM education.enrollments +WHERE enrolled_at::date = CURRENT_DATE; +``` + +**Cursos más populares (últimos 7 días):** +```sql +SELECT + c.title, + COUNT(e.id) as new_enrollments +FROM education.courses c +LEFT JOIN education.enrollments e ON c.id = e.course_id + AND e.enrolled_at >= NOW() - INTERVAL '7 days' +GROUP BY c.id, c.title +ORDER BY new_enrollments DESC +LIMIT 10; +``` + +--- + +## Backup y Restore + +### Backup solo del schema education + +```bash +pg_dump -h localhost -U postgres -n education orbiquant > education_backup.sql +``` + +### Restore + +```bash +psql -h localhost -U postgres orbiquant < education_backup.sql +``` + +--- + +## Versión y Changelog + +**v1.0.0** (2025-12-06) +- Implementación inicial completa +- 14 tablas +- 8 funciones +- 7 vistas +- Sistema de gamificación completo +- Reviews de cursos +- Activity logging + +--- + +**Documentación generada:** 2025-12-06 +**Última revisión:** 2025-12-06 diff --git a/ddl/schemas/education/functions/01-update_updated_at.sql b/ddl/schemas/education/functions/01-update_updated_at.sql new file mode 100644 index 0000000..896e37d --- /dev/null +++ b/ddl/schemas/education/functions/01-update_updated_at.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- FUNCTION: education.update_updated_at_column() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza automáticamente el campo updated_at +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_updated_at_column() IS 'Actualiza automáticamente updated_at en cada UPDATE'; + +-- Aplicar trigger a todas las tablas relevantes +CREATE TRIGGER update_categories_updated_at + BEFORE UPDATE ON education.categories + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_courses_updated_at + BEFORE UPDATE ON education.courses + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_modules_updated_at + BEFORE UPDATE ON education.modules + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_lessons_updated_at + BEFORE UPDATE ON education.lessons + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_enrollments_updated_at + BEFORE UPDATE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_progress_updated_at + BEFORE UPDATE ON education.progress + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_quizzes_updated_at + BEFORE UPDATE ON education.quizzes + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_quiz_questions_updated_at + BEFORE UPDATE ON education.quiz_questions + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_user_gamification_profile_updated_at + BEFORE UPDATE ON education.user_gamification_profile + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); + +CREATE TRIGGER update_course_reviews_updated_at + BEFORE UPDATE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_updated_at_column(); diff --git a/ddl/schemas/education/functions/02-update_enrollment_progress.sql b/ddl/schemas/education/functions/02-update_enrollment_progress.sql new file mode 100644 index 0000000..cffd831 --- /dev/null +++ b/ddl/schemas/education/functions/02-update_enrollment_progress.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- FUNCTION: education.update_enrollment_progress() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza el progreso del enrollment cuando se completa una lección +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_enrollment_progress() +RETURNS TRIGGER AS $$ +DECLARE + v_total_lessons INTEGER; + v_completed_lessons INTEGER; + v_progress_percentage DECIMAL(5,2); +BEGIN + -- Obtener total de lecciones obligatorias del curso + SELECT COUNT(*) + INTO v_total_lessons + FROM education.lessons l + JOIN education.modules m ON l.module_id = m.id + JOIN education.courses c ON m.course_id = c.id + WHERE c.id = ( + SELECT course_id FROM education.enrollments WHERE id = NEW.enrollment_id + ) AND l.is_mandatory = true; + + -- Obtener lecciones completadas + SELECT COUNT(*) + INTO v_completed_lessons + FROM education.progress + WHERE enrollment_id = NEW.enrollment_id + AND is_completed = true; + + -- Calcular porcentaje de progreso + v_progress_percentage := (v_completed_lessons::DECIMAL / NULLIF(v_total_lessons, 0)::DECIMAL) * 100; + + -- Actualizar enrollment + UPDATE education.enrollments + SET + progress_percentage = COALESCE(v_progress_percentage, 0), + completed_lessons = v_completed_lessons, + total_lessons = v_total_lessons, + updated_at = NOW() + WHERE id = NEW.enrollment_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_enrollment_progress() IS 'Actualiza progreso del enrollment al completar lecciones'; + +-- Trigger para actualizar el progreso +CREATE TRIGGER update_enrollment_on_progress + AFTER INSERT OR UPDATE ON education.progress + FOR EACH ROW + WHEN (NEW.is_completed = true) + EXECUTE FUNCTION education.update_enrollment_progress(); diff --git a/ddl/schemas/education/functions/03-auto_complete_enrollment.sql b/ddl/schemas/education/functions/03-auto_complete_enrollment.sql new file mode 100644 index 0000000..db0e68c --- /dev/null +++ b/ddl/schemas/education/functions/03-auto_complete_enrollment.sql @@ -0,0 +1,29 @@ +-- ===================================================== +-- FUNCTION: education.auto_complete_enrollment() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Completa automáticamente el enrollment cuando alcanza 100% +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.auto_complete_enrollment() +RETURNS TRIGGER AS $$ +BEGIN + -- Si el progreso llegó al 100% y está activo, completarlo + IF NEW.progress_percentage >= 100 AND NEW.status = 'active' THEN + NEW.status := 'completed'; + NEW.completed_at := NOW(); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.auto_complete_enrollment() IS 'Completa automáticamente el enrollment al alcanzar 100%'; + +-- Trigger para auto-completar enrollment +CREATE TRIGGER auto_complete_enrollment_trigger + BEFORE UPDATE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.auto_complete_enrollment(); diff --git a/ddl/schemas/education/functions/04-generate_certificate.sql b/ddl/schemas/education/functions/04-generate_certificate.sql new file mode 100644 index 0000000..b1170e4 --- /dev/null +++ b/ddl/schemas/education/functions/04-generate_certificate.sql @@ -0,0 +1,47 @@ +-- ===================================================== +-- FUNCTION: education.generate_certificate_number() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Genera automáticamente el número de certificado y código de verificación +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.generate_certificate_number() +RETURNS TRIGGER AS $$ +DECLARE + v_year INTEGER; + v_sequence INTEGER; +BEGIN + v_year := EXTRACT(YEAR FROM NOW()); + + -- Obtener siguiente número de secuencia para el año + SELECT COALESCE(MAX( + CAST(SUBSTRING(certificate_number FROM 14) AS INTEGER) + ), 0) + 1 + INTO v_sequence + FROM education.certificates + WHERE certificate_number LIKE 'OQI-CERT-' || v_year || '-%'; + + -- Generar número de certificado: OQI-CERT-2025-00001 + NEW.certificate_number := FORMAT('OQI-CERT-%s-%s', + v_year, + LPAD(v_sequence::TEXT, 5, '0') + ); + + -- Generar código de verificación único + NEW.verification_code := UPPER( + SUBSTRING(MD5(RANDOM()::TEXT || NOW()::TEXT) FROM 1 FOR 16) + ); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.generate_certificate_number() IS 'Genera número de certificado y código de verificación automáticamente'; + +-- Trigger para generar número de certificado +CREATE TRIGGER generate_certificate_number_trigger + BEFORE INSERT ON education.certificates + FOR EACH ROW + EXECUTE FUNCTION education.generate_certificate_number(); diff --git a/ddl/schemas/education/functions/05-update_course_stats.sql b/ddl/schemas/education/functions/05-update_course_stats.sql new file mode 100644 index 0000000..c52aef5 --- /dev/null +++ b/ddl/schemas/education/functions/05-update_course_stats.sql @@ -0,0 +1,59 @@ +-- ===================================================== +-- FUNCTION: education.update_course_stats() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza estadísticas denormalizadas del curso +-- ===================================================== + +-- Función para actualizar estadísticas de reviews +CREATE OR REPLACE FUNCTION education.update_course_rating_stats() +RETURNS TRIGGER AS $$ +DECLARE + v_course_id UUID; + v_avg_rating DECIMAL(3,2); + v_total_reviews INTEGER; +BEGIN + -- Obtener course_id del NEW o OLD record + v_course_id := COALESCE(NEW.course_id, OLD.course_id); + + -- Calcular promedio solo de reviews aprobadas + SELECT + COALESCE(AVG(rating), 0), + COUNT(*) + INTO v_avg_rating, v_total_reviews + FROM education.course_reviews + WHERE course_id = v_course_id + AND is_approved = true; + + -- Actualizar estadísticas en el curso + UPDATE education.courses + SET + avg_rating = v_avg_rating, + total_reviews = v_total_reviews, + updated_at = NOW() + WHERE id = v_course_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_course_rating_stats() IS 'Actualiza avg_rating y total_reviews del curso'; + +-- Triggers para actualizar estadísticas +CREATE TRIGGER update_course_rating_on_review_insert + AFTER INSERT ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_course_rating_stats(); + +CREATE TRIGGER update_course_rating_on_review_update + AFTER UPDATE ON education.course_reviews + FOR EACH ROW + WHEN (OLD.rating IS DISTINCT FROM NEW.rating OR OLD.is_approved IS DISTINCT FROM NEW.is_approved) + EXECUTE FUNCTION education.update_course_rating_stats(); + +CREATE TRIGGER update_course_rating_on_review_delete + AFTER DELETE ON education.course_reviews + FOR EACH ROW + EXECUTE FUNCTION education.update_course_rating_stats(); diff --git a/ddl/schemas/education/functions/06-update_enrollment_count.sql b/ddl/schemas/education/functions/06-update_enrollment_count.sql new file mode 100644 index 0000000..b386cb0 --- /dev/null +++ b/ddl/schemas/education/functions/06-update_enrollment_count.sql @@ -0,0 +1,41 @@ +-- ===================================================== +-- FUNCTION: education.update_enrollment_count() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- Descripción: Actualiza el contador de enrollments en courses +-- ===================================================== + +CREATE OR REPLACE FUNCTION education.update_enrollment_count() +RETURNS TRIGGER AS $$ +DECLARE + v_course_id UUID; +BEGIN + v_course_id := COALESCE(NEW.course_id, OLD.course_id); + + -- Actualizar contador de enrollments + UPDATE education.courses + SET + total_enrollments = ( + SELECT COUNT(*) FROM education.enrollments WHERE course_id = v_course_id + ), + updated_at = NOW() + WHERE id = v_course_id; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_enrollment_count() IS 'Actualiza total_enrollments del curso'; + +-- Triggers para actualizar contador +CREATE TRIGGER update_enrollment_count_on_insert + AFTER INSERT ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_enrollment_count(); + +CREATE TRIGGER update_enrollment_count_on_delete + AFTER DELETE ON education.enrollments + FOR EACH ROW + EXECUTE FUNCTION education.update_enrollment_count(); diff --git a/ddl/schemas/education/functions/07-update_gamification_profile.sql b/ddl/schemas/education/functions/07-update_gamification_profile.sql new file mode 100644 index 0000000..b99f470 --- /dev/null +++ b/ddl/schemas/education/functions/07-update_gamification_profile.sql @@ -0,0 +1,158 @@ +-- ===================================================== +-- FUNCTION: education.update_gamification_profile() +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Función adicional para gamificación +-- Descripción: Actualiza el perfil de gamificación del usuario +-- ===================================================== + +-- Función para actualizar XP y nivel +CREATE OR REPLACE FUNCTION education.update_user_xp( + p_user_id UUID, + p_xp_to_add INTEGER +) +RETURNS VOID AS $$ +DECLARE + v_profile RECORD; + v_new_total_xp INTEGER; + v_new_level INTEGER; + v_xp_to_next INTEGER; +BEGIN + -- Obtener o crear perfil + INSERT INTO education.user_gamification_profile (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; + + -- Obtener perfil actual + SELECT * INTO v_profile + FROM education.user_gamification_profile + WHERE user_id = p_user_id; + + -- Calcular nuevo XP total + v_new_total_xp := v_profile.total_xp + p_xp_to_add; + + -- Calcular nuevo nivel (cada nivel requiere 100 XP más que el anterior) + -- Nivel 1: 0-99 XP, Nivel 2: 100-299 XP, Nivel 3: 300-599 XP, etc. + v_new_level := FLOOR((-100 + SQRT(10000 + 800 * v_new_total_xp)) / 200) + 1; + + -- XP necesario para siguiente nivel + v_xp_to_next := (v_new_level * 100 + (v_new_level * (v_new_level - 1) * 100)) - v_new_total_xp; + + -- Actualizar perfil + UPDATE education.user_gamification_profile + SET + total_xp = v_new_total_xp, + current_level = v_new_level, + xp_to_next_level = v_xp_to_next, + weekly_xp = weekly_xp + p_xp_to_add, + monthly_xp = monthly_xp + p_xp_to_add, + last_activity_date = CURRENT_DATE, + updated_at = NOW() + WHERE user_id = p_user_id; + + -- Si subió de nivel, crear achievement + IF v_new_level > v_profile.current_level THEN + INSERT INTO education.user_achievements ( + user_id, + achievement_type, + title, + description, + xp_bonus + ) VALUES ( + p_user_id, + 'level_up', + 'Level Up! - Nivel ' || v_new_level, + 'Has alcanzado el nivel ' || v_new_level, + 50 + ); + END IF; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_user_xp(UUID, INTEGER) IS 'Actualiza XP del usuario y recalcula nivel'; + +-- Función para actualizar streak +CREATE OR REPLACE FUNCTION education.update_user_streak(p_user_id UUID) +RETURNS VOID AS $$ +DECLARE + v_last_activity DATE; + v_current_streak INTEGER; + v_longest_streak INTEGER; +BEGIN + -- Obtener o crear perfil + INSERT INTO education.user_gamification_profile (user_id) + VALUES (p_user_id) + ON CONFLICT (user_id) DO NOTHING; + + -- Obtener datos actuales + SELECT last_activity_date, current_streak_days, longest_streak_days + INTO v_last_activity, v_current_streak, v_longest_streak + FROM education.user_gamification_profile + WHERE user_id = p_user_id; + + -- Actualizar streak + IF v_last_activity IS NULL THEN + -- Primera actividad + v_current_streak := 1; + ELSIF v_last_activity = CURRENT_DATE THEN + -- Ya tuvo actividad hoy, no hacer nada + RETURN; + ELSIF v_last_activity = CURRENT_DATE - INTERVAL '1 day' THEN + -- Actividad día consecutivo + v_current_streak := v_current_streak + 1; + ELSE + -- Se rompió el streak + v_current_streak := 1; + END IF; + + -- Actualizar longest streak si corresponde + IF v_current_streak > v_longest_streak THEN + v_longest_streak := v_current_streak; + + -- Crear achievement por streak milestones + IF v_current_streak IN (7, 30, 100) THEN + INSERT INTO education.user_achievements ( + user_id, + achievement_type, + title, + description, + xp_bonus, + metadata + ) VALUES ( + p_user_id, + 'streak_milestone', + 'Streak de ' || v_current_streak || ' días', + 'Has mantenido una racha de ' || v_current_streak || ' días consecutivos', + v_current_streak * 5, + jsonb_build_object('streak_days', v_current_streak) + ); + END IF; + END IF; + + -- Actualizar perfil + UPDATE education.user_gamification_profile + SET + current_streak_days = v_current_streak, + longest_streak_days = v_longest_streak, + last_activity_date = CURRENT_DATE, + updated_at = NOW() + WHERE user_id = p_user_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION education.update_user_streak(UUID) IS 'Actualiza streak del usuario basado en actividad diaria'; + +-- Trigger para actualizar streak en actividades +CREATE OR REPLACE FUNCTION education.trigger_update_streak() +RETURNS TRIGGER AS $$ +BEGIN + PERFORM education.update_user_streak(NEW.user_id); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_streak_on_activity + AFTER INSERT ON education.user_activity_log + FOR EACH ROW + EXECUTE FUNCTION education.trigger_update_streak(); diff --git a/ddl/schemas/education/functions/08-views.sql b/ddl/schemas/education/functions/08-views.sql new file mode 100644 index 0000000..e6a99a0 --- /dev/null +++ b/ddl/schemas/education/functions/08-views.sql @@ -0,0 +1,142 @@ +-- ===================================================== +-- VIEWS - Schema Education +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +-- Vista: Cursos con estadísticas completas +CREATE OR REPLACE VIEW education.v_courses_with_stats AS +SELECT + c.*, + cat.name as category_name, + cat.slug as category_slug, + COUNT(DISTINCT m.id) as modules_count, + COUNT(DISTINCT l.id) as lessons_count, + SUM(l.video_duration_seconds) as total_duration_seconds, + COUNT(DISTINCT e.id) as enrollments_count, + COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions_count +FROM education.courses c +LEFT JOIN education.categories cat ON c.category_id = cat.id +LEFT JOIN education.modules m ON c.id = m.course_id +LEFT JOIN education.lessons l ON m.id = l.module_id +LEFT JOIN education.enrollments e ON c.id = e.course_id +GROUP BY c.id, cat.name, cat.slug; + +COMMENT ON VIEW education.v_courses_with_stats IS 'Cursos con estadísticas agregadas de módulos, lecciones y enrollments'; + +-- Vista: Progreso del usuario por curso +CREATE OR REPLACE VIEW education.v_user_course_progress AS +SELECT + e.user_id, + e.course_id, + c.title as course_title, + c.slug as course_slug, + c.thumbnail_url, + e.status as enrollment_status, + e.progress_percentage, + e.enrolled_at, + e.completed_at, + e.total_xp_earned, + COUNT(DISTINCT p.id) as lessons_viewed, + COUNT(DISTINCT CASE WHEN p.is_completed THEN p.id END) as lessons_completed +FROM education.enrollments e +JOIN education.courses c ON e.course_id = c.id +LEFT JOIN education.progress p ON e.id = p.enrollment_id +GROUP BY e.id, e.user_id, e.course_id, c.title, c.slug, c.thumbnail_url; + +COMMENT ON VIEW education.v_user_course_progress IS 'Progreso detallado del usuario en cada curso enrollado'; + +-- Vista: Leaderboard de usuarios +CREATE OR REPLACE VIEW education.v_leaderboard_weekly AS +SELECT + ugp.user_id, + ugp.weekly_xp, + ugp.current_level, + ugp.current_streak_days, + RANK() OVER (ORDER BY ugp.weekly_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.weekly_xp > 0 +ORDER BY ugp.weekly_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_weekly IS 'Top 100 usuarios por XP semanal'; + +CREATE OR REPLACE VIEW education.v_leaderboard_monthly AS +SELECT + ugp.user_id, + ugp.monthly_xp, + ugp.current_level, + ugp.current_streak_days, + RANK() OVER (ORDER BY ugp.monthly_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.monthly_xp > 0 +ORDER BY ugp.monthly_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_monthly IS 'Top 100 usuarios por XP mensual'; + +CREATE OR REPLACE VIEW education.v_leaderboard_alltime AS +SELECT + ugp.user_id, + ugp.total_xp, + ugp.current_level, + ugp.total_courses_completed, + RANK() OVER (ORDER BY ugp.total_xp DESC) as rank +FROM education.user_gamification_profile ugp +WHERE ugp.total_xp > 0 +ORDER BY ugp.total_xp DESC +LIMIT 100; + +COMMENT ON VIEW education.v_leaderboard_alltime IS 'Top 100 usuarios por XP total histórico'; + +-- Vista: Estadísticas del usuario +CREATE OR REPLACE VIEW education.v_user_statistics AS +SELECT + ugp.user_id, + ugp.total_xp, + ugp.current_level, + ugp.xp_to_next_level, + ugp.current_streak_days, + ugp.longest_streak_days, + ugp.total_courses_completed, + ugp.total_lessons_completed, + ugp.total_quizzes_passed, + ugp.total_certificates_earned, + ugp.average_quiz_score, + COUNT(DISTINCT e.id) as total_enrollments, + COUNT(DISTINCT CASE WHEN e.status = 'active' THEN e.id END) as active_enrollments, + COUNT(DISTINCT ua.id) as total_achievements +FROM education.user_gamification_profile ugp +LEFT JOIN education.enrollments e ON ugp.user_id = e.user_id +LEFT JOIN education.user_achievements ua ON ugp.user_id = ua.user_id +GROUP BY ugp.user_id, ugp.total_xp, ugp.current_level, ugp.xp_to_next_level, + ugp.current_streak_days, ugp.longest_streak_days, ugp.total_courses_completed, + ugp.total_lessons_completed, ugp.total_quizzes_passed, ugp.total_certificates_earned, + ugp.average_quiz_score; + +COMMENT ON VIEW education.v_user_statistics IS 'Estadísticas completas del usuario (gamificación + progreso)'; + +-- Vista: Cursos populares +CREATE OR REPLACE VIEW education.v_popular_courses AS +SELECT + c.id, + c.title, + c.slug, + c.thumbnail_url, + c.difficulty_level, + c.avg_rating, + c.total_reviews, + c.total_enrollments, + COUNT(DISTINCT e.id) as recent_enrollments_30d, + COUNT(DISTINCT CASE WHEN e.status = 'completed' THEN e.id END) as completions +FROM education.courses c +LEFT JOIN education.enrollments e ON c.id = e.course_id + AND e.enrolled_at >= NOW() - INTERVAL '30 days' +WHERE c.status = 'published' +GROUP BY c.id +ORDER BY recent_enrollments_30d DESC, c.avg_rating DESC +LIMIT 50; + +COMMENT ON VIEW education.v_popular_courses IS 'Top 50 cursos más populares (enrollments últimos 30 días)'; diff --git a/ddl/schemas/education/install.sh b/ddl/schemas/education/install.sh new file mode 100755 index 0000000..ff8f184 --- /dev/null +++ b/ddl/schemas/education/install.sh @@ -0,0 +1,132 @@ +#!/bin/bash + +# ===================================================== +# INSTALL SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# Especificación: ET-EDU-001-database.md +# ===================================================== + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-orbiquant}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} OrbiQuant IA - Education Schema Installation${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" + +# Check if psql is available +if ! command -v psql &> /dev/null; then + echo -e "${RED}Error: psql command not found${NC}" + echo "Please install PostgreSQL client" + exit 1 +fi + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "Configuration:" +echo " Database: $DB_NAME" +echo " Host: $DB_HOST:$DB_PORT" +echo " User: $DB_USER" +echo " Schema: $SCHEMA_NAME" +echo "" + +# Function to execute SQL file +execute_sql() { + local file=$1 + local description=$2 + + echo -e "${YELLOW}▶${NC} $description" + + if [ ! -f "$file" ]; then + echo -e "${RED} ✗ File not found: $file${NC}" + return 1 + fi + + if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f "$file" > /dev/null 2>&1; then + echo -e "${GREEN} ✓ Success${NC}" + return 0 + else + echo -e "${RED} ✗ Failed${NC}" + return 1 + fi +} + +# Create schema if not exists +echo -e "${YELLOW}▶${NC} Creating schema: $SCHEMA_NAME" +PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "CREATE SCHEMA IF NOT EXISTS $SCHEMA_NAME;" > /dev/null 2>&1 +echo -e "${GREEN} ✓ Schema created/verified${NC}" +echo "" + +# 1. Install ENUMs +echo -e "${GREEN}[1/3] Installing ENUMs...${NC}" +execute_sql "$SCRIPT_DIR/00-enums.sql" "Creating ENUM types" +echo "" + +# 2. Install Tables +echo -e "${GREEN}[2/3] Installing Tables...${NC}" +execute_sql "$SCRIPT_DIR/tables/01-categories.sql" "Creating table: categories" +execute_sql "$SCRIPT_DIR/tables/02-courses.sql" "Creating table: courses" +execute_sql "$SCRIPT_DIR/tables/03-modules.sql" "Creating table: modules" +execute_sql "$SCRIPT_DIR/tables/04-lessons.sql" "Creating table: lessons" +execute_sql "$SCRIPT_DIR/tables/05-enrollments.sql" "Creating table: enrollments" +execute_sql "$SCRIPT_DIR/tables/06-progress.sql" "Creating table: progress" +execute_sql "$SCRIPT_DIR/tables/07-quizzes.sql" "Creating table: quizzes" +execute_sql "$SCRIPT_DIR/tables/08-quiz_questions.sql" "Creating table: quiz_questions" +execute_sql "$SCRIPT_DIR/tables/09-quiz_attempts.sql" "Creating table: quiz_attempts" +execute_sql "$SCRIPT_DIR/tables/10-certificates.sql" "Creating table: certificates" +execute_sql "$SCRIPT_DIR/tables/11-user_achievements.sql" "Creating table: user_achievements" +execute_sql "$SCRIPT_DIR/tables/12-user_gamification_profile.sql" "Creating table: user_gamification_profile" +execute_sql "$SCRIPT_DIR/tables/13-user_activity_log.sql" "Creating table: user_activity_log" +execute_sql "$SCRIPT_DIR/tables/14-course_reviews.sql" "Creating table: course_reviews" +echo "" + +# 3. Install Functions and Triggers +echo -e "${GREEN}[3/3] Installing Functions and Triggers...${NC}" +execute_sql "$SCRIPT_DIR/functions/01-update_updated_at.sql" "Creating trigger: update_updated_at" +execute_sql "$SCRIPT_DIR/functions/02-update_enrollment_progress.sql" "Creating function: update_enrollment_progress" +execute_sql "$SCRIPT_DIR/functions/03-auto_complete_enrollment.sql" "Creating function: auto_complete_enrollment" +execute_sql "$SCRIPT_DIR/functions/04-generate_certificate.sql" "Creating function: generate_certificate_number" +execute_sql "$SCRIPT_DIR/functions/05-update_course_stats.sql" "Creating function: update_course_stats" +execute_sql "$SCRIPT_DIR/functions/06-update_enrollment_count.sql" "Creating function: update_enrollment_count" +execute_sql "$SCRIPT_DIR/functions/07-update_gamification_profile.sql" "Creating functions: gamification" +execute_sql "$SCRIPT_DIR/functions/08-views.sql" "Creating views" +echo "" + +# Verify installation +echo -e "${YELLOW}▶${NC} Verifying installation..." + +TABLE_COUNT=$(PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_type = 'BASE TABLE';" 2>/dev/null | xargs) + +if [ "$TABLE_COUNT" -eq "14" ]; then + echo -e "${GREEN} ✓ All 14 tables created successfully${NC}" +else + echo -e "${RED} ✗ Expected 14 tables, found $TABLE_COUNT${NC}" +fi + +echo "" +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} Installation Complete!${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" +echo "Schema '$SCHEMA_NAME' has been installed successfully." +echo "" +echo "Next steps:" +echo " 1. Review the README.md for usage examples" +echo " 2. Run seed data scripts if needed" +echo " 3. Configure Row Level Security (RLS) policies" +echo "" diff --git a/ddl/schemas/education/seeds-example.sql b/ddl/schemas/education/seeds-example.sql new file mode 100644 index 0000000..b64b0d2 --- /dev/null +++ b/ddl/schemas/education/seeds-example.sql @@ -0,0 +1,238 @@ +-- ===================================================== +-- SEED DATA - Schema Education (EJEMPLO) +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== +-- NOTA: Este es un archivo de ejemplo. +-- Los datos reales deben ir en /apps/database/seeds/ +-- ===================================================== + +BEGIN; + +-- ===================================================== +-- 1. CATEGORIES +-- ===================================================== + +INSERT INTO education.categories (id, name, slug, description, color, display_order, is_active) VALUES +('11111111-1111-1111-1111-111111111111', 'Trading Básico', 'trading-basico', 'Fundamentos del trading y mercados financieros', '#3B82F6', 1, true), +('22222222-2222-2222-2222-222222222222', 'Análisis Técnico', 'analisis-tecnico', 'Herramientas y técnicas de análisis técnico', '#10B981', 2, true), +('33333333-3333-3333-3333-333333333333', 'Análisis Fundamental', 'analisis-fundamental', 'Evaluación fundamental de activos', '#F59E0B', 3, true), +('44444444-4444-4444-4444-444444444444', 'Gestión de Riesgo', 'gestion-riesgo', 'Estrategias de gestión de riesgo y capital', '#EF4444', 4, true), +('55555555-5555-5555-5555-555555555555', 'Trading Algorítmico', 'trading-algoritmico', 'Automatización y estrategias algorítmicas', '#8B5CF6', 5, true), +('66666666-6666-6666-6666-666666666666', 'Psicología del Trading', 'psicologia-trading', 'Aspectos psicológicos y emocionales del trading', '#EC4899', 6, true); + +-- ===================================================== +-- 2. COURSES +-- ===================================================== +-- NOTA: instructor_id debe existir en auth.users +-- Para este ejemplo, usar un UUID válido de tu sistema + +INSERT INTO education.courses ( + id, + title, + slug, + short_description, + full_description, + category_id, + difficulty_level, + instructor_id, + instructor_name, + is_free, + xp_reward, + status, + published_at, + total_modules, + total_lessons +) VALUES +( + 'c1111111-1111-1111-1111-111111111111', + 'Introducción al Trading', + 'introduccion-trading', + 'Aprende los conceptos básicos del trading desde cero', + 'Este curso te enseñará los fundamentos del trading, incluyendo tipos de mercados, instrumentos financieros, y cómo realizar tus primeras operaciones de forma segura.', + '11111111-1111-1111-1111-111111111111', + 'beginner', + '00000000-0000-0000-0000-000000000001', -- Reemplazar con ID real + 'Instructor Demo', + true, + 500, + 'published', + NOW(), + 3, + 12 +); + +-- ===================================================== +-- 3. MODULES +-- ===================================================== + +INSERT INTO education.modules (id, course_id, title, description, display_order, duration_minutes) VALUES +('m1111111-1111-1111-1111-111111111111', 'c1111111-1111-1111-1111-111111111111', 'Módulo 1: Fundamentos', 'Conceptos básicos del trading', 1, 120), +('m2222222-2222-2222-2222-222222222222', 'c1111111-1111-1111-1111-111111111111', 'Módulo 2: Mercados Financieros', 'Tipos de mercados y activos', 2, 180), +('m3333333-3333-3333-3333-333333333333', 'c1111111-1111-1111-1111-111111111111', 'Módulo 3: Primeros Pasos', 'Cómo empezar a operar', 3, 150); + +-- ===================================================== +-- 4. LESSONS +-- ===================================================== + +INSERT INTO education.lessons ( + id, + module_id, + title, + description, + content_type, + video_url, + video_duration_seconds, + display_order, + is_preview, + xp_reward +) VALUES +( + 'l1111111-1111-1111-1111-111111111111', + 'm1111111-1111-1111-1111-111111111111', + '¿Qué es el Trading?', + 'Introducción a los conceptos básicos del trading', + 'video', + 'https://example.com/videos/lesson-1.mp4', + 900, + 1, + true, + 10 +), +( + 'l2222222-2222-2222-2222-222222222222', + 'm1111111-1111-1111-1111-111111111111', + 'Tipos de Traders', + 'Conoce los diferentes estilos de trading', + 'video', + 'https://example.com/videos/lesson-2.mp4', + 1200, + 2, + false, + 10 +), +( + 'l3333333-3333-3333-3333-333333333333', + 'm1111111-1111-1111-1111-111111111111', + 'Terminología Básica', + 'Vocabulario esencial del trading', + 'article', + NULL, + NULL, + 3, + false, + 15 +); + +-- ===================================================== +-- 5. QUIZZES +-- ===================================================== + +INSERT INTO education.quizzes ( + id, + module_id, + title, + description, + passing_score_percentage, + max_attempts, + xp_reward, + xp_perfect_score_bonus +) VALUES +( + 'q1111111-1111-1111-1111-111111111111', + 'm1111111-1111-1111-1111-111111111111', + 'Quiz: Fundamentos del Trading', + 'Evalúa tus conocimientos sobre los conceptos básicos', + 70, + 3, + 50, + 20 +); + +-- ===================================================== +-- 6. QUIZ QUESTIONS +-- ===================================================== + +INSERT INTO education.quiz_questions ( + id, + quiz_id, + question_text, + question_type, + options, + explanation, + points, + display_order +) VALUES +( + 'qq111111-1111-1111-1111-111111111111', + 'q1111111-1111-1111-1111-111111111111', + '¿Qué es el trading?', + 'multiple_choice', + '[ + {"id": "a", "text": "Comprar y vender activos financieros", "isCorrect": true}, + {"id": "b", "text": "Solo comprar acciones", "isCorrect": false}, + {"id": "c", "text": "Invertir a largo plazo únicamente", "isCorrect": false}, + {"id": "d", "text": "Ahorrar dinero en un banco", "isCorrect": false} + ]'::jsonb, + 'El trading implica la compra y venta de activos financieros con el objetivo de obtener ganancias a corto o mediano plazo.', + 1, + 1 +), +( + 'qq222222-2222-2222-2222-222222222222', + 'q1111111-1111-1111-1111-111111111111', + '¿El trading es una actividad de riesgo?', + 'true_false', + '[ + {"id": "true", "text": "Verdadero", "isCorrect": true}, + {"id": "false", "text": "Falso", "isCorrect": false} + ]'::jsonb, + 'Sí, el trading es una actividad que conlleva riesgos y es posible perder dinero. Por eso es importante la gestión de riesgo.', + 1, + 2 +); + +-- ===================================================== +-- EJEMPLO DE USO - ENROLLMENTS +-- ===================================================== +-- NOTA: Estos son ejemplos comentados. NO ejecutar sin IDs reales. + +/* +-- Enrollar un usuario a un curso +INSERT INTO education.enrollments (user_id, course_id, total_lessons) +VALUES ( + '00000000-0000-0000-0000-000000000001', -- ID del usuario + 'c1111111-1111-1111-1111-111111111111', -- ID del curso + 12 -- Total de lecciones del curso +); + +-- Registrar progreso en una lección +INSERT INTO education.progress ( + user_id, + lesson_id, + enrollment_id, + is_completed, + watch_percentage +) VALUES ( + '00000000-0000-0000-0000-000000000001', + 'l1111111-1111-1111-1111-111111111111', + '', + true, + 100.00 +); +*/ + +COMMIT; + +-- ===================================================== +-- Verificación +-- ===================================================== + +SELECT 'Categories created:' as info, COUNT(*) as count FROM education.categories; +SELECT 'Courses created:' as info, COUNT(*) as count FROM education.courses; +SELECT 'Modules created:' as info, COUNT(*) as count FROM education.modules; +SELECT 'Lessons created:' as info, COUNT(*) as count FROM education.lessons; +SELECT 'Quizzes created:' as info, COUNT(*) as count FROM education.quizzes; +SELECT 'Questions created:' as info, COUNT(*) as count FROM education.quiz_questions; diff --git a/ddl/schemas/education/tables/01-categories.sql b/ddl/schemas/education/tables/01-categories.sql new file mode 100644 index 0000000..1d33e57 --- /dev/null +++ b/ddl/schemas/education/tables/01-categories.sql @@ -0,0 +1,42 @@ +-- ===================================================== +-- TABLE: education.categories +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información básica + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + + -- Jerarquía + parent_id UUID REFERENCES education.categories(id) ON DELETE SET NULL, + + -- Ordenamiento y visualización + display_order INTEGER DEFAULT 0, + icon_url VARCHAR(500), + color VARCHAR(7), -- Código hex #RRGGBB + + -- Metadata + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_color_format CHECK (color ~ '^#[0-9A-Fa-f]{6}$') +); + +-- Índices +CREATE INDEX idx_categories_parent ON education.categories(parent_id); +CREATE INDEX idx_categories_slug ON education.categories(slug); +CREATE INDEX idx_categories_active ON education.categories(is_active) WHERE is_active = true; + +-- Comentarios +COMMENT ON TABLE education.categories IS 'Categorías de cursos con soporte para jerarquía'; +COMMENT ON COLUMN education.categories.parent_id IS 'Categoría padre para jerarquía'; +COMMENT ON COLUMN education.categories.display_order IS 'Orden de visualización'; +COMMENT ON COLUMN education.categories.color IS 'Color en formato hexadecimal #RRGGBB'; diff --git a/ddl/schemas/education/tables/02-courses.sql b/ddl/schemas/education/tables/02-courses.sql new file mode 100644 index 0000000..8b452f3 --- /dev/null +++ b/ddl/schemas/education/tables/02-courses.sql @@ -0,0 +1,74 @@ +-- ===================================================== +-- TABLE: education.courses +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.courses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Información básica + title VARCHAR(200) NOT NULL, + slug VARCHAR(200) NOT NULL UNIQUE, + short_description VARCHAR(500), + full_description TEXT, + + -- Categorización + category_id UUID NOT NULL REFERENCES education.categories(id) ON DELETE RESTRICT, + difficulty_level education.difficulty_level NOT NULL DEFAULT 'beginner', + + -- Contenido + thumbnail_url VARCHAR(500), + trailer_url VARCHAR(500), -- Video de presentación + + -- Metadata educativa + duration_minutes INTEGER, -- Duración estimada total + prerequisites TEXT[], -- IDs de cursos prerequisitos + learning_objectives TEXT[], -- Array de objetivos + + -- Instructor + instructor_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + instructor_name VARCHAR(200), -- Denormalizado para performance + + -- Pricing (para futuras features) + is_free BOOLEAN DEFAULT true, + price_usd DECIMAL(10,2), + + -- Gamificación + xp_reward INTEGER DEFAULT 0, -- XP al completar el curso + + -- Estado + status education.course_status DEFAULT 'draft', + published_at TIMESTAMPTZ, + + -- Estadísticas (denormalizadas) + total_modules INTEGER DEFAULT 0, + total_lessons INTEGER DEFAULT 0, + total_enrollments INTEGER DEFAULT 0, + avg_rating DECIMAL(3,2) DEFAULT 0.00, + total_reviews INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_rating CHECK (avg_rating >= 0 AND avg_rating <= 5), + CONSTRAINT valid_price CHECK (price_usd >= 0) +); + +-- Índices +CREATE INDEX idx_courses_category ON education.courses(category_id); +CREATE INDEX idx_courses_slug ON education.courses(slug); +CREATE INDEX idx_courses_status ON education.courses(status); +CREATE INDEX idx_courses_difficulty ON education.courses(difficulty_level); +CREATE INDEX idx_courses_instructor ON education.courses(instructor_id); +CREATE INDEX idx_courses_published ON education.courses(published_at) WHERE status = 'published'; + +-- Comentarios +COMMENT ON TABLE education.courses IS 'Cursos del módulo educativo'; +COMMENT ON COLUMN education.courses.instructor_name IS 'Denormalizado para performance en queries'; +COMMENT ON COLUMN education.courses.prerequisites IS 'Array de UUIDs de cursos prerequisitos'; +COMMENT ON COLUMN education.courses.learning_objectives IS 'Array de objetivos de aprendizaje'; +COMMENT ON COLUMN education.courses.xp_reward IS 'XP otorgado al completar el curso'; diff --git a/ddl/schemas/education/tables/03-modules.sql b/ddl/schemas/education/tables/03-modules.sql new file mode 100644 index 0000000..09ffcd2 --- /dev/null +++ b/ddl/schemas/education/tables/03-modules.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- TABLE: education.modules +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.modules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación con curso + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Ordenamiento + display_order INTEGER NOT NULL DEFAULT 0, + + -- Metadata + duration_minutes INTEGER, + + -- Control de acceso + is_locked BOOLEAN DEFAULT false, -- Requiere completar módulos anteriores + unlock_after_module_id UUID REFERENCES education.modules(id) ON DELETE SET NULL, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_course_order UNIQUE(course_id, display_order) +); + +-- Índices +CREATE INDEX idx_modules_course ON education.modules(course_id); +CREATE INDEX idx_modules_order ON education.modules(course_id, display_order); + +-- Comentarios +COMMENT ON TABLE education.modules IS 'Módulos que agrupan lecciones dentro de un curso'; +COMMENT ON COLUMN education.modules.is_locked IS 'Si requiere completar módulos anteriores para desbloquearse'; +COMMENT ON COLUMN education.modules.unlock_after_module_id IS 'Módulo que debe completarse antes de acceder a este'; diff --git a/ddl/schemas/education/tables/04-lessons.sql b/ddl/schemas/education/tables/04-lessons.sql new file mode 100644 index 0000000..30666e4 --- /dev/null +++ b/ddl/schemas/education/tables/04-lessons.sql @@ -0,0 +1,66 @@ +-- ===================================================== +-- TABLE: education.lessons +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.lessons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación con módulo + module_id UUID NOT NULL REFERENCES education.modules(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Tipo de contenido + content_type education.lesson_content_type NOT NULL DEFAULT 'video', + + -- Contenido video + video_url VARCHAR(500), -- URL de Vimeo/S3 + video_duration_seconds INTEGER, + video_provider VARCHAR(50), -- 'vimeo', 's3', etc. + video_id VARCHAR(200), -- ID del video en el provider + + -- Contenido texto/article + article_content TEXT, + + -- Recursos adicionales + attachments JSONB, -- [{name, url, type, size}] + + -- Ordenamiento + display_order INTEGER NOT NULL DEFAULT 0, + + -- Configuración + is_preview BOOLEAN DEFAULT false, -- Puede verse sin enrollment + is_mandatory BOOLEAN DEFAULT true, -- Requerido para completar el curso + + -- Gamificación + xp_reward INTEGER DEFAULT 10, -- XP al completar la lección + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_module_order UNIQUE(module_id, display_order), + CONSTRAINT video_fields_required CHECK ( + (content_type != 'video') OR + (video_url IS NOT NULL AND video_duration_seconds IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_lessons_module ON education.lessons(module_id); +CREATE INDEX idx_lessons_order ON education.lessons(module_id, display_order); +CREATE INDEX idx_lessons_type ON education.lessons(content_type); +CREATE INDEX idx_lessons_preview ON education.lessons(is_preview) WHERE is_preview = true; + +-- Comentarios +COMMENT ON TABLE education.lessons IS 'Lecciones individuales dentro de módulos'; +COMMENT ON COLUMN education.lessons.attachments IS 'Archivos adjuntos en formato JSON: [{name, url, type, size}]'; +COMMENT ON COLUMN education.lessons.is_preview IS 'Puede verse sin enrollment (preview gratuito)'; +COMMENT ON COLUMN education.lessons.is_mandatory IS 'Requerido para completar el curso'; +COMMENT ON COLUMN education.lessons.xp_reward IS 'XP otorgado al completar la lección'; diff --git a/ddl/schemas/education/tables/05-enrollments.sql b/ddl/schemas/education/tables/05-enrollments.sql new file mode 100644 index 0000000..78053f2 --- /dev/null +++ b/ddl/schemas/education/tables/05-enrollments.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.enrollments +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.enrollments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, + + -- Estado + status education.enrollment_status DEFAULT 'active', + + -- Progreso + progress_percentage DECIMAL(5,2) DEFAULT 0.00, + completed_lessons INTEGER DEFAULT 0, + total_lessons INTEGER DEFAULT 0, -- Snapshot del total al enrollarse + + -- Fechas importantes + enrolled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + started_at TIMESTAMPTZ, -- Primera lección vista + completed_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, -- Para cursos con límite de tiempo + + -- Gamificación + total_xp_earned INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course UNIQUE(user_id, course_id), + CONSTRAINT valid_progress CHECK (progress_percentage >= 0 AND progress_percentage <= 100), + CONSTRAINT valid_completion CHECK ( + (status != 'completed') OR + (completed_at IS NOT NULL AND progress_percentage = 100) + ) +); + +-- Índices +CREATE INDEX idx_enrollments_user ON education.enrollments(user_id); +CREATE INDEX idx_enrollments_course ON education.enrollments(course_id); +CREATE INDEX idx_enrollments_status ON education.enrollments(status); +CREATE INDEX idx_enrollments_user_active ON education.enrollments(user_id, status) + WHERE status = 'active'; + +-- Comentarios +COMMENT ON TABLE education.enrollments IS 'Inscripciones de usuarios a cursos'; +COMMENT ON COLUMN education.enrollments.total_lessons IS 'Snapshot del total de lecciones al momento de enrollarse'; +COMMENT ON COLUMN education.enrollments.started_at IS 'Timestamp de la primera lección vista'; +COMMENT ON COLUMN education.enrollments.expires_at IS 'Fecha de expiración para cursos con límite de tiempo'; diff --git a/ddl/schemas/education/tables/06-progress.sql b/ddl/schemas/education/tables/06-progress.sql new file mode 100644 index 0000000..2f84c8d --- /dev/null +++ b/ddl/schemas/education/tables/06-progress.sql @@ -0,0 +1,52 @@ +-- ===================================================== +-- TABLE: education.progress +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.progress ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES education.lessons(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + + -- Estado + is_completed BOOLEAN DEFAULT false, + + -- Progreso de video + last_position_seconds INTEGER DEFAULT 0, + total_watch_time_seconds INTEGER DEFAULT 0, -- Tiempo total visto + watch_percentage DECIMAL(5,2) DEFAULT 0.00, + + -- Tracking + first_viewed_at TIMESTAMPTZ, + last_viewed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_lesson UNIQUE(user_id, lesson_id), + CONSTRAINT valid_watch_percentage CHECK (watch_percentage >= 0 AND watch_percentage <= 100), + CONSTRAINT completion_requires_date CHECK ( + (NOT is_completed) OR (completed_at IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_progress_user ON education.progress(user_id); +CREATE INDEX idx_progress_lesson ON education.progress(lesson_id); +CREATE INDEX idx_progress_enrollment ON education.progress(enrollment_id); +CREATE INDEX idx_progress_completed ON education.progress(is_completed) WHERE is_completed = true; +CREATE INDEX idx_progress_user_enrollment ON education.progress(user_id, enrollment_id); + +-- Comentarios +COMMENT ON TABLE education.progress IS 'Progreso individual del usuario en cada lección'; +COMMENT ON COLUMN education.progress.last_position_seconds IS 'Última posición del video en segundos'; +COMMENT ON COLUMN education.progress.total_watch_time_seconds IS 'Tiempo total de visualización acumulado'; +COMMENT ON COLUMN education.progress.watch_percentage IS 'Porcentaje de la lección completada'; diff --git a/ddl/schemas/education/tables/07-quizzes.sql b/ddl/schemas/education/tables/07-quizzes.sql new file mode 100644 index 0000000..d5ac8a8 --- /dev/null +++ b/ddl/schemas/education/tables/07-quizzes.sql @@ -0,0 +1,57 @@ +-- ===================================================== +-- TABLE: education.quizzes +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quizzes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación (puede estar asociado a módulo o lección) + module_id UUID REFERENCES education.modules(id) ON DELETE CASCADE, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE CASCADE, + + -- Información básica + title VARCHAR(200) NOT NULL, + description TEXT, + + -- Configuración + passing_score_percentage INTEGER DEFAULT 70, -- % mínimo para aprobar + max_attempts INTEGER, -- NULL = intentos ilimitados + time_limit_minutes INTEGER, -- NULL = sin límite de tiempo + + -- Opciones + shuffle_questions BOOLEAN DEFAULT true, + shuffle_answers BOOLEAN DEFAULT true, + show_correct_answers BOOLEAN DEFAULT true, -- Después de completar + + -- Gamificación + xp_reward INTEGER DEFAULT 50, + xp_perfect_score_bonus INTEGER DEFAULT 20, + + -- Estado + is_active BOOLEAN DEFAULT true, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_passing_score CHECK (passing_score_percentage > 0 AND passing_score_percentage <= 100), + CONSTRAINT quiz_association CHECK ( + (module_id IS NOT NULL AND lesson_id IS NULL) OR + (module_id IS NULL AND lesson_id IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_quizzes_module ON education.quizzes(module_id); +CREATE INDEX idx_quizzes_lesson ON education.quizzes(lesson_id); +CREATE INDEX idx_quizzes_active ON education.quizzes(is_active) WHERE is_active = true; + +-- Comentarios +COMMENT ON TABLE education.quizzes IS 'Quizzes/evaluaciones asociadas a módulos o lecciones'; +COMMENT ON COLUMN education.quizzes.max_attempts IS 'NULL = intentos ilimitados'; +COMMENT ON COLUMN education.quizzes.time_limit_minutes IS 'NULL = sin límite de tiempo'; +COMMENT ON COLUMN education.quizzes.xp_perfect_score_bonus IS 'XP bonus por obtener 100% de score'; diff --git a/ddl/schemas/education/tables/08-quiz_questions.sql b/ddl/schemas/education/tables/08-quiz_questions.sql new file mode 100644 index 0000000..76383a1 --- /dev/null +++ b/ddl/schemas/education/tables/08-quiz_questions.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.quiz_questions +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quiz_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE CASCADE, + + -- Pregunta + question_text TEXT NOT NULL, + question_type education.question_type NOT NULL DEFAULT 'multiple_choice', + + -- Opciones de respuesta (para multiple_choice, true_false, multiple_select) + options JSONB, -- [{id, text, isCorrect}] + + -- Respuesta correcta (para fill_blank, code_challenge) + correct_answer TEXT, + + -- Explicación + explanation TEXT, -- Mostrar después de responder + + -- Recursos adicionales + image_url VARCHAR(500), + code_snippet TEXT, + + -- Puntuación + points INTEGER DEFAULT 1, + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_options CHECK ( + (question_type NOT IN ('multiple_choice', 'true_false', 'multiple_select')) OR + (options IS NOT NULL) + ) +); + +-- Índices +CREATE INDEX idx_quiz_questions_quiz ON education.quiz_questions(quiz_id); +CREATE INDEX idx_quiz_questions_order ON education.quiz_questions(quiz_id, display_order); + +-- Comentarios +COMMENT ON TABLE education.quiz_questions IS 'Preguntas individuales de los quizzes'; +COMMENT ON COLUMN education.quiz_questions.options IS 'Array JSON de opciones: [{id, text, isCorrect}]'; +COMMENT ON COLUMN education.quiz_questions.correct_answer IS 'Respuesta correcta para fill_blank y code_challenge'; +COMMENT ON COLUMN education.quiz_questions.explanation IS 'Explicación mostrada después de responder'; diff --git a/ddl/schemas/education/tables/09-quiz_attempts.sql b/ddl/schemas/education/tables/09-quiz_attempts.sql new file mode 100644 index 0000000..626f416 --- /dev/null +++ b/ddl/schemas/education/tables/09-quiz_attempts.sql @@ -0,0 +1,53 @@ +-- ===================================================== +-- TABLE: education.quiz_attempts +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.quiz_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + quiz_id UUID NOT NULL REFERENCES education.quizzes(id) ON DELETE RESTRICT, + enrollment_id UUID REFERENCES education.enrollments(id) ON DELETE SET NULL, + + -- Estado del intento + is_completed BOOLEAN DEFAULT false, + is_passed BOOLEAN DEFAULT false, + + -- Respuestas del usuario + user_answers JSONB, -- [{questionId, answer, isCorrect, points}] + + -- Puntuación + score_points INTEGER DEFAULT 0, + max_points INTEGER DEFAULT 0, + score_percentage DECIMAL(5,2) DEFAULT 0.00, + + -- Tiempo + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + completed_at TIMESTAMPTZ, + time_taken_seconds INTEGER, + + -- XP ganado + xp_earned INTEGER DEFAULT 0, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT valid_score_percentage CHECK (score_percentage >= 0 AND score_percentage <= 100) +); + +-- Índices +CREATE INDEX idx_quiz_attempts_user ON education.quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_quiz ON education.quiz_attempts(quiz_id); +CREATE INDEX idx_quiz_attempts_enrollment ON education.quiz_attempts(enrollment_id); +CREATE INDEX idx_quiz_attempts_user_quiz ON education.quiz_attempts(user_id, quiz_id); +CREATE INDEX idx_quiz_attempts_completed ON education.quiz_attempts(is_completed, completed_at); + +-- Comentarios +COMMENT ON TABLE education.quiz_attempts IS 'Intentos de los usuarios en los quizzes'; +COMMENT ON COLUMN education.quiz_attempts.user_answers IS 'Respuestas del usuario: [{questionId, answer, isCorrect, points}]'; +COMMENT ON COLUMN education.quiz_attempts.time_taken_seconds IS 'Tiempo total empleado en segundos'; diff --git a/ddl/schemas/education/tables/10-certificates.sql b/ddl/schemas/education/tables/10-certificates.sql new file mode 100644 index 0000000..c0016b6 --- /dev/null +++ b/ddl/schemas/education/tables/10-certificates.sql @@ -0,0 +1,54 @@ +-- ===================================================== +-- TABLE: education.certificates +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE RESTRICT, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE RESTRICT, + + -- Información del certificado + certificate_number VARCHAR(50) NOT NULL UNIQUE, -- OQI-CERT-XXXX-YYYY + + -- Datos del certificado (snapshot) + user_name VARCHAR(200) NOT NULL, + course_title VARCHAR(200) NOT NULL, + instructor_name VARCHAR(200), + completion_date DATE NOT NULL, + + -- Metadata de logro + final_score DECIMAL(5,2), -- Promedio de quizzes + total_xp_earned INTEGER, + + -- URL del PDF generado + certificate_url VARCHAR(500), + + -- Verificación + verification_code VARCHAR(100) UNIQUE, -- Para verificar autenticidad + is_verified BOOLEAN DEFAULT true, + + -- Metadata + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course_cert UNIQUE(user_id, course_id) +); + +-- Índices +CREATE INDEX idx_certificates_user ON education.certificates(user_id); +CREATE INDEX idx_certificates_course ON education.certificates(course_id); +CREATE INDEX idx_certificates_number ON education.certificates(certificate_number); +CREATE INDEX idx_certificates_verification ON education.certificates(verification_code); + +-- Comentarios +COMMENT ON TABLE education.certificates IS 'Certificados emitidos al completar cursos'; +COMMENT ON COLUMN education.certificates.certificate_number IS 'Número único formato: OQI-CERT-YYYY-NNNNN'; +COMMENT ON COLUMN education.certificates.verification_code IS 'Código para verificar autenticidad del certificado'; +COMMENT ON COLUMN education.certificates.final_score IS 'Promedio de todos los quizzes del curso'; diff --git a/ddl/schemas/education/tables/11-user_achievements.sql b/ddl/schemas/education/tables/11-user_achievements.sql new file mode 100644 index 0000000..2337773 --- /dev/null +++ b/ddl/schemas/education/tables/11-user_achievements.sql @@ -0,0 +1,47 @@ +-- ===================================================== +-- TABLE: education.user_achievements +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: ET-EDU-001-database.md +-- ===================================================== + +CREATE TABLE education.user_achievements ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relación + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de logro + achievement_type education.achievement_type NOT NULL, + + -- Información del logro + title VARCHAR(200) NOT NULL, + description TEXT, + badge_icon_url VARCHAR(500), + + -- Metadata del logro + metadata JSONB, -- Información específica del logro + + -- Referencias + course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, + quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, + + -- XP bonus por el logro + xp_bonus INTEGER DEFAULT 0, + + -- Metadata + earned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_user_achievements_user ON education.user_achievements(user_id); +CREATE INDEX idx_user_achievements_type ON education.user_achievements(achievement_type); +CREATE INDEX idx_user_achievements_earned ON education.user_achievements(earned_at DESC); +CREATE INDEX idx_user_achievements_course ON education.user_achievements(course_id); + +-- Comentarios +COMMENT ON TABLE education.user_achievements IS 'Logros/badges obtenidos por los usuarios'; +COMMENT ON COLUMN education.user_achievements.metadata IS 'Información adicional específica del tipo de logro'; +COMMENT ON COLUMN education.user_achievements.xp_bonus IS 'XP bonus otorgado por este logro'; diff --git a/ddl/schemas/education/tables/12-user_gamification_profile.sql b/ddl/schemas/education/tables/12-user_gamification_profile.sql new file mode 100644 index 0000000..8b1b310 --- /dev/null +++ b/ddl/schemas/education/tables/12-user_gamification_profile.sql @@ -0,0 +1,56 @@ +-- ===================================================== +-- TABLE: education.user_gamification_profile +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para gamificación +-- ===================================================== + +CREATE TABLE education.user_gamification_profile ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- XP y nivel + total_xp INTEGER NOT NULL DEFAULT 0, + current_level INTEGER NOT NULL DEFAULT 1, + xp_to_next_level INTEGER NOT NULL DEFAULT 100, + + -- Streaks + current_streak_days INTEGER DEFAULT 0, + longest_streak_days INTEGER DEFAULT 0, + last_activity_date DATE, + + -- Estadísticas + total_courses_completed INTEGER DEFAULT 0, + total_lessons_completed INTEGER DEFAULT 0, + total_quizzes_passed INTEGER DEFAULT 0, + total_certificates_earned INTEGER DEFAULT 0, + average_quiz_score DECIMAL(5,2) DEFAULT 0.00, + + -- Ranking + weekly_xp INTEGER DEFAULT 0, + monthly_xp INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_gamification UNIQUE(user_id), + CONSTRAINT valid_level CHECK (current_level >= 1), + CONSTRAINT valid_xp CHECK (total_xp >= 0), + CONSTRAINT valid_streak CHECK (current_streak_days >= 0 AND longest_streak_days >= 0), + CONSTRAINT valid_avg_score CHECK (average_quiz_score >= 0 AND average_quiz_score <= 100) +); + +-- Índices +CREATE INDEX idx_gamification_user ON education.user_gamification_profile(user_id); +CREATE INDEX idx_gamification_level ON education.user_gamification_profile(current_level DESC); +CREATE INDEX idx_gamification_xp ON education.user_gamification_profile(total_xp DESC); +CREATE INDEX idx_gamification_weekly ON education.user_gamification_profile(weekly_xp DESC); +CREATE INDEX idx_gamification_monthly ON education.user_gamification_profile(monthly_xp DESC); + +-- Comentarios +COMMENT ON TABLE education.user_gamification_profile IS 'Perfil de gamificación del usuario con XP, niveles, streaks y estadísticas'; +COMMENT ON COLUMN education.user_gamification_profile.current_streak_days IS 'Días consecutivos de actividad actual'; +COMMENT ON COLUMN education.user_gamification_profile.longest_streak_days IS 'Racha más larga de días consecutivos'; +COMMENT ON COLUMN education.user_gamification_profile.weekly_xp IS 'XP acumulado en la semana actual (para leaderboards)'; +COMMENT ON COLUMN education.user_gamification_profile.monthly_xp IS 'XP acumulado en el mes actual (para leaderboards)'; diff --git a/ddl/schemas/education/tables/13-user_activity_log.sql b/ddl/schemas/education/tables/13-user_activity_log.sql new file mode 100644 index 0000000..50dad0b --- /dev/null +++ b/ddl/schemas/education/tables/13-user_activity_log.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- TABLE: education.user_activity_log +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para tracking de actividad +-- ===================================================== + +CREATE TABLE education.user_activity_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de actividad + activity_type VARCHAR(50) NOT NULL, -- lesson_view, quiz_complete, course_enroll, etc. + + -- Referencias opcionales + course_id UUID REFERENCES education.courses(id) ON DELETE SET NULL, + lesson_id UUID REFERENCES education.lessons(id) ON DELETE SET NULL, + quiz_id UUID REFERENCES education.quizzes(id) ON DELETE SET NULL, + + -- Metadata + metadata JSONB DEFAULT '{}', + xp_earned INTEGER DEFAULT 0, + + -- Contexto + ip_address INET, + user_agent TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_activity_user ON education.user_activity_log(user_id); +CREATE INDEX idx_activity_type ON education.user_activity_log(activity_type); +CREATE INDEX idx_activity_created ON education.user_activity_log(created_at DESC); +CREATE INDEX idx_activity_user_date ON education.user_activity_log(user_id, created_at DESC); +CREATE INDEX idx_activity_course ON education.user_activity_log(course_id) WHERE course_id IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE education.user_activity_log IS 'Log de actividades del usuario en el módulo educativo'; +COMMENT ON COLUMN education.user_activity_log.activity_type IS 'Tipos: lesson_view, lesson_complete, quiz_start, quiz_complete, course_enroll, etc.'; +COMMENT ON COLUMN education.user_activity_log.metadata IS 'Información adicional específica del tipo de actividad'; +COMMENT ON COLUMN education.user_activity_log.xp_earned IS 'XP ganado en esta actividad (si aplica)'; diff --git a/ddl/schemas/education/tables/14-course_reviews.sql b/ddl/schemas/education/tables/14-course_reviews.sql new file mode 100644 index 0000000..f0dd4d4 --- /dev/null +++ b/ddl/schemas/education/tables/14-course_reviews.sql @@ -0,0 +1,48 @@ +-- ===================================================== +-- TABLE: education.course_reviews +-- ===================================================== +-- Proyecto: OrbiQuant IA (Trading Platform) +-- Módulo: OQI-002 - Education +-- Especificación: Tabla adicional para reviews de cursos +-- ===================================================== + +CREATE TABLE education.course_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES education.courses(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES education.enrollments(id) ON DELETE CASCADE, + + -- Review + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + title VARCHAR(200), + content TEXT, + + -- Moderación + is_approved BOOLEAN DEFAULT false, + is_featured BOOLEAN DEFAULT false, + approved_by UUID REFERENCES auth.users(id), + approved_at TIMESTAMPTZ, + + -- Votos útiles + helpful_votes INTEGER DEFAULT 0, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + CONSTRAINT unique_user_course_review UNIQUE(user_id, course_id) +); + +-- Índices +CREATE INDEX idx_reviews_course ON education.course_reviews(course_id); +CREATE INDEX idx_reviews_user ON education.course_reviews(user_id); +CREATE INDEX idx_reviews_rating ON education.course_reviews(rating); +CREATE INDEX idx_reviews_approved ON education.course_reviews(is_approved) WHERE is_approved = true; +CREATE INDEX idx_reviews_featured ON education.course_reviews(is_featured) WHERE is_featured = true; +CREATE INDEX idx_reviews_helpful ON education.course_reviews(helpful_votes DESC); + +-- Comentarios +COMMENT ON TABLE education.course_reviews IS 'Reviews y calificaciones de cursos por usuarios'; +COMMENT ON COLUMN education.course_reviews.is_approved IS 'Review aprobada por moderador'; +COMMENT ON COLUMN education.course_reviews.is_featured IS 'Review destacada para mostrar en página del curso'; +COMMENT ON COLUMN education.course_reviews.helpful_votes IS 'Número de votos útiles de otros usuarios'; diff --git a/ddl/schemas/education/uninstall.sh b/ddl/schemas/education/uninstall.sh new file mode 100755 index 0000000..8172255 --- /dev/null +++ b/ddl/schemas/education/uninstall.sh @@ -0,0 +1,55 @@ +#!/bin/bash + +# ===================================================== +# UNINSTALL SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# ===================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-orbiquant}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${YELLOW}=================================================${NC}" +echo -e "${YELLOW} OrbiQuant IA - Education Schema Uninstall${NC}" +echo -e "${YELLOW}=================================================${NC}" +echo "" + +echo -e "${RED}WARNING: This will DROP the entire '$SCHEMA_NAME' schema and ALL its data!${NC}" +echo "" +read -p "Are you sure you want to continue? (type 'yes' to confirm): " CONFIRM + +if [ "$CONFIRM" != "yes" ]; then + echo "Uninstall cancelled." + exit 0 +fi + +echo "" +echo -e "${YELLOW}▶${NC} Dropping schema: $SCHEMA_NAME (CASCADE)" + +if PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -c "DROP SCHEMA IF EXISTS $SCHEMA_NAME CASCADE;" > /dev/null 2>&1; then + echo -e "${GREEN} ✓ Schema dropped successfully${NC}" +else + echo -e "${RED} ✗ Failed to drop schema${NC}" + exit 1 +fi + +echo "" +echo -e "${GREEN}=================================================${NC}" +echo -e "${GREEN} Uninstall Complete!${NC}" +echo -e "${GREEN}=================================================${NC}" +echo "" +echo "Schema '$SCHEMA_NAME' has been removed." +echo "" diff --git a/ddl/schemas/education/verify.sh b/ddl/schemas/education/verify.sh new file mode 100755 index 0000000..155ff48 --- /dev/null +++ b/ddl/schemas/education/verify.sh @@ -0,0 +1,145 @@ +#!/bin/bash + +# ===================================================== +# VERIFY SCRIPT - Schema Education +# ===================================================== +# Proyecto: OrbiQuant IA (Trading Platform) +# Módulo: OQI-002 - Education +# ===================================================== + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +# Configuration +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" +DB_NAME="${DB_NAME:-orbiquant}" +DB_USER="${DB_USER:-postgres}" +SCHEMA_NAME="education" + +echo -e "${BLUE}=================================================${NC}" +echo -e "${BLUE} OrbiQuant IA - Education Schema Verification${NC}" +echo -e "${BLUE}=================================================${NC}" +echo "" + +# Check if psql is available +if ! command -v psql &> /dev/null; then + echo -e "${RED}Error: psql command not found${NC}" + exit 1 +fi + +# Function to run query and return result +run_query() { + local query=$1 + PGPASSWORD=$DB_PASSWORD psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -t -c "$query" 2>/dev/null | xargs +} + +echo "Configuration:" +echo " Database: $DB_NAME" +echo " Host: $DB_HOST:$DB_PORT" +echo " Schema: $SCHEMA_NAME" +echo "" + +# Check if schema exists +echo -e "${YELLOW}▶${NC} Checking schema existence..." +SCHEMA_EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = '$SCHEMA_NAME';") +if [ "$SCHEMA_EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ Schema exists${NC}" +else + echo -e "${RED} ✗ Schema not found${NC}" + exit 1 +fi + +# Check ENUMs +echo "" +echo -e "${YELLOW}▶${NC} Checking ENUMs..." +EXPECTED_ENUMS=("difficulty_level" "course_status" "enrollment_status" "lesson_content_type" "question_type" "achievement_type") +ENUM_COUNT=0 + +for enum_name in "${EXPECTED_ENUMS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM pg_type WHERE typname = '$enum_name' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") + if [ "$EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ $enum_name${NC}" + ((ENUM_COUNT++)) + else + echo -e "${RED} ✗ $enum_name${NC}" + fi +done + +# Check tables +echo "" +echo -e "${YELLOW}▶${NC} Checking tables..." +EXPECTED_TABLES=("categories" "courses" "modules" "lessons" "enrollments" "progress" "quizzes" "quiz_questions" "quiz_attempts" "certificates" "user_achievements" "user_gamification_profile" "user_activity_log" "course_reviews") +TABLE_COUNT=0 + +for table_name in "${EXPECTED_TABLES[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$table_name';") + if [ "$EXISTS" -eq "1" ]; then + ROW_COUNT=$(run_query "SELECT COUNT(*) FROM $SCHEMA_NAME.$table_name;") + echo -e "${GREEN} ✓ $table_name${NC} ($ROW_COUNT rows)" + ((TABLE_COUNT++)) + else + echo -e "${RED} ✗ $table_name${NC}" + fi +done + +# Check functions +echo "" +echo -e "${YELLOW}▶${NC} Checking functions..." +EXPECTED_FUNCTIONS=("update_updated_at_column" "update_enrollment_progress" "auto_complete_enrollment" "generate_certificate_number" "update_course_rating_stats" "update_enrollment_count" "update_user_xp" "update_user_streak") +FUNCTION_COUNT=0 + +for function_name in "${EXPECTED_FUNCTIONS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM pg_proc WHERE proname = '$function_name' AND pronamespace = (SELECT oid FROM pg_namespace WHERE nspname = '$SCHEMA_NAME');") + if [ "$EXISTS" -ge "1" ]; then + echo -e "${GREEN} ✓ $function_name${NC}" + ((FUNCTION_COUNT++)) + else + echo -e "${RED} ✗ $function_name${NC}" + fi +done + +# Check views +echo "" +echo -e "${YELLOW}▶${NC} Checking views..." +EXPECTED_VIEWS=("v_courses_with_stats" "v_user_course_progress" "v_leaderboard_weekly" "v_leaderboard_monthly" "v_leaderboard_alltime" "v_user_statistics" "v_popular_courses") +VIEW_COUNT=0 + +for view_name in "${EXPECTED_VIEWS[@]}"; do + EXISTS=$(run_query "SELECT COUNT(*) FROM information_schema.views WHERE table_schema = '$SCHEMA_NAME' AND table_name = '$view_name';") + if [ "$EXISTS" -eq "1" ]; then + echo -e "${GREEN} ✓ $view_name${NC}" + ((VIEW_COUNT++)) + else + echo -e "${RED} ✗ $view_name${NC}" + fi +done + +# Summary +echo "" +echo -e "${BLUE}=================================================${NC}" +echo -e "${BLUE} Verification Summary${NC}" +echo -e "${BLUE}=================================================${NC}" +echo "" +echo "ENUMs: $ENUM_COUNT / ${#EXPECTED_ENUMS[@]}" +echo "Tables: $TABLE_COUNT / ${#EXPECTED_TABLES[@]}" +echo "Functions: $FUNCTION_COUNT / ${#EXPECTED_FUNCTIONS[@]}" +echo "Views: $VIEW_COUNT / ${#EXPECTED_VIEWS[@]}" +echo "" + +TOTAL_EXPECTED=$((${#EXPECTED_ENUMS[@]} + ${#EXPECTED_TABLES[@]} + ${#EXPECTED_FUNCTIONS[@]} + ${#EXPECTED_VIEWS[@]})) +TOTAL_FOUND=$((ENUM_COUNT + TABLE_COUNT + FUNCTION_COUNT + VIEW_COUNT)) + +if [ "$TOTAL_FOUND" -eq "$TOTAL_EXPECTED" ]; then + echo -e "${GREEN}✓ All components verified successfully!${NC}" + exit 0 +else + echo -e "${YELLOW}⚠ Some components are missing ($TOTAL_FOUND / $TOTAL_EXPECTED)${NC}" + exit 1 +fi diff --git a/ddl/schemas/financial/00-enums.sql b/ddl/schemas/financial/00-enums.sql new file mode 100644 index 0000000..c8619cc --- /dev/null +++ b/ddl/schemas/financial/00-enums.sql @@ -0,0 +1,131 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA ENUMS +-- ===================================================== +-- Description: Type definitions for financial domain +-- Schema: financial +-- ===================================================== + +-- Tipos de wallet +CREATE TYPE financial.wallet_type AS ENUM ( + 'trading', -- Para operaciones de trading + 'investment', -- Para cuentas PAMM + 'earnings', -- Para ganancias/distribuciones + 'referral' -- Para bonos de referidos +); + +-- Estados de wallet +CREATE TYPE financial.wallet_status AS ENUM ( + 'active', + 'frozen', + 'closed' +); + +-- Tipos de transacción +CREATE TYPE financial.transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'transfer_in', + 'transfer_out', + 'fee', + 'refund', + 'earning', + 'distribution', + 'bonus' +); + +-- Estados de transacción +CREATE TYPE financial.transaction_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'reversed' +); + +-- Planes de suscripción +CREATE TYPE financial.subscription_plan AS ENUM ( + 'free', + 'basic', + 'pro', + 'premium', + 'enterprise' +); + +-- Estados de suscripción +CREATE TYPE financial.subscription_status AS ENUM ( + 'active', + 'past_due', + 'cancelled', + 'incomplete', + 'trialing', + 'unpaid', + 'paused' +); + +-- Monedas soportadas +CREATE TYPE financial.currency_code AS ENUM ( + 'USD', + 'MXN', + 'EUR' +); + +-- Métodos de pago +CREATE TYPE financial.payment_method AS ENUM ( + 'card', + 'bank_transfer', + 'wire', + 'crypto', + 'paypal', + 'stripe' +); + +-- Estados de pago +CREATE TYPE financial.payment_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed', + 'cancelled', + 'refunded' +); + +-- Tipos de invoice +CREATE TYPE financial.invoice_type AS ENUM ( + 'subscription', + 'one_time', + 'usage' +); + +-- Estados de invoice +CREATE TYPE financial.invoice_status AS ENUM ( + 'draft', + 'open', + 'paid', + 'void', + 'uncollectible' +); + +-- Acciones de auditoría +CREATE TYPE financial.audit_action AS ENUM ( + 'created', + 'balance_updated', + 'status_changed', + 'limit_changed', + 'frozen', + 'unfrozen', + 'closed' +); + +COMMENT ON TYPE financial.wallet_type IS 'Tipos de wallets en el sistema'; +COMMENT ON TYPE financial.wallet_status IS 'Estados posibles de una wallet'; +COMMENT ON TYPE financial.transaction_type IS 'Tipos de transacciones financieras'; +COMMENT ON TYPE financial.transaction_status IS 'Estados del ciclo de vida de una transacción'; +COMMENT ON TYPE financial.subscription_plan IS 'Planes de suscripción disponibles'; +COMMENT ON TYPE financial.subscription_status IS 'Estados de suscripción según Stripe'; +COMMENT ON TYPE financial.currency_code IS 'Códigos de moneda ISO 4217 soportados'; +COMMENT ON TYPE financial.payment_method IS 'Métodos de pago aceptados'; +COMMENT ON TYPE financial.payment_status IS 'Estados de procesamiento de pagos'; +COMMENT ON TYPE financial.invoice_type IS 'Tipos de factura'; +COMMENT ON TYPE financial.invoice_status IS 'Estados de factura'; +COMMENT ON TYPE financial.audit_action IS 'Acciones auditables en wallets'; diff --git a/ddl/schemas/financial/functions/01-update_wallet_balance.sql b/ddl/schemas/financial/functions/01-update_wallet_balance.sql new file mode 100644 index 0000000..1fc9aaf --- /dev/null +++ b/ddl/schemas/financial/functions/01-update_wallet_balance.sql @@ -0,0 +1,283 @@ +-- ===================================================== +-- ORBIQUANT IA - UPDATE WALLET BALANCE FUNCTION +-- ===================================================== +-- Description: Safely update wallet balance with audit trail +-- Schema: financial +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.update_wallet_balance( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_operation VARCHAR(20), -- 'add', 'subtract', 'set' + p_transaction_id UUID DEFAULT NULL, + p_actor_id UUID DEFAULT NULL, + p_actor_type VARCHAR(50) DEFAULT 'system', + p_reason TEXT DEFAULT NULL, + p_metadata JSONB DEFAULT '{}' +) +RETURNS TABLE ( + success BOOLEAN, + new_balance DECIMAL(20,8), + new_available DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_old_balance DECIMAL(20,8); + v_old_available DECIMAL(20,8); + v_new_balance DECIMAL(20,8); + v_new_available DECIMAL(20,8); +BEGIN + -- Lock wallet row for update + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + -- Validar que existe + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + -- Validar que está activa + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, v_wallet.balance, v_wallet.available_balance, + 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; + RETURN; + END IF; + + -- Guardar valores antiguos + v_old_balance := v_wallet.balance; + v_old_available := v_wallet.available_balance; + + -- Calcular nuevo balance según operación + CASE p_operation + WHEN 'add' THEN + v_new_balance := v_old_balance + p_amount; + v_new_available := v_old_available + p_amount; + WHEN 'subtract' THEN + v_new_balance := v_old_balance - p_amount; + v_new_available := v_old_available - p_amount; + WHEN 'set' THEN + v_new_balance := p_amount; + v_new_available := p_amount - v_wallet.pending_balance; + ELSE + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Invalid operation: ' || p_operation; + RETURN; + END CASE; + + -- Validar que no quede negativo + IF v_new_balance < 0 THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Insufficient balance (current: ' || v_old_balance::TEXT || ', required: ' || p_amount::TEXT || ')'; + RETURN; + END IF; + + IF v_new_available < 0 THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Insufficient available balance (current: ' || v_old_available::TEXT || ')'; + RETURN; + END IF; + + -- Validar min_balance si existe + IF v_wallet.min_balance IS NOT NULL AND v_new_available < v_wallet.min_balance THEN + RETURN QUERY SELECT false, v_old_balance, v_old_available, + 'Would violate minimum balance requirement (min: ' || v_wallet.min_balance::TEXT || ')'; + RETURN; + END IF; + + -- Actualizar wallet + UPDATE financial.wallets + SET + balance = v_new_balance, + available_balance = v_new_available, + last_transaction_at = NOW(), + updated_at = NOW() + WHERE id = p_wallet_id; + + -- Registrar en audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, + action, + actor_id, + actor_type, + old_values, + new_values, + balance_before, + balance_after, + transaction_id, + reason, + metadata + ) VALUES ( + p_wallet_id, + 'balance_updated', + p_actor_id, + p_actor_type, + jsonb_build_object( + 'balance', v_old_balance, + 'available_balance', v_old_available + ), + jsonb_build_object( + 'balance', v_new_balance, + 'available_balance', v_new_available + ), + v_old_balance, + v_new_balance, + p_transaction_id, + p_reason, + p_metadata + ); + + -- Retornar éxito + RETURN QUERY SELECT true, v_new_balance, v_new_available, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.update_wallet_balance IS 'Safely update wallet balance with validation and audit trail'; + +-- Función helper para reservar fondos (pending balance) +CREATE OR REPLACE FUNCTION financial.reserve_wallet_funds( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_reason TEXT DEFAULT NULL +) +RETURNS TABLE ( + success BOOLEAN, + new_available DECIMAL(20,8), + new_pending DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; +BEGIN + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Wallet is not active'; + RETURN; + END IF; + + IF v_wallet.available_balance < p_amount THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Insufficient available balance'; + RETURN; + END IF; + + -- Mover de available a pending + UPDATE financial.wallets + SET + available_balance = available_balance - p_amount, + pending_balance = pending_balance + p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + + -- Audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, action, actor_type, reason, + old_values, new_values + ) VALUES ( + p_wallet_id, 'balance_updated', 'system', p_reason, + jsonb_build_object('available', v_wallet.available_balance, 'pending', v_wallet.pending_balance), + jsonb_build_object('available', v_wallet.available_balance - p_amount, 'pending', v_wallet.pending_balance + p_amount) + ); + + RETURN QUERY SELECT + true, + v_wallet.available_balance - p_amount, + v_wallet.pending_balance + p_amount, + NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.reserve_wallet_funds IS 'Reserve funds by moving from available to pending balance'; + +-- Función helper para liberar fondos reservados +CREATE OR REPLACE FUNCTION financial.release_wallet_funds( + p_wallet_id UUID, + p_amount DECIMAL(20,8), + p_to_available BOOLEAN DEFAULT true, + p_reason TEXT DEFAULT NULL +) +RETURNS TABLE ( + success BOOLEAN, + new_available DECIMAL(20,8), + new_pending DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_new_balance DECIMAL(20,8); +BEGIN + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.pending_balance < p_amount THEN + RETURN QUERY SELECT false, v_wallet.available_balance, v_wallet.pending_balance, + 'Insufficient pending balance'; + RETURN; + END IF; + + -- Liberar fondos + IF p_to_available THEN + -- Devolver a available + v_new_balance := v_wallet.balance; + UPDATE financial.wallets + SET + available_balance = available_balance + p_amount, + pending_balance = pending_balance - p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + ELSE + -- Remover completamente (ej: después de withdrawal exitoso) + v_new_balance := v_wallet.balance - p_amount; + UPDATE financial.wallets + SET + balance = balance - p_amount, + pending_balance = pending_balance - p_amount, + updated_at = NOW() + WHERE id = p_wallet_id; + END IF; + + -- Audit log + INSERT INTO financial.wallet_audit_log ( + wallet_id, action, actor_type, reason, metadata + ) VALUES ( + p_wallet_id, 'balance_updated', 'system', p_reason, + jsonb_build_object('released_amount', p_amount, 'to_available', p_to_available) + ); + + SELECT available_balance, pending_balance INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id; + + RETURN QUERY SELECT true, v_wallet.available_balance, v_wallet.pending_balance, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.release_wallet_funds IS 'Release reserved funds back to available or remove from balance'; diff --git a/ddl/schemas/financial/functions/02-process_transaction.sql b/ddl/schemas/financial/functions/02-process_transaction.sql new file mode 100644 index 0000000..03530d6 --- /dev/null +++ b/ddl/schemas/financial/functions/02-process_transaction.sql @@ -0,0 +1,326 @@ +-- ===================================================== +-- ORBIQUANT IA - PROCESS TRANSACTION FUNCTION +-- ===================================================== +-- Description: Create and process wallet transactions atomically +-- Schema: financial +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.process_transaction( + p_wallet_id UUID, + p_transaction_type financial.transaction_type, + p_amount DECIMAL(20,8), + p_currency financial.currency_code, + p_fee DECIMAL(15,8) DEFAULT 0, + p_description TEXT DEFAULT NULL, + p_reference_id VARCHAR(100) DEFAULT NULL, + p_destination_wallet_id UUID DEFAULT NULL, + p_idempotency_key VARCHAR(255) DEFAULT NULL, + p_metadata JSONB DEFAULT '{}', + p_auto_complete BOOLEAN DEFAULT false +) +RETURNS TABLE ( + success BOOLEAN, + transaction_id UUID, + new_balance DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet RECORD; + v_tx_id UUID; + v_balance_before DECIMAL(20,8); + v_balance_after DECIMAL(20,8); + v_update_result RECORD; + v_dest_tx_id UUID; +BEGIN + -- Validar idempotency + IF p_idempotency_key IS NOT NULL THEN + SELECT id, wallet_transactions.status INTO v_tx_id, v_wallet + FROM financial.wallet_transactions + WHERE idempotency_key = p_idempotency_key; + + IF FOUND THEN + -- Transacción ya existe + SELECT balance INTO v_balance_after + FROM financial.wallets + WHERE id = p_wallet_id; + + RETURN QUERY SELECT + true, + v_tx_id, + v_balance_after, + 'Transaction already exists (idempotent)'::TEXT; + RETURN; + END IF; + END IF; + + -- Lock wallet + SELECT * INTO v_wallet + FROM financial.wallets + WHERE id = p_wallet_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, NULL::UUID, 0::DECIMAL, 'Wallet not found'; + RETURN; + END IF; + + IF v_wallet.status != 'active' THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Wallet is not active (status: ' || v_wallet.status::TEXT || ')'; + RETURN; + END IF; + + -- Validar currency match + IF v_wallet.currency != p_currency THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Currency mismatch (wallet: ' || v_wallet.currency::TEXT || ', transaction: ' || p_currency::TEXT || ')'; + RETURN; + END IF; + + -- Validar destination para transfers + IF p_transaction_type IN ('transfer_out', 'transfer_in') AND p_destination_wallet_id IS NULL THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Transfer requires destination_wallet_id'; + RETURN; + END IF; + + -- No permitir self-transfers + IF p_destination_wallet_id = p_wallet_id THEN + RETURN QUERY SELECT false, NULL::UUID, v_wallet.balance, + 'Cannot transfer to same wallet'; + RETURN; + END IF; + + v_balance_before := v_wallet.balance; + + -- Crear transacción + INSERT INTO financial.wallet_transactions ( + wallet_id, + transaction_type, + status, + amount, + fee, + currency, + balance_before, + destination_wallet_id, + reference_id, + description, + metadata, + idempotency_key, + processed_at + ) VALUES ( + p_wallet_id, + p_transaction_type, + CASE WHEN p_auto_complete THEN 'completed'::financial.transaction_status + ELSE 'pending'::financial.transaction_status END, + p_amount, + p_fee, + p_currency, + v_balance_before, + p_destination_wallet_id, + p_reference_id, + p_description, + p_metadata, + p_idempotency_key, + CASE WHEN p_auto_complete THEN NOW() ELSE NULL END + ) + RETURNING id INTO v_tx_id; + + -- Si es auto_complete, procesar inmediatamente + IF p_auto_complete THEN + -- Determinar operación de balance + CASE p_transaction_type + WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN + -- Aumentar balance + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + p_wallet_id, + p_amount - p_fee, + 'add', + v_tx_id, + NULL, + 'system', + 'Transaction: ' || p_transaction_type::TEXT + ); + + WHEN 'withdrawal', 'transfer_out', 'fee' THEN + -- Disminuir balance + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + p_wallet_id, + p_amount + p_fee, + 'subtract', + v_tx_id, + NULL, + 'system', + 'Transaction: ' || p_transaction_type::TEXT + ); + + ELSE + RETURN QUERY SELECT false, v_tx_id, v_balance_before, + 'Unknown transaction type: ' || p_transaction_type::TEXT; + RETURN; + END CASE; + + -- Verificar éxito de actualización + IF NOT v_update_result.success THEN + -- Marcar transacción como fallida + UPDATE financial.wallet_transactions + SET + status = 'failed', + failed_reason = v_update_result.error_message, + failed_at = NOW() + WHERE id = v_tx_id; + + RETURN QUERY SELECT false, v_tx_id, v_balance_before, v_update_result.error_message; + RETURN; + END IF; + + v_balance_after := v_update_result.new_balance; + + -- Actualizar balance_after en transacción + UPDATE financial.wallet_transactions + SET + balance_after = v_balance_after, + completed_at = NOW() + WHERE id = v_tx_id; + + -- Si es transfer_out, crear transfer_in en destino + IF p_transaction_type = 'transfer_out' AND p_destination_wallet_id IS NOT NULL THEN + SELECT * INTO v_update_result + FROM financial.process_transaction( + p_destination_wallet_id, + 'transfer_in', + p_amount - p_fee, -- El fee lo paga el origen + p_currency, + 0, -- Sin fee adicional en destino + 'Transfer from wallet ' || p_wallet_id::TEXT, + p_reference_id, + p_wallet_id, -- Origen como destino inverso + p_idempotency_key || '_dest', -- Idempotency para destino + p_metadata, + true -- Auto-complete + ); + + IF v_update_result.success THEN + v_dest_tx_id := v_update_result.transaction_id; + + -- Vincular transacciones + UPDATE financial.wallet_transactions + SET related_transaction_id = v_dest_tx_id + WHERE id = v_tx_id; + + UPDATE financial.wallet_transactions + SET related_transaction_id = v_tx_id + WHERE id = v_dest_tx_id; + END IF; + END IF; + + -- Actualizar totals en wallet + IF p_transaction_type IN ('deposit', 'transfer_in') THEN + UPDATE financial.wallets + SET total_deposits = total_deposits + p_amount + WHERE id = p_wallet_id; + ELSIF p_transaction_type IN ('withdrawal', 'transfer_out') THEN + UPDATE financial.wallets + SET total_withdrawals = total_withdrawals + p_amount + WHERE id = p_wallet_id; + END IF; + + ELSE + -- Transaction pending, no balance update yet + v_balance_after := v_balance_before; + END IF; + + RETURN QUERY SELECT true, v_tx_id, v_balance_after, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.process_transaction IS 'Create and optionally complete a wallet transaction atomically'; + +-- Función para completar transacción pendiente +CREATE OR REPLACE FUNCTION financial.complete_transaction( + p_transaction_id UUID +) +RETURNS TABLE ( + success BOOLEAN, + new_balance DECIMAL(20,8), + error_message TEXT +) +LANGUAGE plpgsql +AS $$ +DECLARE + v_tx RECORD; + v_update_result RECORD; +BEGIN + -- Lock transaction + SELECT * INTO v_tx + FROM financial.wallet_transactions + WHERE id = p_transaction_id + FOR UPDATE; + + IF NOT FOUND THEN + RETURN QUERY SELECT false, 0::DECIMAL, 'Transaction not found'; + RETURN; + END IF; + + IF v_tx.status != 'pending' THEN + RETURN QUERY SELECT false, 0::DECIMAL, + 'Transaction is not pending (status: ' || v_tx.status::TEXT || ')'; + RETURN; + END IF; + + -- Procesar según tipo + CASE v_tx.transaction_type + WHEN 'deposit', 'transfer_in', 'earning', 'distribution', 'bonus', 'refund' THEN + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + v_tx.wallet_id, + v_tx.amount - v_tx.fee, + 'add', + p_transaction_id + ); + + WHEN 'withdrawal', 'transfer_out', 'fee' THEN + SELECT * INTO v_update_result + FROM financial.update_wallet_balance( + v_tx.wallet_id, + v_tx.amount + v_tx.fee, + 'subtract', + p_transaction_id + ); + + ELSE + RETURN QUERY SELECT false, 0::DECIMAL, 'Unknown transaction type'; + RETURN; + END CASE; + + IF NOT v_update_result.success THEN + -- Marcar como fallida + UPDATE financial.wallet_transactions + SET + status = 'failed', + failed_reason = v_update_result.error_message, + failed_at = NOW() + WHERE id = p_transaction_id; + + RETURN QUERY SELECT false, 0::DECIMAL, v_update_result.error_message; + RETURN; + END IF; + + -- Marcar como completada + UPDATE financial.wallet_transactions + SET + status = 'completed', + balance_after = v_update_result.new_balance, + completed_at = NOW(), + processed_at = COALESCE(processed_at, NOW()) + WHERE id = p_transaction_id; + + RETURN QUERY SELECT true, v_update_result.new_balance, NULL::TEXT; +END; +$$; + +COMMENT ON FUNCTION financial.complete_transaction IS 'Complete a pending wallet transaction'; diff --git a/ddl/schemas/financial/functions/03-triggers.sql b/ddl/schemas/financial/functions/03-triggers.sql new file mode 100644 index 0000000..c245ff3 --- /dev/null +++ b/ddl/schemas/financial/functions/03-triggers.sql @@ -0,0 +1,278 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA TRIGGERS +-- ===================================================== +-- Description: Automated triggers for data integrity and audit +-- Schema: financial +-- ===================================================== + +-- ===================================================== +-- TRIGGER: Update timestamps +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.update_timestamp() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + +-- Apply to all tables with updated_at +CREATE TRIGGER trigger_wallets_updated_at + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_transactions_updated_at + BEFORE UPDATE ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_subscriptions_updated_at + BEFORE UPDATE ON financial.subscriptions + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_payments_updated_at + BEFORE UPDATE ON financial.payments + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_invoices_updated_at + BEFORE UPDATE ON financial.invoices + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_exchange_rates_updated_at + BEFORE UPDATE ON financial.currency_exchange_rates + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +CREATE TRIGGER trigger_wallet_limits_updated_at + BEFORE UPDATE ON financial.wallet_limits + FOR EACH ROW + EXECUTE FUNCTION financial.update_timestamp(); + +-- ===================================================== +-- TRIGGER: Auto-generate invoice number +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.generate_invoice_number() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.invoice_number IS NULL THEN + NEW.invoice_number := 'INV-' || TO_CHAR(NOW(), 'YYYYMM') || '-' || + LPAD(nextval('financial.invoice_number_seq')::TEXT, 6, '0'); + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_invoice_number + BEFORE INSERT ON financial.invoices + FOR EACH ROW + EXECUTE FUNCTION financial.generate_invoice_number(); + +-- ===================================================== +-- TRIGGER: Validate wallet balance consistency +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.validate_wallet_balance() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Validar que balance = available + pending + IF NEW.balance != (NEW.available_balance + NEW.pending_balance) THEN + RAISE EXCEPTION 'Balance consistency error: balance (%) != available (%) + pending (%)', + NEW.balance, NEW.available_balance, NEW.pending_balance; + END IF; + + -- Validar que no haya negativos + IF NEW.balance < 0 OR NEW.available_balance < 0 OR NEW.pending_balance < 0 THEN + RAISE EXCEPTION 'Negative balance detected: balance=%, available=%, pending=%', + NEW.balance, NEW.available_balance, NEW.pending_balance; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_wallet_balance_validation + BEFORE INSERT OR UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.validate_wallet_balance(); + +-- ===================================================== +-- TRIGGER: Audit wallet status changes +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.audit_wallet_status_change() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Solo auditar si cambió el status + IF TG_OP = 'UPDATE' AND OLD.status IS DISTINCT FROM NEW.status THEN + INSERT INTO financial.wallet_audit_log ( + wallet_id, + action, + actor_type, + old_values, + new_values, + reason + ) VALUES ( + NEW.id, + 'status_changed', + 'system', + jsonb_build_object('status', OLD.status), + jsonb_build_object('status', NEW.status), + 'Status changed from ' || OLD.status::TEXT || ' to ' || NEW.status::TEXT + ); + + -- Si se cerró, registrar timestamp + IF NEW.status = 'closed' AND NEW.closed_at IS NULL THEN + NEW.closed_at := NOW(); + END IF; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_wallet_status_audit + BEFORE UPDATE ON financial.wallets + FOR EACH ROW + EXECUTE FUNCTION financial.audit_wallet_status_change(); + +-- ===================================================== +-- TRIGGER: Prevent modification of completed transactions +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.protect_completed_transactions() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF OLD.status = 'completed' AND NEW.status != 'completed' THEN + RAISE EXCEPTION 'Cannot modify completed transaction %', OLD.id; + END IF; + + IF OLD.status = 'completed' AND ( + OLD.amount != NEW.amount OR + OLD.wallet_id != NEW.wallet_id OR + OLD.transaction_type != NEW.transaction_type + ) THEN + RAISE EXCEPTION 'Cannot modify core fields of completed transaction %', OLD.id; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_protect_completed_tx + BEFORE UPDATE ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.protect_completed_transactions(); + +-- ===================================================== +-- TRIGGER: Set payment succeeded_at timestamp +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.set_payment_timestamps() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Set succeeded_at when status changes to succeeded + IF NEW.status = 'succeeded' AND OLD.status != 'succeeded' THEN + NEW.succeeded_at := NOW(); + END IF; + + -- Set failed_at when status changes to failed + IF NEW.status = 'failed' AND OLD.status != 'failed' THEN + NEW.failed_at := NOW(); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_payment_timestamps + BEFORE UPDATE ON financial.payments + FOR EACH ROW + EXECUTE FUNCTION financial.set_payment_timestamps(); + +-- ===================================================== +-- TRIGGER: Update subscription ended_at +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.set_subscription_ended_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + -- Set ended_at when status changes to cancelled and cancel_at_period_end is false + IF NEW.status = 'cancelled' AND + OLD.status != 'cancelled' AND + NOT NEW.cancel_at_period_end AND + NEW.ended_at IS NULL THEN + NEW.ended_at := NOW(); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_subscription_ended_at + BEFORE UPDATE ON financial.subscriptions + FOR EACH ROW + EXECUTE FUNCTION financial.set_subscription_ended_at(); + +-- ===================================================== +-- TRIGGER: Validate transaction currency matches wallet +-- ===================================================== + +CREATE OR REPLACE FUNCTION financial.validate_transaction_currency() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +DECLARE + v_wallet_currency financial.currency_code; +BEGIN + -- Get wallet currency + SELECT currency INTO v_wallet_currency + FROM financial.wallets + WHERE id = NEW.wallet_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Wallet % not found', NEW.wallet_id; + END IF; + + -- Validate currency match + IF NEW.currency != v_wallet_currency THEN + RAISE EXCEPTION 'Transaction currency (%) does not match wallet currency (%)', + NEW.currency, v_wallet_currency; + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trigger_transaction_currency_validation + BEFORE INSERT ON financial.wallet_transactions + FOR EACH ROW + EXECUTE FUNCTION financial.validate_transaction_currency(); + +COMMENT ON FUNCTION financial.update_timestamp IS 'Auto-update updated_at timestamp'; +COMMENT ON FUNCTION financial.generate_invoice_number IS 'Auto-generate invoice number with format INV-YYYYMM-XXXXXX'; +COMMENT ON FUNCTION financial.validate_wallet_balance IS 'Ensure balance = available + pending'; +COMMENT ON FUNCTION financial.audit_wallet_status_change IS 'Log wallet status changes to audit log'; +COMMENT ON FUNCTION financial.protect_completed_transactions IS 'Prevent modification of completed transactions'; +COMMENT ON FUNCTION financial.set_payment_timestamps IS 'Auto-set succeeded_at and failed_at timestamps'; +COMMENT ON FUNCTION financial.set_subscription_ended_at IS 'Auto-set ended_at when subscription is cancelled'; +COMMENT ON FUNCTION financial.validate_transaction_currency IS 'Ensure transaction currency matches wallet currency'; diff --git a/ddl/schemas/financial/functions/04-views.sql b/ddl/schemas/financial/functions/04-views.sql new file mode 100644 index 0000000..a179df8 --- /dev/null +++ b/ddl/schemas/financial/functions/04-views.sql @@ -0,0 +1,258 @@ +-- ===================================================== +-- ORBIQUANT IA - FINANCIAL SCHEMA VIEWS +-- ===================================================== +-- Description: Useful views for common financial queries +-- Schema: financial +-- ===================================================== + +-- ===================================================== +-- VIEW: Active user wallets summary +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_user_wallets_summary AS +SELECT + w.user_id, + w.wallet_type, + w.currency, + w.balance, + w.available_balance, + w.pending_balance, + w.status, + w.last_transaction_at, + w.total_deposits, + w.total_withdrawals, + w.created_at, + -- Transaction counts + (SELECT COUNT(*) + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id AND wt.status = 'completed') as total_transactions, + (SELECT COUNT(*) + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id AND wt.status = 'pending') as pending_transactions, + -- Latest transaction + (SELECT wt.created_at + FROM financial.wallet_transactions wt + WHERE wt.wallet_id = w.id + ORDER BY wt.created_at DESC + LIMIT 1) as last_tx_date +FROM financial.wallets w +WHERE w.status = 'active'; + +COMMENT ON VIEW financial.v_user_wallets_summary IS 'Active wallets with transaction statistics'; + +-- ===================================================== +-- VIEW: User total balance across all wallets (USD) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_user_total_balance AS +SELECT + user_id, + SUM(CASE WHEN currency = 'USD' THEN balance ELSE 0 END) as total_usd, + SUM(CASE WHEN currency = 'MXN' THEN balance ELSE 0 END) as total_mxn, + SUM(CASE WHEN currency = 'EUR' THEN balance ELSE 0 END) as total_eur, + -- Totals by wallet type + SUM(CASE WHEN wallet_type = 'trading' AND currency = 'USD' THEN balance ELSE 0 END) as trading_usd, + SUM(CASE WHEN wallet_type = 'investment' AND currency = 'USD' THEN balance ELSE 0 END) as investment_usd, + SUM(CASE WHEN wallet_type = 'earnings' AND currency = 'USD' THEN balance ELSE 0 END) as earnings_usd, + SUM(CASE WHEN wallet_type = 'referral' AND currency = 'USD' THEN balance ELSE 0 END) as referral_usd, + COUNT(*) as wallet_count, + MAX(last_transaction_at) as last_activity +FROM financial.wallets +WHERE status = 'active' +GROUP BY user_id; + +COMMENT ON VIEW financial.v_user_total_balance IS 'Aggregated balance per user across all wallets'; + +-- ===================================================== +-- VIEW: Recent transactions (last 30 days) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_recent_transactions AS +SELECT + wt.id, + wt.wallet_id, + w.user_id, + w.wallet_type, + wt.transaction_type, + wt.status, + wt.amount, + wt.fee, + wt.net_amount, + wt.currency, + wt.description, + wt.reference_id, + wt.balance_before, + wt.balance_after, + wt.created_at, + wt.completed_at, + -- Days since transaction + EXTRACT(DAY FROM NOW() - wt.created_at) as days_ago +FROM financial.wallet_transactions wt +JOIN financial.wallets w ON w.id = wt.wallet_id +WHERE wt.created_at >= NOW() - INTERVAL '30 days' +ORDER BY wt.created_at DESC; + +COMMENT ON VIEW financial.v_recent_transactions IS 'Wallet transactions from last 30 days'; + +-- ===================================================== +-- VIEW: Active subscriptions with user details +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_active_subscriptions AS +SELECT + s.id, + s.user_id, + s.plan, + s.status, + s.price, + s.currency, + s.billing_interval, + s.current_period_start, + s.current_period_end, + s.next_payment_at, + s.cancel_at_period_end, + s.stripe_subscription_id, + -- Days until renewal + EXTRACT(DAY FROM s.current_period_end - NOW()) as days_until_renewal, + -- Is in trial + (s.status = 'trialing') as is_trial, + -- Trial days remaining + CASE + WHEN s.trial_end IS NOT NULL THEN EXTRACT(DAY FROM s.trial_end - NOW()) + ELSE NULL + END as trial_days_remaining +FROM financial.subscriptions s +WHERE s.status IN ('active', 'trialing', 'past_due') +ORDER BY s.current_period_end ASC; + +COMMENT ON VIEW financial.v_active_subscriptions IS 'Active subscriptions with renewal information'; + +-- ===================================================== +-- VIEW: Pending payments +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_pending_payments AS +SELECT + p.id, + p.user_id, + p.subscription_id, + p.amount, + p.currency, + p.payment_method, + p.status, + p.description, + p.stripe_payment_intent_id, + p.created_at, + -- Days pending + EXTRACT(DAY FROM NOW() - p.created_at) as days_pending +FROM financial.payments p +WHERE p.status IN ('pending', 'processing') +ORDER BY p.created_at ASC; + +COMMENT ON VIEW financial.v_pending_payments IS 'Payments awaiting completion'; + +-- ===================================================== +-- VIEW: Unpaid invoices +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_unpaid_invoices AS +SELECT + i.id, + i.user_id, + i.invoice_number, + i.total, + i.amount_due, + i.currency, + i.due_date, + i.status, + i.invoice_date, + i.hosted_invoice_url, + -- Days overdue + CASE + WHEN i.due_date IS NOT NULL AND i.due_date < NOW() + THEN EXTRACT(DAY FROM NOW() - i.due_date) + ELSE 0 + END as days_overdue, + -- Is overdue + (i.due_date IS NOT NULL AND i.due_date < NOW()) as is_overdue +FROM financial.invoices i +WHERE i.status = 'open' AND i.paid = false +ORDER BY i.due_date ASC NULLS LAST; + +COMMENT ON VIEW financial.v_unpaid_invoices IS 'Open invoices with overdue status'; + +-- ===================================================== +-- VIEW: Daily transaction volume +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_daily_transaction_volume AS +SELECT + DATE(wt.created_at) as transaction_date, + wt.transaction_type, + wt.currency, + COUNT(*) as transaction_count, + SUM(wt.amount) as total_amount, + SUM(wt.fee) as total_fees, + SUM(wt.net_amount) as total_net_amount, + AVG(wt.amount) as avg_amount +FROM financial.wallet_transactions wt +WHERE wt.status = 'completed' + AND wt.created_at >= NOW() - INTERVAL '90 days' +GROUP BY DATE(wt.created_at), wt.transaction_type, wt.currency +ORDER BY transaction_date DESC, transaction_type; + +COMMENT ON VIEW financial.v_daily_transaction_volume IS 'Daily aggregated transaction statistics'; + +-- ===================================================== +-- VIEW: Wallet activity summary (last 7 days) +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_wallet_activity_7d AS +SELECT + w.id as wallet_id, + w.user_id, + w.wallet_type, + w.currency, + w.balance, + -- Transaction counts by type + COUNT(CASE WHEN wt.transaction_type = 'deposit' THEN 1 END) as deposits_7d, + COUNT(CASE WHEN wt.transaction_type = 'withdrawal' THEN 1 END) as withdrawals_7d, + COUNT(CASE WHEN wt.transaction_type IN ('transfer_in', 'transfer_out') THEN 1 END) as transfers_7d, + -- Amounts + SUM(CASE WHEN wt.transaction_type = 'deposit' THEN wt.amount ELSE 0 END) as deposit_amount_7d, + SUM(CASE WHEN wt.transaction_type = 'withdrawal' THEN wt.amount ELSE 0 END) as withdrawal_amount_7d, + -- Total activity + COUNT(wt.id) as total_transactions_7d +FROM financial.wallets w +LEFT JOIN financial.wallet_transactions wt ON wt.wallet_id = w.id + AND wt.created_at >= NOW() - INTERVAL '7 days' + AND wt.status = 'completed' +WHERE w.status = 'active' +GROUP BY w.id, w.user_id, w.wallet_type, w.currency, w.balance; + +COMMENT ON VIEW financial.v_wallet_activity_7d IS 'Wallet activity summary for last 7 days'; + +-- ===================================================== +-- VIEW: Subscription revenue metrics +-- ===================================================== + +CREATE OR REPLACE VIEW financial.v_subscription_revenue AS +SELECT + s.plan, + s.billing_interval, + s.currency, + COUNT(*) as active_count, + SUM(s.price) as total_monthly_value, + AVG(s.price) as avg_price, + -- MRR calculation (Monthly Recurring Revenue) + SUM(CASE + WHEN s.billing_interval = 'month' THEN s.price + WHEN s.billing_interval = 'year' THEN s.price / 12 + ELSE 0 + END) as monthly_recurring_revenue +FROM financial.subscriptions s +WHERE s.status IN ('active', 'trialing') +GROUP BY s.plan, s.billing_interval, s.currency +ORDER BY s.plan, s.billing_interval; + +COMMENT ON VIEW financial.v_subscription_revenue IS 'Subscription metrics and MRR calculation'; diff --git a/ddl/schemas/financial/tables/01-wallets.sql b/ddl/schemas/financial/tables/01-wallets.sql new file mode 100644 index 0000000..a34300e --- /dev/null +++ b/ddl/schemas/financial/tables/01-wallets.sql @@ -0,0 +1,85 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLETS TABLE (UNIFIED) +-- ===================================================== +-- Description: Single source of truth for all wallet types +-- Schema: financial +-- ===================================================== +-- UNIFICACIÓN: Esta tabla reemplaza definiciones previas en: +-- - OQI-004 (trading schema) +-- - OQI-005 (investment schema) +-- - OQI-008 (financial schema - legacy) +-- ===================================================== + +CREATE TABLE financial.wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Tipo y estado + wallet_type financial.wallet_type NOT NULL, + status financial.wallet_status NOT NULL DEFAULT 'active', + + -- Balance (precisión de 8 decimales para soportar crypto) + balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + available_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + pending_balance DECIMAL(20,8) NOT NULL DEFAULT 0.00, + + -- Moneda + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Stripe integration (si aplica) + stripe_account_id VARCHAR(255), + stripe_customer_id VARCHAR(255), + + -- Límites operacionales + daily_withdrawal_limit DECIMAL(15,2), + monthly_withdrawal_limit DECIMAL(15,2), + min_balance DECIMAL(15,2) DEFAULT 0.00, + + -- Tracking de uso + last_transaction_at TIMESTAMPTZ, + total_deposits DECIMAL(20,8) DEFAULT 0.00, + total_withdrawals DECIMAL(20,8) DEFAULT 0.00, + + -- Metadata extensible + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_balance CHECK (balance >= 0), + CONSTRAINT positive_available CHECK (available_balance >= 0), + CONSTRAINT positive_pending CHECK (pending_balance >= 0), + CONSTRAINT available_lte_balance CHECK (available_balance <= balance), + CONSTRAINT balance_equation CHECK (balance = available_balance + pending_balance), + CONSTRAINT positive_limits CHECK ( + (daily_withdrawal_limit IS NULL OR daily_withdrawal_limit > 0) AND + (monthly_withdrawal_limit IS NULL OR monthly_withdrawal_limit > 0) + ), + CONSTRAINT unique_user_wallet_type UNIQUE(user_id, wallet_type, currency), + CONSTRAINT closed_status_has_date CHECK ( + (status = 'closed' AND closed_at IS NOT NULL) OR + (status != 'closed' AND closed_at IS NULL) + ) +); + +-- Indexes para performance +CREATE INDEX idx_wallets_user_id ON financial.wallets(user_id); +CREATE INDEX idx_wallets_wallet_type ON financial.wallets(wallet_type); +CREATE INDEX idx_wallets_status ON financial.wallets(status) WHERE status = 'active'; +CREATE INDEX idx_wallets_currency ON financial.wallets(currency); +CREATE INDEX idx_wallets_user_type_currency ON financial.wallets(user_id, wallet_type, currency); +CREATE INDEX idx_wallets_stripe_account ON financial.wallets(stripe_account_id) WHERE stripe_account_id IS NOT NULL; +CREATE INDEX idx_wallets_last_transaction ON financial.wallets(last_transaction_at DESC NULLS LAST); + +-- Comments +COMMENT ON TABLE financial.wallets IS 'Unified wallet table - single source of truth for all wallet types'; +COMMENT ON COLUMN financial.wallets.wallet_type IS 'Type of wallet: trading, investment, earnings, referral'; +COMMENT ON COLUMN financial.wallets.balance IS 'Total balance = available + pending'; +COMMENT ON COLUMN financial.wallets.available_balance IS 'Balance available for immediate use'; +COMMENT ON COLUMN financial.wallets.pending_balance IS 'Balance in pending transactions'; +COMMENT ON COLUMN financial.wallets.stripe_account_id IS 'Stripe Connect account ID for payouts'; +COMMENT ON COLUMN financial.wallets.stripe_customer_id IS 'Stripe Customer ID for payments'; +COMMENT ON COLUMN financial.wallets.metadata IS 'Extensible JSON field for additional data'; diff --git a/ddl/schemas/financial/tables/02-wallet_transactions.sql b/ddl/schemas/financial/tables/02-wallet_transactions.sql new file mode 100644 index 0000000..3ec0e62 --- /dev/null +++ b/ddl/schemas/financial/tables/02-wallet_transactions.sql @@ -0,0 +1,101 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET TRANSACTIONS TABLE +-- ===================================================== +-- Description: Complete transaction history for all wallets +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.wallet_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet relacionada + wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE RESTRICT, + + -- Tipo y estado + transaction_type financial.transaction_type NOT NULL, + status financial.transaction_status NOT NULL DEFAULT 'pending', + + -- Montos (precisión de 8 decimales) + amount DECIMAL(20,8) NOT NULL, + fee DECIMAL(15,8) DEFAULT 0, + net_amount DECIMAL(20,8) GENERATED ALWAYS AS (amount - fee) STORED, + + -- Moneda + currency financial.currency_code NOT NULL, + + -- Balances snapshot (para auditoría) + balance_before DECIMAL(20,8), + balance_after DECIMAL(20,8), + + -- Referencias externas + stripe_payment_intent_id VARCHAR(255), + stripe_transfer_id VARCHAR(255), + stripe_charge_id VARCHAR(255), + reference_id VARCHAR(100), -- ID de referencia interna (ej: trade_id, investment_id) + + -- Para transfers entre wallets + destination_wallet_id UUID REFERENCES financial.wallets(id) ON DELETE RESTRICT, + related_transaction_id UUID REFERENCES financial.wallet_transactions(id), -- TX relacionada (para transfers bidireccionales) + + -- Descripción y notas + description TEXT, + notes TEXT, + metadata JSONB DEFAULT '{}', + + -- Procesamiento + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failed_reason TEXT, + + -- Idempotency + idempotency_key VARCHAR(255) UNIQUE, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT positive_amount CHECK (amount > 0), + CONSTRAINT positive_fee CHECK (fee >= 0), + CONSTRAINT fee_lte_amount CHECK (fee <= amount), + CONSTRAINT destination_for_transfers CHECK ( + (transaction_type IN ('transfer_in', 'transfer_out') AND destination_wallet_id IS NOT NULL) OR + (transaction_type NOT IN ('transfer_in', 'transfer_out')) + ), + CONSTRAINT no_self_transfer CHECK (wallet_id != destination_wallet_id), + CONSTRAINT completed_has_timestamp CHECK ( + (status = 'completed' AND completed_at IS NOT NULL) OR + (status != 'completed') + ), + CONSTRAINT failed_has_reason CHECK ( + (status = 'failed' AND failed_reason IS NOT NULL AND failed_at IS NOT NULL) OR + (status != 'failed') + ) +); + +-- Indexes para performance y queries comunes +CREATE INDEX idx_wt_wallet_id ON financial.wallet_transactions(wallet_id); +CREATE INDEX idx_wt_transaction_type ON financial.wallet_transactions(transaction_type); +CREATE INDEX idx_wt_status ON financial.wallet_transactions(status); +CREATE INDEX idx_wt_created_at ON financial.wallet_transactions(created_at DESC); +CREATE INDEX idx_wt_reference_id ON financial.wallet_transactions(reference_id) WHERE reference_id IS NOT NULL; +CREATE INDEX idx_wt_stripe_payment ON financial.wallet_transactions(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; +CREATE INDEX idx_wt_stripe_transfer ON financial.wallet_transactions(stripe_transfer_id) WHERE stripe_transfer_id IS NOT NULL; +CREATE INDEX idx_wt_destination ON financial.wallet_transactions(destination_wallet_id) WHERE destination_wallet_id IS NOT NULL; +CREATE INDEX idx_wt_wallet_status_created ON financial.wallet_transactions(wallet_id, status, created_at DESC); +CREATE INDEX idx_wt_idempotency ON financial.wallet_transactions(idempotency_key) WHERE idempotency_key IS NOT NULL; + +-- Composite index para queries de rango por wallet +CREATE INDEX idx_wt_wallet_date_range ON financial.wallet_transactions(wallet_id, created_at DESC) + WHERE status = 'completed'; + +-- Comments +COMMENT ON TABLE financial.wallet_transactions IS 'Complete transaction history for all wallet operations'; +COMMENT ON COLUMN financial.wallet_transactions.transaction_type IS 'Type of transaction: deposit, withdrawal, transfer, fee, etc.'; +COMMENT ON COLUMN financial.wallet_transactions.net_amount IS 'Amount after fees (computed column)'; +COMMENT ON COLUMN financial.wallet_transactions.reference_id IS 'Internal reference to related entity (trade, investment, etc.)'; +COMMENT ON COLUMN financial.wallet_transactions.destination_wallet_id IS 'Target wallet for transfer operations'; +COMMENT ON COLUMN financial.wallet_transactions.related_transaction_id IS 'Related transaction for bidirectional transfers'; +COMMENT ON COLUMN financial.wallet_transactions.idempotency_key IS 'Unique key to prevent duplicate transactions'; +COMMENT ON COLUMN financial.wallet_transactions.metadata IS 'Extensible JSON field for transaction-specific data'; diff --git a/ddl/schemas/financial/tables/03-subscriptions.sql b/ddl/schemas/financial/tables/03-subscriptions.sql new file mode 100644 index 0000000..483e3ca --- /dev/null +++ b/ddl/schemas/financial/tables/03-subscriptions.sql @@ -0,0 +1,107 @@ +-- ===================================================== +-- ORBIQUANT IA - SUBSCRIPTIONS TABLE +-- ===================================================== +-- Description: User subscription management with Stripe integration +-- Schema: financial +-- ===================================================== +-- DECISION: Planes en USD como moneda base +-- ===================================================== + +CREATE TABLE financial.subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Plan y estado + plan financial.subscription_plan NOT NULL, + status financial.subscription_status NOT NULL DEFAULT 'incomplete', + + -- Stripe integration + stripe_subscription_id VARCHAR(255) UNIQUE, + stripe_customer_id VARCHAR(255), + stripe_price_id VARCHAR(255), + stripe_product_id VARCHAR(255), + + -- Pricing + price DECIMAL(10,2) NOT NULL, + currency financial.currency_code NOT NULL DEFAULT 'USD', + billing_interval VARCHAR(20) NOT NULL DEFAULT 'month', -- month, year + + -- Billing periods + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + trial_start TIMESTAMPTZ, + trial_end TIMESTAMPTZ, + + -- Cancelación + cancelled_at TIMESTAMPTZ, + cancel_at_period_end BOOLEAN DEFAULT false, + cancellation_reason TEXT, + cancellation_feedback JSONB, + + -- Downgrade/Upgrade tracking + previous_plan financial.subscription_plan, + scheduled_plan financial.subscription_plan, + scheduled_plan_effective_at TIMESTAMPTZ, + + -- Payment tracking + last_payment_at TIMESTAMPTZ, + next_payment_at TIMESTAMPTZ, + failed_payment_count INTEGER DEFAULT 0, + + -- Features/Quotas (se pueden mover a tabla separada si crece) + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + ended_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_price CHECK (price >= 0), + CONSTRAINT valid_billing_interval CHECK (billing_interval IN ('month', 'year')), + CONSTRAINT trial_dates_order CHECK ( + (trial_start IS NULL AND trial_end IS NULL) OR + (trial_start IS NOT NULL AND trial_end IS NOT NULL AND trial_start < trial_end) + ), + CONSTRAINT period_dates_order CHECK ( + (current_period_start IS NULL AND current_period_end IS NULL) OR + (current_period_start IS NOT NULL AND current_period_end IS NOT NULL AND current_period_start < current_period_end) + ), + CONSTRAINT cancel_date_valid CHECK ( + (cancelled_at IS NULL) OR + (cancelled_at >= created_at) + ), + CONSTRAINT ended_when_cancelled CHECK ( + (ended_at IS NULL) OR + (cancelled_at IS NOT NULL AND ended_at >= cancelled_at) + ), + CONSTRAINT scheduled_plan_different CHECK ( + scheduled_plan IS NULL OR scheduled_plan != plan + ) +); + +-- Indexes +CREATE INDEX idx_subscriptions_user_id ON financial.subscriptions(user_id); +CREATE INDEX idx_subscriptions_status ON financial.subscriptions(status); +CREATE INDEX idx_subscriptions_plan ON financial.subscriptions(plan); +CREATE INDEX idx_subscriptions_stripe_sub ON financial.subscriptions(stripe_subscription_id) WHERE stripe_subscription_id IS NOT NULL; +CREATE INDEX idx_subscriptions_stripe_customer ON financial.subscriptions(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; +CREATE INDEX idx_subscriptions_active ON financial.subscriptions(user_id, status) WHERE status = 'active'; +CREATE INDEX idx_subscriptions_period_end ON financial.subscriptions(current_period_end) WHERE status = 'active'; +CREATE INDEX idx_subscriptions_trial_end ON financial.subscriptions(trial_end) WHERE status = 'trialing'; +CREATE INDEX idx_subscriptions_next_payment ON financial.subscriptions(next_payment_at) WHERE next_payment_at IS NOT NULL; + +-- Unique constraint: un usuario solo puede tener una suscripción activa a la vez +CREATE UNIQUE INDEX idx_subscriptions_user_active ON financial.subscriptions(user_id) + WHERE status IN ('active', 'trialing', 'past_due'); + +-- Comments +COMMENT ON TABLE financial.subscriptions IS 'User subscription management with Stripe integration'; +COMMENT ON COLUMN financial.subscriptions.plan IS 'Subscription plan tier'; +COMMENT ON COLUMN financial.subscriptions.status IS 'Subscription status (Stripe-compatible states)'; +COMMENT ON COLUMN financial.subscriptions.price IS 'Subscription price in specified currency'; +COMMENT ON COLUMN financial.subscriptions.billing_interval IS 'Billing frequency: month or year'; +COMMENT ON COLUMN financial.subscriptions.cancel_at_period_end IS 'If true, subscription will cancel at end of current period'; +COMMENT ON COLUMN financial.subscriptions.scheduled_plan IS 'Plan to switch to at scheduled_plan_effective_at'; +COMMENT ON COLUMN financial.subscriptions.failed_payment_count IS 'Number of consecutive failed payment attempts'; +COMMENT ON COLUMN financial.subscriptions.metadata IS 'Plan features, quotas, and additional configuration'; diff --git a/ddl/schemas/financial/tables/04-payments.sql b/ddl/schemas/financial/tables/04-payments.sql new file mode 100644 index 0000000..f3f6175 --- /dev/null +++ b/ddl/schemas/financial/tables/04-payments.sql @@ -0,0 +1,86 @@ +-- ===================================================== +-- ORBIQUANT IA - PAYMENTS TABLE +-- ===================================================== +-- Description: Payment transaction records +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, + invoice_id UUID REFERENCES financial.invoices(id) ON DELETE SET NULL, + wallet_transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_payment_intent_id VARCHAR(255) UNIQUE, + stripe_charge_id VARCHAR(255), + stripe_payment_method_id VARCHAR(255), + + -- Payment details + amount DECIMAL(15,2) NOT NULL, + currency financial.currency_code NOT NULL, + payment_method financial.payment_method NOT NULL, + status financial.payment_status NOT NULL DEFAULT 'pending', + + -- Descripción + description TEXT, + statement_descriptor VARCHAR(255), -- Lo que ve el usuario en su estado de cuenta + + -- Refunds + refunded BOOLEAN DEFAULT false, + refund_amount DECIMAL(15,2), + refund_reason TEXT, + refunded_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Error handling + failure_code VARCHAR(100), + failure_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + succeeded_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_amount CHECK (amount > 0), + CONSTRAINT positive_refund CHECK (refund_amount IS NULL OR refund_amount > 0), + CONSTRAINT refund_lte_amount CHECK (refund_amount IS NULL OR refund_amount <= amount), + CONSTRAINT refunded_has_data CHECK ( + (refunded = false AND refund_amount IS NULL AND refunded_at IS NULL) OR + (refunded = true AND refund_amount IS NOT NULL AND refunded_at IS NOT NULL) + ), + CONSTRAINT succeeded_has_timestamp CHECK ( + (status = 'succeeded' AND succeeded_at IS NOT NULL) OR + (status != 'succeeded') + ), + CONSTRAINT failed_has_data CHECK ( + (status = 'failed' AND failed_at IS NOT NULL) OR + (status != 'failed') + ) +); + +-- Indexes +CREATE INDEX idx_payments_user_id ON financial.payments(user_id); +CREATE INDEX idx_payments_subscription_id ON financial.payments(subscription_id) WHERE subscription_id IS NOT NULL; +CREATE INDEX idx_payments_invoice_id ON financial.payments(invoice_id) WHERE invoice_id IS NOT NULL; +CREATE INDEX idx_payments_status ON financial.payments(status); +CREATE INDEX idx_payments_stripe_intent ON financial.payments(stripe_payment_intent_id) WHERE stripe_payment_intent_id IS NOT NULL; +CREATE INDEX idx_payments_created_at ON financial.payments(created_at DESC); +CREATE INDEX idx_payments_user_created ON financial.payments(user_id, created_at DESC); +CREATE INDEX idx_payments_payment_method ON financial.payments(payment_method); +CREATE INDEX idx_payments_refunded ON financial.payments(refunded) WHERE refunded = true; + +-- Comments +COMMENT ON TABLE financial.payments IS 'Payment transaction records with Stripe integration'; +COMMENT ON COLUMN financial.payments.stripe_payment_intent_id IS 'Stripe PaymentIntent ID'; +COMMENT ON COLUMN financial.payments.payment_method IS 'Payment method used: card, bank_transfer, crypto, etc.'; +COMMENT ON COLUMN financial.payments.statement_descriptor IS 'Text shown on customer bank statement'; +COMMENT ON COLUMN financial.payments.wallet_transaction_id IS 'Related wallet transaction if payment funds a wallet'; +COMMENT ON COLUMN financial.payments.metadata IS 'Additional payment metadata and context'; diff --git a/ddl/schemas/financial/tables/05-invoices.sql b/ddl/schemas/financial/tables/05-invoices.sql new file mode 100644 index 0000000..7892a41 --- /dev/null +++ b/ddl/schemas/financial/tables/05-invoices.sql @@ -0,0 +1,120 @@ +-- ===================================================== +-- ORBIQUANT IA - INVOICES TABLE +-- ===================================================== +-- Description: Invoice records for subscriptions and one-time charges +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + subscription_id UUID REFERENCES financial.subscriptions(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_invoice_id VARCHAR(255) UNIQUE, + stripe_customer_id VARCHAR(255), + + -- Invoice details + invoice_number VARCHAR(100) UNIQUE, -- Número de factura interno + invoice_type financial.invoice_type NOT NULL, + status financial.invoice_status NOT NULL DEFAULT 'draft', + + -- Amounts + subtotal DECIMAL(15,2) NOT NULL DEFAULT 0, + tax DECIMAL(15,2) DEFAULT 0, + total DECIMAL(15,2) NOT NULL, + amount_paid DECIMAL(15,2) DEFAULT 0, + amount_due DECIMAL(15,2) GENERATED ALWAYS AS (total - amount_paid) STORED, + + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Dates + invoice_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + due_date TIMESTAMPTZ, + period_start TIMESTAMPTZ, + period_end TIMESTAMPTZ, + + -- Payment + paid BOOLEAN DEFAULT false, + paid_at TIMESTAMPTZ, + attempted BOOLEAN DEFAULT false, + attempt_count INTEGER DEFAULT 0, + next_payment_attempt TIMESTAMPTZ, + + -- URLs + hosted_invoice_url TEXT, -- Stripe hosted invoice URL + invoice_pdf_url TEXT, + + -- Billing details + billing_email VARCHAR(255), + billing_name VARCHAR(255), + billing_address JSONB, + + -- Line items (si se quiere detalle simple) + line_items JSONB DEFAULT '[]', + + -- Metadata + description TEXT, + notes TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + finalized_at TIMESTAMPTZ, + voided_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT positive_subtotal CHECK (subtotal >= 0), + CONSTRAINT positive_tax CHECK (tax >= 0), + CONSTRAINT positive_total CHECK (total >= 0), + CONSTRAINT positive_paid CHECK (amount_paid >= 0), + CONSTRAINT paid_lte_total CHECK (amount_paid <= total), + CONSTRAINT total_equals_subtotal_plus_tax CHECK (total = subtotal + tax), + CONSTRAINT paid_has_timestamp CHECK ( + (paid = false) OR + (paid = true AND paid_at IS NOT NULL) + ), + CONSTRAINT finalized_for_open_paid CHECK ( + (status IN ('open', 'paid') AND finalized_at IS NOT NULL) OR + (status NOT IN ('open', 'paid')) + ), + CONSTRAINT void_has_timestamp CHECK ( + (status = 'void' AND voided_at IS NOT NULL) OR + (status != 'void') + ), + CONSTRAINT due_date_after_invoice CHECK ( + due_date IS NULL OR due_date >= invoice_date + ), + CONSTRAINT period_dates_order CHECK ( + (period_start IS NULL AND period_end IS NULL) OR + (period_start IS NOT NULL AND period_end IS NOT NULL AND period_start < period_end) + ) +); + +-- Sequence para invoice numbers +CREATE SEQUENCE financial.invoice_number_seq START 1000; + +-- Indexes +CREATE INDEX idx_invoices_user_id ON financial.invoices(user_id); +CREATE INDEX idx_invoices_subscription_id ON financial.invoices(subscription_id) WHERE subscription_id IS NOT NULL; +CREATE INDEX idx_invoices_status ON financial.invoices(status); +CREATE INDEX idx_invoices_stripe_id ON financial.invoices(stripe_invoice_id) WHERE stripe_invoice_id IS NOT NULL; +CREATE INDEX idx_invoices_invoice_number ON financial.invoices(invoice_number); +CREATE INDEX idx_invoices_due_date ON financial.invoices(due_date) WHERE due_date IS NOT NULL AND status = 'open'; +CREATE INDEX idx_invoices_invoice_date ON financial.invoices(invoice_date DESC); +CREATE INDEX idx_invoices_user_date ON financial.invoices(user_id, invoice_date DESC); +CREATE INDEX idx_invoices_unpaid ON financial.invoices(user_id, status) WHERE paid = false AND status = 'open'; +CREATE INDEX idx_invoices_period ON financial.invoices(period_start, period_end) WHERE period_start IS NOT NULL; + +-- Comments +COMMENT ON TABLE financial.invoices IS 'Invoice records for subscriptions and one-time charges'; +COMMENT ON COLUMN financial.invoices.invoice_number IS 'Internal unique invoice number (auto-generated)'; +COMMENT ON COLUMN financial.invoices.invoice_type IS 'Type: subscription, one_time, or usage-based'; +COMMENT ON COLUMN financial.invoices.amount_due IS 'Computed: total - amount_paid'; +COMMENT ON COLUMN financial.invoices.line_items IS 'JSON array of invoice line items with description, amount, quantity'; +COMMENT ON COLUMN financial.invoices.billing_address IS 'JSON object with billing address details'; +COMMENT ON COLUMN financial.invoices.hosted_invoice_url IS 'URL to Stripe-hosted invoice page'; +COMMENT ON COLUMN financial.invoices.attempt_count IS 'Number of payment attempts made'; diff --git a/ddl/schemas/financial/tables/06-wallet_audit_log.sql b/ddl/schemas/financial/tables/06-wallet_audit_log.sql new file mode 100644 index 0000000..05e2547 --- /dev/null +++ b/ddl/schemas/financial/tables/06-wallet_audit_log.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET AUDIT LOG TABLE +-- ===================================================== +-- Description: Audit trail for all wallet state changes +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.wallet_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet referencia + wallet_id UUID NOT NULL REFERENCES financial.wallets(id) ON DELETE CASCADE, + + -- Acción + action financial.audit_action NOT NULL, + + -- Actor (quien realizó el cambio) + actor_id UUID REFERENCES auth.users(id) ON DELETE SET NULL, + actor_type VARCHAR(50) DEFAULT 'user', -- user, system, admin, api + + -- Cambios registrados + old_values JSONB, + new_values JSONB, + + -- Balance snapshot + balance_before DECIMAL(20,8), + balance_after DECIMAL(20,8), + + -- Transacción relacionada (si aplica) + transaction_id UUID REFERENCES financial.wallet_transactions(id) ON DELETE SET NULL, + + -- Contexto + reason TEXT, + metadata JSONB DEFAULT '{}', + + -- IP y user agent (para auditoría de seguridad) + ip_address INET, + user_agent TEXT, + + -- Timestamp + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT balance_change_has_amounts CHECK ( + (action = 'balance_updated' AND balance_before IS NOT NULL AND balance_after IS NOT NULL) OR + (action != 'balance_updated') + ) +); + +-- Indexes +CREATE INDEX idx_wal_wallet_id ON financial.wallet_audit_log(wallet_id); +CREATE INDEX idx_wal_action ON financial.wallet_audit_log(action); +CREATE INDEX idx_wal_actor_id ON financial.wallet_audit_log(actor_id) WHERE actor_id IS NOT NULL; +CREATE INDEX idx_wal_created_at ON financial.wallet_audit_log(created_at DESC); +CREATE INDEX idx_wal_wallet_created ON financial.wallet_audit_log(wallet_id, created_at DESC); +CREATE INDEX idx_wal_transaction_id ON financial.wallet_audit_log(transaction_id) WHERE transaction_id IS NOT NULL; + +-- Partitioning hint: Esta tabla puede crecer mucho, considerar particionamiento por created_at +-- PARTITION BY RANGE (created_at); + +-- Comments +COMMENT ON TABLE financial.wallet_audit_log IS 'Immutable audit trail for all wallet state changes'; +COMMENT ON COLUMN financial.wallet_audit_log.action IS 'Type of action performed on wallet'; +COMMENT ON COLUMN financial.wallet_audit_log.actor_id IS 'User who performed the action (NULL for system actions)'; +COMMENT ON COLUMN financial.wallet_audit_log.actor_type IS 'Type of actor: user, system, admin, api'; +COMMENT ON COLUMN financial.wallet_audit_log.old_values IS 'JSON snapshot of values before change'; +COMMENT ON COLUMN financial.wallet_audit_log.new_values IS 'JSON snapshot of values after change'; +COMMENT ON COLUMN financial.wallet_audit_log.metadata IS 'Additional context and metadata'; diff --git a/ddl/schemas/financial/tables/07-currency_exchange_rates.sql b/ddl/schemas/financial/tables/07-currency_exchange_rates.sql new file mode 100644 index 0000000..21e5e9d --- /dev/null +++ b/ddl/schemas/financial/tables/07-currency_exchange_rates.sql @@ -0,0 +1,131 @@ +-- ===================================================== +-- ORBIQUANT IA - CURRENCY EXCHANGE RATES TABLE +-- ===================================================== +-- Description: Historical exchange rates for multi-currency support +-- Schema: financial +-- ===================================================== + +CREATE TABLE financial.currency_exchange_rates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Par de monedas + from_currency financial.currency_code NOT NULL, + to_currency financial.currency_code NOT NULL, + + -- Tasa de cambio + rate DECIMAL(18,8) NOT NULL, + + -- Fuente de datos + source VARCHAR(100) NOT NULL DEFAULT 'manual', -- manual, api, stripe, coinbase, etc. + provider VARCHAR(100), -- nombre del proveedor si es API + + -- Validez temporal + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_to TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT positive_rate CHECK (rate > 0), + CONSTRAINT different_currencies CHECK (from_currency != to_currency), + CONSTRAINT valid_dates_order CHECK ( + valid_to IS NULL OR valid_to > valid_from + ), + CONSTRAINT unique_rate_period UNIQUE(from_currency, to_currency, valid_from) +); + +-- Indexes +CREATE INDEX idx_cer_currencies ON financial.currency_exchange_rates(from_currency, to_currency); +CREATE INDEX idx_cer_valid_from ON financial.currency_exchange_rates(valid_from DESC); +CREATE INDEX idx_cer_valid_period ON financial.currency_exchange_rates(from_currency, to_currency, valid_from DESC) + WHERE valid_to IS NULL OR valid_to > NOW(); +CREATE INDEX idx_cer_source ON financial.currency_exchange_rates(source); + +-- Comments +COMMENT ON TABLE financial.currency_exchange_rates IS 'Historical exchange rates for currency conversion'; +COMMENT ON COLUMN financial.currency_exchange_rates.rate IS 'Exchange rate: 1 from_currency = rate * to_currency'; +COMMENT ON COLUMN financial.currency_exchange_rates.source IS 'Source of exchange rate data'; +COMMENT ON COLUMN financial.currency_exchange_rates.valid_from IS 'Start of rate validity period'; +COMMENT ON COLUMN financial.currency_exchange_rates.valid_to IS 'End of rate validity period (NULL = currently valid)'; +COMMENT ON COLUMN financial.currency_exchange_rates.metadata IS 'Additional rate metadata (bid, ask, spread, etc.)'; + +-- Función helper para obtener tasa de cambio actual +CREATE OR REPLACE FUNCTION financial.get_exchange_rate( + p_from_currency financial.currency_code, + p_to_currency financial.currency_code, + p_at_time TIMESTAMPTZ DEFAULT NOW() +) +RETURNS DECIMAL(18,8) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_rate DECIMAL(18,8); +BEGIN + -- Si son la misma moneda, retornar 1 + IF p_from_currency = p_to_currency THEN + RETURN 1.0; + END IF; + + -- Buscar tasa de cambio válida + SELECT rate INTO v_rate + FROM financial.currency_exchange_rates + WHERE from_currency = p_from_currency + AND to_currency = p_to_currency + AND valid_from <= p_at_time + AND (valid_to IS NULL OR valid_to > p_at_time) + ORDER BY valid_from DESC + LIMIT 1; + + -- Si no se encuentra, intentar inversa + IF v_rate IS NULL THEN + SELECT 1.0 / rate INTO v_rate + FROM financial.currency_exchange_rates + WHERE from_currency = p_to_currency + AND to_currency = p_from_currency + AND valid_from <= p_at_time + AND (valid_to IS NULL OR valid_to > p_at_time) + ORDER BY valid_from DESC + LIMIT 1; + END IF; + + -- Si aún no hay tasa, retornar NULL + RETURN v_rate; +END; +$$; + +COMMENT ON FUNCTION financial.get_exchange_rate IS 'Get exchange rate between currencies at specific time'; + +-- Función para convertir montos +CREATE OR REPLACE FUNCTION financial.convert_currency( + p_amount DECIMAL, + p_from_currency financial.currency_code, + p_to_currency financial.currency_code, + p_at_time TIMESTAMPTZ DEFAULT NOW() +) +RETURNS DECIMAL(20,8) +LANGUAGE plpgsql +STABLE +AS $$ +DECLARE + v_rate DECIMAL(18,8); +BEGIN + -- Obtener tasa de cambio + v_rate := financial.get_exchange_rate(p_from_currency, p_to_currency, p_at_time); + + -- Si no hay tasa, retornar NULL + IF v_rate IS NULL THEN + RETURN NULL; + END IF; + + -- Convertir y retornar + RETURN p_amount * v_rate; +END; +$$; + +COMMENT ON FUNCTION financial.convert_currency IS 'Convert amount between currencies at specific time'; diff --git a/ddl/schemas/financial/tables/08-wallet_limits.sql b/ddl/schemas/financial/tables/08-wallet_limits.sql new file mode 100644 index 0000000..fb819c7 --- /dev/null +++ b/ddl/schemas/financial/tables/08-wallet_limits.sql @@ -0,0 +1,101 @@ +-- ===================================================== +-- ORBIQUANT IA - WALLET LIMITS TABLE +-- ===================================================== +-- Description: Configurable limits and thresholds for wallets +-- Schema: financial +-- ===================================================== +-- Separado de wallets para permitir límites más complejos +-- y dinámicos basados en plan, nivel de verificación, etc. +-- ===================================================== + +CREATE TABLE financial.wallet_limits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Wallet o configuración global + wallet_id UUID REFERENCES financial.wallets(id) ON DELETE CASCADE, + wallet_type financial.wallet_type, -- Para límites por tipo de wallet + subscription_plan financial.subscription_plan, -- Para límites por plan + + -- Límites de transacción única + min_deposit DECIMAL(15,2), + max_deposit DECIMAL(15,2), + min_withdrawal DECIMAL(15,2), + max_withdrawal DECIMAL(15,2), + min_transfer DECIMAL(15,2), + max_transfer DECIMAL(15,2), + + -- Límites periódicos + daily_deposit_limit DECIMAL(15,2), + daily_withdrawal_limit DECIMAL(15,2), + daily_transfer_limit DECIMAL(15,2), + + weekly_deposit_limit DECIMAL(15,2), + weekly_withdrawal_limit DECIMAL(15,2), + weekly_transfer_limit DECIMAL(15,2), + + monthly_deposit_limit DECIMAL(15,2), + monthly_withdrawal_limit DECIMAL(15,2), + monthly_transfer_limit DECIMAL(15,2), + + -- Límites de volumen + max_pending_transactions INTEGER, + max_daily_transaction_count INTEGER, + + -- Balance limits + min_balance DECIMAL(15,2) DEFAULT 0, + max_balance DECIMAL(15,2), + + -- Moneda de los límites + currency financial.currency_code NOT NULL DEFAULT 'USD', + + -- Prioridad (mayor número = mayor prioridad) + priority INTEGER DEFAULT 0, + + -- Vigencia + active BOOLEAN DEFAULT true, + valid_from TIMESTAMPTZ DEFAULT NOW(), + valid_to TIMESTAMPTZ, + + -- Metadata + description TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT wallet_or_type_or_plan CHECK ( + (wallet_id IS NOT NULL AND wallet_type IS NULL AND subscription_plan IS NULL) OR + (wallet_id IS NULL AND wallet_type IS NOT NULL AND subscription_plan IS NULL) OR + (wallet_id IS NULL AND wallet_type IS NULL AND subscription_plan IS NOT NULL) + ), + CONSTRAINT positive_limits CHECK ( + (min_deposit IS NULL OR min_deposit > 0) AND + (max_deposit IS NULL OR max_deposit > 0) AND + (min_withdrawal IS NULL OR min_withdrawal > 0) AND + (max_withdrawal IS NULL OR max_withdrawal > 0) AND + (min_transfer IS NULL OR min_transfer > 0) AND + (max_transfer IS NULL OR max_transfer > 0) + ), + CONSTRAINT min_max_deposit CHECK (min_deposit IS NULL OR max_deposit IS NULL OR min_deposit <= max_deposit), + CONSTRAINT min_max_withdrawal CHECK (min_withdrawal IS NULL OR max_withdrawal IS NULL OR min_withdrawal <= max_withdrawal), + CONSTRAINT min_max_transfer CHECK (min_transfer IS NULL OR max_transfer IS NULL OR min_transfer <= max_transfer), + CONSTRAINT valid_dates_order CHECK (valid_to IS NULL OR valid_to > valid_from) +); + +-- Indexes +CREATE INDEX idx_wl_wallet_id ON financial.wallet_limits(wallet_id) WHERE wallet_id IS NOT NULL; +CREATE INDEX idx_wl_wallet_type ON financial.wallet_limits(wallet_type) WHERE wallet_type IS NOT NULL; +CREATE INDEX idx_wl_subscription_plan ON financial.wallet_limits(subscription_plan) WHERE subscription_plan IS NOT NULL; +CREATE INDEX idx_wl_active ON financial.wallet_limits(active, priority DESC) WHERE active = true; +CREATE INDEX idx_wl_valid_period ON financial.wallet_limits(valid_from, valid_to) + WHERE active = true AND (valid_to IS NULL OR valid_to > NOW()); + +-- Comments +COMMENT ON TABLE financial.wallet_limits IS 'Configurable transaction limits for wallets'; +COMMENT ON COLUMN financial.wallet_limits.wallet_id IS 'Specific wallet (takes highest priority)'; +COMMENT ON COLUMN financial.wallet_limits.wallet_type IS 'Limits for all wallets of this type'; +COMMENT ON COLUMN financial.wallet_limits.subscription_plan IS 'Limits based on subscription plan'; +COMMENT ON COLUMN financial.wallet_limits.priority IS 'Higher number = higher priority when multiple limits apply'; +COMMENT ON COLUMN financial.wallet_limits.currency IS 'Currency for all limit amounts'; diff --git a/ddl/schemas/financial/tables/09-customers.sql b/ddl/schemas/financial/tables/09-customers.sql new file mode 100644 index 0000000..394add5 --- /dev/null +++ b/ddl/schemas/financial/tables/09-customers.sql @@ -0,0 +1,68 @@ +-- ============================================================================ +-- FINANCIAL SCHEMA - Tabla: customers +-- ============================================================================ +-- Clientes de Stripe y datos de facturacion +-- Vincula usuarios con su informacion de pago +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS financial.customers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relacion con usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Stripe + stripe_customer_id VARCHAR(100) UNIQUE, + stripe_default_payment_method_id VARCHAR(100), + + -- Datos de facturacion + billing_name VARCHAR(255), + billing_email VARCHAR(255), + billing_phone VARCHAR(50), + + -- Direccion de facturacion + billing_address_line1 VARCHAR(255), + billing_address_line2 VARCHAR(255), + billing_city VARCHAR(100), + billing_state VARCHAR(100), + billing_postal_code VARCHAR(20), + billing_country VARCHAR(2), -- ISO 3166-1 alpha-2 + + -- Datos fiscales (Mexico) + tax_id VARCHAR(20), -- RFC + tax_id_type VARCHAR(20) DEFAULT 'mx_rfc', -- Tipo de ID fiscal + legal_name VARCHAR(255), -- Razon social + + -- Preferencias + currency financial.currency_code NOT NULL DEFAULT 'USD', + locale VARCHAR(10) DEFAULT 'en-US', + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + delinquent BOOLEAN NOT NULL DEFAULT FALSE, + delinquent_since TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_customers_user UNIQUE(user_id), + CONSTRAINT chk_valid_country CHECK (billing_country IS NULL OR LENGTH(billing_country) = 2) +); + +-- Indices +CREATE INDEX idx_customers_user ON financial.customers(user_id); +CREATE INDEX idx_customers_stripe ON financial.customers(stripe_customer_id) WHERE stripe_customer_id IS NOT NULL; +CREATE INDEX idx_customers_email ON financial.customers(billing_email) WHERE billing_email IS NOT NULL; +CREATE INDEX idx_customers_delinquent ON financial.customers(delinquent) WHERE delinquent = TRUE; +CREATE INDEX idx_customers_tax_id ON financial.customers(tax_id) WHERE tax_id IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE financial.customers IS 'Clientes de Stripe con datos de facturacion'; +COMMENT ON COLUMN financial.customers.stripe_customer_id IS 'ID del cliente en Stripe (cus_xxx)'; +COMMENT ON COLUMN financial.customers.tax_id IS 'RFC para Mexico, VAT para EU, etc.'; +COMMENT ON COLUMN financial.customers.delinquent IS 'True si tiene pagos vencidos'; diff --git a/ddl/schemas/financial/tables/10-payment_methods.sql b/ddl/schemas/financial/tables/10-payment_methods.sql new file mode 100644 index 0000000..b2cd2d1 --- /dev/null +++ b/ddl/schemas/financial/tables/10-payment_methods.sql @@ -0,0 +1,180 @@ +-- ============================================================================ +-- FINANCIAL SCHEMA - Tabla: payment_methods +-- ============================================================================ +-- Metodos de pago guardados por usuarios para pagos recurrentes +-- Integra con Stripe para almacenamiento seguro de tarjetas y cuentas +-- ============================================================================ + +-- Enum para tipo de metodo de pago guardado +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'saved_payment_type' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN + CREATE TYPE financial.saved_payment_type AS ENUM ( + 'card', + 'bank_account', + 'sepa_debit', + 'crypto_wallet' + ); + END IF; +END$$; + +-- Enum para estado del metodo de pago +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'payment_method_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'financial')) THEN + CREATE TYPE financial.payment_method_status AS ENUM ( + 'pending_verification', + 'active', + 'expired', + 'failed', + 'removed' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS financial.payment_methods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + customer_id UUID REFERENCES financial.customers(id) ON DELETE SET NULL, + + -- Stripe integration + stripe_payment_method_id VARCHAR(100) UNIQUE, + stripe_fingerprint VARCHAR(100), -- Para detectar duplicados + + -- Tipo y estado + payment_type financial.saved_payment_type NOT NULL, + status financial.payment_method_status NOT NULL DEFAULT 'pending_verification', + + -- Informacion del metodo (datos no sensibles) + -- Para tarjetas: last4, brand, exp_month, exp_year + -- Para bancos: last4, bank_name, account_type + display_info JSONB NOT NULL DEFAULT '{}', + + -- Metodo por defecto + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Datos de tarjeta (solo informacion visible) + card_brand VARCHAR(20), -- 'visa', 'mastercard', 'amex', etc. + card_last4 VARCHAR(4), + card_exp_month INTEGER, + card_exp_year INTEGER, + card_funding VARCHAR(20), -- 'credit', 'debit', 'prepaid' + + -- Datos de cuenta bancaria (solo informacion visible) + bank_name VARCHAR(100), + bank_last4 VARCHAR(4), + bank_account_type VARCHAR(20), -- 'checking', 'savings' + + -- Datos de crypto wallet + crypto_network VARCHAR(20), -- 'ethereum', 'bitcoin', 'polygon' + crypto_address_last8 VARCHAR(8), + + -- Verificacion + verified_at TIMESTAMPTZ, + verification_method VARCHAR(50), -- 'micro_deposits', '3d_secure', 'instant' + + -- Billing address (para 3DS y validacion) + billing_address JSONB DEFAULT '{}', + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ, -- Para tarjetas con fecha de expiracion + removed_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT chk_card_info CHECK ( + payment_type != 'card' OR ( + card_brand IS NOT NULL AND + card_last4 IS NOT NULL AND + card_exp_month IS NOT NULL AND + card_exp_year IS NOT NULL + ) + ), + CONSTRAINT chk_bank_info CHECK ( + payment_type != 'bank_account' OR ( + bank_name IS NOT NULL AND + bank_last4 IS NOT NULL + ) + ), + CONSTRAINT chk_crypto_info CHECK ( + payment_type != 'crypto_wallet' OR ( + crypto_network IS NOT NULL AND + crypto_address_last8 IS NOT NULL + ) + ), + CONSTRAINT chk_valid_exp_month CHECK ( + card_exp_month IS NULL OR (card_exp_month >= 1 AND card_exp_month <= 12) + ), + CONSTRAINT chk_valid_exp_year CHECK ( + card_exp_year IS NULL OR card_exp_year >= 2024 + ) +); + +-- Indices +CREATE INDEX idx_payment_methods_user ON financial.payment_methods(user_id); +CREATE INDEX idx_payment_methods_customer ON financial.payment_methods(customer_id); +CREATE INDEX idx_payment_methods_stripe ON financial.payment_methods(stripe_payment_method_id); +CREATE INDEX idx_payment_methods_status ON financial.payment_methods(status); +CREATE INDEX idx_payment_methods_default ON financial.payment_methods(user_id, is_default) + WHERE is_default = TRUE; +CREATE INDEX idx_payment_methods_fingerprint ON financial.payment_methods(stripe_fingerprint) + WHERE stripe_fingerprint IS NOT NULL; +CREATE INDEX idx_payment_methods_active ON financial.payment_methods(user_id, payment_type) + WHERE status = 'active'; + +-- Comentarios +COMMENT ON TABLE financial.payment_methods IS 'Metodos de pago guardados por usuarios con integracion Stripe'; +COMMENT ON COLUMN financial.payment_methods.stripe_payment_method_id IS 'ID del PaymentMethod en Stripe'; +COMMENT ON COLUMN financial.payment_methods.stripe_fingerprint IS 'Fingerprint para detectar tarjetas duplicadas'; +COMMENT ON COLUMN financial.payment_methods.display_info IS 'Informacion visible del metodo para UI'; +COMMENT ON COLUMN financial.payment_methods.is_default IS 'Metodo de pago por defecto del usuario'; +COMMENT ON COLUMN financial.payment_methods.billing_address IS 'Direccion de facturacion para 3D Secure'; + +-- Trigger para asegurar un solo metodo por defecto por usuario +CREATE OR REPLACE FUNCTION financial.ensure_single_default_payment_method() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_default = TRUE THEN + UPDATE financial.payment_methods + SET is_default = FALSE, updated_at = NOW() + WHERE user_id = NEW.user_id + AND id != NEW.id + AND is_default = TRUE; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_ensure_single_default_payment_method + BEFORE INSERT OR UPDATE OF is_default ON financial.payment_methods + FOR EACH ROW + WHEN (NEW.is_default = TRUE) + EXECUTE FUNCTION financial.ensure_single_default_payment_method(); + +-- Funcion para marcar tarjetas expiradas +CREATE OR REPLACE FUNCTION financial.check_expired_cards() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE financial.payment_methods + SET status = 'expired', updated_at = NOW() + WHERE payment_type = 'card' + AND status = 'active' + AND ( + card_exp_year < EXTRACT(YEAR FROM CURRENT_DATE) OR + (card_exp_year = EXTRACT(YEAR FROM CURRENT_DATE) AND card_exp_month < EXTRACT(MONTH FROM CURRENT_DATE)) + ); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION financial.check_expired_cards() IS 'Marca como expiradas las tarjetas vencidas. Ejecutar mensualmente.'; diff --git a/ddl/schemas/investment/00-enums.sql b/ddl/schemas/investment/00-enums.sql new file mode 100644 index 0000000..689f19c --- /dev/null +++ b/ddl/schemas/investment/00-enums.sql @@ -0,0 +1,52 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for PAMM investment system +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Agentes de inversión (Trading Agents) +CREATE TYPE investment.trading_agent AS ENUM ( + 'atlas', -- Conservador: 3-5% mensual + 'orion', -- Moderado: 5-10% mensual + 'nova' -- Agresivo: 10%+ mensual +); + +-- Perfil de riesgo (unificado con cuestionario) +CREATE TYPE investment.risk_profile AS ENUM ( + 'conservative', + 'moderate', + 'aggressive' +); + +-- Estado de cuenta PAMM +CREATE TYPE investment.account_status AS ENUM ( + 'pending_kyc', + 'active', + 'suspended', + 'closed' +); + +-- Frecuencia de distribución (DECISIÓN: mensual por defecto) +CREATE TYPE investment.distribution_frequency AS ENUM ( + 'monthly', + 'quarterly' +); + +-- Tipo de transacción +CREATE TYPE investment.transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'distribution' +); + +-- Estado de transacción +CREATE TYPE investment.transaction_status AS ENUM ( + 'pending', + 'processing', + 'completed', + 'failed', + 'cancelled' +); diff --git a/ddl/schemas/investment/tables/01-products.sql b/ddl/schemas/investment/tables/01-products.sql new file mode 100644 index 0000000..6bc2136 --- /dev/null +++ b/ddl/schemas/investment/tables/01-products.sql @@ -0,0 +1,60 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - PRODUCTS TABLE +-- ===================================================== +-- Description: PAMM investment products +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.products ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + code VARCHAR(20) NOT NULL UNIQUE, -- PAMM-ATLAS, PAMM-ORION, PAMM-NOVA + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Agente asociado + trading_agent investment.trading_agent NOT NULL, + + -- Parámetros de inversión + min_investment DECIMAL(15,2) NOT NULL, + max_investment DECIMAL(15,2), + + -- Rentabilidad objetivo + target_return_min DECIMAL(5,2), -- % mensual mínimo esperado + target_return_max DECIMAL(5,2), -- % mensual máximo esperado + + -- Distribución de ganancias + distribution_frequency investment.distribution_frequency DEFAULT 'monthly', + investor_share_percent DECIMAL(5,2) DEFAULT 80.00, -- 80% para inversor + platform_share_percent DECIMAL(5,2) DEFAULT 20.00, -- 20% para plataforma + + -- Perfil de riesgo recomendado + recommended_risk_profile investment.risk_profile NOT NULL, + + -- Estado + is_active BOOLEAN DEFAULT true, + is_accepting_new_investors BOOLEAN DEFAULT true, + + -- Límites + total_capacity DECIMAL(15,2), -- Capacidad total del producto + current_aum DECIMAL(15,2) DEFAULT 0.00, -- Assets Under Management + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_products_agent ON investment.products(trading_agent); +CREATE INDEX idx_products_active ON investment.products(is_active) WHERE is_active = true; +CREATE INDEX idx_products_risk_profile ON investment.products(recommended_risk_profile); + +-- Comentarios +COMMENT ON TABLE investment.products IS 'PAMM investment products linked to trading agents'; +COMMENT ON COLUMN investment.products.code IS 'Unique product code (e.g., PAMM-ATLAS)'; +COMMENT ON COLUMN investment.products.current_aum IS 'Current Assets Under Management'; +COMMENT ON COLUMN investment.products.investor_share_percent IS 'Percentage of profits distributed to investors (80%)'; +COMMENT ON COLUMN investment.products.platform_share_percent IS 'Percentage of profits retained by platform (20%)'; diff --git a/ddl/schemas/investment/tables/02-accounts.sql b/ddl/schemas/investment/tables/02-accounts.sql new file mode 100644 index 0000000..8b906c7 --- /dev/null +++ b/ddl/schemas/investment/tables/02-accounts.sql @@ -0,0 +1,67 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - ACCOUNTS TABLE +-- ===================================================== +-- Description: Individual investor PAMM accounts +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Producto PAMM + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, + + -- Identificación + account_number VARCHAR(20) NOT NULL UNIQUE, -- INV-202512-00001 + + -- Balance + initial_balance DECIMAL(15,2) NOT NULL, + current_balance DECIMAL(15,2) NOT NULL, + total_deposits DECIMAL(15,2) DEFAULT 0.00, + total_withdrawals DECIMAL(15,2) DEFAULT 0.00, + total_distributions DECIMAL(15,2) DEFAULT 0.00, + + -- Rentabilidad + total_return_percent DECIMAL(10,4) DEFAULT 0.00, + total_return_amount DECIMAL(15,2) DEFAULT 0.00, + + -- Perfil de riesgo del usuario + user_risk_profile investment.risk_profile NOT NULL, + questionnaire_id UUID REFERENCES investment.risk_questionnaire(id), + + -- Estado + status investment.account_status DEFAULT 'pending_kyc', + + -- KYC/Compliance + kyc_verified BOOLEAN DEFAULT false, + kyc_verified_at TIMESTAMPTZ, + kyc_verified_by VARCHAR(100), + + -- Fechas importantes + opened_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + last_distribution_at TIMESTAMPTZ, + + -- Metadata + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_accounts_user ON investment.accounts(user_id); +CREATE INDEX idx_accounts_product ON investment.accounts(product_id); +CREATE INDEX idx_accounts_status ON investment.accounts(status); +CREATE INDEX idx_accounts_active ON investment.accounts(status) WHERE status = 'active'; +CREATE INDEX idx_accounts_number ON investment.accounts(account_number); + +-- Comentarios +COMMENT ON TABLE investment.accounts IS 'Individual investor PAMM accounts'; +COMMENT ON COLUMN investment.accounts.account_number IS 'Unique account identifier (INV-YYYYMM-NNNNN)'; +COMMENT ON COLUMN investment.accounts.current_balance IS 'Current account balance including all deposits, withdrawals, and distributions'; +COMMENT ON COLUMN investment.accounts.total_return_percent IS 'Cumulative return percentage since account opening'; +COMMENT ON COLUMN investment.accounts.user_risk_profile IS 'Risk profile from questionnaire, must match product recommendation'; diff --git a/ddl/schemas/investment/tables/03-transactions.sql b/ddl/schemas/investment/tables/03-transactions.sql new file mode 100644 index 0000000..cce6a12 --- /dev/null +++ b/ddl/schemas/investment/tables/03-transactions.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - TRANSACTIONS TABLE +-- ===================================================== +-- Description: Deposits, withdrawals, and distributions +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Cuenta asociada + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, + + -- Identificación + transaction_number VARCHAR(30) NOT NULL UNIQUE, -- TXN-202512-00001 + + -- Tipo y monto + transaction_type investment.transaction_type NOT NULL, + amount DECIMAL(15,2) NOT NULL CHECK (amount > 0), + + -- Estado + status investment.transaction_status DEFAULT 'pending', + + -- Detalles de pago (para deposits/withdrawals) + payment_method VARCHAR(50), -- bank_transfer, card, crypto + payment_reference VARCHAR(100), + payment_metadata JSONB, + + -- Distribución (para transaction_type = 'distribution') + distribution_id UUID REFERENCES investment.distributions(id), + + -- Balance después de transacción + balance_before DECIMAL(15,2), + balance_after DECIMAL(15,2), + + -- Procesamiento + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failure_reason TEXT, + + -- Aprobación (para withdrawals) + requires_approval BOOLEAN DEFAULT false, + approved_by VARCHAR(100), + approved_at TIMESTAMPTZ, + + -- Metadata + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_transactions_account ON investment.transactions(account_id); +CREATE INDEX idx_transactions_type ON investment.transactions(transaction_type); +CREATE INDEX idx_transactions_status ON investment.transactions(status); +CREATE INDEX idx_transactions_number ON investment.transactions(transaction_number); +CREATE INDEX idx_transactions_distribution ON investment.transactions(distribution_id); +CREATE INDEX idx_transactions_requested ON investment.transactions(requested_at DESC); + +-- Comentarios +COMMENT ON TABLE investment.transactions IS 'All account transactions: deposits, withdrawals, and distributions'; +COMMENT ON COLUMN investment.transactions.transaction_number IS 'Unique transaction identifier (TXN-YYYYMM-NNNNN)'; +COMMENT ON COLUMN investment.transactions.payment_method IS 'Payment method for deposits/withdrawals'; +COMMENT ON COLUMN investment.transactions.distribution_id IS 'Link to distribution record if transaction_type is distribution'; +COMMENT ON COLUMN investment.transactions.requires_approval IS 'Whether withdrawal requires manual approval'; diff --git a/ddl/schemas/investment/tables/04-distributions.sql b/ddl/schemas/investment/tables/04-distributions.sql new file mode 100644 index 0000000..6ca102a --- /dev/null +++ b/ddl/schemas/investment/tables/04-distributions.sql @@ -0,0 +1,69 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - DISTRIBUTIONS TABLE +-- ===================================================== +-- Description: Profit distributions (80/20 split) +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.distributions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Producto PAMM + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE RESTRICT, + + -- Periodo + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + period_label VARCHAR(20) NOT NULL, -- 2025-12, 2025-Q4 + + -- Performance del agente de trading + total_profit_amount DECIMAL(15,2) NOT NULL, -- Ganancia total generada + total_profit_percent DECIMAL(10,4) NOT NULL, -- % de retorno + + -- Distribución 80/20 + investor_total_amount DECIMAL(15,2) NOT NULL, -- 80% para inversores + platform_total_amount DECIMAL(15,2) NOT NULL, -- 20% para plataforma + + -- Cuentas participantes + participating_accounts INTEGER NOT NULL, + total_aum_at_period_start DECIMAL(15,2) NOT NULL, + total_aum_at_period_end DECIMAL(15,2) NOT NULL, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed + + -- Procesamiento + calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approved_by VARCHAR(100), + approved_at TIMESTAMPTZ, + distributed_at TIMESTAMPTZ, + + -- Metadata + notes TEXT, + distribution_metadata JSONB, -- Detalles adicionales + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Validación + CONSTRAINT valid_period CHECK (period_end > period_start), + CONSTRAINT valid_split CHECK ( + investor_total_amount + platform_total_amount = total_profit_amount + ) +); + +-- Índices +CREATE INDEX idx_distributions_product ON investment.distributions(product_id); +CREATE INDEX idx_distributions_period ON investment.distributions(period_start, period_end); +CREATE INDEX idx_distributions_status ON investment.distributions(status); +CREATE UNIQUE INDEX idx_distributions_product_period ON investment.distributions(product_id, period_label); + +-- Comentarios +COMMENT ON TABLE investment.distributions IS 'Periodic profit distributions with 80/20 split'; +COMMENT ON COLUMN investment.distributions.period_label IS 'Human-readable period identifier (YYYY-MM or YYYY-QN)'; +COMMENT ON COLUMN investment.distributions.total_profit_amount IS 'Total profit generated by trading agent during period'; +COMMENT ON COLUMN investment.distributions.investor_total_amount IS '80% of total profit distributed to all investors'; +COMMENT ON COLUMN investment.distributions.platform_total_amount IS '20% of total profit retained by platform'; +COMMENT ON COLUMN investment.distributions.total_aum_at_period_start IS 'Total Assets Under Management at period start'; diff --git a/ddl/schemas/investment/tables/05-risk_questionnaire.sql b/ddl/schemas/investment/tables/05-risk_questionnaire.sql new file mode 100644 index 0000000..69e96dd --- /dev/null +++ b/ddl/schemas/investment/tables/05-risk_questionnaire.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- INVESTMENT SCHEMA - RISK QUESTIONNAIRE TABLE +-- ===================================================== +-- Description: Risk assessment questionnaire (15 questions) +-- Schema: investment +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE investment.risk_questionnaire ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Respuestas (15 preguntas) + responses JSONB NOT NULL, -- [{question_id, answer, score}] + + -- Resultado + total_score INTEGER NOT NULL CHECK (total_score >= 0 AND total_score <= 100), + calculated_profile investment.risk_profile NOT NULL, + + -- Recomendación de agente + recommended_agent investment.trading_agent, + + -- Validez + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, -- Válido por 1 año + is_expired BOOLEAN GENERATED ALWAYS AS (expires_at < NOW()) STORED, + + -- Metadata + ip_address INET, + user_agent TEXT, + completion_time_seconds INTEGER, -- Tiempo que tardó en completar + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_questionnaire_user ON investment.risk_questionnaire(user_id); +CREATE INDEX idx_questionnaire_profile ON investment.risk_questionnaire(calculated_profile); +CREATE INDEX idx_questionnaire_valid ON investment.risk_questionnaire(user_id, expires_at DESC) + WHERE expires_at > NOW(); + +-- Comentarios +COMMENT ON TABLE investment.risk_questionnaire IS 'Risk assessment questionnaire responses (valid for 1 year)'; +COMMENT ON COLUMN investment.risk_questionnaire.responses IS 'Array of question responses with scores: [{question_id, answer, score}]'; +COMMENT ON COLUMN investment.risk_questionnaire.total_score IS 'Sum of all question scores (0-100)'; +COMMENT ON COLUMN investment.risk_questionnaire.calculated_profile IS 'Risk profile calculated from total_score'; +COMMENT ON COLUMN investment.risk_questionnaire.recommended_agent IS 'Trading agent recommendation based on risk profile'; +COMMENT ON COLUMN investment.risk_questionnaire.expires_at IS 'Questionnaire expires after 1 year, user must retake'; + +-- Ejemplo de estructura de responses JSONB: +COMMENT ON COLUMN investment.risk_questionnaire.responses IS +'Example: [ + {"question_id": "Q1", "answer": "A", "score": 5}, + {"question_id": "Q2", "answer": "B", "score": 10}, + ... +] +Scoring logic: +- Conservative (0-40): Atlas agent recommended +- Moderate (41-70): Orion agent recommended +- Aggressive (71-100): Nova agent recommended'; diff --git a/ddl/schemas/investment/tables/06-withdrawal_requests.sql b/ddl/schemas/investment/tables/06-withdrawal_requests.sql new file mode 100644 index 0000000..8659bc0 --- /dev/null +++ b/ddl/schemas/investment/tables/06-withdrawal_requests.sql @@ -0,0 +1,119 @@ +-- ============================================================================ +-- INVESTMENT SCHEMA - Tabla: withdrawal_requests +-- ============================================================================ +-- Solicitudes de retiro de cuentas PAMM +-- Requiere aprobacion manual para montos grandes +-- ============================================================================ + +-- Enum para estado de solicitud +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'withdrawal_status' AND typnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'investment')) THEN + CREATE TYPE investment.withdrawal_status AS ENUM ( + 'pending', + 'under_review', + 'approved', + 'processing', + 'completed', + 'rejected', + 'cancelled' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS investment.withdrawal_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Solicitud + request_number VARCHAR(20) NOT NULL UNIQUE, + amount DECIMAL(20, 8) NOT NULL, + currency VARCHAR(10) NOT NULL DEFAULT 'USD', + + -- Estado + status investment.withdrawal_status NOT NULL DEFAULT 'pending', + + -- Destino del retiro + destination_type VARCHAR(20) NOT NULL, -- 'wallet', 'bank', 'crypto' + destination_details JSONB NOT NULL DEFAULT '{}', + + -- Fees + fee_amount DECIMAL(20, 8) NOT NULL DEFAULT 0, + fee_percentage DECIMAL(5, 2) NOT NULL DEFAULT 0, + net_amount DECIMAL(20, 8) GENERATED ALWAYS AS (amount - fee_amount) STORED, + + -- Aprobacion + requires_approval BOOLEAN NOT NULL DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + -- Procesamiento + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + transaction_reference VARCHAR(100), + + -- Rechazo/Cancelacion + rejection_reason TEXT, + cancelled_at TIMESTAMPTZ, + cancellation_reason TEXT, + + -- Metadata + ip_address INET, + user_agent TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT chk_positive_amount CHECK (amount > 0), + CONSTRAINT chk_valid_fee CHECK (fee_amount >= 0 AND fee_amount <= amount), + CONSTRAINT chk_fee_percentage CHECK (fee_percentage >= 0 AND fee_percentage <= 100), + CONSTRAINT chk_destination_type CHECK (destination_type IN ('wallet', 'bank', 'crypto')) +); + +-- Indices +CREATE INDEX idx_withdrawal_requests_account ON investment.withdrawal_requests(account_id); +CREATE INDEX idx_withdrawal_requests_user ON investment.withdrawal_requests(user_id); +CREATE INDEX idx_withdrawal_requests_status ON investment.withdrawal_requests(status); +CREATE INDEX idx_withdrawal_requests_created ON investment.withdrawal_requests(created_at DESC); +CREATE INDEX idx_withdrawal_requests_pending ON investment.withdrawal_requests(status, created_at) + WHERE status IN ('pending', 'under_review'); + +-- Comentarios +COMMENT ON TABLE investment.withdrawal_requests IS 'Solicitudes de retiro de cuentas PAMM'; +COMMENT ON COLUMN investment.withdrawal_requests.request_number IS 'Numero unico de solicitud (WR-YYYYMMDD-XXXX)'; +COMMENT ON COLUMN investment.withdrawal_requests.requires_approval IS 'True si el monto requiere aprobacion manual'; +COMMENT ON COLUMN investment.withdrawal_requests.destination_details IS 'Detalles del destino (IBAN, wallet address, etc.)'; + +-- Funcion para generar numero de solicitud +CREATE OR REPLACE FUNCTION investment.generate_withdrawal_request_number() +RETURNS TRIGGER AS $$ +DECLARE + v_date TEXT; + v_seq INTEGER; +BEGIN + v_date := TO_CHAR(NOW(), 'YYYYMMDD'); + + SELECT COALESCE(MAX( + CAST(SUBSTRING(request_number FROM 'WR-[0-9]{8}-([0-9]+)') AS INTEGER) + ), 0) + 1 + INTO v_seq + FROM investment.withdrawal_requests + WHERE request_number LIKE 'WR-' || v_date || '-%'; + + NEW.request_number := 'WR-' || v_date || '-' || LPAD(v_seq::TEXT, 4, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tr_generate_withdrawal_request_number + BEFORE INSERT ON investment.withdrawal_requests + FOR EACH ROW + WHEN (NEW.request_number IS NULL) + EXECUTE FUNCTION investment.generate_withdrawal_request_number(); diff --git a/ddl/schemas/investment/tables/07-daily_performance.sql b/ddl/schemas/investment/tables/07-daily_performance.sql new file mode 100644 index 0000000..58fd8ba --- /dev/null +++ b/ddl/schemas/investment/tables/07-daily_performance.sql @@ -0,0 +1,115 @@ +-- ============================================================================ +-- INVESTMENT SCHEMA - Tabla: daily_performance +-- ============================================================================ +-- Snapshots diarios de rendimiento de cuentas PAMM +-- Usado para graficos, reportes y calculo de metricas +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS investment.daily_performance ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Relaciones + account_id UUID NOT NULL REFERENCES investment.accounts(id) ON DELETE CASCADE, + product_id UUID NOT NULL REFERENCES investment.products(id) ON DELETE CASCADE, + + -- Fecha del snapshot + snapshot_date DATE NOT NULL, + + -- Balance + opening_balance DECIMAL(20, 8) NOT NULL, + closing_balance DECIMAL(20, 8) NOT NULL, + + -- Rendimiento del dia + daily_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + daily_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, + + -- Rendimiento acumulado + cumulative_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + cumulative_return_percentage DECIMAL(10, 6) NOT NULL DEFAULT 0, + + -- Movimientos del dia + deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, + withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, + distributions_received DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Metricas del agente de trading + trades_executed INTEGER NOT NULL DEFAULT 0, + winning_trades INTEGER NOT NULL DEFAULT 0, + losing_trades INTEGER NOT NULL DEFAULT 0, + win_rate DECIMAL(5, 2), + + -- Volatilidad y riesgo + max_drawdown DECIMAL(10, 6), + sharpe_ratio DECIMAL(10, 6), + volatility DECIMAL(10, 6), + + -- High/Low del dia + high_water_mark DECIMAL(20, 8), + lowest_point DECIMAL(20, 8), + + -- Metadata del snapshot + snapshot_source VARCHAR(50) DEFAULT 'cron', -- 'cron', 'manual', 'system' + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_daily_performance_account_date UNIQUE(account_id, snapshot_date), + CONSTRAINT chk_valid_balances CHECK (opening_balance >= 0 AND closing_balance >= 0), + CONSTRAINT chk_valid_movements CHECK (deposits >= 0 AND withdrawals >= 0), + CONSTRAINT chk_valid_trades CHECK ( + trades_executed >= 0 AND + winning_trades >= 0 AND + losing_trades >= 0 AND + winning_trades + losing_trades <= trades_executed + ), + CONSTRAINT chk_valid_win_rate CHECK (win_rate IS NULL OR (win_rate >= 0 AND win_rate <= 100)) +); + +-- Indices +CREATE INDEX idx_daily_performance_account ON investment.daily_performance(account_id); +CREATE INDEX idx_daily_performance_product ON investment.daily_performance(product_id); +CREATE INDEX idx_daily_performance_date ON investment.daily_performance(snapshot_date DESC); +CREATE INDEX idx_daily_performance_account_date ON investment.daily_performance(account_id, snapshot_date DESC); + +-- Indice parcial para ultimos 30 dias (hot data) +CREATE INDEX idx_daily_performance_recent ON investment.daily_performance(account_id, snapshot_date) + WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'; + +-- Comentarios +COMMENT ON TABLE investment.daily_performance IS 'Snapshots diarios de rendimiento de cuentas PAMM'; +COMMENT ON COLUMN investment.daily_performance.snapshot_date IS 'Fecha del snapshot (una entrada por dia por cuenta)'; +COMMENT ON COLUMN investment.daily_performance.daily_return_percentage IS 'Retorno del dia como porcentaje'; +COMMENT ON COLUMN investment.daily_performance.cumulative_return_percentage IS 'Retorno acumulado desde apertura de cuenta'; +COMMENT ON COLUMN investment.daily_performance.max_drawdown IS 'Maximo drawdown del dia'; +COMMENT ON COLUMN investment.daily_performance.high_water_mark IS 'Punto mas alto alcanzado'; + +-- Vista para resumen mensual +CREATE OR REPLACE VIEW investment.v_monthly_performance AS +SELECT + account_id, + product_id, + DATE_TRUNC('month', snapshot_date) AS month, + MIN(opening_balance) AS month_opening, + MAX(closing_balance) AS month_closing, + SUM(daily_pnl) AS total_pnl, + AVG(daily_return_percentage) AS avg_daily_return, + SUM(deposits) AS total_deposits, + SUM(withdrawals) AS total_withdrawals, + SUM(distributions_received) AS total_distributions, + SUM(trades_executed) AS total_trades, + SUM(winning_trades) AS total_winning, + SUM(losing_trades) AS total_losing, + CASE + WHEN SUM(winning_trades) + SUM(losing_trades) > 0 + THEN ROUND(SUM(winning_trades)::DECIMAL / (SUM(winning_trades) + SUM(losing_trades)) * 100, 2) + ELSE NULL + END AS monthly_win_rate, + MIN(lowest_point) AS monthly_low, + MAX(high_water_mark) AS monthly_high, + COUNT(*) AS trading_days +FROM investment.daily_performance +GROUP BY account_id, product_id, DATE_TRUNC('month', snapshot_date); + +COMMENT ON VIEW investment.v_monthly_performance IS 'Resumen mensual de rendimiento agregado'; diff --git a/ddl/schemas/llm/00-enums.sql b/ddl/schemas/llm/00-enums.sql new file mode 100644 index 0000000..107bcdb --- /dev/null +++ b/ddl/schemas/llm/00-enums.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- LLM SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for LLM agent system +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Rol del mensaje +CREATE TYPE llm.message_role AS ENUM ( + 'user', + 'assistant', + 'system', + 'tool' +); + +-- Estado de la conversación +CREATE TYPE llm.conversation_status AS ENUM ( + 'active', + 'archived', + 'deleted' +); + +-- Tipo de conversación +CREATE TYPE llm.conversation_type AS ENUM ( + 'general', -- Conversación general + 'trading_advice', -- Consulta sobre trading + 'education', -- Preguntas educativas + 'market_analysis', -- Análisis de mercado + 'support', -- Soporte técnico + 'onboarding' -- Onboarding de usuario +); + +-- Tono de comunicación +CREATE TYPE llm.communication_tone AS ENUM ( + 'casual', + 'professional', + 'technical' +); + +-- Nivel de verbosidad +CREATE TYPE llm.verbosity_level AS ENUM ( + 'brief', + 'normal', + 'detailed' +); + +-- Frecuencia de alertas +CREATE TYPE llm.alert_frequency AS ENUM ( + 'low', + 'normal', + 'high' +); + +-- Tipo de memoria +CREATE TYPE llm.memory_type AS ENUM ( + 'fact', -- Hecho sobre el usuario + 'preference', -- Preferencia del usuario + 'context', -- Contexto de conversaciones + 'goal', -- Objetivo del usuario + 'constraint' -- Restricción o límite +); diff --git a/ddl/schemas/llm/tables/01-conversations.sql b/ddl/schemas/llm/tables/01-conversations.sql new file mode 100644 index 0000000..abac1b6 --- /dev/null +++ b/ddl/schemas/llm/tables/01-conversations.sql @@ -0,0 +1,63 @@ +-- ===================================================== +-- LLM SCHEMA - CONVERSATIONS TABLE +-- ===================================================== +-- Description: Chat conversations with LLM agent +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.conversations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Identificación + title VARCHAR(200), -- Auto-generado o definido por usuario + + -- Tipo y contexto + conversation_type llm.conversation_type DEFAULT 'general', + + -- Estado + status llm.conversation_status DEFAULT 'active', + + -- Resumen de conversación (generado por LLM) + summary TEXT, + + -- Metadata + total_messages INTEGER DEFAULT 0, + total_tokens_used INTEGER DEFAULT 0, + + -- Tags para búsqueda + tags TEXT[] DEFAULT '{}', + + -- Contexto de negocio + related_symbols VARCHAR(20)[] DEFAULT '{}', -- Símbolos discutidos + related_topics TEXT[] DEFAULT '{}', -- Temas discutidos + + -- Fechas + started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_message_at TIMESTAMPTZ, + archived_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_conversations_user ON llm.conversations(user_id); +CREATE INDEX idx_conversations_status ON llm.conversations(status); +CREATE INDEX idx_conversations_type ON llm.conversations(conversation_type); +CREATE INDEX idx_conversations_active ON llm.conversations(user_id, last_message_at DESC) + WHERE status = 'active'; +CREATE INDEX idx_conversations_tags ON llm.conversations USING GIN(tags); +CREATE INDEX idx_conversations_symbols ON llm.conversations USING GIN(related_symbols); + +-- Comentarios +COMMENT ON TABLE llm.conversations IS 'Chat conversations between users and LLM agent'; +COMMENT ON COLUMN llm.conversations.title IS 'Conversation title (auto-generated from first messages or user-defined)'; +COMMENT ON COLUMN llm.conversations.summary IS 'AI-generated summary of conversation content'; +COMMENT ON COLUMN llm.conversations.total_tokens_used IS 'Cumulative token count for cost tracking'; +COMMENT ON COLUMN llm.conversations.related_symbols IS 'Trading symbols mentioned in conversation'; +COMMENT ON COLUMN llm.conversations.related_topics IS 'Topics discussed (e.g., technical_analysis, risk_management)'; diff --git a/ddl/schemas/llm/tables/02-messages.sql b/ddl/schemas/llm/tables/02-messages.sql new file mode 100644 index 0000000..bdf4ecc --- /dev/null +++ b/ddl/schemas/llm/tables/02-messages.sql @@ -0,0 +1,98 @@ +-- ===================================================== +-- LLM SCHEMA - MESSAGES TABLE +-- ===================================================== +-- Description: Individual messages in conversations +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Conversación + conversation_id UUID NOT NULL REFERENCES llm.conversations(id) ON DELETE CASCADE, + + -- Rol y contenido + role llm.message_role NOT NULL, + content TEXT NOT NULL, + + -- Metadata de LLM + model_name VARCHAR(100), -- claude-opus-4-5, gpt-4, etc. + prompt_tokens INTEGER, + completion_tokens INTEGER, + total_tokens INTEGER, + + -- Contexto utilizado + context_used JSONB, -- RAG context, market data, user profile, etc. + + -- Tools/Functions llamadas + tool_calls JSONB, -- Function calls realizadas + tool_results JSONB, -- Resultados de tool calls + + -- Metadata de procesamiento + response_time_ms INTEGER, + temperature DECIMAL(3,2), + + -- Feedback del usuario + user_rating INTEGER CHECK (user_rating >= 1 AND user_rating <= 5), + user_feedback TEXT, + + -- Referencias + references_symbols VARCHAR(20)[] DEFAULT '{}', + references_concepts TEXT[] DEFAULT '{}', + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_messages_conversation ON llm.messages(conversation_id); +CREATE INDEX idx_messages_role ON llm.messages(role); +CREATE INDEX idx_messages_created ON llm.messages(created_at DESC); +CREATE INDEX idx_messages_conversation_created ON llm.messages(conversation_id, created_at ASC); +CREATE INDEX idx_messages_rated ON llm.messages(user_rating) WHERE user_rating IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE llm.messages IS 'Individual messages in LLM conversations'; +COMMENT ON COLUMN llm.messages.role IS 'Message sender: user, assistant, system, or tool'; +COMMENT ON COLUMN llm.messages.content IS 'Message text content'; +COMMENT ON COLUMN llm.messages.model_name IS 'LLM model used to generate response'; +COMMENT ON COLUMN llm.messages.context_used IS 'Context provided to LLM (RAG docs, market data, user profile)'; +COMMENT ON COLUMN llm.messages.tool_calls IS 'Functions/tools called by LLM during response generation'; +COMMENT ON COLUMN llm.messages.user_rating IS 'User satisfaction rating (1-5 stars)'; + +-- Ejemplo de context_used JSONB: +COMMENT ON COLUMN llm.messages.context_used IS +'Example: { + "market_data": { + "symbol": "BTCUSDT", + "price": 45234.12, + "change_24h": 0.0234 + }, + "user_profile": { + "risk_profile": "moderate", + "preferred_symbols": ["BTCUSDT", "ETHUSDT"] + }, + "rag_documents": [ + {"doc_id": "123", "relevance": 0.89, "snippet": "..."}, + {"doc_id": "456", "relevance": 0.76, "snippet": "..."} + ] +}'; + +-- Ejemplo de tool_calls JSONB: +COMMENT ON COLUMN llm.messages.tool_calls IS +'Example: [ + { + "tool": "get_market_data", + "params": {"symbol": "BTCUSDT", "timeframe": "1h"}, + "result": {...} + }, + { + "tool": "calculate_indicator", + "params": {"indicator": "rsi", "period": 14}, + "result": {"rsi": 65.42} + } +]'; diff --git a/ddl/schemas/llm/tables/03-user_preferences.sql b/ddl/schemas/llm/tables/03-user_preferences.sql new file mode 100644 index 0000000..c5ae01a --- /dev/null +++ b/ddl/schemas/llm/tables/03-user_preferences.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- LLM SCHEMA - USER PREFERENCES TABLE +-- ===================================================== +-- Description: User preferences for LLM agent interactions +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.user_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Preferencias de comunicación + language VARCHAR(5) DEFAULT 'es', -- ISO 639-1 + tone llm.communication_tone DEFAULT 'professional', + verbosity llm.verbosity_level DEFAULT 'normal', + + -- Preferencias de trading + preferred_symbols VARCHAR(20)[] DEFAULT '{}', + preferred_timeframe VARCHAR(10) DEFAULT '1h', + risk_tolerance VARCHAR(20) DEFAULT 'moderate', -- conservative, moderate, aggressive + + -- Preferencias de notificación + proactive_alerts BOOLEAN DEFAULT true, + alert_frequency llm.alert_frequency DEFAULT 'normal', + notification_hours_start TIME DEFAULT '08:00:00', + notification_hours_end TIME DEFAULT '22:00:00', + timezone VARCHAR(50) DEFAULT 'America/Mexico_City', + + -- Intereses + topics_of_interest TEXT[] DEFAULT '{}', -- trading, education, news, market_analysis + + -- Nivel de experiencia (para personalizar explicaciones) + trading_experience_level VARCHAR(20) DEFAULT 'beginner', -- beginner, intermediate, advanced, expert + + -- Preferencias de análisis + preferred_analysis_types TEXT[] DEFAULT '{}', -- technical, fundamental, sentiment, onchain + + -- Formato de respuestas + include_charts BOOLEAN DEFAULT true, + include_data_tables BOOLEAN DEFAULT true, + include_explanations BOOLEAN DEFAULT true, + + -- Metadata + onboarding_completed BOOLEAN DEFAULT false, + onboarding_completed_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE UNIQUE INDEX idx_user_preferences_user ON llm.user_preferences(user_id); +CREATE INDEX idx_user_preferences_language ON llm.user_preferences(language); + +-- Comentarios +COMMENT ON TABLE llm.user_preferences IS 'User preferences for personalized LLM agent interactions'; +COMMENT ON COLUMN llm.user_preferences.language IS 'Preferred language for responses (ISO 639-1 code)'; +COMMENT ON COLUMN llm.user_preferences.tone IS 'Communication style: casual, professional, or technical'; +COMMENT ON COLUMN llm.user_preferences.verbosity IS 'Response length preference: brief, normal, or detailed'; +COMMENT ON COLUMN llm.user_preferences.preferred_symbols IS 'Trading pairs user is most interested in'; +COMMENT ON COLUMN llm.user_preferences.proactive_alerts IS 'Whether agent should send proactive notifications'; +COMMENT ON COLUMN llm.user_preferences.alert_frequency IS 'How often to receive alerts: low, normal, or high'; +COMMENT ON COLUMN llm.user_preferences.notification_hours_start IS 'Start of notification window in user timezone'; +COMMENT ON COLUMN llm.user_preferences.notification_hours_end IS 'End of notification window in user timezone'; +COMMENT ON COLUMN llm.user_preferences.topics_of_interest IS 'Topics user wants to learn about or discuss'; +COMMENT ON COLUMN llm.user_preferences.trading_experience_level IS 'User experience level for tailoring explanations'; diff --git a/ddl/schemas/llm/tables/04-user_memory.sql b/ddl/schemas/llm/tables/04-user_memory.sql new file mode 100644 index 0000000..4d4f6b4 --- /dev/null +++ b/ddl/schemas/llm/tables/04-user_memory.sql @@ -0,0 +1,82 @@ +-- ===================================================== +-- LLM SCHEMA - USER MEMORY TABLE +-- ===================================================== +-- Description: Persistent memory about users for personalization +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE llm.user_memory ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Tipo de memoria + memory_type llm.memory_type NOT NULL, + + -- Contenido + key VARCHAR(200) NOT NULL, + value TEXT NOT NULL, + + -- Importancia + importance_score DECIMAL(3,2) DEFAULT 0.50 CHECK (importance_score >= 0.00 AND importance_score <= 1.00), + + -- Fuente + source_conversation_id UUID REFERENCES llm.conversations(id) ON DELETE SET NULL, + extracted_from TEXT, -- Fragmento del que se extrajo la memoria + extraction_method VARCHAR(50) DEFAULT 'llm', -- llm, manual, system + + -- Validez + is_active BOOLEAN DEFAULT true, + expires_at TIMESTAMPTZ, + + -- Confirmación (algunas memorias pueden requerir confirmación del usuario) + requires_confirmation BOOLEAN DEFAULT false, + confirmed_by_user BOOLEAN DEFAULT false, + confirmed_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_user_memory_key UNIQUE(user_id, memory_type, key) +); + +-- Índices +CREATE INDEX idx_memory_user ON llm.user_memory(user_id); +CREATE INDEX idx_memory_type ON llm.user_memory(memory_type); +CREATE INDEX idx_memory_active ON llm.user_memory(is_active) WHERE is_active = true; +CREATE INDEX idx_memory_importance ON llm.user_memory(importance_score DESC); +CREATE INDEX idx_memory_conversation ON llm.user_memory(source_conversation_id); +CREATE INDEX idx_memory_expires ON llm.user_memory(expires_at) WHERE expires_at IS NOT NULL; + +-- Comentarios +COMMENT ON TABLE llm.user_memory IS 'Persistent memory about users for LLM personalization and context'; +COMMENT ON COLUMN llm.user_memory.memory_type IS 'Type of memory: fact, preference, context, goal, or constraint'; +COMMENT ON COLUMN llm.user_memory.key IS 'Memory identifier (e.g., "favorite_symbol", "trading_goal", "risk_limit")'; +COMMENT ON COLUMN llm.user_memory.value IS 'Memory content (e.g., "BTCUSDT", "save for house", "max 5% per trade")'; +COMMENT ON COLUMN llm.user_memory.importance_score IS 'Importance weight (0.00-1.00) for retrieval prioritization'; +COMMENT ON COLUMN llm.user_memory.extracted_from IS 'Original text from which memory was extracted'; +COMMENT ON COLUMN llm.user_memory.requires_confirmation IS 'Whether this memory needs explicit user confirmation'; + +-- Ejemplos de memorias por tipo: +COMMENT ON COLUMN llm.user_memory.memory_type IS +'Memory type examples: +- fact: "trading_since" = "2020", "max_loss_experienced" = "15%" +- preference: "favorite_indicator" = "RSI", "avoids_margin_trading" = "true" +- context: "recent_portfolio_loss" = "trying to recover", "learning_focus" = "risk management" +- goal: "monthly_target" = "5% return", "learning_goal" = "master technical analysis" +- constraint: "max_risk_per_trade" = "2%", "no_trading_during_work" = "9am-5pm"'; + +-- Ejemplo de metadata JSONB: +COMMENT ON COLUMN llm.user_memory.metadata IS +'Example: { + "confidence": 0.85, + "last_mentioned": "2025-12-05T10:30:00Z", + "mention_count": 5, + "related_memories": ["mem_123", "mem_456"], + "tags": ["trading_style", "risk_management"] +}'; diff --git a/ddl/schemas/llm/tables/05-embeddings.sql b/ddl/schemas/llm/tables/05-embeddings.sql new file mode 100644 index 0000000..d913ea8 --- /dev/null +++ b/ddl/schemas/llm/tables/05-embeddings.sql @@ -0,0 +1,122 @@ +-- ===================================================== +-- LLM SCHEMA - EMBEDDINGS TABLE +-- ===================================================== +-- Description: Vector embeddings for RAG and semantic search +-- Schema: llm +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- NOTA: Requiere extensión pgvector +-- CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE llm.embeddings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Tipo de contenido + content_type VARCHAR(50) NOT NULL, -- message, document, faq, tutorial, article + + -- Referencia al contenido original + content_id UUID, -- ID del mensaje, documento, etc. + + -- Contenido + content TEXT NOT NULL, + content_hash VARCHAR(64), -- SHA-256 para deduplicación + + -- Metadata del contenido + title VARCHAR(500), + description TEXT, + + -- Vector embedding (dimensión depende del modelo) + -- OpenAI text-embedding-3-small: 1536 dims + -- OpenAI text-embedding-3-large: 3072 dims + -- Voyage AI: 1024 dims + embedding vector(1536), -- Ajustar según modelo usado + + -- Modelo usado para generar embedding + embedding_model VARCHAR(100) NOT NULL, -- text-embedding-3-small, voyage-2, etc. + + -- Metadata para filtrado + user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, -- Si es contenido específico de usuario + is_public BOOLEAN DEFAULT true, + + -- Categorización + category VARCHAR(100), -- education, trading, market_news, platform_help + subcategory VARCHAR(100), + tags TEXT[] DEFAULT '{}', + + -- Relevancia + importance_score DECIMAL(3,2) DEFAULT 0.50, + + -- Contexto adicional + context_metadata JSONB, -- Metadata adicional para mejorar recuperación + + -- Fuente + source_url VARCHAR(500), + source_type VARCHAR(50), -- internal, external, generated + + -- Validez + is_active BOOLEAN DEFAULT true, + expires_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_embeddings_type ON llm.embeddings(content_type); +CREATE INDEX idx_embeddings_user ON llm.embeddings(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX idx_embeddings_category ON llm.embeddings(category); +CREATE INDEX idx_embeddings_tags ON llm.embeddings USING GIN(tags); +CREATE INDEX idx_embeddings_active ON llm.embeddings(is_active) WHERE is_active = true; +CREATE INDEX idx_embeddings_hash ON llm.embeddings(content_hash); + +-- Índice para búsqueda vectorial (HNSW para mejor performance) +-- Requiere pgvector +CREATE INDEX idx_embeddings_vector_hnsw ON llm.embeddings + USING hnsw (embedding vector_cosine_ops); + +-- Índice alternativo: IVFFlat (más rápido de construir, menos preciso) +-- CREATE INDEX idx_embeddings_vector_ivfflat ON llm.embeddings +-- USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); + +-- Comentarios +COMMENT ON TABLE llm.embeddings IS 'Vector embeddings for RAG and semantic search using pgvector'; +COMMENT ON COLUMN llm.embeddings.content_type IS 'Type of content: message, document, faq, tutorial, article'; +COMMENT ON COLUMN llm.embeddings.content_id IS 'Reference to original content (e.g., message ID)'; +COMMENT ON COLUMN llm.embeddings.content IS 'Text content that was embedded'; +COMMENT ON COLUMN llm.embeddings.content_hash IS 'SHA-256 hash for deduplication'; +COMMENT ON COLUMN llm.embeddings.embedding IS 'Vector embedding (dimension depends on model)'; +COMMENT ON COLUMN llm.embeddings.embedding_model IS 'Model used to generate embedding'; +COMMENT ON COLUMN llm.embeddings.is_public IS 'Whether embedding is accessible to all users or user-specific'; +COMMENT ON COLUMN llm.embeddings.importance_score IS 'Relevance score for retrieval prioritization'; + +-- Ejemplo de uso para búsqueda semántica: +COMMENT ON TABLE llm.embeddings IS +'Vector search example: +SELECT + content, + title, + 1 - (embedding <=> query_embedding) AS similarity +FROM llm.embeddings +WHERE is_active = true + AND category = ''education'' +ORDER BY embedding <=> query_embedding +LIMIT 5; + +Operators: +- <-> : L2 distance +- <#> : inner product +- <=> : cosine distance (recommended)'; + +-- Ejemplo de context_metadata JSONB: +COMMENT ON COLUMN llm.embeddings.context_metadata IS +'Example: { + "language": "es", + "difficulty_level": "beginner", + "reading_time_minutes": 5, + "author": "system", + "last_updated": "2025-12-01", + "related_symbols": ["BTCUSDT"], + "related_topics": ["technical_analysis", "rsi"] +}'; diff --git a/ddl/schemas/ml/00-enums.sql b/ddl/schemas/ml/00-enums.sql new file mode 100644 index 0000000..7b01f33 --- /dev/null +++ b/ddl/schemas/ml/00-enums.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ML SCHEMA - ENUMS +-- ===================================================== +-- Description: Enumerations for ML signals system +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +-- Tipo de modelo ML +CREATE TYPE ml.model_type AS ENUM ( + 'classification', + 'regression', + 'time_series', + 'clustering', + 'anomaly_detection', + 'reinforcement_learning' +); + +-- Framework de ML +CREATE TYPE ml.framework AS ENUM ( + 'sklearn', + 'tensorflow', + 'pytorch', + 'xgboost', + 'lightgbm', + 'prophet', + 'custom' +); + +-- Estado del modelo +CREATE TYPE ml.model_status AS ENUM ( + 'development', + 'testing', + 'staging', + 'production', + 'deprecated', + 'archived' +); + +-- Tipo de predicción +CREATE TYPE ml.prediction_type AS ENUM ( + 'price_direction', -- UP/DOWN/NEUTRAL + 'price_target', -- Precio objetivo + 'volatility', -- Alta/Media/Baja + 'trend', -- Tendencia + 'signal', -- BUY/SELL/HOLD + 'risk_score' -- Score de riesgo +); + +-- Resultado de predicción +CREATE TYPE ml.prediction_result AS ENUM ( + 'buy', + 'sell', + 'hold', + 'up', + 'down', + 'neutral' +); + +-- Estado de outcome +CREATE TYPE ml.outcome_status AS ENUM ( + 'pending', + 'correct', + 'incorrect', + 'partially_correct', + 'expired' +); diff --git a/ddl/schemas/ml/tables/01-models.sql b/ddl/schemas/ml/tables/01-models.sql new file mode 100644 index 0000000..d47077e --- /dev/null +++ b/ddl/schemas/ml/tables/01-models.sql @@ -0,0 +1,65 @@ +-- ===================================================== +-- ML SCHEMA - MODELS TABLE +-- ===================================================== +-- Description: ML models registry +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + name VARCHAR(100) NOT NULL UNIQUE, + display_name VARCHAR(200) NOT NULL, + description TEXT, + + -- Tipo y framework + model_type ml.model_type NOT NULL, + framework ml.framework NOT NULL, + + -- Categoría + category VARCHAR(50) NOT NULL, -- sentiment, technical, fundamental, hybrid + + -- Alcance + applies_to_symbols VARCHAR(20)[] DEFAULT '{}', -- ['BTCUSDT', 'ETHUSDT'] o [] para todos + applies_to_timeframes VARCHAR(10)[] DEFAULT '{}', -- ['1h', '4h', '1d'] o [] para todos + + -- Estado + status ml.model_status DEFAULT 'development', + + -- Versión actual en producción + current_version_id UUID, + + -- Metadata + owner VARCHAR(100) NOT NULL, + repository_url VARCHAR(500), + documentation_url VARCHAR(500), + + -- Métricas agregadas (de todas las versiones) + total_predictions INTEGER DEFAULT 0, + total_correct_predictions INTEGER DEFAULT 0, + overall_accuracy DECIMAL(5,4), + + -- Fechas + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deployed_at TIMESTAMPTZ, + deprecated_at TIMESTAMPTZ +); + +-- Índices +CREATE INDEX idx_models_name ON ml.models(name); +CREATE INDEX idx_models_status ON ml.models(status); +CREATE INDEX idx_models_type ON ml.models(model_type); +CREATE INDEX idx_models_category ON ml.models(category); +CREATE INDEX idx_models_production ON ml.models(status) WHERE status = 'production'; + +-- Comentarios +COMMENT ON TABLE ml.models IS 'Registry of ML models for trading signals'; +COMMENT ON COLUMN ml.models.name IS 'Unique technical name (e.g., sentiment_analyzer_v1)'; +COMMENT ON COLUMN ml.models.applies_to_symbols IS 'Symbols this model can analyze. Empty array = all symbols'; +COMMENT ON COLUMN ml.models.applies_to_timeframes IS 'Timeframes this model supports. Empty array = all timeframes'; +COMMENT ON COLUMN ml.models.current_version_id IS 'Reference to current production version'; +COMMENT ON COLUMN ml.models.overall_accuracy IS 'Aggregated accuracy across all versions and predictions'; diff --git a/ddl/schemas/ml/tables/02-model_versions.sql b/ddl/schemas/ml/tables/02-model_versions.sql new file mode 100644 index 0000000..255b677 --- /dev/null +++ b/ddl/schemas/ml/tables/02-model_versions.sql @@ -0,0 +1,102 @@ +-- ===================================================== +-- ML SCHEMA - MODEL VERSIONS TABLE +-- ===================================================== +-- Description: Versioned ML model artifacts and metadata +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.model_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Modelo padre + model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, + + -- Versión + version VARCHAR(50) NOT NULL, -- Semantic versioning: 1.0.0, 1.1.0, 2.0.0 + + -- Artefacto + artifact_path VARCHAR(500) NOT NULL, -- S3 path, local path, registry URL + artifact_size_bytes BIGINT, + checksum VARCHAR(64), -- SHA-256 + + -- Métricas de entrenamiento + training_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + validation_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + test_metrics JSONB, -- {accuracy, precision, recall, f1, loss} + + -- Features + feature_set JSONB NOT NULL, -- Lista de features usadas + feature_importance JSONB, -- Importancia de cada feature + + -- Hiperparámetros + hyperparameters JSONB, -- Parámetros del modelo + + -- Dataset info + training_dataset_size INTEGER, + training_dataset_path VARCHAR(500), + data_version VARCHAR(50), + + -- Estado + is_production BOOLEAN DEFAULT false, + deployed_at TIMESTAMPTZ, + deployment_metadata JSONB, + + -- Metadata de entrenamiento + training_started_at TIMESTAMPTZ, + training_completed_at TIMESTAMPTZ, + training_duration_seconds INTEGER, + trained_by VARCHAR(100), + training_environment JSONB, -- Python version, library versions, etc. + + -- Performance en producción + production_predictions INTEGER DEFAULT 0, + production_accuracy DECIMAL(5,4), + + -- Notas + release_notes TEXT, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_model_version UNIQUE(model_id, version) +); + +-- Índices +CREATE INDEX idx_model_versions_model ON ml.model_versions(model_id); +CREATE INDEX idx_model_versions_production ON ml.model_versions(is_production) + WHERE is_production = true; +CREATE INDEX idx_model_versions_version ON ml.model_versions(version); +CREATE INDEX idx_model_versions_deployed ON ml.model_versions(deployed_at DESC); + +-- Comentarios +COMMENT ON TABLE ml.model_versions IS 'Versioned artifacts and metadata for ML models'; +COMMENT ON COLUMN ml.model_versions.version IS 'Semantic version (major.minor.patch)'; +COMMENT ON COLUMN ml.model_versions.artifact_path IS 'Location of serialized model file'; +COMMENT ON COLUMN ml.model_versions.checksum IS 'SHA-256 hash for artifact integrity verification'; +COMMENT ON COLUMN ml.model_versions.feature_set IS 'Array of feature names used by this version'; +COMMENT ON COLUMN ml.model_versions.hyperparameters IS 'Model hyperparameters for reproducibility'; +COMMENT ON COLUMN ml.model_versions.is_production IS 'Whether this version is currently deployed in production'; + +-- Ejemplo de training_metrics JSONB: +COMMENT ON COLUMN ml.model_versions.training_metrics IS +'Example: { + "accuracy": 0.8542, + "precision": 0.8234, + "recall": 0.7891, + "f1_score": 0.8058, + "loss": 0.3421, + "auc_roc": 0.9123 +}'; + +-- Ejemplo de feature_set JSONB: +COMMENT ON COLUMN ml.model_versions.feature_set IS +'Example: [ + "rsi_14", + "macd_signal", + "volume_sma_20", + "price_change_1h", + "sentiment_score" +]'; diff --git a/ddl/schemas/ml/tables/03-predictions.sql b/ddl/schemas/ml/tables/03-predictions.sql new file mode 100644 index 0000000..dea1663 --- /dev/null +++ b/ddl/schemas/ml/tables/03-predictions.sql @@ -0,0 +1,93 @@ +-- ===================================================== +-- ML SCHEMA - PREDICTIONS TABLE +-- ===================================================== +-- Description: ML model predictions and signals +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.predictions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Modelo y versión + model_id UUID NOT NULL REFERENCES ml.models(id) ON DELETE CASCADE, + model_version_id UUID NOT NULL REFERENCES ml.model_versions(id) ON DELETE CASCADE, + + -- Símbolo y timeframe + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + + -- Tipo de predicción + prediction_type ml.prediction_type NOT NULL, + + -- Resultado de predicción + prediction_result ml.prediction_result, + prediction_value DECIMAL(20,8), -- Para predicciones numéricas + + -- Confianza + confidence_score DECIMAL(5,4) NOT NULL CHECK (confidence_score >= 0 AND confidence_score <= 1), + + -- Input features utilizados + input_features JSONB NOT NULL, + + -- Output completo del modelo + model_output JSONB, -- Raw output del modelo + + -- Contexto de mercado al momento de predicción + market_price DECIMAL(20,8), + market_timestamp TIMESTAMPTZ NOT NULL, + + -- Horizonte temporal + prediction_horizon VARCHAR(20), -- 1h, 4h, 1d, 1w + valid_until TIMESTAMPTZ, + + -- Metadata + prediction_metadata JSONB, + + -- Procesamiento + inference_time_ms INTEGER, -- Tiempo de inferencia en milisegundos + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_predictions_model ON ml.predictions(model_id); +CREATE INDEX idx_predictions_version ON ml.predictions(model_version_id); +CREATE INDEX idx_predictions_symbol ON ml.predictions(symbol); +CREATE INDEX idx_predictions_symbol_time ON ml.predictions(symbol, market_timestamp DESC); +CREATE INDEX idx_predictions_type ON ml.predictions(prediction_type); +CREATE INDEX idx_predictions_created ON ml.predictions(created_at DESC); +CREATE INDEX idx_predictions_valid ON ml.predictions(valid_until) + WHERE valid_until IS NOT NULL AND valid_until > NOW(); + +-- Particionamiento por fecha (opcional, para alto volumen) +-- CREATE INDEX idx_predictions_timestamp ON ml.predictions(market_timestamp DESC); + +-- Comentarios +COMMENT ON TABLE ml.predictions IS 'ML model predictions and trading signals'; +COMMENT ON COLUMN ml.predictions.prediction_type IS 'Type of prediction being made'; +COMMENT ON COLUMN ml.predictions.prediction_result IS 'Categorical result (buy/sell/hold/up/down/neutral)'; +COMMENT ON COLUMN ml.predictions.prediction_value IS 'Numeric prediction value (e.g., target price, probability)'; +COMMENT ON COLUMN ml.predictions.confidence_score IS 'Model confidence in prediction (0.0 to 1.0)'; +COMMENT ON COLUMN ml.predictions.input_features IS 'Feature values used for this prediction'; +COMMENT ON COLUMN ml.predictions.prediction_horizon IS 'Time horizon for prediction validity'; +COMMENT ON COLUMN ml.predictions.inference_time_ms IS 'Model inference latency in milliseconds'; + +-- Ejemplo de input_features JSONB: +COMMENT ON COLUMN ml.predictions.input_features IS +'Example: { + "rsi_14": 65.42, + "macd_signal": 0.0234, + "volume_sma_20": 1234567.89, + "price_change_1h": 0.0145, + "sentiment_score": 0.72 +}'; + +-- Ejemplo de model_output JSONB: +COMMENT ON COLUMN ml.predictions.model_output IS +'Example: { + "probabilities": {"buy": 0.72, "sell": 0.15, "hold": 0.13}, + "raw_score": 0.5823, + "feature_contributions": {...} +}'; diff --git a/ddl/schemas/ml/tables/04-prediction_outcomes.sql b/ddl/schemas/ml/tables/04-prediction_outcomes.sql new file mode 100644 index 0000000..40322a8 --- /dev/null +++ b/ddl/schemas/ml/tables/04-prediction_outcomes.sql @@ -0,0 +1,68 @@ +-- ===================================================== +-- ML SCHEMA - PREDICTION OUTCOMES TABLE +-- ===================================================== +-- Description: Actual outcomes vs predictions for model evaluation +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.prediction_outcomes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Predicción asociada + prediction_id UUID NOT NULL REFERENCES ml.predictions(id) ON DELETE CASCADE, + + -- Resultado real + actual_result ml.prediction_result, + actual_value DECIMAL(20,8), + + -- Evaluación + outcome_status ml.outcome_status NOT NULL DEFAULT 'pending', + is_correct BOOLEAN, + + -- Métricas de error + absolute_error DECIMAL(20,8), -- |predicted - actual| + relative_error DECIMAL(10,6), -- (predicted - actual) / actual + + -- Contexto de mercado en verificación + actual_price DECIMAL(20,8), + price_change_percent DECIMAL(10,6), + + -- Timing + outcome_timestamp TIMESTAMPTZ NOT NULL, -- Cuando se verificó el outcome + time_to_outcome_hours DECIMAL(10,2), -- Horas desde predicción hasta outcome + + -- Metadata + outcome_metadata JSONB, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_prediction_outcome UNIQUE(prediction_id) +); + +-- Índices +CREATE INDEX idx_outcomes_prediction ON ml.prediction_outcomes(prediction_id); +CREATE INDEX idx_outcomes_status ON ml.prediction_outcomes(outcome_status); +CREATE INDEX idx_outcomes_correct ON ml.prediction_outcomes(is_correct) + WHERE is_correct IS NOT NULL; +CREATE INDEX idx_outcomes_timestamp ON ml.prediction_outcomes(outcome_timestamp DESC); + +-- Comentarios +COMMENT ON TABLE ml.prediction_outcomes IS 'Actual results for model predictions, used for performance tracking'; +COMMENT ON COLUMN ml.prediction_outcomes.actual_result IS 'What actually happened (buy/sell/hold/up/down/neutral)'; +COMMENT ON COLUMN ml.prediction_outcomes.actual_value IS 'Actual numeric value (e.g., actual price reached)'; +COMMENT ON COLUMN ml.prediction_outcomes.is_correct IS 'Whether prediction matched actual outcome'; +COMMENT ON COLUMN ml.prediction_outcomes.absolute_error IS 'Absolute difference between predicted and actual'; +COMMENT ON COLUMN ml.prediction_outcomes.relative_error IS 'Percentage error relative to actual value'; +COMMENT ON COLUMN ml.prediction_outcomes.time_to_outcome_hours IS 'Time elapsed from prediction to outcome verification'; + +-- Ejemplo de outcome_metadata JSONB: +COMMENT ON COLUMN ml.prediction_outcomes.outcome_metadata IS +'Example: { + "verification_method": "automated", + "market_volatility": "high", + "external_events": ["fed_announcement"], + "notes": "Prediction incorrect due to unexpected news" +}'; diff --git a/ddl/schemas/ml/tables/05-feature_store.sql b/ddl/schemas/ml/tables/05-feature_store.sql new file mode 100644 index 0000000..67b1b32 --- /dev/null +++ b/ddl/schemas/ml/tables/05-feature_store.sql @@ -0,0 +1,120 @@ +-- ===================================================== +-- ML SCHEMA - FEATURE STORE TABLE +-- ===================================================== +-- Description: Pre-calculated features for ML models +-- Schema: ml +-- Author: Database Agent +-- Date: 2025-12-06 +-- ===================================================== + +CREATE TABLE ml.feature_store ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación del feature set + symbol VARCHAR(20) NOT NULL, + timeframe VARCHAR(10) NOT NULL, + timestamp TIMESTAMPTZ NOT NULL, + + -- Features técnicos + technical_features JSONB, -- RSI, MACD, Bollinger, SMA, EMA, etc. + + -- Features de volumen + volume_features JSONB, -- Volume profiles, OBV, VWAP, etc. + + -- Features de precio + price_features JSONB, -- Price changes, returns, volatility, etc. + + -- Features de sentimiento (si disponible) + sentiment_features JSONB, -- Social sentiment, news sentiment, etc. + + -- Features on-chain (para crypto) + onchain_features JSONB, -- Network metrics, whale activity, etc. + + -- Features derivados + derived_features JSONB, -- Features calculados de otros features + + -- Metadata + feature_version VARCHAR(20), -- Versión del cálculo de features + calculation_timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + calculation_duration_ms INTEGER, + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_feature_set UNIQUE(symbol, timeframe, timestamp) +); + +-- Índices +CREATE INDEX idx_feature_store_symbol ON ml.feature_store(symbol); +CREATE INDEX idx_feature_store_symbol_time ON ml.feature_store(symbol, timeframe, timestamp DESC); +CREATE INDEX idx_feature_store_timestamp ON ml.feature_store(timestamp DESC); +CREATE INDEX idx_feature_store_version ON ml.feature_store(feature_version); + +-- Particionamiento por fecha (recomendado para alto volumen) +-- Se puede implementar particionamiento mensual o trimestral + +-- Comentarios +COMMENT ON TABLE ml.feature_store IS 'Pre-calculated features for ML model inference and training'; +COMMENT ON COLUMN ml.feature_store.symbol IS 'Trading symbol (e.g., BTCUSDT)'; +COMMENT ON COLUMN ml.feature_store.timeframe IS 'Timeframe for features (1m, 5m, 15m, 1h, 4h, 1d)'; +COMMENT ON COLUMN ml.feature_store.timestamp IS 'Timestamp of the candle/bar these features represent'; +COMMENT ON COLUMN ml.feature_store.feature_version IS 'Version of feature calculation logic'; + +-- Ejemplos de features JSONB: +COMMENT ON COLUMN ml.feature_store.technical_features IS +'Example: { + "rsi_14": 65.42, + "rsi_9": 68.21, + "macd": 0.0234, + "macd_signal": 0.0189, + "macd_histogram": 0.0045, + "bb_upper": 45678.90, + "bb_middle": 45234.12, + "bb_lower": 44789.34, + "sma_20": 45123.45, + "ema_12": 45234.56, + "ema_26": 45012.34 +}'; + +COMMENT ON COLUMN ml.feature_store.volume_features IS +'Example: { + "volume": 1234567.89, + "volume_sma_20": 1123456.78, + "volume_ratio": 1.098, + "obv": 98765432.10, + "vwap": 45234.12, + "buy_volume": 678901.23, + "sell_volume": 555666.66 +}'; + +COMMENT ON COLUMN ml.feature_store.price_features IS +'Example: { + "open": 45100.00, + "high": 45500.00, + "low": 44900.00, + "close": 45234.12, + "price_change_1h": 0.0145, + "price_change_4h": 0.0234, + "price_change_24h": 0.0567, + "volatility_1h": 0.0089, + "atr_14": 234.56 +}'; + +COMMENT ON COLUMN ml.feature_store.sentiment_features IS +'Example: { + "social_sentiment": 0.72, + "news_sentiment": 0.65, + "fear_greed_index": 68, + "twitter_volume": 12345, + "reddit_mentions": 678 +}'; + +COMMENT ON COLUMN ml.feature_store.onchain_features IS +'Example: { + "active_addresses": 123456, + "transaction_volume": 98765.43, + "exchange_inflow": 1234.56, + "exchange_outflow": 2345.67, + "whale_transactions": 23, + "network_growth": 0.023 +}'; diff --git a/ddl/schemas/trading/00-enums.sql b/ddl/schemas/trading/00-enums.sql new file mode 100644 index 0000000..d30b9eb --- /dev/null +++ b/ddl/schemas/trading/00-enums.sql @@ -0,0 +1,78 @@ +-- ============================================================================ +-- Schema: trading +-- File: 00-enums.sql +-- Description: Enumeraciones para el módulo de trading +-- Dependencies: None +-- ============================================================================ + +-- Tipo de orden +CREATE TYPE trading.order_type AS ENUM ( + 'market', + 'limit', + 'stop', + 'stop_limit', + 'trailing_stop' +); + +-- Estado de orden (CON 'partially_filled' que faltaba!) +CREATE TYPE trading.order_status AS ENUM ( + 'pending', + 'open', + 'partially_filled', -- AGREGADO - faltaba en análisis + 'filled', + 'cancelled', + 'rejected', + 'expired' +); + +-- Lado de la orden +CREATE TYPE trading.order_side AS ENUM ( + 'buy', + 'sell' +); + +-- Estado de posición +CREATE TYPE trading.position_status AS ENUM ( + 'open', + 'closed', + 'liquidated' +); + +-- Tipo de señal (interfaz con ML) +CREATE TYPE trading.signal_type AS ENUM ( + 'entry_long', + 'entry_short', + 'exit_long', + 'exit_short', + 'hold' +); + +-- Nivel de confianza +CREATE TYPE trading.confidence_level AS ENUM ( + 'low', + 'medium', + 'high', + 'very_high' +); + +-- Timeframes soportados +CREATE TYPE trading.timeframe AS ENUM ( + '1m', '5m', '15m', '30m', + '1h', '4h', + '1d', '1w', '1M' +); + +-- Tipo de bot +CREATE TYPE trading.bot_type AS ENUM ( + 'paper', -- Paper trading (simulación) + 'live', -- Trading real + 'backtest' -- Backtesting +); + +-- Estado de bot +CREATE TYPE trading.bot_status AS ENUM ( + 'active', + 'paused', + 'stopped', + 'error' +); diff --git a/ddl/schemas/trading/functions/01-calculate_position_pnl.sql b/ddl/schemas/trading/functions/01-calculate_position_pnl.sql new file mode 100644 index 0000000..6059978 --- /dev/null +++ b/ddl/schemas/trading/functions/01-calculate_position_pnl.sql @@ -0,0 +1,96 @@ +-- ============================================================================ +-- Schema: trading +-- Function: calculate_position_pnl +-- Description: Calcula el PnL (realized y unrealized) de una posición +-- Parameters: +-- p_position_id: ID de la posición +-- p_current_price: Precio actual del mercado (para unrealized PnL) +-- Returns: JSONB con realized_pnl, unrealized_pnl, pnl_percentage +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.calculate_position_pnl( + p_position_id UUID, + p_current_price DECIMAL(20,8) DEFAULT NULL +) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + v_position RECORD; + v_realized_pnl DECIMAL(20,8) := 0; + v_unrealized_pnl DECIMAL(20,8) := 0; + v_pnl_percentage DECIMAL(10,4) := 0; + v_total_commission DECIMAL(20,8) := 0; + v_current_value DECIMAL(20,8); +BEGIN + -- Obtener datos de la posición + SELECT + p.position_side, + p.status, + p.entry_price, + p.entry_quantity, + p.entry_value, + p.entry_commission, + p.exit_price, + p.exit_quantity, + p.exit_value, + p.exit_commission + INTO v_position + FROM trading.positions p + WHERE p.id = p_position_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Position % not found', p_position_id; + END IF; + + -- Calcular comisiones totales + v_total_commission := COALESCE(v_position.entry_commission, 0) + COALESCE(v_position.exit_commission, 0); + + -- Calcular PnL según el estado + IF v_position.status = 'closed' OR v_position.status = 'liquidated' THEN + -- Posición cerrada: calcular realized PnL + IF v_position.position_side = 'buy' THEN + -- Long position: profit = (exit_price - entry_price) * quantity - commissions + v_realized_pnl := (v_position.exit_value - v_position.entry_value) - v_total_commission; + ELSE + -- Short position: profit = (entry_price - exit_price) * quantity - commissions + v_realized_pnl := (v_position.entry_value - v_position.exit_value) - v_total_commission; + END IF; + + -- Calcular porcentaje + IF v_position.entry_value > 0 THEN + v_pnl_percentage := (v_realized_pnl / v_position.entry_value) * 100; + END IF; + + ELSIF v_position.status = 'open' AND p_current_price IS NOT NULL THEN + -- Posición abierta: calcular unrealized PnL + v_current_value := p_current_price * v_position.entry_quantity; + + IF v_position.position_side = 'buy' THEN + -- Long position + v_unrealized_pnl := (v_current_value - v_position.entry_value) - v_position.entry_commission; + ELSE + -- Short position + v_unrealized_pnl := (v_position.entry_value - v_current_value) - v_position.entry_commission; + END IF; + + -- Calcular porcentaje + IF v_position.entry_value > 0 THEN + v_pnl_percentage := (v_unrealized_pnl / v_position.entry_value) * 100; + END IF; + END IF; + + -- Retornar resultado + RETURN jsonb_build_object( + 'position_id', p_position_id, + 'status', v_position.status, + 'realized_pnl', v_realized_pnl, + 'unrealized_pnl', v_unrealized_pnl, + 'pnl_percentage', v_pnl_percentage, + 'total_commission', v_total_commission + ); +END; +$$; + +-- Comentarios +COMMENT ON FUNCTION trading.calculate_position_pnl IS 'Calcula el PnL realizado y no realizado de una posición'; diff --git a/ddl/schemas/trading/functions/02-update_bot_stats.sql b/ddl/schemas/trading/functions/02-update_bot_stats.sql new file mode 100644 index 0000000..aede3e3 --- /dev/null +++ b/ddl/schemas/trading/functions/02-update_bot_stats.sql @@ -0,0 +1,88 @@ +-- ============================================================================ +-- Schema: trading +-- Function: update_bot_stats +-- Description: Actualiza las estadísticas de un bot basándose en sus posiciones +-- Parameters: +-- p_bot_id: ID del bot +-- Returns: JSONB con las estadísticas actualizadas +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.update_bot_stats( + p_bot_id UUID +) +RETURNS JSONB +LANGUAGE plpgsql +AS $$ +DECLARE + v_total_trades INTEGER := 0; + v_winning_trades INTEGER := 0; + v_losing_trades INTEGER := 0; + v_total_pnl DECIMAL(20,8) := 0; + v_win_rate DECIMAL(5,2) := 0; + v_current_capital DECIMAL(20,8); + v_initial_capital DECIMAL(20,8); +BEGIN + -- Obtener capital inicial + SELECT initial_capital INTO v_initial_capital + FROM trading.bots + WHERE id = p_bot_id; + + IF NOT FOUND THEN + RAISE EXCEPTION 'Bot % not found', p_bot_id; + END IF; + + -- Contar trades cerrados + SELECT + COUNT(*)::INTEGER, + COUNT(*) FILTER (WHERE realized_pnl > 0)::INTEGER, + COUNT(*) FILTER (WHERE realized_pnl < 0)::INTEGER, + COALESCE(SUM(realized_pnl), 0) + INTO + v_total_trades, + v_winning_trades, + v_losing_trades, + v_total_pnl + FROM trading.positions + WHERE bot_id = p_bot_id + AND status IN ('closed', 'liquidated'); + + -- Calcular win rate + IF v_total_trades > 0 THEN + v_win_rate := (v_winning_trades::DECIMAL / v_total_trades::DECIMAL) * 100; + END IF; + + -- Calcular capital actual + v_current_capital := v_initial_capital + v_total_pnl; + + -- Actualizar bot + UPDATE trading.bots + SET + total_trades = v_total_trades, + winning_trades = v_winning_trades, + total_profit_loss = v_total_pnl, + win_rate = v_win_rate, + current_capital = v_current_capital, + updated_at = NOW() + WHERE id = p_bot_id; + + -- Retornar estadísticas + RETURN jsonb_build_object( + 'bot_id', p_bot_id, + 'total_trades', v_total_trades, + 'winning_trades', v_winning_trades, + 'losing_trades', v_losing_trades, + 'win_rate', v_win_rate, + 'total_profit_loss', v_total_pnl, + 'initial_capital', v_initial_capital, + 'current_capital', v_current_capital, + 'roi_percentage', CASE + WHEN v_initial_capital > 0 + THEN ((v_current_capital - v_initial_capital) / v_initial_capital * 100) + ELSE 0 + END + ); +END; +$$; + +-- Comentarios +COMMENT ON FUNCTION trading.update_bot_stats IS 'Actualiza las estadísticas agregadas de un trading bot'; diff --git a/ddl/schemas/trading/functions/03-initialize_paper_balance.sql b/ddl/schemas/trading/functions/03-initialize_paper_balance.sql new file mode 100644 index 0000000..c350e33 --- /dev/null +++ b/ddl/schemas/trading/functions/03-initialize_paper_balance.sql @@ -0,0 +1,165 @@ +-- ============================================================================ +-- TRADING SCHEMA - Funcion: initialize_paper_balance +-- ============================================================================ +-- Inicializa el balance de paper trading para un usuario +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.initialize_paper_balance( + p_user_id UUID, + p_initial_amount DECIMAL(20, 8) DEFAULT 10000.00, + p_asset VARCHAR(10) DEFAULT 'USDT' +) +RETURNS UUID AS $$ +DECLARE + v_balance_id UUID; +BEGIN + INSERT INTO trading.paper_balances ( + user_id, + asset, + total, + available, + locked, + initial_balance + ) + VALUES ( + p_user_id, + p_asset, + p_initial_amount, + p_initial_amount, + 0, + p_initial_amount + ) + ON CONFLICT (user_id, asset) DO NOTHING + RETURNING id INTO v_balance_id; + + -- Si ya existia, obtener el ID + IF v_balance_id IS NULL THEN + SELECT id INTO v_balance_id + FROM trading.paper_balances + WHERE user_id = p_user_id AND asset = p_asset; + END IF; + + RETURN v_balance_id; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.initialize_paper_balance IS 'Inicializa balance de paper trading para un usuario (default $10,000 USDT)'; + +-- ============================================================================ +-- Funcion: reset_paper_balance +-- ============================================================================ +-- Resetea el balance de paper trading a su valor inicial +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.reset_paper_balance( + p_user_id UUID, + p_asset VARCHAR(10) DEFAULT 'USDT', + p_new_amount DECIMAL(20, 8) DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_initial DECIMAL(20, 8); +BEGIN + -- Obtener balance inicial original o usar el nuevo monto + IF p_new_amount IS NOT NULL THEN + v_initial := p_new_amount; + ELSE + SELECT initial_balance INTO v_initial + FROM trading.paper_balances + WHERE user_id = p_user_id AND asset = p_asset; + END IF; + + -- Resetear balance + UPDATE trading.paper_balances + SET + total = v_initial, + available = v_initial, + locked = 0, + initial_balance = v_initial, + total_deposits = 0, + total_withdrawals = 0, + total_pnl = 0, + last_reset_at = NOW(), + reset_count = reset_count + 1, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.reset_paper_balance IS 'Resetea el balance de paper trading al valor inicial'; + +-- ============================================================================ +-- Funcion: update_paper_balance +-- ============================================================================ +-- Actualiza el balance de paper trading (lock/unlock/pnl) +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.update_paper_balance( + p_user_id UUID, + p_asset VARCHAR(10), + p_amount DECIMAL(20, 8), + p_operation VARCHAR(20) -- 'lock', 'unlock', 'pnl', 'deposit', 'withdrawal' +) +RETURNS BOOLEAN AS $$ +BEGIN + CASE p_operation + WHEN 'lock' THEN + UPDATE trading.paper_balances + SET + available = available - p_amount, + locked = locked + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND available >= p_amount; + + WHEN 'unlock' THEN + UPDATE trading.paper_balances + SET + available = available + p_amount, + locked = locked - p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND locked >= p_amount; + + WHEN 'pnl' THEN + UPDATE trading.paper_balances + SET + total = total + p_amount, + available = available + p_amount, + total_pnl = total_pnl + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + WHEN 'deposit' THEN + UPDATE trading.paper_balances + SET + total = total + p_amount, + available = available + p_amount, + total_deposits = total_deposits + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id AND asset = p_asset; + + WHEN 'withdrawal' THEN + UPDATE trading.paper_balances + SET + total = total - p_amount, + available = available - p_amount, + total_withdrawals = total_withdrawals + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id + AND asset = p_asset + AND available >= p_amount; + + ELSE + RETURN FALSE; + END CASE; + + RETURN FOUND; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.update_paper_balance IS 'Actualiza balance de paper trading (lock/unlock/pnl/deposit/withdrawal)'; diff --git a/ddl/schemas/trading/functions/04-create_default_watchlist.sql b/ddl/schemas/trading/functions/04-create_default_watchlist.sql new file mode 100644 index 0000000..bca56b8 --- /dev/null +++ b/ddl/schemas/trading/functions/04-create_default_watchlist.sql @@ -0,0 +1,80 @@ +-- ============================================================================ +-- TRADING SCHEMA - Funcion: create_default_watchlist +-- ============================================================================ +-- Crea watchlist default y balance inicial para nuevos usuarios +-- Se ejecuta como trigger en auth.users +-- ============================================================================ + +CREATE OR REPLACE FUNCTION trading.create_user_trading_defaults() +RETURNS TRIGGER AS $$ +DECLARE + v_watchlist_id UUID; + v_btc_id UUID; + v_eth_id UUID; + v_bnb_id UUID; +BEGIN + -- 1. Crear watchlist default "My Favorites" + INSERT INTO trading.watchlists ( + user_id, + name, + description, + is_default, + display_order + ) + VALUES ( + NEW.id, + 'My Favorites', + 'Default watchlist with popular trading pairs', + true, + 0 + ) + RETURNING id INTO v_watchlist_id; + + -- 2. Agregar simbolos populares si existen en la tabla symbols + SELECT id INTO v_btc_id FROM trading.symbols WHERE symbol = 'BTCUSDT' LIMIT 1; + SELECT id INTO v_eth_id FROM trading.symbols WHERE symbol = 'ETHUSDT' LIMIT 1; + SELECT id INTO v_bnb_id FROM trading.symbols WHERE symbol = 'BNBUSDT' LIMIT 1; + + IF v_btc_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_btc_id, 0) + ON CONFLICT DO NOTHING; + END IF; + + IF v_eth_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_eth_id, 1) + ON CONFLICT DO NOTHING; + END IF; + + IF v_bnb_id IS NOT NULL THEN + INSERT INTO trading.watchlist_items (watchlist_id, symbol_id, display_order) + VALUES (v_watchlist_id, v_bnb_id, 2) + ON CONFLICT DO NOTHING; + END IF; + + -- 3. Inicializar balance de paper trading ($10,000 USDT) + PERFORM trading.initialize_paper_balance(NEW.id, 10000.00, 'USDT'); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +COMMENT ON FUNCTION trading.create_user_trading_defaults IS 'Trigger function: crea watchlist default y balance inicial para nuevos usuarios'; + +-- ============================================================================ +-- Trigger en auth.users +-- ============================================================================ +-- Nota: Este trigger debe crearse despues de que exista la tabla auth.users +-- Se recomienda ejecutarlo en un script de post-instalacion o manualmente +-- ============================================================================ + +-- DROP TRIGGER IF EXISTS tr_create_user_trading_defaults ON auth.users; + +-- CREATE TRIGGER tr_create_user_trading_defaults +-- AFTER INSERT ON auth.users +-- FOR EACH ROW +-- EXECUTE FUNCTION trading.create_user_trading_defaults(); + +-- COMMENT ON TRIGGER tr_create_user_trading_defaults ON auth.users IS +-- 'Crea watchlist default y balance de paper trading para nuevos usuarios'; diff --git a/ddl/schemas/trading/tables/01-symbols.sql b/ddl/schemas/trading/tables/01-symbols.sql new file mode 100644 index 0000000..7858d70 --- /dev/null +++ b/ddl/schemas/trading/tables/01-symbols.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- Schema: trading +-- Table: symbols +-- Description: Catálogo de símbolos/instrumentos financieros operables +-- Dependencies: None +-- ============================================================================ + +CREATE TABLE trading.symbols ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identificación + symbol VARCHAR(20) NOT NULL UNIQUE, -- BTC/USDT, EUR/USD + name VARCHAR(100) NOT NULL, + base_asset VARCHAR(10) NOT NULL, -- BTC, EUR + quote_asset VARCHAR(10) NOT NULL, -- USDT, USD + + -- Tipo + asset_class VARCHAR(50) NOT NULL, -- crypto, forex, stocks + exchange VARCHAR(50), -- binance, coinbase + + -- Precisión + price_precision INTEGER NOT NULL DEFAULT 8, + quantity_precision INTEGER NOT NULL DEFAULT 8, + + -- Límites + min_quantity DECIMAL(20,8), + max_quantity DECIMAL(20,8), + min_notional DECIMAL(20,8), + + -- Estado + is_active BOOLEAN DEFAULT true, + is_tradeable BOOLEAN DEFAULT true, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_symbols_symbol ON trading.symbols(symbol); +CREATE INDEX idx_symbols_active ON trading.symbols(is_active) WHERE is_active = true; +CREATE INDEX idx_symbols_asset_class ON trading.symbols(asset_class); +CREATE INDEX idx_symbols_exchange ON trading.symbols(exchange); + +-- Comentarios +COMMENT ON TABLE trading.symbols IS 'Catálogo de símbolos/instrumentos financieros operables'; +COMMENT ON COLUMN trading.symbols.symbol IS 'Símbolo del instrumento (e.g., BTC/USDT)'; +COMMENT ON COLUMN trading.symbols.asset_class IS 'Clase de activo: crypto, forex, stocks, commodities'; +COMMENT ON COLUMN trading.symbols.is_tradeable IS 'Indica si el símbolo está disponible para trading'; diff --git a/ddl/schemas/trading/tables/02-watchlists.sql b/ddl/schemas/trading/tables/02-watchlists.sql new file mode 100644 index 0000000..bd53a13 --- /dev/null +++ b/ddl/schemas/trading/tables/02-watchlists.sql @@ -0,0 +1,40 @@ +-- ============================================================================ +-- Schema: trading +-- Table: watchlists +-- Description: Listas de seguimiento personalizadas de usuarios +-- Dependencies: auth.users +-- ============================================================================ + +CREATE TABLE trading.watchlists ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Configuración + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Estado + is_default BOOLEAN DEFAULT false, + is_public BOOLEAN DEFAULT false, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: nombre único por usuario + CONSTRAINT uq_watchlists_user_name UNIQUE (user_id, name) +); + +-- Índices +CREATE INDEX idx_watchlists_user ON trading.watchlists(user_id); +CREATE INDEX idx_watchlists_user_default ON trading.watchlists(user_id, is_default) WHERE is_default = true; + +-- Comentarios +COMMENT ON TABLE trading.watchlists IS 'Listas de seguimiento personalizadas de símbolos'; +COMMENT ON COLUMN trading.watchlists.is_default IS 'Indica si es la watchlist por defecto del usuario'; +COMMENT ON COLUMN trading.watchlists.is_public IS 'Indica si la watchlist es visible públicamente'; diff --git a/ddl/schemas/trading/tables/03-watchlist_items.sql b/ddl/schemas/trading/tables/03-watchlist_items.sql new file mode 100644 index 0000000..e20dc6d --- /dev/null +++ b/ddl/schemas/trading/tables/03-watchlist_items.sql @@ -0,0 +1,38 @@ +-- ============================================================================ +-- Schema: trading +-- Table: watchlist_items +-- Description: Items individuales dentro de watchlists +-- Dependencies: trading.watchlists, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.watchlist_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id) ON DELETE CASCADE, + + -- Configuración personalizada + notes TEXT, + alert_price_high DECIMAL(20,8), + alert_price_low DECIMAL(20,8), + + -- Ordenamiento + display_order INTEGER DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: un símbolo solo una vez por watchlist + CONSTRAINT uq_watchlist_items_watchlist_symbol UNIQUE (watchlist_id, symbol_id) +); + +-- Índices +CREATE INDEX idx_watchlist_items_watchlist ON trading.watchlist_items(watchlist_id); +CREATE INDEX idx_watchlist_items_symbol ON trading.watchlist_items(symbol_id); + +-- Comentarios +COMMENT ON TABLE trading.watchlist_items IS 'Símbolos individuales dentro de una watchlist'; +COMMENT ON COLUMN trading.watchlist_items.alert_price_high IS 'Precio superior para alertas'; +COMMENT ON COLUMN trading.watchlist_items.alert_price_low IS 'Precio inferior para alertas'; diff --git a/ddl/schemas/trading/tables/04-bots.sql b/ddl/schemas/trading/tables/04-bots.sql new file mode 100644 index 0000000..3eef4fc --- /dev/null +++ b/ddl/schemas/trading/tables/04-bots.sql @@ -0,0 +1,64 @@ +-- ============================================================================ +-- Schema: trading +-- Table: bots +-- Description: Trading bots configurados por usuarios +-- Dependencies: auth.users, trading.bot_type, trading.bot_status, trading.timeframe +-- ============================================================================ + +CREATE TABLE trading.bots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Propietario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Configuración básica + name VARCHAR(100) NOT NULL, + bot_type trading.bot_type NOT NULL DEFAULT 'paper', + status trading.bot_status NOT NULL DEFAULT 'paused', + + -- Símbolos a operar + symbols VARCHAR(20)[] NOT NULL, + timeframe trading.timeframe NOT NULL DEFAULT '1h', + + -- Capital + initial_capital DECIMAL(20,8) NOT NULL, + current_capital DECIMAL(20,8) NOT NULL, + + -- Risk management + max_position_size_pct DECIMAL(5,2) DEFAULT 10.00, -- % del capital + max_daily_loss_pct DECIMAL(5,2) DEFAULT 5.00, + max_drawdown_pct DECIMAL(5,2) DEFAULT 20.00, + + -- Estrategia (referencia a ML signal provider) + strategy_type VARCHAR(50), -- 'atlas', 'orion', 'nova', 'custom' + strategy_config JSONB DEFAULT '{}', + + -- Estadísticas + total_trades INTEGER DEFAULT 0, + winning_trades INTEGER DEFAULT 0, + total_profit_loss DECIMAL(20,8) DEFAULT 0, + win_rate DECIMAL(5,2) DEFAULT 0, + + -- Metadata + started_at TIMESTAMPTZ, + stopped_at TIMESTAMPTZ, + last_trade_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_bots_user ON trading.bots(user_id); +CREATE INDEX idx_bots_status ON trading.bots(status); +CREATE INDEX idx_bots_type ON trading.bots(bot_type); +CREATE INDEX idx_bots_user_status ON trading.bots(user_id, status); + +-- Comentarios +COMMENT ON TABLE trading.bots IS 'Trading bots automatizados configurados por usuarios'; +COMMENT ON COLUMN trading.bots.bot_type IS 'Tipo: paper (simulación), live (real), backtest'; +COMMENT ON COLUMN trading.bots.strategy_type IS 'Estrategia ML: atlas, orion, nova, custom'; +COMMENT ON COLUMN trading.bots.max_position_size_pct IS 'Máximo % del capital por posición'; +COMMENT ON COLUMN trading.bots.max_daily_loss_pct IS 'Máxima pérdida diaria permitida (%)'; +COMMENT ON COLUMN trading.bots.max_drawdown_pct IS 'Máximo drawdown permitido (%)'; diff --git a/ddl/schemas/trading/tables/05-orders.sql b/ddl/schemas/trading/tables/05-orders.sql new file mode 100644 index 0000000..c937b21 --- /dev/null +++ b/ddl/schemas/trading/tables/05-orders.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- Schema: trading +-- Table: orders +-- Description: Órdenes de trading (pendientes, ejecutadas, canceladas) +-- Dependencies: auth.users, trading.bots, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id), + + -- Identificador externo (de exchange) + external_order_id VARCHAR(100), + + -- Tipo y lado + order_type trading.order_type NOT NULL, + order_side trading.order_side NOT NULL, + status trading.order_status NOT NULL DEFAULT 'pending', + + -- Precios + price DECIMAL(20,8), -- Precio límite (NULL para market orders) + stop_price DECIMAL(20,8), -- Precio stop + average_fill_price DECIMAL(20,8), -- Precio promedio de ejecución + + -- Cantidades + quantity DECIMAL(20,8) NOT NULL, + filled_quantity DECIMAL(20,8) DEFAULT 0, + remaining_quantity DECIMAL(20,8) NOT NULL, + + -- Costos + commission DECIMAL(20,8) DEFAULT 0, + commission_asset VARCHAR(10), + + -- Time in force + time_in_force VARCHAR(20) DEFAULT 'GTC', -- GTC, IOC, FOK + + -- Validez + expires_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}', + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + filled_at TIMESTAMPTZ, + cancelled_at TIMESTAMPTZ +); + +-- Índices +CREATE INDEX idx_orders_user ON trading.orders(user_id); +CREATE INDEX idx_orders_bot ON trading.orders(bot_id); +CREATE INDEX idx_orders_symbol ON trading.orders(symbol_id); +CREATE INDEX idx_orders_status ON trading.orders(status); +CREATE INDEX idx_orders_created ON trading.orders(created_at DESC); +CREATE INDEX idx_orders_external ON trading.orders(external_order_id); + +-- Comentarios +COMMENT ON TABLE trading.orders IS 'Órdenes de trading (todas las órdenes del sistema)'; +COMMENT ON COLUMN trading.orders.external_order_id IS 'ID de la orden en el exchange externo'; +COMMENT ON COLUMN trading.orders.time_in_force IS 'GTC (Good Till Cancel), IOC (Immediate or Cancel), FOK (Fill or Kill)'; +COMMENT ON COLUMN trading.orders.average_fill_price IS 'Precio promedio de ejecución para órdenes parcialmente completadas'; diff --git a/ddl/schemas/trading/tables/06-positions.sql b/ddl/schemas/trading/tables/06-positions.sql new file mode 100644 index 0000000..92da537 --- /dev/null +++ b/ddl/schemas/trading/tables/06-positions.sql @@ -0,0 +1,70 @@ +-- ============================================================================ +-- Schema: trading +-- Table: positions +-- Description: Posiciones abiertas y cerradas de trading +-- Dependencies: auth.users, trading.bots, trading.symbols +-- ============================================================================ + +CREATE TABLE trading.positions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + bot_id UUID REFERENCES trading.bots(id) ON DELETE SET NULL, + symbol_id UUID NOT NULL REFERENCES trading.symbols(id), + + -- Tipo + position_side trading.order_side NOT NULL, -- buy (long), sell (short) + status trading.position_status NOT NULL DEFAULT 'open', + + -- Entrada + entry_price DECIMAL(20,8) NOT NULL, + entry_quantity DECIMAL(20,8) NOT NULL, + entry_value DECIMAL(20,8) NOT NULL, + entry_commission DECIMAL(20,8) DEFAULT 0, + + -- Posiciones parciales (para cierres parciales y promedios) + current_quantity DECIMAL(20,8) NOT NULL, -- Cantidad actual (puede diferir de entry_quantity) + average_entry_price DECIMAL(20,8) NOT NULL, -- Precio promedio de entrada (para DCA) + + -- Salida + exit_price DECIMAL(20,8), + exit_quantity DECIMAL(20,8), + exit_value DECIMAL(20,8), + exit_commission DECIMAL(20,8) DEFAULT 0, + + -- PnL + realized_pnl DECIMAL(20,8) DEFAULT 0, + unrealized_pnl DECIMAL(20,8) DEFAULT 0, + pnl_percentage DECIMAL(10,4) DEFAULT 0, + + -- Stop loss y take profit + stop_loss_price DECIMAL(20,8), + take_profit_price DECIMAL(20,8), + + -- Metadata + metadata JSONB DEFAULT '{}', + close_reason VARCHAR(50), -- 'take_profit', 'stop_loss', 'manual', 'liquidation' + + -- Timestamps + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_positions_user ON trading.positions(user_id); +CREATE INDEX idx_positions_bot ON trading.positions(bot_id); +CREATE INDEX idx_positions_symbol ON trading.positions(symbol_id); +CREATE INDEX idx_positions_status ON trading.positions(status); +CREATE INDEX idx_positions_opened ON trading.positions(opened_at DESC); +CREATE INDEX idx_positions_user_status ON trading.positions(user_id, status); + +-- Comentarios +COMMENT ON TABLE trading.positions IS 'Posiciones de trading abiertas y cerradas'; +COMMENT ON COLUMN trading.positions.position_side IS 'buy = posición larga, sell = posición corta'; +COMMENT ON COLUMN trading.positions.realized_pnl IS 'Ganancia/pérdida realizada (posición cerrada)'; +COMMENT ON COLUMN trading.positions.unrealized_pnl IS 'Ganancia/pérdida no realizada (posición abierta)'; +COMMENT ON COLUMN trading.positions.close_reason IS 'Razón del cierre: take_profit, stop_loss, manual, liquidation'; +COMMENT ON COLUMN trading.positions.current_quantity IS 'Cantidad actual de la posición (puede diferir de entry_quantity tras cierres parciales)'; +COMMENT ON COLUMN trading.positions.average_entry_price IS 'Precio promedio de entrada (calculado con DCA o adiciones)'; diff --git a/ddl/schemas/trading/tables/07-trades.sql b/ddl/schemas/trading/tables/07-trades.sql new file mode 100644 index 0000000..3094231 --- /dev/null +++ b/ddl/schemas/trading/tables/07-trades.sql @@ -0,0 +1,49 @@ +-- ============================================================================ +-- Schema: trading +-- Table: trades +-- Description: Historial detallado de trades ejecutados +-- Dependencies: trading.orders, trading.positions +-- ============================================================================ + +CREATE TABLE trading.trades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + order_id UUID NOT NULL REFERENCES trading.orders(id) ON DELETE CASCADE, + position_id UUID REFERENCES trading.positions(id) ON DELETE SET NULL, + + -- Identificador externo (de exchange) + external_trade_id VARCHAR(100), + + -- Detalles del trade + symbol VARCHAR(20) NOT NULL, + side trading.order_side NOT NULL, + price DECIMAL(20,8) NOT NULL, + quantity DECIMAL(20,8) NOT NULL, + quote_quantity DECIMAL(20,8) NOT NULL, -- price * quantity + + -- Comisión + commission DECIMAL(20,8) DEFAULT 0, + commission_asset VARCHAR(10), + + -- Metadata + is_maker BOOLEAN DEFAULT false, + metadata JSONB DEFAULT '{}', + + -- Timestamps + executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_trades_order ON trading.trades(order_id); +CREATE INDEX idx_trades_position ON trading.trades(position_id); +CREATE INDEX idx_trades_symbol ON trading.trades(symbol); +CREATE INDEX idx_trades_executed ON trading.trades(executed_at DESC); +CREATE INDEX idx_trades_external ON trading.trades(external_trade_id); + +-- Comentarios +COMMENT ON TABLE trading.trades IS 'Historial detallado de trades ejecutados (fills individuales)'; +COMMENT ON COLUMN trading.trades.external_trade_id IS 'ID del trade en el exchange externo'; +COMMENT ON COLUMN trading.trades.quote_quantity IS 'Valor total del trade (price * quantity)'; +COMMENT ON COLUMN trading.trades.is_maker IS 'true si el trade fue maker, false si fue taker'; diff --git a/ddl/schemas/trading/tables/08-signals.sql b/ddl/schemas/trading/tables/08-signals.sql new file mode 100644 index 0000000..c8065c6 --- /dev/null +++ b/ddl/schemas/trading/tables/08-signals.sql @@ -0,0 +1,68 @@ +-- ============================================================================ +-- Schema: trading +-- Table: signals +-- Description: INTERFAZ con ML - Señales de trading generadas por modelos ML +-- Dependencies: trading.signal_type, trading.confidence_level, trading.timeframe +-- +-- IMPORTANTE: Esta tabla es la INTERFAZ entre Trading y ML Signals +-- Resuelve la dependencia circular permitiendo que ambos módulos +-- trabajen independientemente. ML escribe señales aquí, Trading las consume. +-- ============================================================================ + +CREATE TABLE trading.signals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Origen de la señal + source VARCHAR(50) NOT NULL, -- 'ml_atlas', 'ml_orion', 'ml_nova', 'manual' + model_version VARCHAR(50), + + -- Target + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe NOT NULL, + + -- Señal + signal_type trading.signal_type NOT NULL, + confidence trading.confidence_level NOT NULL, + confidence_score DECIMAL(5,4), -- 0.0000 to 1.0000 + + -- Precios objetivo + entry_price DECIMAL(20,8), + target_price DECIMAL(20,8), + stop_loss DECIMAL(20,8), + + -- Predicciones + predicted_delta_high DECIMAL(10,4), -- % esperado de subida + predicted_delta_low DECIMAL(10,4), -- % esperado de bajada + + -- Resultado (se actualiza después) + actual_outcome VARCHAR(20), -- 'hit_target', 'hit_stop', 'expired', 'cancelled' + actual_delta DECIMAL(10,4), + outcome_at TIMESTAMPTZ, + + -- Validez + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ NOT NULL, + is_active BOOLEAN DEFAULT true, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Índices +CREATE INDEX idx_signals_symbol ON trading.signals(symbol); +CREATE INDEX idx_signals_active ON trading.signals(is_active, valid_until) WHERE is_active = true; +CREATE INDEX idx_signals_source ON trading.signals(source); +CREATE INDEX idx_signals_created ON trading.signals(created_at DESC); +CREATE INDEX idx_signals_symbol_timeframe ON trading.signals(symbol, timeframe); +CREATE INDEX idx_signals_outcome ON trading.signals(actual_outcome); + +-- Comentarios +COMMENT ON TABLE trading.signals IS 'INTERFAZ: Señales de trading generadas por modelos ML o manualmente'; +COMMENT ON COLUMN trading.signals.source IS 'Origen: ml_atlas, ml_orion, ml_nova, manual'; +COMMENT ON COLUMN trading.signals.confidence_score IS 'Score numérico de confianza entre 0 y 1'; +COMMENT ON COLUMN trading.signals.predicted_delta_high IS 'Porcentaje esperado de subida desde entry_price'; +COMMENT ON COLUMN trading.signals.predicted_delta_low IS 'Porcentaje esperado de bajada desde entry_price'; +COMMENT ON COLUMN trading.signals.actual_outcome IS 'Resultado real: hit_target, hit_stop, expired, cancelled'; diff --git a/ddl/schemas/trading/tables/09-trading_metrics.sql b/ddl/schemas/trading/tables/09-trading_metrics.sql new file mode 100644 index 0000000..3c83eab --- /dev/null +++ b/ddl/schemas/trading/tables/09-trading_metrics.sql @@ -0,0 +1,67 @@ +-- ============================================================================ +-- Schema: trading +-- Table: trading_metrics +-- Description: Métricas diarias de performance por bot +-- Dependencies: trading.bots +-- ============================================================================ + +CREATE TABLE trading.trading_metrics ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Referencias + bot_id UUID NOT NULL REFERENCES trading.bots(id) ON DELETE CASCADE, + + -- Período + metric_date DATE NOT NULL, + + -- Capital + starting_capital DECIMAL(20,8) NOT NULL, + ending_capital DECIMAL(20,8) NOT NULL, + peak_capital DECIMAL(20,8) NOT NULL, + + -- Trading + total_trades INTEGER DEFAULT 0, + winning_trades INTEGER DEFAULT 0, + losing_trades INTEGER DEFAULT 0, + + -- PnL + gross_profit DECIMAL(20,8) DEFAULT 0, + gross_loss DECIMAL(20,8) DEFAULT 0, + net_profit DECIMAL(20,8) DEFAULT 0, + + -- Ratios + win_rate DECIMAL(5,2) DEFAULT 0, + profit_factor DECIMAL(10,4) DEFAULT 0, -- gross_profit / gross_loss + sharpe_ratio DECIMAL(10,4), + max_drawdown DECIMAL(10,4) DEFAULT 0, + + -- Trades + avg_win DECIMAL(20,8) DEFAULT 0, + avg_loss DECIMAL(20,8) DEFAULT 0, + largest_win DECIMAL(20,8) DEFAULT 0, + largest_loss DECIMAL(20,8) DEFAULT 0, + + -- Tiempos promedio + avg_trade_duration_minutes INTEGER, + + -- Metadata + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraint: una métrica por bot por día + CONSTRAINT uq_trading_metrics_bot_date UNIQUE (bot_id, metric_date) +); + +-- Índices +CREATE INDEX idx_trading_metrics_bot ON trading.trading_metrics(bot_id); +CREATE INDEX idx_trading_metrics_date ON trading.trading_metrics(metric_date DESC); +CREATE INDEX idx_trading_metrics_bot_date ON trading.trading_metrics(bot_id, metric_date DESC); + +-- Comentarios +COMMENT ON TABLE trading.trading_metrics IS 'Métricas diarias de performance por bot'; +COMMENT ON COLUMN trading.trading_metrics.profit_factor IS 'Ratio gross_profit / gross_loss (>1 es rentable)'; +COMMENT ON COLUMN trading.trading_metrics.sharpe_ratio IS 'Ratio de Sharpe ajustado por riesgo'; +COMMENT ON COLUMN trading.trading_metrics.max_drawdown IS 'Máximo drawdown del día (%)'; diff --git a/ddl/schemas/trading/tables/10-paper_balances.sql b/ddl/schemas/trading/tables/10-paper_balances.sql new file mode 100644 index 0000000..e282203 --- /dev/null +++ b/ddl/schemas/trading/tables/10-paper_balances.sql @@ -0,0 +1,51 @@ +-- ============================================================================ +-- TRADING SCHEMA - Tabla: paper_balances +-- ============================================================================ +-- Balances virtuales para paper trading +-- Permite a usuarios practicar sin riesgo real +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS trading.paper_balances ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Usuario + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- Asset y balance + asset VARCHAR(10) NOT NULL DEFAULT 'USDT', + total DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + available DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + locked DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Tracking + initial_balance DECIMAL(20, 8) NOT NULL DEFAULT 10000.00, + total_deposits DECIMAL(20, 8) NOT NULL DEFAULT 0, + total_withdrawals DECIMAL(20, 8) NOT NULL DEFAULT 0, + total_pnl DECIMAL(20, 8) NOT NULL DEFAULT 0, + + -- Reset tracking + last_reset_at TIMESTAMPTZ, + reset_count INTEGER NOT NULL DEFAULT 0, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT uq_paper_balances_user_asset UNIQUE(user_id, asset), + CONSTRAINT chk_balance_consistency CHECK (total = available + locked), + CONSTRAINT chk_balance_non_negative CHECK (total >= 0 AND available >= 0 AND locked >= 0), + CONSTRAINT chk_initial_positive CHECK (initial_balance > 0) +); + +-- Indices +CREATE INDEX idx_paper_balances_user ON trading.paper_balances(user_id); +CREATE INDEX idx_paper_balances_asset ON trading.paper_balances(asset); + +-- Comentarios +COMMENT ON TABLE trading.paper_balances IS 'Balances virtuales para paper trading - cada usuario tiene balance por asset'; +COMMENT ON COLUMN trading.paper_balances.total IS 'Balance total = available + locked'; +COMMENT ON COLUMN trading.paper_balances.available IS 'Balance disponible para nuevas ordenes'; +COMMENT ON COLUMN trading.paper_balances.locked IS 'Balance bloqueado en ordenes abiertas'; +COMMENT ON COLUMN trading.paper_balances.initial_balance IS 'Balance inicial (default $10,000 USDT)'; +COMMENT ON COLUMN trading.paper_balances.reset_count IS 'Numero de veces que el usuario ha reseteado su balance'; diff --git a/schemas/00_init_schemas.sql b/schemas/00_init_schemas.sql new file mode 100644 index 0000000..eac578d --- /dev/null +++ b/schemas/00_init_schemas.sql @@ -0,0 +1,123 @@ +-- ============================================================================ +-- OrbiQuant IA - Inicialización de Esquemas +-- ============================================================================ +-- Archivo: 00_init_schemas.sql +-- Descripción: Creación de esquemas y extensiones base +-- Fecha: 2025-12-05 +-- ============================================================================ + +-- Extensiones requeridas +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; -- Generación de UUIDs +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- Funciones criptográficas +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- Búsqueda de texto similar + +-- ============================================================================ +-- ESQUEMAS +-- ============================================================================ + +-- Esquema público (usuarios y configuración global) +-- Ya existe por defecto, solo documentamos +COMMENT ON SCHEMA public IS 'Usuarios, perfiles, configuración global y autenticación'; + +-- Esquema de educación +CREATE SCHEMA IF NOT EXISTS education; +COMMENT ON SCHEMA education IS 'Cursos, lecciones, inscripciones y contenido educativo'; + +-- Esquema de trading +CREATE SCHEMA IF NOT EXISTS trading; +COMMENT ON SCHEMA trading IS 'Bots, señales, estrategias y operaciones de trading'; + +-- Esquema de inversión +CREATE SCHEMA IF NOT EXISTS investment; +COMMENT ON SCHEMA investment IS 'Cuentas de inversión, productos y gestión de portafolios'; + +-- Esquema financiero +CREATE SCHEMA IF NOT EXISTS financial; +COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets y transacciones'; + +-- Esquema de machine learning +CREATE SCHEMA IF NOT EXISTS ml; +COMMENT ON SCHEMA ml IS 'Modelos ML, predicciones, features y métricas'; + +-- Esquema de auditoría +CREATE SCHEMA IF NOT EXISTS audit; +COMMENT ON SCHEMA audit IS 'Logs de auditoría, eventos del sistema y seguridad'; + +-- ============================================================================ +-- TIPOS ENUMERADOS GLOBALES +-- ============================================================================ + +-- Estados comunes +CREATE TYPE status_enum AS ENUM ('active', 'inactive', 'pending', 'suspended', 'deleted'); + +-- Roles del sistema +CREATE TYPE user_role_enum AS ENUM ( + 'investor', -- Cliente final pasivo + 'trader_pro', -- Usuario activo con herramientas + 'student', -- Alumno de cursos + 'admin', -- Administrador + 'risk_officer', -- Oficial de riesgos + 'support' -- Soporte al cliente +); + +-- Perfiles de riesgo +CREATE TYPE risk_profile_enum AS ENUM ('conservative', 'moderate', 'aggressive'); + +-- Direcciones de trading +CREATE TYPE trade_direction_enum AS ENUM ('long', 'short'); + +-- Estados de orden +CREATE TYPE order_status_enum AS ENUM ('pending', 'open', 'filled', 'cancelled', 'expired'); + +-- Tipos de transacción financiera +CREATE TYPE transaction_type_enum AS ENUM ( + 'deposit', + 'withdrawal', + 'subscription_payment', + 'course_purchase', + 'investment_deposit', + 'investment_withdrawal', + 'profit_distribution', + 'fee', + 'refund' +); + +-- ============================================================================ +-- FUNCIÓN PARA TIMESTAMPS AUTOMÁTICOS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +-- ============================================================================ +-- FUNCIÓN PARA AUDITORÍA AUTOMÁTICA +-- ============================================================================ + +CREATE OR REPLACE FUNCTION audit.log_changes() +RETURNS TRIGGER AS $$ +BEGIN + INSERT INTO audit.audit_logs ( + table_name, + record_id, + action, + old_data, + new_data, + user_id, + ip_address + ) VALUES ( + TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, + COALESCE(NEW.id, OLD.id)::text, + TG_OP, + CASE WHEN TG_OP = 'DELETE' THEN row_to_json(OLD) ELSE NULL END, + CASE WHEN TG_OP IN ('INSERT', 'UPDATE') THEN row_to_json(NEW) ELSE NULL END, + current_setting('app.current_user_id', true)::uuid, + current_setting('app.client_ip', true) + ); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/01_public_schema.sql b/schemas/01_public_schema.sql new file mode 100644 index 0000000..c9b54a5 --- /dev/null +++ b/schemas/01_public_schema.sql @@ -0,0 +1,280 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema PUBLIC +-- ============================================================================ +-- Archivo: 01_public_schema.sql +-- Descripción: Usuarios, perfiles, autenticación y configuración +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO public; + +-- ============================================================================ +-- TABLA: users +-- Descripción: Usuarios base del sistema (compatible con Supabase Auth) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email VARCHAR(255) NOT NULL UNIQUE, + email_verified BOOLEAN DEFAULT FALSE, + phone VARCHAR(20), + phone_verified BOOLEAN DEFAULT FALSE, + encrypted_password VARCHAR(255), + + -- Metadata de auth + confirmation_token VARCHAR(255), + confirmation_sent_at TIMESTAMPTZ, + confirmed_at TIMESTAMPTZ, + recovery_token VARCHAR(255), + recovery_sent_at TIMESTAMPTZ, + + -- 2FA + totp_secret VARCHAR(255), + totp_enabled BOOLEAN DEFAULT FALSE, + backup_codes TEXT[], + + -- Control de acceso + role user_role_enum DEFAULT 'investor', + status status_enum DEFAULT 'pending', + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMPTZ, + last_login_at TIMESTAMPTZ, + last_login_ip INET, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +CREATE INDEX idx_users_role ON users(role); + +-- ============================================================================ +-- TABLA: profiles +-- Descripción: Información extendida del usuario +-- ============================================================================ +CREATE TABLE IF NOT EXISTS profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- Datos personales + first_name VARCHAR(100), + last_name VARCHAR(100), + display_name VARCHAR(100), + avatar_url TEXT, + date_of_birth DATE, + + -- Ubicación + country_code CHAR(2), + timezone VARCHAR(50) DEFAULT 'UTC', + language VARCHAR(5) DEFAULT 'es', + + -- Preferencias + preferred_currency CHAR(3) DEFAULT 'USD', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_profiles_user_id ON profiles(user_id); + +-- ============================================================================ +-- TABLA: user_settings +-- Descripción: Configuraciones y preferencias del usuario +-- ============================================================================ +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE, + + -- Notificaciones + email_notifications BOOLEAN DEFAULT TRUE, + push_notifications BOOLEAN DEFAULT TRUE, + sms_notifications BOOLEAN DEFAULT FALSE, + + -- Alertas de trading + price_alerts BOOLEAN DEFAULT TRUE, + signal_alerts BOOLEAN DEFAULT TRUE, + portfolio_alerts BOOLEAN DEFAULT TRUE, + + -- Reportes + weekly_report BOOLEAN DEFAULT TRUE, + monthly_report BOOLEAN DEFAULT TRUE, + + -- Privacidad + profile_public BOOLEAN DEFAULT FALSE, + show_portfolio_value BOOLEAN DEFAULT FALSE, + + -- UI + theme VARCHAR(20) DEFAULT 'dark', + chart_preferences JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- ============================================================================ +-- TABLA: kyc_verifications +-- Descripción: Verificación de identidad (KYC) +-- ============================================================================ +CREATE TYPE kyc_status_enum AS ENUM ('pending', 'submitted', 'approved', 'rejected', 'expired'); +CREATE TYPE kyc_document_type AS ENUM ('passport', 'national_id', 'drivers_license', 'residence_permit'); + +CREATE TABLE IF NOT EXISTS kyc_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Estado + status kyc_status_enum DEFAULT 'pending', + level INT DEFAULT 0, -- 0: ninguno, 1: básico, 2: completo + + -- Documentos + document_type kyc_document_type, + document_number VARCHAR(50), + document_country CHAR(2), + document_expiry DATE, + document_front_url TEXT, + document_back_url TEXT, + selfie_url TEXT, + proof_of_address_url TEXT, + + -- Verificación + verified_at TIMESTAMPTZ, + verified_by UUID REFERENCES users(id), + rejection_reason TEXT, + + -- Metadata + verification_provider VARCHAR(50), + external_verification_id VARCHAR(100), + + -- Timestamps + submitted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_kyc_user_id ON kyc_verifications(user_id); +CREATE INDEX idx_kyc_status ON kyc_verifications(status); + +-- ============================================================================ +-- TABLA: risk_profiles +-- Descripción: Perfil de riesgo del usuario (cuestionario) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS risk_profiles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Resultado + profile risk_profile_enum NOT NULL, + score INT, -- 0-100 + + -- Respuestas del cuestionario (JSON) + questionnaire_answers JSONB NOT NULL, + + -- Parámetros derivados + max_drawdown_tolerance DECIMAL(5,2), -- % máximo de pérdida tolerada + investment_horizon_months INT, -- Horizonte en meses + experience_level INT, -- 1-5 + + -- Validez + valid_until TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_risk_profiles_user_id ON risk_profiles(user_id); + +-- ============================================================================ +-- TABLA: sessions +-- Descripción: Sesiones activas de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Token + refresh_token VARCHAR(255) NOT NULL UNIQUE, + + -- Información del dispositivo + user_agent TEXT, + ip_address INET, + device_info JSONB, + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + last_active_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_refresh_token ON sessions(refresh_token); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); + +-- ============================================================================ +-- TABLA: notifications +-- Descripción: Notificaciones del sistema +-- ============================================================================ +CREATE TYPE notification_type_enum AS ENUM ( + 'system', 'trading', 'investment', 'education', 'payment', 'security', 'marketing' +); + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Contenido + type notification_type_enum NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + data JSONB, + + -- Estado + read_at TIMESTAMPTZ, + + -- Canales enviados + sent_email BOOLEAN DEFAULT FALSE, + sent_push BOOLEAN DEFAULT FALSE, + sent_sms BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_notifications_user_id ON notifications(user_id); +CREATE INDEX idx_notifications_read ON notifications(user_id, read_at); +CREATE INDEX idx_notifications_created ON notifications(created_at DESC); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_users_updated_at + BEFORE UPDATE ON users + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_profiles_updated_at + BEFORE UPDATE ON profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_user_settings_updated_at + BEFORE UPDATE ON user_settings + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_kyc_updated_at + BEFORE UPDATE ON kyc_verifications + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +CREATE TRIGGER update_risk_profiles_updated_at + BEFORE UPDATE ON risk_profiles + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); diff --git a/schemas/01b_oauth_providers.sql b/schemas/01b_oauth_providers.sql new file mode 100644 index 0000000..afda232 --- /dev/null +++ b/schemas/01b_oauth_providers.sql @@ -0,0 +1,220 @@ +-- ============================================================================ +-- OrbiQuant IA - OAuth Providers +-- ============================================================================ +-- Archivo: 01b_oauth_providers.sql +-- Descripción: Proveedores OAuth y vinculación de cuentas sociales +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO public; + +-- ============================================================================ +-- ENUM: auth_provider_enum +-- ============================================================================ +CREATE TYPE auth_provider_enum AS ENUM ( + 'email', -- Email/password tradicional + 'phone', -- Teléfono (SMS/WhatsApp) + 'google', -- Google OAuth + 'facebook', -- Facebook OAuth + 'twitter', -- X (Twitter) OAuth + 'apple', -- Apple Sign In + 'github' -- GitHub OAuth +); + +-- ============================================================================ +-- TABLA: oauth_accounts +-- Descripción: Cuentas OAuth vinculadas a usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS oauth_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Proveedor + provider auth_provider_enum NOT NULL, + provider_account_id VARCHAR(255) NOT NULL, + + -- Tokens OAuth + access_token TEXT, + refresh_token TEXT, + token_expires_at TIMESTAMPTZ, + token_type VARCHAR(50), + scope TEXT, + + -- Datos del perfil del proveedor + provider_email VARCHAR(255), + provider_name VARCHAR(255), + provider_avatar_url TEXT, + provider_profile JSONB, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + -- Constraints + UNIQUE(provider, provider_account_id), + UNIQUE(user_id, provider) +); + +CREATE INDEX idx_oauth_user_id ON oauth_accounts(user_id); +CREATE INDEX idx_oauth_provider ON oauth_accounts(provider); +CREATE INDEX idx_oauth_provider_account ON oauth_accounts(provider, provider_account_id); + +-- ============================================================================ +-- TABLA: phone_verifications +-- Descripción: Verificaciones de teléfono (OTP via SMS/WhatsApp) +-- ============================================================================ +CREATE TYPE phone_channel_enum AS ENUM ('sms', 'whatsapp', 'call'); + +CREATE TABLE IF NOT EXISTS phone_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Teléfono + phone_number VARCHAR(20) NOT NULL, + country_code VARCHAR(5) NOT NULL, + + -- OTP + otp_code VARCHAR(6) NOT NULL, + otp_hash VARCHAR(255) NOT NULL, + channel phone_channel_enum DEFAULT 'whatsapp', + + -- Estado + verified BOOLEAN DEFAULT FALSE, + attempts INT DEFAULT 0, + max_attempts INT DEFAULT 3, + + -- Vinculación + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + purpose VARCHAR(50) NOT NULL DEFAULT 'login', -- login, register, verify, 2fa + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + verified_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_phone_verif_phone ON phone_verifications(phone_number); +CREATE INDEX idx_phone_verif_expires ON phone_verifications(expires_at); +CREATE INDEX idx_phone_verif_user ON phone_verifications(user_id); + +-- ============================================================================ +-- TABLA: email_verifications +-- Descripción: Tokens de verificación de email +-- ============================================================================ +CREATE TABLE IF NOT EXISTS email_verifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Email + email VARCHAR(255) NOT NULL, + + -- Token + token VARCHAR(255) NOT NULL UNIQUE, + token_hash VARCHAR(255) NOT NULL, + + -- Vinculación + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + purpose VARCHAR(50) NOT NULL DEFAULT 'verify', -- verify, reset_password, change_email + + -- Estado + used BOOLEAN DEFAULT FALSE, + + -- Validez + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_email_verif_email ON email_verifications(email); +CREATE INDEX idx_email_verif_token ON email_verifications(token_hash); +CREATE INDEX idx_email_verif_user ON email_verifications(user_id); + +-- ============================================================================ +-- TABLA: auth_logs +-- Descripción: Logs de eventos de autenticación +-- ============================================================================ +CREATE TYPE auth_event_enum AS ENUM ( + 'login_success', + 'login_failed', + 'logout', + 'register', + 'password_reset_request', + 'password_reset_complete', + 'email_verified', + 'phone_verified', + 'oauth_linked', + 'oauth_unlinked', + '2fa_enabled', + '2fa_disabled', + '2fa_verified', + 'session_revoked', + 'account_locked', + 'account_unlocked' +); + +CREATE TABLE IF NOT EXISTS auth_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + + -- Evento + event auth_event_enum NOT NULL, + provider auth_provider_enum, + + -- Contexto + ip_address INET, + user_agent TEXT, + device_fingerprint VARCHAR(255), + location JSONB, -- {country, city, region} + + -- Metadata + metadata JSONB, + + -- Resultado + success BOOLEAN DEFAULT TRUE, + error_message TEXT, + + -- Timestamps + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_auth_logs_user ON auth_logs(user_id); +CREATE INDEX idx_auth_logs_event ON auth_logs(event); +CREATE INDEX idx_auth_logs_created ON auth_logs(created_at DESC); +CREATE INDEX idx_auth_logs_ip ON auth_logs(ip_address); + +-- ============================================================================ +-- MODIFICAR TABLA users: agregar campo auth_provider +-- ============================================================================ +ALTER TABLE users ADD COLUMN IF NOT EXISTS primary_auth_provider auth_provider_enum DEFAULT 'email'; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_oauth_accounts_updated_at + BEFORE UPDATE ON oauth_accounts + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- ============================================================================ +-- FUNCIONES DE UTILIDAD +-- ============================================================================ + +-- Función para generar OTP de 6 dígitos +CREATE OR REPLACE FUNCTION generate_otp() +RETURNS VARCHAR(6) AS $$ +BEGIN + RETURN LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0'); +END; +$$ LANGUAGE plpgsql; + +-- Función para limpiar verificaciones expiradas +CREATE OR REPLACE FUNCTION cleanup_expired_verifications() +RETURNS void AS $$ +BEGIN + DELETE FROM phone_verifications WHERE expires_at < NOW() AND verified = FALSE; + DELETE FROM email_verifications WHERE expires_at < NOW() AND used = FALSE; +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/02_education_schema.sql b/schemas/02_education_schema.sql new file mode 100644 index 0000000..12f95a5 --- /dev/null +++ b/schemas/02_education_schema.sql @@ -0,0 +1,398 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema EDUCATION +-- ============================================================================ +-- Archivo: 02_education_schema.sql +-- Descripción: Cursos, lecciones, inscripciones y contenido educativo +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO education; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE course_level_enum AS ENUM ('beginner', 'intermediate', 'advanced', 'expert'); +CREATE TYPE course_status_enum AS ENUM ('draft', 'published', 'archived'); +CREATE TYPE content_type_enum AS ENUM ('video', 'text', 'quiz', 'exercise', 'resource'); +CREATE TYPE enrollment_status_enum AS ENUM ('active', 'completed', 'expired', 'cancelled'); + +-- ============================================================================ +-- TABLA: categories +-- Descripción: Categorías de cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS categories ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + icon VARCHAR(50), + parent_id UUID REFERENCES categories(id), + sort_order INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_categories_parent ON categories(parent_id); +CREATE INDEX idx_categories_slug ON categories(slug); + +-- ============================================================================ +-- TABLA: courses +-- Descripción: Cursos de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS courses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Información básica + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + short_description VARCHAR(500), + thumbnail_url TEXT, + preview_video_url TEXT, + + -- Categorización + category_id UUID REFERENCES categories(id), + level course_level_enum DEFAULT 'beginner', + tags TEXT[], + + -- Pricing + is_free BOOLEAN DEFAULT FALSE, + price DECIMAL(10,2) DEFAULT 0, + currency CHAR(3) DEFAULT 'USD', + + -- Acceso + requires_subscription BOOLEAN DEFAULT FALSE, + min_subscription_tier VARCHAR(20), -- 'basic', 'pro', 'elite' + + -- Metadata + duration_minutes INT, + lessons_count INT DEFAULT 0, + enrolled_count INT DEFAULT 0, + + -- Rating + average_rating DECIMAL(3,2) DEFAULT 0, + ratings_count INT DEFAULT 0, + + -- Estado + status course_status_enum DEFAULT 'draft', + published_at TIMESTAMPTZ, + + -- Autor + instructor_id UUID REFERENCES public.users(id), + + -- AI Generation + ai_generated BOOLEAN DEFAULT FALSE, + ai_generation_prompt TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_courses_slug ON courses(slug); +CREATE INDEX idx_courses_category ON courses(category_id); +CREATE INDEX idx_courses_status ON courses(status); +CREATE INDEX idx_courses_level ON courses(level); +CREATE INDEX idx_courses_instructor ON courses(instructor_id); + +-- ============================================================================ +-- TABLA: modules +-- Descripción: Módulos/secciones de un curso +-- ============================================================================ +CREATE TABLE IF NOT EXISTS modules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + title VARCHAR(255) NOT NULL, + description TEXT, + sort_order INT DEFAULT 0, + + -- Progreso requerido del módulo anterior para desbloquear + unlock_after_module_id UUID REFERENCES modules(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_modules_course ON modules(course_id); + +-- ============================================================================ +-- TABLA: lessons +-- Descripción: Lecciones individuales +-- ============================================================================ +CREATE TABLE IF NOT EXISTS lessons ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + module_id UUID NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + -- Contenido + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + content_type content_type_enum DEFAULT 'video', + + -- Video + video_url TEXT, + video_duration_seconds INT, + video_provider VARCHAR(50), -- 'youtube', 'vimeo', 'internal' + + -- Texto + content_markdown TEXT, + content_html TEXT, + + -- Recursos adicionales + resources JSONB DEFAULT '[]', -- [{name, url, type}] + + -- Orden + sort_order INT DEFAULT 0, + + -- Acceso + is_preview BOOLEAN DEFAULT FALSE, -- Disponible sin inscripción + + -- AI + ai_generated BOOLEAN DEFAULT FALSE, + ai_summary TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(course_id, slug) +); + +CREATE INDEX idx_lessons_module ON lessons(module_id); +CREATE INDEX idx_lessons_course ON lessons(course_id); + +-- ============================================================================ +-- TABLA: quizzes +-- Descripción: Cuestionarios y evaluaciones +-- ============================================================================ +CREATE TABLE IF NOT EXISTS quizzes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + title VARCHAR(255) NOT NULL, + description TEXT, + + -- Configuración + passing_score INT DEFAULT 70, -- Porcentaje mínimo + max_attempts INT, -- NULL = ilimitado + time_limit_minutes INT, + shuffle_questions BOOLEAN DEFAULT FALSE, + show_correct_answers BOOLEAN DEFAULT TRUE, + + -- AI + ai_generated BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quizzes_lesson ON quizzes(lesson_id); +CREATE INDEX idx_quizzes_course ON quizzes(course_id); + +-- ============================================================================ +-- TABLA: quiz_questions +-- Descripción: Preguntas de cuestionarios +-- ============================================================================ +CREATE TYPE question_type_enum AS ENUM ('multiple_choice', 'true_false', 'multiple_answer', 'short_answer'); + +CREATE TABLE IF NOT EXISTS quiz_questions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + + question_type question_type_enum DEFAULT 'multiple_choice', + question_text TEXT NOT NULL, + explanation TEXT, -- Explicación mostrada después de responder + + -- Opciones (para multiple_choice y multiple_answer) + options JSONB, -- [{id, text, is_correct}] + + -- Para short_answer + correct_answers TEXT[], -- Respuestas aceptadas + + points INT DEFAULT 1, + sort_order INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quiz_questions_quiz ON quiz_questions(quiz_id); + +-- ============================================================================ +-- TABLA: enrollments +-- Descripción: Inscripciones de usuarios a cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS enrollments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + -- Estado + status enrollment_status_enum DEFAULT 'active', + + -- Progreso + progress_percentage DECIMAL(5,2) DEFAULT 0, + lessons_completed INT DEFAULT 0, + + -- Acceso + enrolled_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ, -- NULL = acceso permanente + completed_at TIMESTAMPTZ, + + -- Pago (si aplica) + payment_id UUID, -- Referencia a financial.payments + + -- Certificado + certificate_issued BOOLEAN DEFAULT FALSE, + certificate_url TEXT, + certificate_issued_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, course_id) +); + +CREATE INDEX idx_enrollments_user ON enrollments(user_id); +CREATE INDEX idx_enrollments_course ON enrollments(course_id); +CREATE INDEX idx_enrollments_status ON enrollments(status); + +-- ============================================================================ +-- TABLA: lesson_progress +-- Descripción: Progreso por lección +-- ============================================================================ +CREATE TABLE IF NOT EXISTS lesson_progress ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE, + enrollment_id UUID NOT NULL REFERENCES enrollments(id) ON DELETE CASCADE, + + -- Progreso de video + video_watched_seconds INT DEFAULT 0, + video_completed BOOLEAN DEFAULT FALSE, + + -- Estado + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Notas del usuario + user_notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, lesson_id) +); + +CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id); +CREATE INDEX idx_lesson_progress_lesson ON lesson_progress(lesson_id); +CREATE INDEX idx_lesson_progress_enrollment ON lesson_progress(enrollment_id); + +-- ============================================================================ +-- TABLA: quiz_attempts +-- Descripción: Intentos de cuestionarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS quiz_attempts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + quiz_id UUID NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE, + enrollment_id UUID REFERENCES enrollments(id) ON DELETE SET NULL, + + -- Resultado + score DECIMAL(5,2), + passed BOOLEAN, + + -- Respuestas + answers JSONB, -- [{question_id, answer, is_correct}] + + -- Tiempo + started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + submitted_at TIMESTAMPTZ, + time_spent_seconds INT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_quiz_attempts_user ON quiz_attempts(user_id); +CREATE INDEX idx_quiz_attempts_quiz ON quiz_attempts(quiz_id); + +-- ============================================================================ +-- TABLA: course_reviews +-- Descripción: Reseñas de cursos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS course_reviews ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE, + + rating INT NOT NULL CHECK (rating >= 1 AND rating <= 5), + review_text TEXT, + + -- Moderación + is_approved BOOLEAN DEFAULT TRUE, + is_featured BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, course_id) +); + +CREATE INDEX idx_course_reviews_course ON course_reviews(course_id); + +-- ============================================================================ +-- TABLA: ai_content_generations +-- Descripción: Registro de contenido generado por IA +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ai_content_generations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencia + entity_type VARCHAR(50) NOT NULL, -- 'course', 'lesson', 'quiz' + entity_id UUID NOT NULL, + + -- Generación + prompt TEXT NOT NULL, + model_used VARCHAR(100), + generated_content TEXT NOT NULL, + tokens_used INT, + + -- Estado + approved BOOLEAN DEFAULT FALSE, + approved_by UUID REFERENCES public.users(id), + approved_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_ai_generations_entity ON ai_content_generations(entity_type, entity_id); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_courses_updated_at + BEFORE UPDATE ON courses + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_modules_updated_at + BEFORE UPDATE ON modules + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_lessons_updated_at + BEFORE UPDATE ON lessons + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_enrollments_updated_at + BEFORE UPDATE ON enrollments + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_lesson_progress_updated_at + BEFORE UPDATE ON lesson_progress + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/03_trading_schema.sql b/schemas/03_trading_schema.sql new file mode 100644 index 0000000..bfa2a7a --- /dev/null +++ b/schemas/03_trading_schema.sql @@ -0,0 +1,428 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema TRADING +-- ============================================================================ +-- Archivo: 03_trading_schema.sql +-- Descripción: Bots, señales, estrategias y operaciones de trading +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO trading; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE bot_status_enum AS ENUM ('active', 'paused', 'stopped', 'error', 'maintenance'); +CREATE TYPE signal_status_enum AS ENUM ('pending', 'active', 'triggered', 'expired', 'cancelled'); +CREATE TYPE position_status_enum AS ENUM ('open', 'closed', 'pending'); +CREATE TYPE timeframe_enum AS ENUM ('1m', '5m', '15m', '30m', '1h', '4h', '1d', '1w'); +CREATE TYPE amd_phase_enum AS ENUM ('accumulation', 'manipulation', 'distribution', 'unknown'); +CREATE TYPE volatility_regime_enum AS ENUM ('low', 'medium', 'high', 'extreme'); + +-- ============================================================================ +-- TABLA: symbols +-- Descripción: Instrumentos financieros disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS symbols ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + symbol VARCHAR(20) NOT NULL UNIQUE, -- 'XAUUSD', 'EURUSD', 'BTCUSD' + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Clasificación + asset_class VARCHAR(50), -- 'forex', 'crypto', 'commodities', 'indices', 'stocks' + base_currency VARCHAR(10), + quote_currency VARCHAR(10), + + -- Trading info + pip_value DECIMAL(10,6), + lot_size DECIMAL(10,2), + min_lot DECIMAL(10,4), + max_lot DECIMAL(10,2), + tick_size DECIMAL(10,6), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + trading_hours JSONB, -- [{day, open, close}] + + -- Data provider + data_provider VARCHAR(50), + provider_symbol VARCHAR(50), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_symbols_symbol ON symbols(symbol); +CREATE INDEX idx_symbols_asset_class ON symbols(asset_class); + +-- ============================================================================ +-- TABLA: strategies +-- Descripción: Estrategias de trading disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS strategies ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + + -- Tipo + strategy_type VARCHAR(50), -- 'intraday', 'swing', 'scalping', 'position' + + -- Configuración + default_timeframe timeframe_enum DEFAULT '15m', + supported_symbols TEXT[], -- NULL = todos + risk_reward_ratio DECIMAL(4,2) DEFAULT 2.0, + + -- Parámetros configurables (JSON Schema) + parameters_schema JSONB, + default_parameters JSONB, + + -- ML Model asociado + uses_ml_model BOOLEAN DEFAULT TRUE, + ml_model_id UUID, -- Referencia a ml.models + + -- AMD + uses_amd BOOLEAN DEFAULT TRUE, + favorable_phases amd_phase_enum[], + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_strategies_slug ON strategies(slug); + +-- ============================================================================ +-- TABLA: bots (Agentes de Trading) +-- Descripción: Instancias de bots/agentes de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS bots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + avatar_url TEXT, + + -- Perfil de riesgo + risk_profile public.risk_profile_enum NOT NULL, + + -- Objetivos + target_monthly_return DECIMAL(5,2), -- % objetivo + max_drawdown DECIMAL(5,2), -- % máximo + max_position_size DECIMAL(5,2), -- % del capital + + -- Estrategias + strategy_id UUID REFERENCES strategies(id), + strategy_parameters JSONB, + + -- Configuración + supported_symbols TEXT[], + default_timeframe timeframe_enum DEFAULT '15m', + min_confidence DECIMAL(3,2) DEFAULT 0.55, + + -- Horarios de operación + trading_schedule JSONB, -- Horarios permitidos + + -- Estado + status bot_status_enum DEFAULT 'stopped', + last_activity_at TIMESTAMPTZ, + error_message TEXT, + + -- Métricas globales (actualizadas periódicamente) + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + total_profit DECIMAL(15,2) DEFAULT 0, + sharpe_ratio DECIMAL(5,2), + sortino_ratio DECIMAL(5,2), + max_drawdown_actual DECIMAL(5,2), + + -- Público + is_public BOOLEAN DEFAULT TRUE, -- Visible para asignar a cuentas + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_bots_slug ON bots(slug); +CREATE INDEX idx_bots_risk_profile ON bots(risk_profile); +CREATE INDEX idx_bots_status ON bots(status); + +-- ============================================================================ +-- TABLA: signals +-- Descripción: Señales de trading generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS signals ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Origen + bot_id UUID REFERENCES bots(id), + strategy_id UUID REFERENCES strategies(id), + symbol_id UUID REFERENCES symbols(id), + symbol VARCHAR(20) NOT NULL, + + -- Señal + direction public.trade_direction_enum NOT NULL, + timeframe timeframe_enum NOT NULL, + + -- Precios + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8) NOT NULL, + take_profit DECIMAL(20,8) NOT NULL, + current_price DECIMAL(20,8), + + -- Predicción ML + predicted_delta_high DECIMAL(20,8), + predicted_delta_low DECIMAL(20,8), + prob_tp_first DECIMAL(5,4), -- Probabilidad de tocar TP antes que SL + confidence_score DECIMAL(5,4), + + -- Contexto de mercado + amd_phase amd_phase_enum, + volatility_regime volatility_regime_enum, + market_context JSONB, -- Datos adicionales del contexto + + -- R:R + risk_reward_ratio DECIMAL(4,2), + risk_pips DECIMAL(10,4), + reward_pips DECIMAL(10,4), + + -- Estado + status signal_status_enum DEFAULT 'pending', + triggered_at TIMESTAMPTZ, + expired_at TIMESTAMPTZ, + + -- Resultado (si se operó) + outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'manual_close', 'expired' + pnl DECIMAL(15,2), + + -- Timestamps + valid_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_signals_bot ON signals(bot_id); +CREATE INDEX idx_signals_symbol ON signals(symbol); +CREATE INDEX idx_signals_status ON signals(status); +CREATE INDEX idx_signals_created ON signals(created_at DESC); +CREATE INDEX idx_signals_direction ON signals(direction); + +-- ============================================================================ +-- TABLA: positions +-- Descripción: Posiciones de trading (abiertas y cerradas) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS positions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + bot_id UUID REFERENCES bots(id), + signal_id UUID REFERENCES signals(id), + investment_account_id UUID, -- Referencia a investment.accounts + symbol_id UUID REFERENCES symbols(id), + symbol VARCHAR(20) NOT NULL, + + -- Posición + direction public.trade_direction_enum NOT NULL, + lot_size DECIMAL(10,4) NOT NULL, + + -- Precios + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + exit_price DECIMAL(20,8), + + -- Broker + broker_order_id VARCHAR(100), + broker_position_id VARCHAR(100), + + -- Estado + status position_status_enum DEFAULT 'open', + opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMPTZ, + close_reason VARCHAR(50), -- 'tp', 'sl', 'manual', 'forced', 'margin_call' + + -- P&L + realized_pnl DECIMAL(15,2), + unrealized_pnl DECIMAL(15,2), + commission DECIMAL(10,2) DEFAULT 0, + swap DECIMAL(10,2) DEFAULT 0, + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_positions_bot ON positions(bot_id); +CREATE INDEX idx_positions_signal ON positions(signal_id); +CREATE INDEX idx_positions_account ON positions(investment_account_id); +CREATE INDEX idx_positions_status ON positions(status); +CREATE INDEX idx_positions_opened ON positions(opened_at DESC); + +-- ============================================================================ +-- TABLA: price_alerts +-- Descripción: Alertas de precio configuradas por usuarios +-- ============================================================================ +CREATE TYPE alert_condition_enum AS ENUM ('above', 'below', 'crosses_above', 'crosses_below'); + +CREATE TABLE IF NOT EXISTS price_alerts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + condition alert_condition_enum NOT NULL, + price DECIMAL(20,8) NOT NULL, + note TEXT, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + triggered_at TIMESTAMPTZ, + triggered_price DECIMAL(20,8), + + -- Notificación + notify_email BOOLEAN DEFAULT TRUE, + notify_push BOOLEAN DEFAULT TRUE, + + -- Recurrencia + is_recurring BOOLEAN DEFAULT FALSE, -- Si se reactiva después de triggear + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_price_alerts_user ON price_alerts(user_id); +CREATE INDEX idx_price_alerts_symbol ON price_alerts(symbol); +CREATE INDEX idx_price_alerts_active ON price_alerts(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TABLA: watchlists +-- Descripción: Listas de seguimiento de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS watchlists ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + name VARCHAR(100) NOT NULL, + is_default BOOLEAN DEFAULT FALSE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_watchlists_user ON watchlists(user_id); + +-- ============================================================================ +-- TABLA: watchlist_items +-- Descripción: Items en watchlists +-- ============================================================================ +CREATE TABLE IF NOT EXISTS watchlist_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + watchlist_id UUID NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + sort_order INT DEFAULT 0, + notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(watchlist_id, symbol) +); + +CREATE INDEX idx_watchlist_items_watchlist ON watchlist_items(watchlist_id); + +-- ============================================================================ +-- TABLA: paper_trading_accounts +-- Descripción: Cuentas de paper trading (simulación) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS paper_trading_accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + name VARCHAR(100) DEFAULT 'Paper Account', + initial_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, + current_balance DECIMAL(15,2) NOT NULL DEFAULT 100000, + currency CHAR(3) DEFAULT 'USD', + + -- Métricas + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + total_pnl DECIMAL(15,2) DEFAULT 0, + max_drawdown DECIMAL(5,2) DEFAULT 0, + + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_paper_accounts_user ON paper_trading_accounts(user_id); + +-- ============================================================================ +-- TABLA: paper_trading_positions +-- Descripción: Posiciones de paper trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS paper_trading_positions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES paper_trading_accounts(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + symbol VARCHAR(20) NOT NULL, + direction public.trade_direction_enum NOT NULL, + lot_size DECIMAL(10,4) NOT NULL, + + entry_price DECIMAL(20,8) NOT NULL, + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + exit_price DECIMAL(20,8), + + status position_status_enum DEFAULT 'open', + opened_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + closed_at TIMESTAMPTZ, + close_reason VARCHAR(50), + + realized_pnl DECIMAL(15,2), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_paper_positions_account ON paper_trading_positions(account_id); +CREATE INDEX idx_paper_positions_user ON paper_trading_positions(user_id); +CREATE INDEX idx_paper_positions_status ON paper_trading_positions(status); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_symbols_updated_at + BEFORE UPDATE ON symbols + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_strategies_updated_at + BEFORE UPDATE ON strategies + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_bots_updated_at + BEFORE UPDATE ON bots + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_signals_updated_at + BEFORE UPDATE ON signals + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_positions_updated_at + BEFORE UPDATE ON positions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/04_investment_schema.sql b/schemas/04_investment_schema.sql new file mode 100644 index 0000000..b91cd22 --- /dev/null +++ b/schemas/04_investment_schema.sql @@ -0,0 +1,426 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema INVESTMENT +-- ============================================================================ +-- Archivo: 04_investment_schema.sql +-- Descripción: Cuentas de inversión, productos y gestión de portafolios +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO investment; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE product_type_enum AS ENUM ( + 'fixed_return', -- Rendimiento fijo objetivo (ej: 5% mensual) + 'variable_return', -- Rendimiento variable con reparto de utilidades + 'long_term_portfolio' -- Cartera de largo plazo (acciones, ETFs) +); + +CREATE TYPE account_status_enum AS ENUM ( + 'pending_kyc', -- Esperando verificación KYC + 'pending_deposit', -- Esperando depósito inicial + 'active', -- Cuenta activa operando + 'paused', -- Pausada por usuario o admin + 'suspended', -- Suspendida por compliance + 'closed' -- Cerrada +); + +CREATE TYPE fee_type_enum AS ENUM ( + 'management', -- Comisión de administración + 'performance', -- Comisión de rendimiento + 'deposit', -- Comisión de depósito + 'withdrawal', -- Comisión de retiro + 'subscription' -- Comisión de suscripción +); + +-- ============================================================================ +-- TABLA: products +-- Descripción: Productos de inversión disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS products ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + short_description VARCHAR(500), + + -- Tipo y riesgo + product_type product_type_enum NOT NULL, + risk_profile public.risk_profile_enum NOT NULL, + + -- Objetivos + target_monthly_return DECIMAL(5,2), -- % objetivo mensual + max_drawdown DECIMAL(5,2), -- % máximo drawdown permitido + guaranteed_return BOOLEAN DEFAULT FALSE, -- SIEMPRE FALSE para cumplimiento + + -- Comisiones + management_fee_percent DECIMAL(5,2) DEFAULT 0, -- % anual sobre AUM + performance_fee_percent DECIMAL(5,2) DEFAULT 0, -- % sobre ganancias + profit_share_platform DECIMAL(5,2), -- % de utilidades para plataforma (ej: 50) + profit_share_client DECIMAL(5,2), -- % de utilidades para cliente (ej: 50) + + -- Límites + min_investment DECIMAL(15,2) DEFAULT 100, + max_investment DECIMAL(15,2), + min_investment_period_days INT DEFAULT 30, + + -- Restricciones + requires_kyc_level INT DEFAULT 1, + allowed_risk_profiles public.risk_profile_enum[], + + -- Bot/Agente asociado + default_bot_id UUID REFERENCES trading.bots(id), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + is_visible BOOLEAN DEFAULT TRUE, + + -- Metadata + terms_url TEXT, + risk_disclosure_url TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_products_slug ON products(slug); +CREATE INDEX idx_products_type ON products(product_type); +CREATE INDEX idx_products_risk ON products(risk_profile); + +-- ============================================================================ +-- TABLA: accounts +-- Descripción: Cuentas de inversión de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + product_id UUID NOT NULL REFERENCES products(id), + bot_id UUID REFERENCES trading.bots(id), + + -- Identificación + account_number VARCHAR(20) NOT NULL UNIQUE, -- OQ-INV-XXXXXX + name VARCHAR(100), + + -- Moneda + currency CHAR(3) DEFAULT 'USD', + + -- Balances + initial_deposit DECIMAL(15,2) NOT NULL, + current_balance DECIMAL(15,2) NOT NULL, + available_balance DECIMAL(15,2) NOT NULL, -- Balance disponible para retiro + reserved_balance DECIMAL(15,2) DEFAULT 0, -- En operaciones abiertas + + -- Rendimiento acumulado + total_profit DECIMAL(15,2) DEFAULT 0, + total_fees_paid DECIMAL(15,2) DEFAULT 0, + total_deposits DECIMAL(15,2) DEFAULT 0, + total_withdrawals DECIMAL(15,2) DEFAULT 0, + + -- Métricas de rendimiento + total_return_percent DECIMAL(8,4) DEFAULT 0, + monthly_return_percent DECIMAL(8,4) DEFAULT 0, + max_drawdown_percent DECIMAL(5,2) DEFAULT 0, + sharpe_ratio DECIMAL(5,2), + + -- Estado + status account_status_enum DEFAULT 'pending_deposit', + activated_at TIMESTAMPTZ, + paused_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + + -- Configuración del usuario + auto_compound BOOLEAN DEFAULT TRUE, -- Reinvertir ganancias + max_drawdown_override DECIMAL(5,2), -- Override del drawdown máximo + pause_on_drawdown BOOLEAN DEFAULT TRUE, -- Pausar si alcanza DD máximo + + -- Aceptación de términos + terms_accepted_at TIMESTAMPTZ, + risk_disclosure_accepted_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_accounts_user ON accounts(user_id); +CREATE INDEX idx_accounts_product ON accounts(product_id); +CREATE INDEX idx_accounts_status ON accounts(status); +CREATE INDEX idx_accounts_number ON accounts(account_number); + +-- ============================================================================ +-- TABLA: account_transactions +-- Descripción: Movimientos en cuentas de inversión +-- ============================================================================ +CREATE TYPE account_transaction_type AS ENUM ( + 'deposit', + 'withdrawal', + 'profit', + 'loss', + 'fee', + 'adjustment', + 'transfer_in', + 'transfer_out' +); + +CREATE TABLE IF NOT EXISTS account_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + + -- Tipo y monto + transaction_type account_transaction_type NOT NULL, + amount DECIMAL(15,2) NOT NULL, -- Positivo o negativo según tipo + currency CHAR(3) DEFAULT 'USD', + + -- Balances + balance_before DECIMAL(15,2) NOT NULL, + balance_after DECIMAL(15,2) NOT NULL, + + -- Referencia externa + reference_type VARCHAR(50), -- 'wallet_transaction', 'position', 'fee_charge' + reference_id UUID, + + -- Descripción + description TEXT, + + -- Estado + status VARCHAR(20) DEFAULT 'completed', -- 'pending', 'completed', 'cancelled' + processed_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_account_tx_account ON account_transactions(account_id); +CREATE INDEX idx_account_tx_type ON account_transactions(transaction_type); +CREATE INDEX idx_account_tx_created ON account_transactions(created_at DESC); + +-- ============================================================================ +-- TABLA: performance_snapshots +-- Descripción: Snapshots periódicos de rendimiento +-- ============================================================================ +CREATE TABLE IF NOT EXISTS performance_snapshots ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + + -- Período + snapshot_date DATE NOT NULL, + period_type VARCHAR(20) NOT NULL, -- 'daily', 'weekly', 'monthly' + + -- Valores + opening_balance DECIMAL(15,2) NOT NULL, + closing_balance DECIMAL(15,2) NOT NULL, + deposits DECIMAL(15,2) DEFAULT 0, + withdrawals DECIMAL(15,2) DEFAULT 0, + profit_loss DECIMAL(15,2) NOT NULL, + fees DECIMAL(15,2) DEFAULT 0, + + -- Métricas + return_percent DECIMAL(8,4), + drawdown_percent DECIMAL(5,2), + + -- Trading stats + total_trades INT DEFAULT 0, + winning_trades INT DEFAULT 0, + positions_opened INT DEFAULT 0, + positions_closed INT DEFAULT 0, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(account_id, snapshot_date, period_type) +); + +CREATE INDEX idx_perf_snapshots_account ON performance_snapshots(account_id); +CREATE INDEX idx_perf_snapshots_date ON performance_snapshots(snapshot_date DESC); + +-- ============================================================================ +-- TABLA: profit_distributions +-- Descripción: Distribución de utilidades (para cuentas con profit sharing) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS profit_distributions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + + -- Período + period_start DATE NOT NULL, + period_end DATE NOT NULL, + + -- Cálculo + gross_profit DECIMAL(15,2) NOT NULL, + management_fee DECIMAL(15,2) DEFAULT 0, + net_profit DECIMAL(15,2) NOT NULL, + + -- Distribución + platform_share_percent DECIMAL(5,2) NOT NULL, + client_share_percent DECIMAL(5,2) NOT NULL, + platform_amount DECIMAL(15,2) NOT NULL, + client_amount DECIMAL(15,2) NOT NULL, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'distributed', 'cancelled' + distributed_at TIMESTAMPTZ, + approved_by UUID REFERENCES auth.users(id), + + -- Referencia de pago + payment_reference VARCHAR(100), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_profit_dist_account ON profit_distributions(account_id); +CREATE INDEX idx_profit_dist_period ON profit_distributions(period_end DESC); +CREATE INDEX idx_profit_dist_status ON profit_distributions(status); + +-- ============================================================================ +-- TABLA: deposit_requests +-- Descripción: Solicitudes de depósito a cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS deposit_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Origen + source_type VARCHAR(50) NOT NULL, -- 'wallet', 'external' + source_wallet_id UUID, -- Referencia a financial.wallets + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'approved', 'completed', 'rejected' + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + + -- Transacción resultante + transaction_id UUID REFERENCES account_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_deposit_requests_account ON deposit_requests(account_id); +CREATE INDEX idx_deposit_requests_user ON deposit_requests(user_id); +CREATE INDEX idx_deposit_requests_status ON deposit_requests(status); + +-- ============================================================================ +-- TABLA: withdrawal_requests +-- Descripción: Solicitudes de retiro de cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS withdrawal_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Destino + destination_type VARCHAR(50) NOT NULL, -- 'wallet', 'bank_transfer' + destination_wallet_id UUID, + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected' + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + + -- Comisiones + fee_amount DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(15,2), + + -- Transacción resultante + transaction_id UUID REFERENCES account_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_withdrawal_requests_account ON withdrawal_requests(account_id); +CREATE INDEX idx_withdrawal_requests_user ON withdrawal_requests(user_id); +CREATE INDEX idx_withdrawal_requests_status ON withdrawal_requests(status); + +-- ============================================================================ +-- TABLA: bot_assignments +-- Descripción: Asignación de bots a cuentas de inversión +-- ============================================================================ +CREATE TABLE IF NOT EXISTS bot_assignments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + bot_id UUID NOT NULL REFERENCES trading.bots(id), + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + assigned_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + deactivated_at TIMESTAMPTZ, + + -- Razón del cambio + assignment_reason TEXT, + deactivation_reason TEXT, + + -- Usuario que asignó + assigned_by UUID REFERENCES auth.users(id), -- NULL = automático + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_bot_assignments_account ON bot_assignments(account_id); +CREATE INDEX idx_bot_assignments_bot ON bot_assignments(bot_id); +CREATE INDEX idx_bot_assignments_active ON bot_assignments(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_products_updated_at + BEFORE UPDATE ON products + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_accounts_updated_at + BEFORE UPDATE ON accounts + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_deposit_requests_updated_at + BEFORE UPDATE ON deposit_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_withdrawal_requests_updated_at + BEFORE UPDATE ON withdrawal_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_profit_distributions_updated_at + BEFORE UPDATE ON profit_distributions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================ +-- FUNCIÓN: Generar número de cuenta +-- ============================================================================ +CREATE OR REPLACE FUNCTION generate_account_number() +RETURNS TRIGGER AS $$ +DECLARE + seq_num INT; +BEGIN + SELECT COALESCE(MAX(CAST(SUBSTRING(account_number FROM 8) AS INT)), 0) + 1 + INTO seq_num + FROM investment.accounts; + + NEW.account_number := 'OQ-INV-' || LPAD(seq_num::TEXT, 6, '0'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER set_account_number + BEFORE INSERT ON accounts + FOR EACH ROW + WHEN (NEW.account_number IS NULL) + EXECUTE FUNCTION generate_account_number(); diff --git a/schemas/05_financial_schema.sql b/schemas/05_financial_schema.sql new file mode 100644 index 0000000..deb2b80 --- /dev/null +++ b/schemas/05_financial_schema.sql @@ -0,0 +1,500 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema FINANCIAL +-- ============================================================================ +-- Archivo: 05_financial_schema.sql +-- Descripción: Pagos, suscripciones, wallets y transacciones +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO financial; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE subscription_status_enum AS ENUM ( + 'trialing', -- Período de prueba + 'active', -- Activa y pagando + 'past_due', -- Pago vencido + 'cancelled', -- Cancelada + 'unpaid', -- Sin pago + 'paused' -- Pausada +); + +CREATE TYPE payment_status_enum AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed', + 'refunded', + 'cancelled' +); + +CREATE TYPE payment_method_enum AS ENUM ( + 'card', + 'bank_transfer', + 'paypal', + 'crypto', + 'wallet_balance' +); + +-- ============================================================================ +-- TABLA: subscription_plans +-- Descripción: Planes de suscripción disponibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS subscription_plans ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(50) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + + -- Pricing + price_monthly DECIMAL(10,2) NOT NULL, + price_yearly DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + -- Stripe + stripe_price_id_monthly VARCHAR(100), + stripe_price_id_yearly VARCHAR(100), + stripe_product_id VARCHAR(100), + + -- Features (JSON array) + features JSONB DEFAULT '[]', -- [{name, description, included: boolean}] + + -- Límites + max_watchlists INT, + max_alerts INT, + ml_predictions_access BOOLEAN DEFAULT FALSE, + signals_access BOOLEAN DEFAULT FALSE, + backtesting_access BOOLEAN DEFAULT FALSE, + api_access BOOLEAN DEFAULT FALSE, + priority_support BOOLEAN DEFAULT FALSE, + + -- Educación + courses_access VARCHAR(20) DEFAULT 'none', -- 'none', 'free_only', 'basic', 'all' + + -- Orden de display + sort_order INT DEFAULT 0, + is_featured BOOLEAN DEFAULT FALSE, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_plans_slug ON subscription_plans(slug); + +-- ============================================================================ +-- TABLA: stripe_customers +-- Descripción: Clientes de Stripe vinculados a usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS stripe_customers ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE, + + stripe_customer_id VARCHAR(100) NOT NULL UNIQUE, + email VARCHAR(255), + + -- Método de pago por defecto + default_payment_method_id VARCHAR(100), + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_stripe_customers_user ON stripe_customers(user_id); +CREATE INDEX idx_stripe_customers_stripe ON stripe_customers(stripe_customer_id); + +-- ============================================================================ +-- TABLA: subscriptions +-- Descripción: Suscripciones activas de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + plan_id UUID NOT NULL REFERENCES subscription_plans(id), + + -- Stripe + stripe_subscription_id VARCHAR(100) UNIQUE, + stripe_customer_id VARCHAR(100) REFERENCES stripe_customers(stripe_customer_id), + + -- Estado + status subscription_status_enum DEFAULT 'active', + + -- Período de facturación + billing_cycle VARCHAR(20) DEFAULT 'monthly', -- 'monthly', 'yearly' + current_period_start TIMESTAMPTZ, + current_period_end TIMESTAMPTZ, + + -- Trial + trial_start TIMESTAMPTZ, + trial_end TIMESTAMPTZ, + + -- Cancelación + cancel_at_period_end BOOLEAN DEFAULT FALSE, + cancelled_at TIMESTAMPTZ, + cancellation_reason TEXT, + + -- Precio actual (puede diferir del plan por descuentos) + current_price DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_subscriptions_user ON subscriptions(user_id); +CREATE INDEX idx_subscriptions_plan ON subscriptions(plan_id); +CREATE INDEX idx_subscriptions_status ON subscriptions(status); +CREATE INDEX idx_subscriptions_stripe ON subscriptions(stripe_subscription_id); + +-- ============================================================================ +-- TABLA: wallets +-- Descripción: Wallets internos de usuarios +-- ============================================================================ +CREATE TABLE IF NOT EXISTS wallets ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT, + + -- Balance + currency CHAR(3) NOT NULL DEFAULT 'USD', + balance DECIMAL(15,2) NOT NULL DEFAULT 0, + available_balance DECIMAL(15,2) NOT NULL DEFAULT 0, + pending_balance DECIMAL(15,2) NOT NULL DEFAULT 0, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + -- Límites + daily_withdrawal_limit DECIMAL(15,2) DEFAULT 10000, + monthly_withdrawal_limit DECIMAL(15,2) DEFAULT 50000, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(user_id, currency) +); + +CREATE INDEX idx_wallets_user ON wallets(user_id); + +-- ============================================================================ +-- TABLA: wallet_transactions +-- Descripción: Transacciones en wallets +-- ============================================================================ +CREATE TABLE IF NOT EXISTS wallet_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + wallet_id UUID NOT NULL REFERENCES wallets(id) ON DELETE RESTRICT, + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Tipo y monto + transaction_type public.transaction_type_enum NOT NULL, + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Balance + balance_before DECIMAL(15,2) NOT NULL, + balance_after DECIMAL(15,2) NOT NULL, + + -- Referencia + reference_type VARCHAR(50), -- 'payment', 'subscription', 'investment_account', 'refund' + reference_id UUID, + external_reference VARCHAR(100), -- ID externo (Stripe, etc.) + + -- Descripción + description TEXT, + + -- Estado + status payment_status_enum DEFAULT 'succeeded', + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_wallet_tx_wallet ON wallet_transactions(wallet_id); +CREATE INDEX idx_wallet_tx_user ON wallet_transactions(user_id); +CREATE INDEX idx_wallet_tx_type ON wallet_transactions(transaction_type); +CREATE INDEX idx_wallet_tx_created ON wallet_transactions(created_at DESC); + +-- ============================================================================ +-- TABLA: payments +-- Descripción: Pagos procesados +-- ============================================================================ +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Tipo + payment_type VARCHAR(50) NOT NULL, -- 'subscription', 'course', 'deposit', 'one_time' + + -- Monto + amount DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + fee DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(10,2), + + -- Método + payment_method payment_method_enum, + payment_method_details JSONB, -- Últimos 4 dígitos, tipo de tarjeta, etc. + + -- Stripe + stripe_payment_intent_id VARCHAR(100), + stripe_charge_id VARCHAR(100), + stripe_invoice_id VARCHAR(100), + + -- Estado + status payment_status_enum DEFAULT 'pending', + failure_reason TEXT, + + -- Referencia + reference_type VARCHAR(50), -- 'subscription', 'course', 'wallet_deposit' + reference_id UUID, + + -- Descripción + description TEXT, + + -- Metadata + metadata JSONB, + ip_address INET, + + -- Facturación + invoice_url TEXT, + receipt_url TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_payments_user ON payments(user_id); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_type ON payments(payment_type); +CREATE INDEX idx_payments_stripe ON payments(stripe_payment_intent_id); +CREATE INDEX idx_payments_created ON payments(created_at DESC); + +-- ============================================================================ +-- TABLA: refunds +-- Descripción: Reembolsos procesados +-- ============================================================================ +CREATE TABLE IF NOT EXISTS refunds ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Monto + amount DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Stripe + stripe_refund_id VARCHAR(100), + + -- Razón + reason VARCHAR(100), + notes TEXT, + + -- Estado + status payment_status_enum DEFAULT 'pending', + + -- Procesado por + processed_by UUID REFERENCES auth.users(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_refunds_payment ON refunds(payment_id); +CREATE INDEX idx_refunds_user ON refunds(user_id); + +-- ============================================================================ +-- TABLA: invoices +-- Descripción: Facturas generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Número de factura + invoice_number VARCHAR(50) NOT NULL UNIQUE, + + -- Stripe + stripe_invoice_id VARCHAR(100) UNIQUE, + + -- Montos + subtotal DECIMAL(10,2) NOT NULL, + tax DECIMAL(10,2) DEFAULT 0, + total DECIMAL(10,2) NOT NULL, + amount_paid DECIMAL(10,2) DEFAULT 0, + amount_due DECIMAL(10,2), + currency CHAR(3) DEFAULT 'USD', + + -- Estado + status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'open', 'paid', 'void', 'uncollectible' + + -- Fechas + due_date DATE, + paid_at TIMESTAMPTZ, + + -- Items + line_items JSONB DEFAULT '[]', -- [{description, quantity, unit_price, amount}] + + -- PDFs + pdf_url TEXT, + hosted_invoice_url TEXT, + + -- Datos fiscales del cliente + billing_details JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_invoices_user ON invoices(user_id); +CREATE INDEX idx_invoices_status ON invoices(status); +CREATE INDEX idx_invoices_number ON invoices(invoice_number); + +-- ============================================================================ +-- TABLA: payout_requests +-- Descripción: Solicitudes de retiro a cuenta bancaria/externa +-- ============================================================================ +CREATE TABLE IF NOT EXISTS payout_requests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES auth.users(id), + wallet_id UUID NOT NULL REFERENCES wallets(id), + + -- Monto + amount DECIMAL(15,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + fee DECIMAL(10,2) DEFAULT 0, + net_amount DECIMAL(15,2), + + -- Destino + payout_method VARCHAR(50) NOT NULL, -- 'bank_transfer', 'paypal', 'crypto' + destination_details JSONB, -- Cuenta bancaria, dirección, etc. + + -- Estado + status VARCHAR(20) DEFAULT 'pending', -- 'pending', 'processing', 'completed', 'rejected', 'cancelled' + processed_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + + -- Procesamiento + processed_by UUID REFERENCES auth.users(id), + rejection_reason TEXT, + external_reference VARCHAR(100), + + -- Transacción de wallet + wallet_transaction_id UUID REFERENCES wallet_transactions(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_payouts_user ON payout_requests(user_id); +CREATE INDEX idx_payouts_wallet ON payout_requests(wallet_id); +CREATE INDEX idx_payouts_status ON payout_requests(status); + +-- ============================================================================ +-- TABLA: promo_codes +-- Descripción: Códigos promocionales y descuentos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS promo_codes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + + -- Tipo de descuento + discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed_amount' + discount_value DECIMAL(10,2) NOT NULL, + currency CHAR(3) DEFAULT 'USD', + + -- Aplicabilidad + applies_to VARCHAR(50) DEFAULT 'all', -- 'all', 'subscription', 'course' + applicable_plan_ids UUID[], + applicable_course_ids UUID[], + + -- Límites + max_uses INT, + current_uses INT DEFAULT 0, + max_uses_per_user INT DEFAULT 1, + + -- Validez + valid_from TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + valid_until TIMESTAMPTZ, + + -- Requisitos + min_purchase_amount DECIMAL(10,2), + first_time_only BOOLEAN DEFAULT FALSE, + + -- Estado + is_active BOOLEAN DEFAULT TRUE, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_promo_codes_code ON promo_codes(code); +CREATE INDEX idx_promo_codes_active ON promo_codes(is_active) WHERE is_active = TRUE; + +-- ============================================================================ +-- TABLA: promo_code_uses +-- Descripción: Uso de códigos promocionales +-- ============================================================================ +CREATE TABLE IF NOT EXISTS promo_code_uses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + promo_code_id UUID NOT NULL REFERENCES promo_codes(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + payment_id UUID REFERENCES payments(id), + + discount_applied DECIMAL(10,2) NOT NULL, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_promo_uses_code ON promo_code_uses(promo_code_id); +CREATE INDEX idx_promo_uses_user ON promo_code_uses(user_id); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_plans_updated_at + BEFORE UPDATE ON subscription_plans + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_stripe_customers_updated_at + BEFORE UPDATE ON stripe_customers + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_subscriptions_updated_at + BEFORE UPDATE ON subscriptions + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_wallets_updated_at + BEFORE UPDATE ON wallets + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_payments_updated_at + BEFORE UPDATE ON payments + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_invoices_updated_at + BEFORE UPDATE ON invoices + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_payouts_updated_at + BEFORE UPDATE ON payout_requests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); diff --git a/schemas/06_ml_schema.sql b/schemas/06_ml_schema.sql new file mode 100644 index 0000000..935adce --- /dev/null +++ b/schemas/06_ml_schema.sql @@ -0,0 +1,426 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema ML (Machine Learning) +-- ============================================================================ +-- Archivo: 06_ml_schema.sql +-- Descripción: Modelos ML, predicciones, features y métricas +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO ml; + +-- ============================================================================ +-- TIPOS ENUMERADOS +-- ============================================================================ + +CREATE TYPE model_type_enum AS ENUM ( + 'range_predictor', -- Predicción de rangos (ΔHigh/ΔLow) + 'tpsl_classifier', -- Clasificación TP vs SL + 'signal_generator', -- Generador de señales + 'regime_classifier', -- Clasificación de régimen de mercado + 'amd_detector', -- Detector de fases AMD + 'volatility_model', -- Modelo de volatilidad + 'ensemble' -- Meta-modelo ensemble +); + +CREATE TYPE model_status_enum AS ENUM ( + 'training', + 'validating', + 'ready', + 'deployed', + 'deprecated', + 'failed' +); + +-- ============================================================================ +-- TABLA: models +-- Descripción: Registro de modelos ML +-- ============================================================================ +CREATE TABLE IF NOT EXISTS models ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Identificación + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + + -- Tipo y versión + model_type model_type_enum NOT NULL, + version VARCHAR(20) NOT NULL, + is_latest BOOLEAN DEFAULT FALSE, + + -- Símbolos y timeframes + symbols TEXT[], -- NULL = todos + timeframes trading.timeframe_enum[], + + -- Arquitectura + algorithm VARCHAR(50), -- 'xgboost', 'gru', 'transformer', 'ensemble' + architecture_config JSONB, -- Configuración de arquitectura + + -- Hiperparámetros + hyperparameters JSONB, + + -- Features + feature_columns TEXT[], + feature_count INT, + + -- Artifact + artifact_path TEXT, -- Path al modelo serializado + artifact_size_mb DECIMAL(10,2), + + -- Estado + status model_status_enum DEFAULT 'training', + deployed_at TIMESTAMPTZ, + + -- Metadata + training_duration_seconds INT, + total_samples INT, + created_by UUID REFERENCES public.users(id), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(slug, version) +); + +CREATE INDEX idx_models_slug ON models(slug); +CREATE INDEX idx_models_type ON models(model_type); +CREATE INDEX idx_models_status ON models(status); +CREATE INDEX idx_models_latest ON models(is_latest) WHERE is_latest = TRUE; + +-- ============================================================================ +-- TABLA: training_runs +-- Descripción: Ejecuciones de entrenamiento +-- ============================================================================ +CREATE TABLE IF NOT EXISTS training_runs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID REFERENCES models(id) ON DELETE SET NULL, + + -- Configuración + run_name VARCHAR(100), + config JSONB NOT NULL, + + -- Datos + training_data_start DATE, + training_data_end DATE, + validation_data_start DATE, + validation_data_end DATE, + total_samples INT, + training_samples INT, + validation_samples INT, + + -- Walk-forward + walk_forward_splits INT, + walk_forward_config JSONB, + + -- Estado + status VARCHAR(20) DEFAULT 'running', -- 'running', 'completed', 'failed', 'cancelled' + started_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ, + error_message TEXT, + + -- Recursos + gpu_used BOOLEAN DEFAULT FALSE, + memory_peak_mb INT, + duration_seconds INT, + + -- Logs + logs_path TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_training_runs_model ON training_runs(model_id); +CREATE INDEX idx_training_runs_status ON training_runs(status); + +-- ============================================================================ +-- TABLA: model_metrics +-- Descripción: Métricas de rendimiento de modelos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS model_metrics ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + training_run_id UUID REFERENCES training_runs(id), + + -- Tipo de métricas + metric_set VARCHAR(50) NOT NULL, -- 'training', 'validation', 'test', 'production' + split_index INT, -- Para walk-forward + + -- Métricas de regresión + mae DECIMAL(10,6), + rmse DECIMAL(10,6), + mape DECIMAL(10,6), + r2_score DECIMAL(10,6), + + -- Métricas de clasificación + accuracy DECIMAL(5,4), + precision_score DECIMAL(5,4), + recall_score DECIMAL(5,4), + f1_score DECIMAL(5,4), + roc_auc DECIMAL(5,4), + + -- Métricas por clase + confusion_matrix JSONB, + classification_report JSONB, + + -- Métricas de trading + win_rate DECIMAL(5,4), + profit_factor DECIMAL(6,2), + sharpe_ratio DECIMAL(6,2), + sortino_ratio DECIMAL(6,2), + max_drawdown DECIMAL(5,4), + + -- Feature importance + feature_importance JSONB, + + -- Timestamp + calculated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_model_metrics_model ON model_metrics(model_id); +CREATE INDEX idx_model_metrics_set ON model_metrics(metric_set); + +-- ============================================================================ +-- TABLA: predictions +-- Descripción: Predicciones generadas +-- ============================================================================ +CREATE TABLE IF NOT EXISTS predictions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id), + + -- Contexto + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe_enum NOT NULL, + prediction_timestamp TIMESTAMPTZ NOT NULL, + + -- Input + candle_timestamp TIMESTAMPTZ NOT NULL, -- Timestamp de la vela de entrada + input_features JSONB, -- Features usadas (opcional, para debugging) + + -- Predicción de rango + predicted_delta_high DECIMAL(20,8), + predicted_delta_low DECIMAL(20,8), + predicted_delta_high_1h DECIMAL(20,8), + predicted_delta_low_1h DECIMAL(20,8), + + -- Clasificación de bins (ATR-based) + predicted_high_bin INT, + predicted_low_bin INT, + bin_probabilities JSONB, + + -- TP/SL prediction + prob_tp_first DECIMAL(5,4), + rr_config VARCHAR(20), -- 'rr_2_1', 'rr_3_1' + + -- Confianza + confidence_score DECIMAL(5,4), + model_uncertainty DECIMAL(5,4), + + -- Contexto de mercado predicho + predicted_amd_phase trading.amd_phase_enum, + predicted_volatility trading.volatility_regime_enum, + + -- Resultado real (llenado posteriormente) + actual_delta_high DECIMAL(20,8), + actual_delta_low DECIMAL(20,8), + actual_tp_sl_outcome VARCHAR(20), -- 'tp_hit', 'sl_hit', 'neither' + outcome_recorded_at TIMESTAMPTZ, + + -- Error calculado + error_high DECIMAL(20,8), + error_low DECIMAL(20,8), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_predictions_model ON predictions(model_id); +CREATE INDEX idx_predictions_symbol ON predictions(symbol); +CREATE INDEX idx_predictions_timestamp ON predictions(prediction_timestamp DESC); +CREATE INDEX idx_predictions_candle ON predictions(candle_timestamp); + +-- Particionamiento por fecha para mejor rendimiento +-- (En producción, considerar particionar por mes) + +-- ============================================================================ +-- TABLA: prediction_accuracy_daily +-- Descripción: Precisión de predicciones agregada por día +-- ============================================================================ +CREATE TABLE IF NOT EXISTS prediction_accuracy_daily ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE, + symbol VARCHAR(20) NOT NULL, + date DATE NOT NULL, + + -- Conteos + total_predictions INT DEFAULT 0, + predictions_evaluated INT DEFAULT 0, + + -- Métricas de rango + mae_high DECIMAL(10,6), + mae_low DECIMAL(10,6), + mape_high DECIMAL(10,6), + mape_low DECIMAL(10,6), + + -- Métricas de TP/SL + tp_sl_predictions INT DEFAULT 0, + tp_correct INT DEFAULT 0, + sl_correct INT DEFAULT 0, + accuracy_tp_sl DECIMAL(5,4), + + -- Métricas de bins + bin_accuracy DECIMAL(5,4), + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(model_id, symbol, date) +); + +CREATE INDEX idx_pred_accuracy_model ON prediction_accuracy_daily(model_id); +CREATE INDEX idx_pred_accuracy_date ON prediction_accuracy_daily(date DESC); + +-- ============================================================================ +-- TABLA: feature_store +-- Descripción: Features pre-calculadas para inferencia rápida +-- ============================================================================ +CREATE TABLE IF NOT EXISTS feature_store ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe_enum NOT NULL, + candle_timestamp TIMESTAMPTZ NOT NULL, + + -- OHLCV base + open DECIMAL(20,8) NOT NULL, + high DECIMAL(20,8) NOT NULL, + low DECIMAL(20,8) NOT NULL, + close DECIMAL(20,8) NOT NULL, + volume DECIMAL(20,4), + + -- Features calculadas (las 21 del modelo) + features JSONB NOT NULL, + + -- Indicadores técnicos + indicators JSONB, + + -- Validación + is_valid BOOLEAN DEFAULT TRUE, + validation_errors TEXT[], + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + + UNIQUE(symbol, timeframe, candle_timestamp) +); + +CREATE INDEX idx_feature_store_symbol ON feature_store(symbol, timeframe); +CREATE INDEX idx_feature_store_timestamp ON feature_store(candle_timestamp DESC); + +-- ============================================================================ +-- TABLA: model_drift_alerts +-- Descripción: Alertas de drift de modelo +-- ============================================================================ +CREATE TABLE IF NOT EXISTS model_drift_alerts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + model_id UUID NOT NULL REFERENCES models(id), + + -- Tipo de drift + drift_type VARCHAR(50) NOT NULL, -- 'feature_drift', 'prediction_drift', 'performance_drift' + + -- Detalles + metric_name VARCHAR(100), + expected_value DECIMAL(10,6), + actual_value DECIMAL(10,6), + deviation_percent DECIMAL(10,4), + + -- Severidad + severity VARCHAR(20), -- 'low', 'medium', 'high', 'critical' + + -- Estado + status VARCHAR(20) DEFAULT 'active', -- 'active', 'acknowledged', 'resolved' + acknowledged_by UUID REFERENCES public.users(id), + acknowledged_at TIMESTAMPTZ, + resolved_at TIMESTAMPTZ, + + -- Acción tomada + action_taken TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_drift_alerts_model ON model_drift_alerts(model_id); +CREATE INDEX idx_drift_alerts_status ON model_drift_alerts(status); + +-- ============================================================================ +-- TABLA: ab_tests +-- Descripción: Tests A/B de modelos +-- ============================================================================ +CREATE TABLE IF NOT EXISTS ab_tests ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + name VARCHAR(100) NOT NULL, + description TEXT, + + -- Modelos + control_model_id UUID NOT NULL REFERENCES models(id), + treatment_model_id UUID NOT NULL REFERENCES models(id), + + -- Configuración + traffic_split DECIMAL(3,2) DEFAULT 0.50, -- % al tratamiento + target_metric VARCHAR(50), -- Métrica principal a optimizar + + -- Estado + status VARCHAR(20) DEFAULT 'draft', -- 'draft', 'running', 'paused', 'completed', 'cancelled' + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + + -- Resultados + control_samples INT DEFAULT 0, + treatment_samples INT DEFAULT 0, + control_metric_value DECIMAL(10,6), + treatment_metric_value DECIMAL(10,6), + statistical_significance DECIMAL(5,4), + winner VARCHAR(20), -- 'control', 'treatment', 'inconclusive' + + created_by UUID REFERENCES public.users(id), + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_ab_tests_status ON ab_tests(status); + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +CREATE TRIGGER update_models_updated_at + BEFORE UPDATE ON models + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +CREATE TRIGGER update_ab_tests_updated_at + BEFORE UPDATE ON ab_tests + FOR EACH ROW + EXECUTE FUNCTION public.update_updated_at_column(); + +-- ============================================================================ +-- FUNCIÓN: Marcar modelo como latest +-- ============================================================================ +CREATE OR REPLACE FUNCTION set_model_as_latest() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_latest = TRUE THEN + UPDATE ml.models + SET is_latest = FALSE + WHERE slug = NEW.slug + AND id != NEW.id + AND is_latest = TRUE; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER ensure_single_latest_model + AFTER INSERT OR UPDATE ON models + FOR EACH ROW + WHEN (NEW.is_latest = TRUE) + EXECUTE FUNCTION set_model_as_latest(); diff --git a/schemas/07_audit_schema.sql b/schemas/07_audit_schema.sql new file mode 100644 index 0000000..ada98e7 --- /dev/null +++ b/schemas/07_audit_schema.sql @@ -0,0 +1,402 @@ +-- ============================================================================ +-- OrbiQuant IA - Esquema AUDIT +-- ============================================================================ +-- Archivo: 07_audit_schema.sql +-- Descripción: Logs de auditoría, eventos del sistema y seguridad +-- Fecha: 2025-12-05 +-- ============================================================================ + +SET search_path TO audit; + +-- ============================================================================ +-- TABLA: audit_logs +-- Descripción: Log general de acciones en el sistema +-- ============================================================================ +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Tabla afectada + table_name VARCHAR(100) NOT NULL, + record_id TEXT, + + -- Acción + action VARCHAR(20) NOT NULL, -- 'INSERT', 'UPDATE', 'DELETE' + + -- Datos + old_data JSONB, + new_data JSONB, + + -- Usuario + user_id UUID, + user_email VARCHAR(255), + + -- Contexto + ip_address INET, + user_agent TEXT, + session_id UUID, + + -- Timestamp + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para búsqueda eficiente +CREATE INDEX idx_audit_logs_table ON audit_logs(table_name); +CREATE INDEX idx_audit_logs_record ON audit_logs(table_name, record_id); +CREATE INDEX idx_audit_logs_user ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_created ON audit_logs(created_at DESC); + +-- Particionamiento por mes (para producción) +-- CREATE TABLE audit_logs_2025_01 PARTITION OF audit_logs +-- FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); + +-- ============================================================================ +-- TABLA: security_events +-- Descripción: Eventos de seguridad +-- ============================================================================ +CREATE TYPE security_event_type AS ENUM ( + 'login_success', + 'login_failed', + 'logout', + 'password_changed', + 'password_reset_requested', + 'password_reset_completed', + '2fa_enabled', + '2fa_disabled', + '2fa_failed', + 'account_locked', + 'account_unlocked', + 'suspicious_activity', + 'api_key_created', + 'api_key_revoked', + 'permission_denied', + 'rate_limit_exceeded' +); + +CREATE TYPE security_severity AS ENUM ('info', 'warning', 'error', 'critical'); + +CREATE TABLE IF NOT EXISTS security_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID REFERENCES auth.users(id), + user_email VARCHAR(255), + + -- Evento + event_type security_event_type NOT NULL, + severity security_severity DEFAULT 'info', + description TEXT, + + -- Contexto + ip_address INET, + user_agent TEXT, + location JSONB, -- {country, city, lat, lon} + + -- Datos adicionales + metadata JSONB, + + -- Estado + acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_by UUID REFERENCES auth.users(id), + acknowledged_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_security_events_user ON security_events(user_id); +CREATE INDEX idx_security_events_type ON security_events(event_type); +CREATE INDEX idx_security_events_severity ON security_events(severity); +CREATE INDEX idx_security_events_created ON security_events(created_at DESC); +CREATE INDEX idx_security_events_unack ON security_events(acknowledged) WHERE acknowledged = FALSE; + +-- ============================================================================ +-- TABLA: system_events +-- Descripción: Eventos del sistema (no de usuario) +-- ============================================================================ +CREATE TYPE system_event_type AS ENUM ( + 'service_started', + 'service_stopped', + 'service_error', + 'database_backup', + 'database_restore', + 'deployment', + 'config_changed', + 'scheduled_job_started', + 'scheduled_job_completed', + 'scheduled_job_failed', + 'integration_connected', + 'integration_disconnected', + 'integration_error', + 'alert_triggered', + 'maintenance_started', + 'maintenance_completed' +); + +CREATE TABLE IF NOT EXISTS system_events ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Evento + event_type system_event_type NOT NULL, + service_name VARCHAR(100), + description TEXT, + + -- Severidad + severity security_severity DEFAULT 'info', + + -- Detalles + details JSONB, + error_message TEXT, + stack_trace TEXT, + + -- Metadata + hostname VARCHAR(255), + environment VARCHAR(50), -- 'development', 'staging', 'production' + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_system_events_type ON system_events(event_type); +CREATE INDEX idx_system_events_service ON system_events(service_name); +CREATE INDEX idx_system_events_severity ON system_events(severity); +CREATE INDEX idx_system_events_created ON system_events(created_at DESC); + +-- ============================================================================ +-- TABLA: trading_audit +-- Descripción: Auditoría específica de operaciones de trading +-- ============================================================================ +CREATE TABLE IF NOT EXISTS trading_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Referencias + bot_id UUID, + signal_id UUID, + position_id UUID, + account_id UUID, + user_id UUID, + + -- Evento + event_type VARCHAR(50) NOT NULL, -- 'signal_generated', 'order_placed', 'order_filled', etc. + description TEXT, + + -- Datos de la operación + symbol VARCHAR(20), + direction VARCHAR(10), + lot_size DECIMAL(10,4), + price DECIMAL(20,8), + + -- Precios + entry_price DECIMAL(20,8), + stop_loss DECIMAL(20,8), + take_profit DECIMAL(20,8), + + -- Resultado + pnl DECIMAL(15,2), + outcome VARCHAR(20), + + -- Contexto del modelo + model_id UUID, + confidence DECIMAL(5,4), + amd_phase VARCHAR(20), + + -- Metadata + metadata JSONB, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_trading_audit_bot ON trading_audit(bot_id); +CREATE INDEX idx_trading_audit_signal ON trading_audit(signal_id); +CREATE INDEX idx_trading_audit_position ON trading_audit(position_id); +CREATE INDEX idx_trading_audit_account ON trading_audit(account_id); +CREATE INDEX idx_trading_audit_event ON trading_audit(event_type); +CREATE INDEX idx_trading_audit_symbol ON trading_audit(symbol); +CREATE INDEX idx_trading_audit_created ON trading_audit(created_at DESC); + +-- ============================================================================ +-- TABLA: api_request_logs +-- Descripción: Logs de requests a la API +-- ============================================================================ +CREATE TABLE IF NOT EXISTS api_request_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID, + api_key_id UUID, + + -- Request + method VARCHAR(10) NOT NULL, + path VARCHAR(500) NOT NULL, + query_params JSONB, + headers JSONB, + body_size INT, + + -- Response + status_code INT, + response_time_ms INT, + response_size INT, + + -- Contexto + ip_address INET, + user_agent TEXT, + + -- Error (si aplica) + error_message TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +-- Índices para análisis +CREATE INDEX idx_api_logs_user ON api_request_logs(user_id); +CREATE INDEX idx_api_logs_path ON api_request_logs(path); +CREATE INDEX idx_api_logs_status ON api_request_logs(status_code); +CREATE INDEX idx_api_logs_created ON api_request_logs(created_at DESC); + +-- ============================================================================ +-- TABLA: data_access_logs +-- Descripción: Log de acceso a datos sensibles +-- ============================================================================ +CREATE TABLE IF NOT EXISTS data_access_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario que accedió + user_id UUID NOT NULL REFERENCES auth.users(id), + + -- Dato accedido + resource_type VARCHAR(100) NOT NULL, -- 'user_profile', 'kyc_document', 'wallet_balance', etc. + resource_id UUID, + resource_owner_id UUID, -- Usuario dueño del dato + + -- Acción + action VARCHAR(50) NOT NULL, -- 'view', 'export', 'modify' + + -- Contexto + reason TEXT, -- Justificación del acceso + ip_address INET, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_data_access_user ON data_access_logs(user_id); +CREATE INDEX idx_data_access_resource ON data_access_logs(resource_type, resource_id); +CREATE INDEX idx_data_access_owner ON data_access_logs(resource_owner_id); +CREATE INDEX idx_data_access_created ON data_access_logs(created_at DESC); + +-- ============================================================================ +-- TABLA: compliance_logs +-- Descripción: Logs de cumplimiento regulatorio +-- ============================================================================ +CREATE TABLE IF NOT EXISTS compliance_logs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Usuario + user_id UUID REFERENCES auth.users(id), + + -- Evento + event_type VARCHAR(100) NOT NULL, + -- 'terms_accepted', 'risk_disclosure_accepted', 'kyc_submitted', 'kyc_approved', + -- 'aml_check_passed', 'suspicious_activity_flagged', etc. + + -- Detalles + description TEXT, + document_version VARCHAR(50), + document_url TEXT, + + -- Metadata + metadata JSONB, + ip_address INET, + + -- Estado de revisión + requires_review BOOLEAN DEFAULT FALSE, + reviewed_by UUID REFERENCES auth.users(id), + reviewed_at TIMESTAMPTZ, + review_notes TEXT, + + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_compliance_user ON compliance_logs(user_id); +CREATE INDEX idx_compliance_event ON compliance_logs(event_type); +CREATE INDEX idx_compliance_review ON compliance_logs(requires_review) WHERE requires_review = TRUE; +CREATE INDEX idx_compliance_created ON compliance_logs(created_at DESC); + +-- ============================================================================ +-- VISTAS DE ADMINISTRACIÓN +-- ============================================================================ + +-- Vista: Actividad reciente de usuarios +CREATE OR REPLACE VIEW admin_user_activity AS +SELECT + u.id AS user_id, + u.email, + p.first_name, + p.last_name, + u.role, + u.status, + u.last_login_at, + u.last_login_ip, + (SELECT COUNT(*) FROM audit.security_events se WHERE se.user_id = u.id AND se.created_at > NOW() - INTERVAL '24 hours') AS security_events_24h, + (SELECT COUNT(*) FROM audit.api_request_logs ar WHERE ar.user_id = u.id AND ar.created_at > NOW() - INTERVAL '24 hours') AS api_requests_24h +FROM auth.users u +LEFT JOIN public.profiles p ON p.user_id = u.id; + +-- Vista: Alertas de seguridad pendientes +CREATE OR REPLACE VIEW admin_security_alerts AS +SELECT + se.*, + u.email AS user_email, + p.first_name, + p.last_name +FROM audit.security_events se +LEFT JOIN auth.users u ON u.id = se.user_id +LEFT JOIN public.profiles p ON p.user_id = se.user_id +WHERE se.acknowledged = FALSE + AND se.severity IN ('warning', 'error', 'critical') +ORDER BY se.created_at DESC; + +-- Vista: Resumen de trading por bot +CREATE OR REPLACE VIEW admin_bot_trading_summary AS +SELECT + ta.bot_id, + b.name AS bot_name, + b.risk_profile, + DATE(ta.created_at) AS date, + COUNT(*) FILTER (WHERE ta.event_type = 'signal_generated') AS signals_generated, + COUNT(*) FILTER (WHERE ta.event_type = 'order_placed') AS orders_placed, + COUNT(*) FILTER (WHERE ta.event_type = 'order_filled') AS orders_filled, + SUM(ta.pnl) AS total_pnl, + COUNT(*) FILTER (WHERE ta.pnl > 0) AS winning_trades, + COUNT(*) FILTER (WHERE ta.pnl < 0) AS losing_trades +FROM audit.trading_audit ta +LEFT JOIN trading.bots b ON b.id = ta.bot_id +WHERE ta.bot_id IS NOT NULL +GROUP BY ta.bot_id, b.name, b.risk_profile, DATE(ta.created_at); + +-- ============================================================================ +-- FUNCIONES DE LIMPIEZA +-- ============================================================================ + +-- Función para limpiar logs antiguos +CREATE OR REPLACE FUNCTION cleanup_old_logs(retention_days INT DEFAULT 90) +RETURNS TABLE( + table_name TEXT, + rows_deleted BIGINT +) AS $$ +DECLARE + cutoff_date TIMESTAMPTZ; +BEGIN + cutoff_date := NOW() - (retention_days || ' days')::INTERVAL; + + -- API request logs (30 días por defecto) + DELETE FROM audit.api_request_logs WHERE created_at < cutoff_date; + RETURN QUERY SELECT 'api_request_logs'::TEXT, COUNT(*)::BIGINT FROM audit.api_request_logs WHERE created_at < cutoff_date; + + -- Audit logs (90 días por defecto, excepto críticos) + DELETE FROM audit.audit_logs WHERE created_at < cutoff_date; + + -- Security events (mantener indefinidamente los críticos) + DELETE FROM audit.security_events + WHERE created_at < cutoff_date + AND severity NOT IN ('error', 'critical'); + +END; +$$ LANGUAGE plpgsql; diff --git a/schemas/_MAP.md b/schemas/_MAP.md new file mode 100644 index 0000000..e30e99b --- /dev/null +++ b/schemas/_MAP.md @@ -0,0 +1,283 @@ +# _MAP - Database Schemas + +> Índice de navegación para los esquemas de base de datos +> +> **ACTUALIZADO:** 2025-12-06 - Estructura DDL completada según análisis de requisitos +> **Política:** Carga Limpia (DDL-First) - Ver DIRECTIVA-POLITICA-CARGA-LIMPIA.md +> **Total:** 63 tablas, 91 archivos SQL + +## Estructura de Archivos + +``` +apps/database/ +├── ddl/ +│ └── schemas/ +│ ├── auth/ # 10 tablas - Autenticación y usuarios +│ ├── education/ # 14 tablas - Plataforma educativa +│ ├── trading/ # 9 tablas - Trading y Paper Engine +│ ├── investment/ # 5 tablas - Cuentas PAMM +│ ├── financial/ # 8 tablas - Pagos, wallets unificadas +│ ├── ml/ # 5 tablas - Machine Learning +│ ├── llm/ # 5 tablas - LLM Agent +│ └── audit/ # 7 tablas - Auditoría y compliance +├── seeds/ +│ ├── prod/ # Seeds de producción +│ └── dev/ # Seeds de desarrollo +└── scripts/ + ├── create-database.sh + └── drop-and-recreate-database.sh +``` + +## Resumen de Schemas + +| Schema | Propósito | Tablas | Funciones | Estado | +|--------|-----------|--------|-----------|--------| +| auth | Autenticación y usuarios | 10 | 4 | ✅ Completado | +| education | Plataforma educativa | 14 | 8 | ✅ Completado | +| trading | Trading y Paper Engine | 9 | 2 | ✅ Completado | +| investment | Cuentas PAMM | 5 | - | ✅ Completado | +| financial | Wallets unificadas, pagos | 8 | 4 | ✅ Completado | +| ml | Machine Learning signals | 5 | - | ✅ Completado | +| llm | LLM Agent, RAG | 5 | - | ✅ Completado | +| audit | Logs, compliance | 7 | - | ✅ Completado | + +--- + +## Detalle por Schema + +### auth (10 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `users` | 01-users.sql | Usuarios base del sistema | +| `user_profiles` | 02-user_profiles.sql | Información extendida | +| `oauth_accounts` | 03-oauth_accounts.sql | Cuentas OAuth (Google, GitHub) | +| `sessions` | 04-sessions.sql | Sesiones activas | +| `email_verifications` | 05-email_verifications.sql | Verificación de email | +| `phone_verifications` | 06-phone_verifications.sql | Verificación de teléfono | +| `password_reset_tokens` | 07-password_reset_tokens.sql | Tokens de reset | +| `auth_logs` | 08-auth_logs.sql | Log de autenticación | +| `login_attempts` | 09-login_attempts.sql | **NUEVO** - Control de intentos | +| `rate_limiting_config` | 10-rate_limiting_config.sql | **NUEVO** - Configuración rate limit | + +**Funciones:** update_updated_at, log_auth_event, cleanup_expired_sessions, create_user_profile_trigger + +### education (14 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `categories` | 01-categories.sql | Categorías de cursos | +| `courses` | 02-courses.sql | Cursos de trading | +| `modules` | 03-modules.sql | Módulos de cursos | +| `lessons` | 04-lessons.sql | Lecciones individuales | +| `enrollments` | 05-enrollments.sql | Inscripciones | +| `progress` | 06-progress.sql | Progreso por lección | +| `quizzes` | 07-quizzes.sql | Cuestionarios | +| `quiz_questions` | 08-quiz_questions.sql | Preguntas de quiz | +| `quiz_attempts` | 09-quiz_attempts.sql | Intentos de quiz | +| `certificates` | 10-certificates.sql | Certificados emitidos | +| `user_achievements` | 11-user_achievements.sql | Logros de usuarios | +| `user_gamification_profile` | 12-user_gamification_profile.sql | **NUEVO** - Perfil gamificación | +| `user_activity_log` | 13-user_activity_log.sql | **NUEVO** - Log de actividad | +| `course_reviews` | 14-course_reviews.sql | **NUEVO** - Reseñas de cursos | + +**Funciones:** update_updated_at, update_enrollment_progress, auto_complete_enrollment, generate_certificate, update_course_stats, update_enrollment_count, update_gamification_profile, views + +### trading (9 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `symbols` | 01-symbols.sql | Instrumentos financieros | +| `watchlists` | 02-watchlists.sql | Listas de seguimiento | +| `watchlist_items` | 03-watchlist_items.sql | Items en watchlists | +| `bots` | 04-bots.sql | Agentes de trading (Atlas, Orion, Nova) | +| `orders` | 05-orders.sql | Órdenes de trading | +| `positions` | 06-positions.sql | Posiciones abiertas/cerradas | +| `trades` | 07-trades.sql | Historial de trades | +| `signals` | 08-signals.sql | **INTERFAZ ML** - Señales generadas | +| `trading_metrics` | 09-trading_metrics.sql | Métricas de rendimiento | + +**Funciones:** calculate_position_pnl, update_bot_stats + +> **Nota:** La tabla `signals` sirve como interfaz entre Trading (consume) y ML (produce), resolviendo la dependencia circular. + +### investment (5 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `products` | 01-products.sql | Productos PAMM | +| `accounts` | 02-accounts.sql | Cuentas de inversión | +| `transactions` | 03-transactions.sql | Movimientos de cuenta | +| `distributions` | 04-distributions.sql | Distribución de ganancias (80/20) | +| `risk_questionnaire` | 05-risk_questionnaire.sql | **NUEVO** - Cuestionario de riesgo | + +> **Delimitación:** Este schema solo maneja PAMM. Portfolio personal se gestiona desde dashboard (OQI-008). + +### financial (8 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `wallets` | 01-wallets.sql | **UNIFICADO** - Wallets del sistema | +| `wallet_transactions` | 02-wallet_transactions.sql | Transacciones de wallet | +| `subscriptions` | 03-subscriptions.sql | Suscripciones activas | +| `payments` | 04-payments.sql | Pagos procesados (Stripe) | +| `invoices` | 05-invoices.sql | Facturas | +| `wallet_audit_log` | 06-wallet_audit_log.sql | **NUEVO** - Auditoría de wallets | +| `currency_exchange_rates` | 07-currency_exchange_rates.sql | **NUEVO** - Tipos de cambio USD/MXN | +| `wallet_limits` | 08-wallet_limits.sql | **NUEVO** - Límites de operación | + +**Funciones:** update_wallet_balance, process_transaction, triggers, views + +> **Decisión:** USD como moneda principal, MXN mediante conversión. Ver DEC-001. + +### ml (5 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `models` | 01-models.sql | Registro de modelos ML | +| `model_versions` | 02-model_versions.sql | **NUEVO** - Versionado de modelos | +| `predictions` | 03-predictions.sql | Predicciones generadas | +| `prediction_outcomes` | 04-prediction_outcomes.sql | Resultados de predicciones | +| `feature_store` | 05-feature_store.sql | Features pre-calculadas | + +### llm (5 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `conversations` | 01-conversations.sql | Conversaciones con LLM | +| `messages` | 02-messages.sql | Mensajes de conversación | +| `user_preferences` | 03-user_preferences.sql | **NUEVO** - Preferencias de usuario | +| `user_memory` | 04-user_memory.sql | **NUEVO** - Memoria persistente | +| `embeddings` | 05-embeddings.sql | RAG - Embeddings (pgvector) | + +> **Decisión:** pgvector para embeddings. Ver DEC-004. + +### audit (7 tablas) + +| Tabla | Archivo | Descripción | +|-------|---------|-------------| +| `audit_logs` | 01-audit_logs.sql | Log general de auditoría | +| `security_events` | 02-security_events.sql | Eventos de seguridad | +| `system_events` | 03-system_events.sql | Eventos del sistema | +| `trading_audit` | 04-trading_audit.sql | Auditoría de trading | +| `api_request_logs` | 05-api_request_logs.sql | Logs de API | +| `data_access_logs` | 06-data_access_logs.sql | Acceso a datos sensibles (GDPR) | +| `compliance_logs` | 07-compliance_logs.sql | Cumplimiento regulatorio | + +--- + +## Diagrama de Relaciones + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ auth │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ +│ │ users │──│ profiles │ │ sessions │ │ oauth_accounts │ │ +│ └────┬─────┘ └──────────┘ └──────────┘ └──────────────────┘ │ +│ │ │ +└───────┼─────────────────────────────────────────────────────────────┘ + │ + ├──────────────────────────┬──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ education │ │ trading │ │ investment │ +│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ +│ │ courses │ │ │ │ bots │─┼──────────┼─│ products │ │ +│ └─────┬─────┘ │ │ └─────┬─────┘ │ │ └─────┬─────┘ │ +│ │ │ │ │ │ │ │ │ +│ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ +│ │ lessons │ │ │ │ signals◄──┼─┼── ml │ │ accounts │ │ +│ └───────────┘ │ │ └─────┬─────┘ │ │ └─────┬─────┘ │ +│ │ │ │ │ │ │ │ +│ ┌───────────┐ │ │ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ +│ │enrollments│ │ │ │ positions │─┼──────────┼─│transactions│ │ +│ └───────────┘ │ │ └───────────┘ │ │ └───────────┘ │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + │ │ │ + └──────────────────────────┼──────────────────────────┘ + │ + ▼ + ┌───────────────┐ + │ financial │ + │ ┌───────────┐ │ + │ │ wallets │◄──── UNIFICADO + │ └─────┬─────┘ │ + │ │ │ + │ ┌─────▼─────┐ │ + │ │ payments │ │ + │ └───────────┘ │ + │ │ + │ ┌───────────┐ │ + │ │subscript. │ │ + │ └───────────┘ │ + └───────────────┘ + +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ ml │ │ llm │ │ audit │ +│ ┌───────────┐ │ │ ┌───────────┐ │ │ ┌───────────┐ │ +│ │ models │ │ │ │ conversat.│ │ │ │audit_logs │ │ +│ └─────┬─────┘ │ │ └─────┬─────┘ │ │ └───────────┘ │ +│ │ │ │ │ │ │ │ +│ ┌─────▼─────┐ │ │ ┌─────▼─────┐ │ │ ┌───────────┐ │ +│ │predictions├─┼──────────┼─│ embeddings│ │ │ │sec_events │ │ +│ └───────────┘ │ signals │ └───────────┘ │ │ └───────────┘ │ +└───────────────┘ └───────────────┘ └───────────────┘ +``` + +--- + +## Orden de Ejecución (create-database.sh) + +```bash +1. Extensions (uuid-ossp, pgcrypto, pg_trgm, btree_gin, vector) +2. Schemas (auth, education, financial, trading, investment, ml, llm, audit) +3. DDL por schema en orden de dependencia: + - auth (sin dependencias) + - education (depende de auth) + - financial (depende de auth) + - trading (depende de auth) + - investment(depende de auth, trading) + - ml (depende de auth, trading) + - llm (depende de auth) + - audit (depende de auth) +4. Seeds (prod o dev) +``` + +--- + +## Comandos de Uso + +```bash +# Crear base de datos nueva +./scripts/create-database.sh + +# Eliminar y recrear (desarrollo) +./scripts/drop-and-recreate-database.sh + +# Solo cargar seeds +./scripts/create-database.sh --seeds-only --env dev +``` + +--- + +## Notas Importantes + +- **TIMESTAMPTZ**: Todas las columnas de fecha usan timezone (DEC-007) +- **gen_random_uuid()**: Para generación de UUIDs (no uuid_generate_v4()) +- **Row Level Security (RLS)**: Implementar en fase de seguridad +- **Particionamiento**: Considerar para `predictions`, `audit_logs` en producción +- **Backups**: Configurar backup incremental cada 6 horas + +--- + +## Referencias + +- [DIRECTIVA-POLITICA-CARGA-LIMPIA.md](../DIRECTIVA-POLITICA-CARGA-LIMPIA.md) +- [DECISIONES-ARQUITECTONICAS.md](../../docs/99-analisis/DECISIONES-ARQUITECTONICAS.md) +- [PLAN-IMPLEMENTACION-CORRECCIONES.md](../../docs/99-analisis/PLAN-IMPLEMENTACION-CORRECCIONES.md) + +--- +*Última actualización: 2025-12-06* +*Generado por Requirements-Analyst Agent* diff --git a/scripts/create-database.sh b/scripts/create-database.sh new file mode 100755 index 0000000..80f5546 --- /dev/null +++ b/scripts/create-database.sh @@ -0,0 +1,308 @@ +#!/bin/bash +# ============================================================================ +# CREATE DATABASE SCRIPT - OrbiQuant IA +# ============================================================================ +# +# Script de carga limpia para crear la base de datos desde DDL. +# Cumple con DIRECTIVA-POLITICA-CARGA-LIMPIA.md +# +# USO: +# ./create-database.sh # Crear BD (si no existe) +# ./create-database.sh --drop-first # Drop y recrear +# ./create-database.sh --seeds-only # Solo ejecutar seeds +# +# ============================================================================ + +set -e + +# Colores para output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuración +DB_NAME="${DB_NAME:-orbiquant}" +DB_USER="${DB_USER:-orbiquant_user}" +DB_PASSWORD="${DB_PASSWORD:-orbiquant_dev_2025}" +DB_HOST="${DB_HOST:-localhost}" +DB_PORT="${DB_PORT:-5432}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DDL_DIR="$SCRIPT_DIR/../ddl" +SEEDS_DIR="$SCRIPT_DIR/../seeds" + +# Función de logging +log() { + echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[✓]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[!]${NC} $1" +} + +log_error() { + echo -e "${RED}[✗]${NC} $1" +} + +# Función para ejecutar SQL +run_sql() { + local file=$1 + local description=$2 + + if [ -f "$file" ]; then + log "Ejecutando: $description" + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -f "$file" -q + log_success "$description" + else + log_warning "Archivo no encontrado: $file" + fi +} + +# Función para ejecutar SQL en orden +run_sql_dir() { + local dir=$1 + local description=$2 + + if [ -d "$dir" ]; then + log "Procesando directorio: $description" + for file in "$dir"/*.sql; do + if [ -f "$file" ]; then + local filename=$(basename "$file") + run_sql "$file" "$filename" + fi + done + else + log_warning "Directorio no encontrado: $dir" + fi +} + +# Verificar conexión +check_connection() { + log "Verificando conexión a PostgreSQL..." + if PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c '\q' 2>/dev/null; then + log_success "Conexión exitosa" + else + log_error "No se puede conectar a PostgreSQL" + exit 1 + fi +} + +# Drop database si existe +drop_database() { + log "Eliminando base de datos $DB_NAME si existe..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "DROP DATABASE IF EXISTS $DB_NAME;" -q + log_success "Base de datos eliminada" +} + +# Crear database +create_database() { + log "Creando base de datos $DB_NAME..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d postgres -c "CREATE DATABASE $DB_NAME WITH ENCODING 'UTF8' LC_COLLATE='en_US.UTF-8' LC_CTYPE='en_US.UTF-8' TEMPLATE=template0;" -q 2>/dev/null || true + log_success "Base de datos creada" +} + +# Crear schemas +create_schemas() { + log "Creando schemas..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF +-- Schemas principales +CREATE SCHEMA IF NOT EXISTS auth; +CREATE SCHEMA IF NOT EXISTS education; +CREATE SCHEMA IF NOT EXISTS trading; +CREATE SCHEMA IF NOT EXISTS investment; +CREATE SCHEMA IF NOT EXISTS financial; +CREATE SCHEMA IF NOT EXISTS ml; +CREATE SCHEMA IF NOT EXISTS llm; +CREATE SCHEMA IF NOT EXISTS audit; + +-- Comentarios +COMMENT ON SCHEMA auth IS 'Autenticación y usuarios'; +COMMENT ON SCHEMA education IS 'Plataforma educativa'; +COMMENT ON SCHEMA trading IS 'Trading y paper engine'; +COMMENT ON SCHEMA investment IS 'Cuentas PAMM'; +COMMENT ON SCHEMA financial IS 'Pagos, suscripciones, wallets'; +COMMENT ON SCHEMA ml IS 'Machine Learning signals'; +COMMENT ON SCHEMA llm IS 'LLM Agent'; +COMMENT ON SCHEMA audit IS 'Auditoría y logs'; + +-- Search path +ALTER DATABASE $DB_NAME SET search_path TO auth, public; +EOF + log_success "Schemas creados" +} + +# Cargar extensiones +load_extensions() { + log "Cargando extensiones..." + PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -q << EOF +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; +CREATE EXTENSION IF NOT EXISTS "pg_trgm"; +CREATE EXTENSION IF NOT EXISTS "btree_gin"; +-- CREATE EXTENSION IF NOT EXISTS "vector"; -- Descomentar cuando se instale pgvector +EOF + log_success "Extensiones cargadas" +} + +# Cargar DDL por schema +load_ddl() { + local schema=$1 + local schema_dir="$DDL_DIR/schemas/$schema" + + log "Cargando DDL para schema: $schema" + + # 1. Enums primero + if [ -f "$schema_dir/00-enums.sql" ]; then + run_sql "$schema_dir/00-enums.sql" "$schema: ENUMs" + fi + + # 2. Tablas en orden + run_sql_dir "$schema_dir/tables" "$schema: Tables" + + # 3. Funciones + run_sql_dir "$schema_dir/functions" "$schema: Functions" + + # 4. Triggers + run_sql_dir "$schema_dir/triggers" "$schema: Triggers" + + # 5. Views + run_sql_dir "$schema_dir/views" "$schema: Views" + + # 6. Indexes adicionales + if [ -f "$schema_dir/99-indexes.sql" ]; then + run_sql "$schema_dir/99-indexes.sql" "$schema: Indexes" + fi + + log_success "Schema $schema cargado" +} + +# Cargar todos los DDL +load_all_ddl() { + log "Cargando todos los DDL..." + + # Orden de carga (respeta dependencias) + local schemas=("auth" "education" "financial" "trading" "investment" "ml" "llm" "audit") + + for schema in "${schemas[@]}"; do + if [ -d "$DDL_DIR/schemas/$schema" ]; then + load_ddl "$schema" + else + log_warning "Schema $schema no tiene DDL definido" + fi + done + + log_success "Todos los DDL cargados" +} + +# Cargar seeds +load_seeds() { + local env=${1:-prod} + local seeds_path="$SEEDS_DIR/$env" + + log "Cargando seeds ($env)..." + + if [ -d "$seeds_path" ]; then + for schema_dir in "$seeds_path"/*/; do + if [ -d "$schema_dir" ]; then + local schema=$(basename "$schema_dir") + run_sql_dir "$schema_dir" "Seeds: $schema" + fi + done + log_success "Seeds cargados" + else + log_warning "No hay seeds para ambiente: $env" + fi +} + +# Validar integridad +validate_database() { + log "Validando integridad de la base de datos..." + + # Contar tablas por schema + local result=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " + SELECT schemaname, COUNT(*) + FROM pg_tables + WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') + GROUP BY schemaname + ORDER BY schemaname; + ") + + echo "" + echo "=== Resumen de Tablas por Schema ===" + echo "$result" + echo "" + + # Verificar FKs + local fk_count=$(PGPASSWORD=$DB_PASSWORD psql -h $DB_HOST -p $DB_PORT -U $DB_USER -d $DB_NAME -t -c " + SELECT COUNT(*) FROM information_schema.table_constraints WHERE constraint_type = 'FOREIGN KEY'; + ") + log_success "Foreign Keys: $fk_count" + + log_success "Validación completada" +} + +# Main +main() { + echo "" + echo "==============================================" + echo " OrbiQuant IA - Database Setup" + echo " Política de Carga Limpia (DDL-First)" + echo "==============================================" + echo "" + + local drop_first=false + local seeds_only=false + local seed_env="prod" + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --drop-first) + drop_first=true + shift + ;; + --seeds-only) + seeds_only=true + shift + ;; + --env) + seed_env=$2 + shift 2 + ;; + *) + log_error "Argumento desconocido: $1" + exit 1 + ;; + esac + done + + check_connection + + if [ "$seeds_only" = true ]; then + load_seeds "$seed_env" + exit 0 + fi + + if [ "$drop_first" = true ]; then + drop_database + fi + + create_database + create_schemas + load_extensions + load_all_ddl + load_seeds "$seed_env" + validate_database + + echo "" + log_success "Base de datos creada exitosamente!" + echo "" +} + +main "$@" diff --git a/scripts/drop-and-recreate-database.sh b/scripts/drop-and-recreate-database.sh new file mode 100755 index 0000000..1bfec57 --- /dev/null +++ b/scripts/drop-and-recreate-database.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# ============================================================================ +# DROP AND RECREATE DATABASE - OrbiQuant IA +# ============================================================================ +# +# Alias para carga limpia completa. +# Wrapper de create-database.sh con --drop-first +# +# ============================================================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "$SCRIPT_DIR/create-database.sh" --drop-first "$@" diff --git a/scripts/migrate_all_tickers.sh b/scripts/migrate_all_tickers.sh new file mode 100755 index 0000000..62f2689 --- /dev/null +++ b/scripts/migrate_all_tickers.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Migrate all tickers from MySQL to PostgreSQL +# Run in background: nohup ./migrate_all_tickers.sh > migration.log 2>&1 & + +set -e + +MYSQL_HOST="72.60.226.4" +MYSQL_USER="root" +MYSQL_PASS="AfcItz2391,." +MYSQL_DB="db_trading_meta" + +PG_USER="orbiquant_user" +PG_PASS="orbiquant_dev_2025" +PG_DB="orbiquant_trading" + +# Ticker mapping +declare -A TICKERS=( + ["C:EURUSD"]="2" + ["C:GBPUSD"]="3" + ["C:USDJPY"]="4" + ["C:USDCAD"]="5" + ["C:AUDUSD"]="6" + ["C:NZDUSD"]="7" + ["C:EURGBP"]="8" + ["C:EURAUD"]="9" + ["C:EURCHF"]="10" + ["C:GBPJPY"]="11" + ["C:GBPAUD"]="12" + ["C:GBPCAD"]="13" + ["C:GBPNZD"]="14" + ["C:AUDCAD"]="15" + ["C:AUDCHF"]="16" + ["C:AUDNZD"]="17" + ["C:XAUUSD"]="18" +) + +migrate_ticker() { + local mysql_sym=$1 + local pg_id=$2 + local tmpfile="/tmp/ticker_${pg_id}.tsv" + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting $mysql_sym (id=$pg_id)..." + + # Export from MySQL + mysql -h $MYSQL_HOST -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ + --batch --skip-column-names \ + -e "SELECT $pg_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_sym' ORDER BY date_agg" 2>/dev/null \ + > "$tmpfile" + + local count=$(wc -l < "$tmpfile") + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Exported $count rows for $mysql_sym" + + # Truncate staging and import + PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c "TRUNCATE market_data.ohlcv_5m_staging;" >/dev/null + + PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB \ + -c "\COPY market_data.ohlcv_5m_staging FROM '$tmpfile' WITH (FORMAT text, DELIMITER E'\t')" >/dev/null + + # Upsert to main table + local inserted=$(PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -t -c " + INSERT INTO market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + SELECT DISTINCT ON (ticker_id, timestamp) + ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch + FROM market_data.ohlcv_5m_staging + ON CONFLICT (ticker_id, timestamp) DO NOTHING; + SELECT COUNT(*) FROM market_data.ohlcv_5m WHERE ticker_id = $pg_id; + ") + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Completed $mysql_sym: $inserted rows in PostgreSQL" + rm -f "$tmpfile" +} + +echo "============================================" +echo "Starting full migration at $(date)" +echo "============================================" + +for mysql_sym in "${!TICKERS[@]}"; do + pg_id=${TICKERS[$mysql_sym]} + migrate_ticker "$mysql_sym" "$pg_id" +done + +echo "" +echo "============================================" +echo "Migration complete at $(date)" +echo "============================================" + +# Final verification +echo "" +echo "Final row counts:" +PGPASSWORD=$PG_PASS psql -h localhost -U $PG_USER -d $PG_DB -c " +SELECT t.symbol, COUNT(*) as rows +FROM market_data.ohlcv_5m o +JOIN market_data.tickers t ON t.id = o.ticker_id +GROUP BY t.symbol +ORDER BY t.symbol; +" diff --git a/scripts/migrate_direct.sh b/scripts/migrate_direct.sh new file mode 100755 index 0000000..5558d66 --- /dev/null +++ b/scripts/migrate_direct.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Direct MySQL to PostgreSQL migration script +# Uses mysqldump and psql for direct data transfer + +set -e + +# Configuration +MYSQL_HOST="72.60.226.4" +MYSQL_PORT="3306" +MYSQL_USER="root" +MYSQL_PASS="AfcItz2391,." +MYSQL_DB="db_trading_meta" + +PG_HOST="localhost" +PG_PORT="5432" +PG_USER="orbiquant_user" +PG_PASS="orbiquant_dev_2025" +PG_DB="orbiquant_trading" + +# Ticker mapping: MySQL symbol -> (PG symbol, ticker_id) +declare -A TICKER_MAP=( + ["X:BTCUSD"]="1" + ["C:EURUSD"]="2" + ["C:GBPUSD"]="3" + ["C:USDJPY"]="4" + ["C:USDCAD"]="5" + ["C:AUDUSD"]="6" + ["C:NZDUSD"]="7" + ["C:EURGBP"]="8" + ["C:EURAUD"]="9" + ["C:EURCHF"]="10" + ["C:GBPJPY"]="11" + ["C:GBPAUD"]="12" + ["C:GBPCAD"]="13" + ["C:GBPNZD"]="14" + ["C:AUDCAD"]="15" + ["C:AUDCHF"]="16" + ["C:AUDNZD"]="17" + ["C:XAUUSD"]="18" +) + +migrate_ticker() { + local mysql_ticker=$1 + local pg_ticker_id=$2 + + echo "Migrating $mysql_ticker (ticker_id=$pg_ticker_id)..." + + # Export from MySQL and import to PostgreSQL + mysql -h $MYSQL_HOST -P $MYSQL_PORT -u $MYSQL_USER -p"$MYSQL_PASS" $MYSQL_DB \ + -N -e "SELECT $pg_ticker_id, date_agg, open, high, low, close, volume, vwap, ts FROM tickers_agg_data WHERE ticker='$mysql_ticker' ORDER BY date_agg" \ + | PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "COPY market_data.ohlcv_5m (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) FROM STDIN WITH (FORMAT csv, DELIMITER E'\t')" + + echo "Completed $mysql_ticker" +} + +# Main +echo "Starting migration..." +echo "MySQL: $MYSQL_HOST:$MYSQL_PORT/$MYSQL_DB" +echo "PostgreSQL: $PG_HOST:$PG_PORT/$PG_DB" +echo "" + +# Migrate specific ticker or all +if [ -n "$1" ]; then + # Migrate specific ticker + mysql_ticker=$1 + pg_ticker_id=${TICKER_MAP[$mysql_ticker]} + if [ -z "$pg_ticker_id" ]; then + echo "Unknown ticker: $mysql_ticker" + exit 1 + fi + migrate_ticker "$mysql_ticker" "$pg_ticker_id" +else + # Migrate all tickers + for mysql_ticker in "${!TICKER_MAP[@]}"; do + pg_ticker_id=${TICKER_MAP[$mysql_ticker]} + migrate_ticker "$mysql_ticker" "$pg_ticker_id" + done +fi + +echo "" +echo "Migration complete!" + +# Verify counts +echo "" +echo "Verification:" +PGPASSWORD=$PG_PASS psql -h $PG_HOST -p $PG_PORT -U $PG_USER -d $PG_DB \ + -c "SELECT t.symbol, COUNT(*) as rows FROM market_data.ohlcv_5m o JOIN market_data.tickers t ON t.id = o.ticker_id GROUP BY t.symbol ORDER BY t.symbol" diff --git a/scripts/migrate_mysql_to_postgres.py b/scripts/migrate_mysql_to_postgres.py new file mode 100644 index 0000000..0e584e2 --- /dev/null +++ b/scripts/migrate_mysql_to_postgres.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +""" +MySQL to PostgreSQL Migration Script +OrbiQuant IA Trading Platform + +Migrates market data from MySQL (remote) to PostgreSQL (local). + +Usage: + python migrate_mysql_to_postgres.py --full # Full migration + python migrate_mysql_to_postgres.py --incremental # Only new data + python migrate_mysql_to_postgres.py --ticker BTCUSD # Specific ticker +""" + +import os +import sys +import argparse +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +import pandas as pd +import numpy as np +from tqdm import tqdm + +# Database connections +import mysql.connector +import psycopg2 +from psycopg2.extras import execute_values + +# Configuration +MYSQL_CONFIG = { + 'host': '72.60.226.4', + 'port': 3306, + 'user': 'root', + 'password': 'AfcItz2391,.', + 'database': 'db_trading_meta' +} + +POSTGRES_CONFIG = { + 'host': 'localhost', + 'port': 5432, + 'user': 'orbiquant_user', + 'password': 'orbiquant_dev_2025', + 'database': 'orbiquant_trading' +} + +# Ticker mapping (MySQL symbol -> asset_type) +TICKER_MAPPING = { + 'X:BTCUSD': ('BTCUSD', 'crypto', 'BTC', 'USD'), + 'C:EURUSD': ('EURUSD', 'forex', 'EUR', 'USD'), + 'C:GBPUSD': ('GBPUSD', 'forex', 'GBP', 'USD'), + 'C:USDJPY': ('USDJPY', 'forex', 'USD', 'JPY'), + 'C:USDCAD': ('USDCAD', 'forex', 'USD', 'CAD'), + 'C:AUDUSD': ('AUDUSD', 'forex', 'AUD', 'USD'), + 'C:NZDUSD': ('NZDUSD', 'forex', 'NZD', 'USD'), + 'C:EURGBP': ('EURGBP', 'forex', 'EUR', 'GBP'), + 'C:EURAUD': ('EURAUD', 'forex', 'EUR', 'AUD'), + 'C:EURCHF': ('EURCHF', 'forex', 'EUR', 'CHF'), + 'C:GBPJPY': ('GBPJPY', 'forex', 'GBP', 'JPY'), + 'C:GBPAUD': ('GBPAUD', 'forex', 'GBP', 'AUD'), + 'C:GBPCAD': ('GBPCAD', 'forex', 'GBP', 'CAD'), + 'C:GBPNZD': ('GBPNZD', 'forex', 'GBP', 'NZD'), + 'C:AUDCAD': ('AUDCAD', 'forex', 'AUD', 'CAD'), + 'C:AUDCHF': ('AUDCHF', 'forex', 'AUD', 'CHF'), + 'C:AUDNZD': ('AUDNZD', 'forex', 'AUD', 'NZD'), + 'C:XAUUSD': ('XAUUSD', 'commodity', 'XAU', 'USD'), +} + +# Logging setup +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + + +class MySQLConnection: + """MySQL connection manager.""" + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.conn = None + + def __enter__(self): + self.conn = mysql.connector.connect(**self.config) + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn: + self.conn.close() + + +class PostgresConnection: + """PostgreSQL connection manager.""" + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.conn = None + + def __enter__(self): + self.conn = psycopg2.connect(**self.config) + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + if self.conn: + self.conn.close() + + +def create_database_if_not_exists(): + """Create PostgreSQL database if it doesn't exist.""" + config = POSTGRES_CONFIG.copy() + db_name = config.pop('database') + + try: + conn = psycopg2.connect(**config, database='postgres') + conn.autocommit = True + cursor = conn.cursor() + + # Check if database exists + cursor.execute( + "SELECT 1 FROM pg_catalog.pg_database WHERE datname = %s", + (db_name,) + ) + + if not cursor.fetchone(): + cursor.execute(f'CREATE DATABASE {db_name}') + logger.info(f"Created database: {db_name}") + else: + logger.info(f"Database {db_name} already exists") + + cursor.close() + conn.close() + + except Exception as e: + logger.error(f"Error creating database: {e}") + raise + + +def run_migrations(): + """Run SQL migrations.""" + migration_file = os.path.join( + os.path.dirname(__file__), + '..', 'migrations', '001_create_market_data_schema.sql' + ) + + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + + # Read and execute migration + with open(migration_file, 'r') as f: + sql = f.read() + + try: + cursor.execute(sql) + conn.commit() + logger.info("Migrations completed successfully") + except Exception as e: + conn.rollback() + logger.warning(f"Migration error (may already exist): {e}") + + cursor.close() + + +def insert_tickers(): + """Insert ticker master data.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + + for mysql_symbol, (symbol, asset_type, base, quote) in TICKER_MAPPING.items(): + try: + cursor.execute(""" + INSERT INTO market_data.tickers + (symbol, name, asset_type, base_currency, quote_currency) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (symbol) DO NOTHING + """, (symbol, f"{base}/{quote}", asset_type, base, quote)) + except Exception as e: + logger.warning(f"Error inserting ticker {symbol}: {e}") + + conn.commit() + cursor.close() + logger.info(f"Inserted {len(TICKER_MAPPING)} tickers") + + +def get_ticker_id_map() -> Dict[str, int]: + """Get mapping of symbol to ticker_id.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + cursor.execute("SELECT symbol, id FROM market_data.tickers") + result = {row[0]: row[1] for row in cursor.fetchall()} + cursor.close() + return result + + +def get_last_timestamp(ticker_id: int) -> Optional[datetime]: + """Get the last timestamp for a ticker in PostgreSQL.""" + with PostgresConnection(POSTGRES_CONFIG) as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT MAX(timestamp) FROM market_data.ohlcv_5m + WHERE ticker_id = %s + """, (ticker_id,)) + result = cursor.fetchone()[0] + cursor.close() + return result + + +def migrate_ohlcv_data( + mysql_ticker: str, + pg_ticker_id: int, + start_date: Optional[datetime] = None, + batch_size: int = 50000 +): + """Migrate OHLCV data for a specific ticker.""" + + # Build query + query = f""" + SELECT + date_agg, + open, + high, + low, + close, + volume, + vwap, + ts + FROM tickers_agg_data + WHERE ticker = '{mysql_ticker}' + """ + + if start_date: + query += f" AND date_agg > '{start_date.strftime('%Y-%m-%d %H:%M:%S')}'" + + query += " ORDER BY date_agg" + + with MySQLConnection(MYSQL_CONFIG) as mysql_conn: + mysql_cursor = mysql_conn.cursor() + mysql_cursor.execute(query) + + total_rows = 0 + batch = [] + + with PostgresConnection(POSTGRES_CONFIG) as pg_conn: + pg_cursor = pg_conn.cursor() + + for row in tqdm(mysql_cursor, desc=f"Migrating {mysql_ticker}"): + date_agg, open_p, high, low, close, volume, vwap, ts = row + + batch.append(( + pg_ticker_id, + date_agg, + float(open_p), + float(high), + float(low), + float(close), + float(volume), + float(vwap) if vwap else None, + int(ts) if ts else None + )) + + if len(batch) >= batch_size: + insert_batch(pg_cursor, batch) + pg_conn.commit() + total_rows += len(batch) + batch = [] + + # Insert remaining + if batch: + insert_batch(pg_cursor, batch) + pg_conn.commit() + total_rows += len(batch) + + pg_cursor.close() + + mysql_cursor.close() + + logger.info(f"Migrated {total_rows} rows for {mysql_ticker}") + return total_rows + + +def insert_batch(cursor, batch: List[tuple]): + """Insert batch of OHLCV data.""" + query = """ + INSERT INTO market_data.ohlcv_5m + (ticker_id, timestamp, open, high, low, close, volume, vwap, ts_epoch) + VALUES %s + ON CONFLICT (ticker_id, timestamp) DO NOTHING + """ + execute_values(cursor, query, batch) + + +def migrate_all(incremental: bool = False, tickers: Optional[List[str]] = None): + """Migrate all data from MySQL to PostgreSQL.""" + + logger.info("Starting migration...") + + # Step 1: Create database and run migrations + create_database_if_not_exists() + run_migrations() + + # Step 2: Insert tickers + insert_tickers() + + # Step 3: Get ticker ID mapping + ticker_id_map = get_ticker_id_map() + logger.info(f"Ticker ID map: {ticker_id_map}") + + # Step 4: Migrate OHLCV data + total_migrated = 0 + + for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): + if tickers and pg_symbol not in tickers: + continue + + if pg_symbol not in ticker_id_map: + logger.warning(f"Ticker {pg_symbol} not found in PostgreSQL") + continue + + ticker_id = ticker_id_map[pg_symbol] + + # Check for incremental + start_date = None + if incremental: + start_date = get_last_timestamp(ticker_id) + if start_date: + logger.info(f"Incremental from {start_date} for {pg_symbol}") + + rows = migrate_ohlcv_data(mysql_symbol, ticker_id, start_date) + total_migrated += rows + + logger.info(f"Migration complete. Total rows: {total_migrated}") + + +def verify_migration(): + """Verify migration by comparing row counts.""" + logger.info("Verifying migration...") + + with MySQLConnection(MYSQL_CONFIG) as mysql_conn: + mysql_cursor = mysql_conn.cursor() + + with PostgresConnection(POSTGRES_CONFIG) as pg_conn: + pg_cursor = pg_conn.cursor() + + for mysql_symbol, (pg_symbol, _, _, _) in TICKER_MAPPING.items(): + # MySQL count + mysql_cursor.execute( + f"SELECT COUNT(*) FROM tickers_agg_data WHERE ticker = '{mysql_symbol}'" + ) + mysql_count = mysql_cursor.fetchone()[0] + + # PostgreSQL count + pg_cursor.execute(""" + SELECT COUNT(*) FROM market_data.ohlcv_5m o + JOIN market_data.tickers t ON t.id = o.ticker_id + WHERE t.symbol = %s + """, (pg_symbol,)) + pg_count = pg_cursor.fetchone()[0] + + status = "✅" if mysql_count == pg_count else "❌" + logger.info(f"{status} {pg_symbol}: MySQL={mysql_count}, PostgreSQL={pg_count}") + + pg_cursor.close() + mysql_cursor.close() + + +def main(): + parser = argparse.ArgumentParser(description='MySQL to PostgreSQL Migration') + parser.add_argument('--full', action='store_true', help='Full migration') + parser.add_argument('--incremental', action='store_true', help='Incremental migration') + parser.add_argument('--ticker', type=str, help='Specific ticker to migrate') + parser.add_argument('--verify', action='store_true', help='Verify migration') + parser.add_argument('--schema-only', action='store_true', help='Only create schema') + + args = parser.parse_args() + + if args.schema_only: + create_database_if_not_exists() + run_migrations() + insert_tickers() + logger.info("Schema created successfully") + return + + if args.verify: + verify_migration() + return + + tickers = [args.ticker] if args.ticker else None + incremental = args.incremental + + migrate_all(incremental=incremental, tickers=tickers) + + +if __name__ == '__main__': + main() diff --git a/scripts/validate-ddl.sh b/scripts/validate-ddl.sh new file mode 100644 index 0000000..d926a11 --- /dev/null +++ b/scripts/validate-ddl.sh @@ -0,0 +1,305 @@ +#!/bin/bash + +# ============================================================================ +# OrbiQuant IA - Trading Platform +# Script: validate-ddl.sh +# Description: Validates and executes all DDL scripts in correct order +# ============================================================================ + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Load environment variables +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DB_DIR="$(dirname "$SCRIPT_DIR")" +DDL_DIR="$DB_DIR/ddl" +ENV_FILE="$DB_DIR/.env" + +if [ -f "$ENV_FILE" ]; then + source "$ENV_FILE" +else + echo -e "${RED}Error: .env file not found at $ENV_FILE${NC}" + exit 1 +fi + +# Database connection parameters +export PGHOST="${DB_HOST:-localhost}" +export PGPORT="${DB_PORT:-5432}" +export PGDATABASE="${DB_NAME:-orbiquant_trading}" +export PGUSER="${DB_USER:-orbiquant_user}" +export PGPASSWORD="${DB_PASSWORD}" + +# Output file for combined SQL +MASTER_SQL="/tmp/orbiquant_ddl_master.sql" +ERROR_LOG="/tmp/orbiquant_ddl_errors.log" + +echo -e "${BLUE}============================================${NC}" +echo -e "${BLUE}OrbiQuant Trading Platform - DDL Validation${NC}" +echo -e "${BLUE}============================================${NC}" +echo "" + +# Check PostgreSQL connection +echo -e "${YELLOW}[1/5] Checking PostgreSQL connection...${NC}" +if pg_isready -h "$PGHOST" -p "$PGPORT" > /dev/null 2>&1; then + echo -e "${GREEN}✓ PostgreSQL is ready${NC}" +else + echo -e "${RED}✗ PostgreSQL is not ready${NC}" + exit 1 +fi + +# Check if database exists, create if not +echo -e "${YELLOW}[2/5] Checking database existence...${NC}" +DB_EXISTS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -tAc "SELECT 1 FROM pg_database WHERE datname='$PGDATABASE'") +if [ "$DB_EXISTS" != "1" ]; then + echo -e "${YELLOW}Database $PGDATABASE does not exist. Creating...${NC}" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -c "CREATE DATABASE $PGDATABASE;" + echo -e "${GREEN}✓ Database created${NC}" +else + echo -e "${GREEN}✓ Database exists${NC}" +fi + +# Generate master SQL file +echo -e "${YELLOW}[3/5] Generating master SQL script...${NC}" +cat > "$MASTER_SQL" << 'EOF' +-- ============================================================================ +-- OrbiQuant IA - Trading Platform +-- Master DDL Script - Auto-generated +-- ============================================================================ + +\set ON_ERROR_STOP on +\timing on + +-- Drop existing schemas (CASCADE will drop all dependent objects) +DO $$ +BEGIN + DROP SCHEMA IF EXISTS audit CASCADE; + DROP SCHEMA IF EXISTS llm CASCADE; + DROP SCHEMA IF EXISTS ml CASCADE; + DROP SCHEMA IF EXISTS financial CASCADE; + DROP SCHEMA IF EXISTS investment CASCADE; + DROP SCHEMA IF EXISTS trading CASCADE; + DROP SCHEMA IF EXISTS education CASCADE; + DROP SCHEMA IF EXISTS auth CASCADE; + RAISE NOTICE 'All schemas dropped successfully'; +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Error dropping schemas: %', SQLERRM; +END $$; + +EOF + +# Add extensions +echo -e " ${BLUE}→${NC} Adding extensions..." +if [ -f "$DDL_DIR/00-extensions.sql" ]; then + echo "-- Extensions" >> "$MASTER_SQL" + cat "$DDL_DIR/00-extensions.sql" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" +else + echo -e "${RED}Warning: 00-extensions.sql not found${NC}" +fi + +# Add schemas +echo -e " ${BLUE}→${NC} Adding schemas..." +if [ -f "$DDL_DIR/01-schemas.sql" ]; then + echo "-- Schemas" >> "$MASTER_SQL" + cat "$DDL_DIR/01-schemas.sql" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" +else + echo -e "${RED}Warning: 01-schemas.sql not found${NC}" +fi + +# Schema order (important for foreign key dependencies) +SCHEMAS=("auth" "education" "trading" "investment" "financial" "ml" "llm" "audit") + +# Process each schema +for schema in "${SCHEMAS[@]}"; do + echo -e " ${BLUE}→${NC} Processing schema: $schema" + + SCHEMA_DIR="$DDL_DIR/schemas/$schema" + + if [ ! -d "$SCHEMA_DIR" ]; then + echo -e "${YELLOW} Warning: Schema directory not found: $SCHEMA_DIR${NC}" + continue + fi + + echo "" >> "$MASTER_SQL" + echo "-- ============================================================================" >> "$MASTER_SQL" + echo "-- Schema: $schema" >> "$MASTER_SQL" + echo "-- ============================================================================" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + + # Add enums first (try both 00-enums.sql and 01-enums.sql) + for enum_file in "$SCHEMA_DIR/00-enums.sql" "$SCHEMA_DIR/01-enums.sql"; do + if [ -f "$enum_file" ]; then + echo " ${BLUE}→${NC} Adding enums from $(basename "$enum_file")" + echo "-- Enums for $schema" >> "$MASTER_SQL" + cat "$enum_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + break + fi + done + + # Add tables in numeric order + if [ -d "$SCHEMA_DIR/tables" ]; then + echo " ${BLUE}→${NC} Adding tables..." + for table_file in $(ls "$SCHEMA_DIR/tables/"*.sql 2>/dev/null | sort -V); do + echo " - $(basename "$table_file")" + echo "-- Table: $(basename "$table_file" .sql)" >> "$MASTER_SQL" + cat "$table_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + done + fi + + # Add functions + if [ -d "$SCHEMA_DIR/functions" ]; then + echo " ${BLUE}→${NC} Adding functions..." + for func_file in $(ls "$SCHEMA_DIR/functions/"*.sql 2>/dev/null | sort -V); do + echo " - $(basename "$func_file")" + echo "-- Function: $(basename "$func_file" .sql)" >> "$MASTER_SQL" + cat "$func_file" >> "$MASTER_SQL" + echo "" >> "$MASTER_SQL" + done + fi +done + +# Add summary query at the end +cat >> "$MASTER_SQL" << 'EOF' + +-- ============================================================================ +-- Summary: Count created objects +-- ============================================================================ + +\echo '' +\echo '============================================' +\echo 'Database Objects Summary' +\echo '============================================' + +SELECT + schemaname as schema, + COUNT(*) as table_count +FROM pg_tables +WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY schemaname +ORDER BY schemaname; + +\echo '' +\echo 'Functions by Schema:' + +SELECT + n.nspname as schema, + COUNT(*) as function_count +FROM pg_proc p +JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY n.nspname +ORDER BY n.nspname; + +\echo '' +\echo 'Enums by Schema:' + +SELECT + n.nspname as schema, + COUNT(*) as enum_count +FROM pg_type t +JOIN pg_namespace n ON t.typnamespace = n.oid +WHERE t.typtype = 'e' + AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +GROUP BY n.nspname +ORDER BY n.nspname; + +\echo '' +\echo '============================================' +\echo 'DDL Validation Complete' +\echo '============================================' + +EOF + +echo -e "${GREEN}✓ Master SQL script generated: $MASTER_SQL${NC}" + +# Execute the master SQL file +echo -e "${YELLOW}[4/5] Executing DDL scripts...${NC}" +echo -e "${BLUE}Database: $PGDATABASE${NC}" +echo -e "${BLUE}User: $PGUSER${NC}" +echo -e "${BLUE}Host: $PGHOST:$PGPORT${NC}" +echo "" + +if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -f "$MASTER_SQL" 2> "$ERROR_LOG"; then + echo "" + echo -e "${GREEN}✓ DDL execution completed successfully${NC}" + + # Show any warnings (if error log has content but exit code was 0) + if [ -s "$ERROR_LOG" ]; then + echo -e "${YELLOW}Warnings/Notices:${NC}" + cat "$ERROR_LOG" + fi +else + echo "" + echo -e "${RED}✗ DDL execution failed${NC}" + echo -e "${RED}Error details:${NC}" + cat "$ERROR_LOG" + + echo "" + echo -e "${YELLOW}Analyzing errors...${NC}" + + # Try to identify which file caused the error + if grep -q "syntax error" "$ERROR_LOG"; then + echo -e "${RED}Syntax errors found:${NC}" + grep -A 2 "syntax error" "$ERROR_LOG" + fi + + if grep -q "does not exist" "$ERROR_LOG"; then + echo -e "${RED}Missing dependencies (tables/functions):${NC}" + grep "does not exist" "$ERROR_LOG" + fi + + if grep -q "violates foreign key" "$ERROR_LOG"; then + echo -e "${RED}Foreign key violations:${NC}" + grep "violates foreign key" "$ERROR_LOG" + fi + + exit 1 +fi + +# Final validation +echo -e "${YELLOW}[5/5] Final validation...${NC}" + +# Count created objects +TOTAL_TABLES=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_tables + WHERE schemaname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +TOTAL_FUNCTIONS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_proc p + JOIN pg_namespace n ON p.pronamespace = n.oid + WHERE n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +TOTAL_ENUMS=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -tAc " + SELECT COUNT(*) + FROM pg_type t + JOIN pg_namespace n ON t.typnamespace = n.oid + WHERE t.typtype = 'e' + AND n.nspname IN ('auth', 'education', 'trading', 'investment', 'financial', 'ml', 'llm', 'audit') +") + +echo "" +echo -e "${GREEN}============================================${NC}" +echo -e "${GREEN}Validation Summary${NC}" +echo -e "${GREEN}============================================${NC}" +echo -e "Total Tables: ${GREEN}$TOTAL_TABLES${NC}" +echo -e "Total Functions: ${GREEN}$TOTAL_FUNCTIONS${NC}" +echo -e "Total Enums: ${GREEN}$TOTAL_ENUMS${NC}" +echo "" +echo -e "${GREEN}✓ All DDL scripts validated successfully${NC}" +echo "" +echo -e "Master SQL file: ${BLUE}$MASTER_SQL${NC}" +echo -e "Error log: ${BLUE}$ERROR_LOG${NC}"