[DDL] feat: Sprint 6 - Add broker_integration and portfolio_management schemas

broker_integration (5 tables):
- broker_accounts: Cuentas MT4/MT5/API conectadas
- broker_prices: Precios en tiempo real
- spread_statistics: Estadisticas historicas de spread
- price_adjustment_model: Modelos de ajuste de precio
- trade_execution: Ejecucion de ordenes

portfolio_management (6 tables):
- portfolios: Portafolios de inversion
- portfolio_accounts: Cuentas vinculadas a portafolios
- investment_goals: Metas de inversion
- rebalance_suggestions: Sugerencias de rebalanceo
- portfolio_snapshots: Snapshots historicos
- monte_carlo_projections: Proyecciones Monte Carlo

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rckrdmrd 2026-01-16 20:19:27 -06:00
parent 62c811be45
commit 4631a58b42
7 changed files with 1793 additions and 0 deletions

View File

@ -0,0 +1,255 @@
-- ============================================================================
-- SCHEMA: broker_integration
-- TABLE: broker_accounts
-- DESCRIPTION: Cuentas de broker conectadas (MT4, MT5, etc.)
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS broker_integration;
COMMENT ON SCHEMA broker_integration IS
'Integracion con brokers de trading (MT4, MT5, API de brokers)';
-- Enum para tipo de broker
DO $$ BEGIN
CREATE TYPE broker_integration.broker_type AS ENUM (
'mt4',
'mt5',
'ctrader',
'fix_api',
'rest_api',
'binance',
'interactive_brokers',
'oanda',
'alpaca',
'custom'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para tipo de cuenta
DO $$ BEGIN
CREATE TYPE broker_integration.account_type AS ENUM (
'demo',
'live',
'contest'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de conexion
DO $$ BEGIN
CREATE TYPE broker_integration.connection_status AS ENUM (
'connected',
'disconnected',
'connecting',
'error',
'maintenance',
'suspended',
'expired'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Cuentas de Broker
CREATE TABLE IF NOT EXISTS broker_integration.broker_accounts (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Broker info
broker_type broker_integration.broker_type NOT NULL,
broker_name VARCHAR(100) NOT NULL, -- "XM", "IC Markets"
broker_server VARCHAR(255), -- Server name/address
-- Cuenta
account_type broker_integration.account_type NOT NULL DEFAULT 'demo',
account_number VARCHAR(50) NOT NULL,
account_name VARCHAR(100),
-- Conexion
connection_status broker_integration.connection_status NOT NULL DEFAULT 'disconnected',
connection_provider VARCHAR(50), -- "metaapi", "direct"
provider_account_id VARCHAR(100), -- ID en el proveedor
-- Credenciales (encriptadas)
login_encrypted TEXT,
password_encrypted TEXT,
investor_password_encrypted TEXT,
api_token_encrypted TEXT,
additional_credentials JSONB DEFAULT '{}'::JSONB,
-- Configuracion
leverage INTEGER,
currency VARCHAR(10) NOT NULL DEFAULT 'USD',
timezone VARCHAR(50) DEFAULT 'UTC',
-- Balance y equity
balance DECIMAL(15, 2),
equity DECIMAL(15, 2),
margin DECIMAL(15, 2),
free_margin DECIMAL(15, 2),
margin_level DECIMAL(10, 2),
-- Configuracion de trading
enable_trading BOOLEAN NOT NULL DEFAULT FALSE,
max_positions INTEGER,
max_lot_size DECIMAL(10, 2),
min_lot_size DECIMAL(10, 4) DEFAULT 0.01,
lot_step DECIMAL(10, 4) DEFAULT 0.01,
-- Permisos
allow_copy_trading BOOLEAN NOT NULL DEFAULT FALSE,
allow_signals BOOLEAN NOT NULL DEFAULT TRUE,
allow_auto_trading BOOLEAN NOT NULL DEFAULT FALSE,
-- Restricciones
allowed_symbols VARCHAR(20)[], -- NULL = todos
blocked_symbols VARCHAR(20)[],
allowed_directions VARCHAR(10)[], -- ['long', 'short']
trading_hours JSONB,
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
-- Ultima sincronizacion
last_sync_at TIMESTAMPTZ,
last_sync_error TEXT,
sync_interval_seconds INTEGER DEFAULT 60,
-- Estadisticas
total_trades INTEGER NOT NULL DEFAULT 0,
open_positions INTEGER NOT NULL DEFAULT 0,
total_profit DECIMAL(15, 2) NOT NULL DEFAULT 0,
today_profit DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
connected_at TIMESTAMPTZ,
disconnected_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT broker_accounts_unique UNIQUE (tenant_id, broker_type, account_number)
);
COMMENT ON TABLE broker_integration.broker_accounts IS
'Cuentas de broker conectadas para trading real o demo';
COMMENT ON COLUMN broker_integration.broker_accounts.enable_trading IS
'Si TRUE, permite ejecutar ordenes reales en esta cuenta';
-- Indices
CREATE INDEX IF NOT EXISTS idx_broker_accounts_tenant
ON broker_integration.broker_accounts(tenant_id);
CREATE INDEX IF NOT EXISTS idx_broker_accounts_user
ON broker_integration.broker_accounts(user_id);
CREATE INDEX IF NOT EXISTS idx_broker_accounts_type
ON broker_integration.broker_accounts(broker_type);
CREATE INDEX IF NOT EXISTS idx_broker_accounts_status
ON broker_integration.broker_accounts(connection_status);
CREATE INDEX IF NOT EXISTS idx_broker_accounts_active
ON broker_integration.broker_accounts(user_id, is_active, connection_status)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_broker_accounts_default
ON broker_integration.broker_accounts(user_id, is_default)
WHERE is_default = TRUE;
CREATE INDEX IF NOT EXISTS idx_broker_accounts_sync
ON broker_integration.broker_accounts(last_sync_at)
WHERE is_active = TRUE AND connection_status = 'connected';
-- Funcion de timestamp
CREATE OR REPLACE FUNCTION broker_integration.update_broker_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS broker_account_updated_at ON broker_integration.broker_accounts;
CREATE TRIGGER broker_account_updated_at
BEFORE UPDATE ON broker_integration.broker_accounts
FOR EACH ROW
EXECUTE FUNCTION broker_integration.update_broker_timestamp();
-- Trigger para asegurar solo una cuenta default
CREATE OR REPLACE FUNCTION broker_integration.ensure_single_default_broker()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_default = TRUE THEN
UPDATE broker_integration.broker_accounts
SET is_default = FALSE
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_default = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS broker_account_single_default ON broker_integration.broker_accounts;
CREATE TRIGGER broker_account_single_default
BEFORE INSERT OR UPDATE OF is_default ON broker_integration.broker_accounts
FOR EACH ROW
WHEN (NEW.is_default = TRUE)
EXECUTE FUNCTION broker_integration.ensure_single_default_broker();
-- Vista de cuentas activas
CREATE OR REPLACE VIEW broker_integration.v_active_accounts AS
SELECT
id,
user_id,
broker_type,
broker_name,
account_type,
account_number,
connection_status,
balance,
equity,
leverage,
currency,
open_positions,
is_default,
last_sync_at
FROM broker_integration.broker_accounts
WHERE is_active = TRUE
ORDER BY is_default DESC, broker_name;
-- RLS Policies
ALTER TABLE broker_integration.broker_accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY broker_accounts_tenant ON broker_integration.broker_accounts
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY broker_accounts_user ON broker_integration.broker_accounts
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT USAGE ON SCHEMA broker_integration TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON broker_integration.broker_accounts TO trading_app;
GRANT SELECT ON broker_integration.broker_accounts TO trading_readonly;
GRANT SELECT ON broker_integration.v_active_accounts TO trading_app;

View File

@ -0,0 +1,96 @@
-- ============================================================================
-- SCHEMA: broker_integration
-- TABLE: broker_prices
-- DESCRIPTION: Precios en tiempo real de brokers
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Tabla de Precios de Broker
CREATE TABLE IF NOT EXISTS broker_integration.broker_prices (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
broker_account_id UUID NOT NULL REFERENCES broker_integration.broker_accounts(id) ON DELETE CASCADE,
-- Simbolo
symbol VARCHAR(20) NOT NULL,
-- Precios
bid DECIMAL(15, 8) NOT NULL,
ask DECIMAL(15, 8) NOT NULL,
spread DECIMAL(10, 4), -- En pips
spread_points INTEGER, -- En puntos
-- Volumen
bid_volume DECIMAL(15, 4),
ask_volume DECIMAL(15, 4),
-- Cambio
change_24h DECIMAL(15, 8),
change_percent_24h DECIMAL(10, 4),
high_24h DECIMAL(15, 8),
low_24h DECIMAL(15, 8),
-- Sesion
session_open DECIMAL(15, 8),
session_high DECIMAL(15, 8),
session_low DECIMAL(15, 8),
previous_close DECIMAL(15, 8),
-- Trading
is_trading_enabled BOOLEAN NOT NULL DEFAULT TRUE,
trading_session VARCHAR(20), -- 'pre_market', 'regular', 'after_hours'
-- Timestamp del broker
broker_timestamp TIMESTAMPTZ,
-- Timestamps
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT broker_prices_unique UNIQUE (broker_account_id, symbol)
);
COMMENT ON TABLE broker_integration.broker_prices IS
'Precios en tiempo real recibidos de brokers conectados';
-- Indices
CREATE INDEX IF NOT EXISTS idx_broker_prices_account
ON broker_integration.broker_prices(broker_account_id);
CREATE INDEX IF NOT EXISTS idx_broker_prices_symbol
ON broker_integration.broker_prices(symbol);
CREATE INDEX IF NOT EXISTS idx_broker_prices_received
ON broker_integration.broker_prices(received_at DESC);
-- Vista de spreads actuales
CREATE OR REPLACE VIEW broker_integration.v_current_spreads AS
SELECT
bp.symbol,
ba.broker_name,
bp.bid,
bp.ask,
bp.spread,
bp.spread_points,
bp.is_trading_enabled,
bp.received_at
FROM broker_integration.broker_prices bp
JOIN broker_integration.broker_accounts ba ON bp.broker_account_id = ba.id
WHERE ba.is_active = TRUE
ORDER BY bp.symbol, bp.spread;
-- RLS
ALTER TABLE broker_integration.broker_prices ENABLE ROW LEVEL SECURITY;
CREATE POLICY broker_prices_tenant ON broker_integration.broker_prices
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON broker_integration.broker_prices TO trading_app;
GRANT SELECT ON broker_integration.broker_prices TO trading_readonly;
GRANT SELECT ON broker_integration.v_current_spreads TO trading_app;

View File

@ -0,0 +1,259 @@
-- ============================================================================
-- SCHEMA: broker_integration
-- TABLE: spread_statistics, price_adjustment_model
-- DESCRIPTION: Estadisticas de spread y modelos de ajuste de precio
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Tabla de Estadisticas de Spread
CREATE TABLE IF NOT EXISTS broker_integration.spread_statistics (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
broker_account_id UUID REFERENCES broker_integration.broker_accounts(id) ON DELETE CASCADE,
-- Simbolo y periodo
symbol VARCHAR(20) NOT NULL,
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
period_type VARCHAR(20) NOT NULL, -- 'hourly', 'daily', 'session'
-- Estadisticas de spread
spread_min DECIMAL(10, 4),
spread_max DECIMAL(10, 4),
spread_avg DECIMAL(10, 4),
spread_median DECIMAL(10, 4),
spread_stddev DECIMAL(10, 4),
spread_percentile_95 DECIMAL(10, 4),
-- Spread por sesion
spread_asia_avg DECIMAL(10, 4),
spread_london_avg DECIMAL(10, 4),
spread_ny_avg DECIMAL(10, 4),
-- Frecuencia de widening
widening_events INTEGER NOT NULL DEFAULT 0,
max_widening_factor DECIMAL(10, 2),
-- Samples
sample_count INTEGER NOT NULL DEFAULT 0,
trading_hours_covered INTEGER, -- Horas de trading cubiertas
-- Comparacion historica
spread_vs_7d_avg DECIMAL(10, 4), -- % vs promedio 7 dias
spread_vs_30d_avg DECIMAL(10, 4), -- % vs promedio 30 dias
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
calculated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT spread_stats_unique UNIQUE (broker_account_id, symbol, period_start, period_type)
);
COMMENT ON TABLE broker_integration.spread_statistics IS
'Estadisticas historicas de spread por simbolo, broker y periodo';
-- Indices
CREATE INDEX IF NOT EXISTS idx_spread_stats_account
ON broker_integration.spread_statistics(broker_account_id);
CREATE INDEX IF NOT EXISTS idx_spread_stats_symbol
ON broker_integration.spread_statistics(symbol, period_start DESC);
CREATE INDEX IF NOT EXISTS idx_spread_stats_period
ON broker_integration.spread_statistics(period_type, period_start DESC);
-- ============================================================================
-- TABLE: price_adjustment_model
-- ============================================================================
-- Tabla de Modelos de Ajuste de Precio
CREATE TABLE IF NOT EXISTS broker_integration.price_adjustment_model (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
-- Simbolo
symbol VARCHAR(20) NOT NULL,
-- Tipo de modelo
model_type VARCHAR(50) NOT NULL DEFAULT 'spread_based',
-- Parametros del modelo
base_spread_pips DECIMAL(10, 4) NOT NULL DEFAULT 1,
slippage_avg_pips DECIMAL(10, 4) NOT NULL DEFAULT 0.2,
slippage_max_pips DECIMAL(10, 4) NOT NULL DEFAULT 2,
-- Ajustes por sesion
asia_adjustment DECIMAL(5, 4) DEFAULT 1.2, -- Multiplicador
london_adjustment DECIMAL(5, 4) DEFAULT 1.0,
ny_adjustment DECIMAL(5, 4) DEFAULT 1.0,
overlap_adjustment DECIMAL(5, 4) DEFAULT 0.9, -- London-NY overlap
-- Ajustes por volatilidad
low_volatility_adjustment DECIMAL(5, 4) DEFAULT 0.8,
high_volatility_adjustment DECIMAL(5, 4) DEFAULT 1.5,
extreme_volatility_adjustment DECIMAL(5, 4) DEFAULT 2.5,
-- Ajustes por horario
market_open_adjustment DECIMAL(5, 4) DEFAULT 1.5,
market_close_adjustment DECIMAL(5, 4) DEFAULT 1.3,
news_event_adjustment DECIMAL(5, 4) DEFAULT 2.0,
-- Limites
max_total_adjustment DECIMAL(5, 2) DEFAULT 5.0, -- Max pips de ajuste total
min_acceptable_spread DECIMAL(10, 4),
max_acceptable_spread DECIMAL(10, 4),
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
-- Validez
valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
valid_until TIMESTAMPTZ,
-- Performance
accuracy_score DECIMAL(5, 2), -- 0-100
last_accuracy_check TIMESTAMPTZ,
predictions_count INTEGER NOT NULL DEFAULT 0,
accurate_predictions INTEGER NOT NULL DEFAULT 0,
-- Metadata
training_data_range JSONB, -- {"start": "...", "end": "..."}
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT price_adj_model_unique UNIQUE (tenant_id, symbol, model_type)
);
COMMENT ON TABLE broker_integration.price_adjustment_model IS
'Modelos para ajustar precios de entrada considerando spread y slippage';
-- Indices
CREATE INDEX IF NOT EXISTS idx_price_adj_symbol
ON broker_integration.price_adjustment_model(symbol);
CREATE INDEX IF NOT EXISTS idx_price_adj_active
ON broker_integration.price_adjustment_model(symbol, is_active)
WHERE is_active = TRUE;
-- Funcion para calcular ajuste de precio
CREATE OR REPLACE FUNCTION broker_integration.calculate_price_adjustment(
p_symbol VARCHAR,
p_direction VARCHAR,
p_current_price DECIMAL,
p_session VARCHAR DEFAULT NULL,
p_volatility VARCHAR DEFAULT 'normal'
)
RETURNS TABLE (
adjusted_entry DECIMAL,
adjusted_sl DECIMAL,
adjusted_tp DECIMAL,
total_adjustment_pips DECIMAL,
adjustment_breakdown JSONB
) AS $$
DECLARE
v_model broker_integration.price_adjustment_model;
v_adjustment DECIMAL;
v_session_mult DECIMAL;
v_volatility_mult DECIMAL;
BEGIN
SELECT * INTO v_model
FROM broker_integration.price_adjustment_model
WHERE symbol = p_symbol
AND is_active = TRUE
AND (valid_until IS NULL OR valid_until > NOW())
ORDER BY valid_from DESC
LIMIT 1;
IF NOT FOUND THEN
-- Retornar sin ajuste
RETURN QUERY SELECT
p_current_price,
p_current_price,
p_current_price,
0::DECIMAL,
'{}'::JSONB;
RETURN;
END IF;
-- Determinar multiplicador de sesion
v_session_mult := CASE p_session
WHEN 'asia' THEN v_model.asia_adjustment
WHEN 'london' THEN v_model.london_adjustment
WHEN 'ny' THEN v_model.ny_adjustment
WHEN 'overlap' THEN v_model.overlap_adjustment
ELSE 1.0
END;
-- Determinar multiplicador de volatilidad
v_volatility_mult := CASE p_volatility
WHEN 'low' THEN v_model.low_volatility_adjustment
WHEN 'high' THEN v_model.high_volatility_adjustment
WHEN 'extreme' THEN v_model.extreme_volatility_adjustment
ELSE 1.0
END;
-- Calcular ajuste total en pips
v_adjustment := (v_model.base_spread_pips + v_model.slippage_avg_pips)
* v_session_mult * v_volatility_mult;
-- Limitar ajuste
v_adjustment := LEAST(v_adjustment, v_model.max_total_adjustment);
-- Convertir a precio
v_adjustment := v_adjustment * 0.0001; -- 1 pip = 0.0001
RETURN QUERY SELECT
CASE p_direction
WHEN 'long' THEN p_current_price + v_adjustment
ELSE p_current_price - v_adjustment
END,
p_current_price, -- SL sin ajustar
p_current_price, -- TP sin ajustar
v_adjustment * 10000, -- En pips
jsonb_build_object(
'base_spread', v_model.base_spread_pips,
'slippage', v_model.slippage_avg_pips,
'session_multiplier', v_session_mult,
'volatility_multiplier', v_volatility_mult
);
END;
$$ LANGUAGE plpgsql;
-- Trigger para updated_at
DROP TRIGGER IF EXISTS price_adj_updated_at ON broker_integration.price_adjustment_model;
CREATE TRIGGER price_adj_updated_at
BEFORE UPDATE ON broker_integration.price_adjustment_model
FOR EACH ROW
EXECUTE FUNCTION broker_integration.update_broker_timestamp();
-- RLS
ALTER TABLE broker_integration.spread_statistics ENABLE ROW LEVEL SECURITY;
ALTER TABLE broker_integration.price_adjustment_model ENABLE ROW LEVEL SECURITY;
CREATE POLICY spread_stats_tenant ON broker_integration.spread_statistics
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY price_adj_tenant ON broker_integration.price_adjustment_model
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON broker_integration.spread_statistics TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON broker_integration.price_adjustment_model TO trading_app;
GRANT SELECT ON broker_integration.spread_statistics TO trading_readonly;
GRANT SELECT ON broker_integration.price_adjustment_model TO trading_readonly;
GRANT EXECUTE ON FUNCTION broker_integration.calculate_price_adjustment TO trading_app;

View File

@ -0,0 +1,258 @@
-- ============================================================================
-- SCHEMA: broker_integration
-- TABLE: trade_execution
-- DESCRIPTION: Ejecucion de ordenes en broker
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para estado de ejecucion
DO $$ BEGIN
CREATE TYPE broker_integration.execution_status AS ENUM (
'pending', -- Pendiente de enviar
'submitted', -- Enviada al broker
'accepted', -- Aceptada por broker
'filled', -- Ejecutada completamente
'partial_fill', -- Ejecutada parcialmente
'rejected', -- Rechazada
'cancelled', -- Cancelada
'expired', -- Expirada
'error' -- Error de sistema
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para tipo de orden
DO $$ BEGIN
CREATE TYPE broker_integration.order_type AS ENUM (
'market',
'limit',
'stop',
'stop_limit',
'trailing_stop'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para direccion
DO $$ BEGIN
CREATE TYPE broker_integration.trade_direction AS ENUM (
'buy',
'sell'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Ejecucion de Trades
CREATE TABLE IF NOT EXISTS broker_integration.trade_execution (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Referencias
broker_account_id UUID NOT NULL REFERENCES broker_integration.broker_accounts(id) ON DELETE CASCADE,
signal_id UUID, -- Senal que origino
position_id UUID, -- Posicion resultante
-- Orden
order_type broker_integration.order_type NOT NULL,
direction broker_integration.trade_direction NOT NULL,
symbol VARCHAR(20) NOT NULL,
-- Tamaño
lot_size DECIMAL(10, 4) NOT NULL,
units INTEGER,
-- Precios solicitados
requested_price DECIMAL(15, 8),
limit_price DECIMAL(15, 8),
stop_price DECIMAL(15, 8),
-- Stop Loss / Take Profit
stop_loss DECIMAL(15, 8),
take_profit DECIMAL(15, 8),
-- Estado
status broker_integration.execution_status NOT NULL DEFAULT 'pending',
status_message TEXT,
-- Ejecucion
executed_price DECIMAL(15, 8),
executed_lot_size DECIMAL(10, 4),
slippage DECIMAL(10, 4), -- En pips
slippage_cost DECIMAL(15, 4), -- En moneda de cuenta
-- IDs del broker
broker_order_id VARCHAR(100),
broker_ticket VARCHAR(100),
broker_deal_id VARCHAR(100),
-- Timestamps de ejecucion
submitted_at TIMESTAMPTZ,
accepted_at TIMESTAMPTZ,
executed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
-- Latencia
submission_latency_ms INTEGER, -- Tiempo hasta submit
execution_latency_ms INTEGER, -- Tiempo hasta fill
total_latency_ms INTEGER,
-- Costos
commission DECIMAL(15, 4) DEFAULT 0,
swap DECIMAL(15, 4) DEFAULT 0,
spread_cost DECIMAL(15, 4) DEFAULT 0,
-- Contexto
execution_source VARCHAR(50), -- 'manual', 'signal', 'bot', 'copy_trade'
execution_context JSONB DEFAULT '{}'::JSONB,
-- Errores
error_code VARCHAR(50),
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER DEFAULT 3,
-- Validez
valid_until TIMESTAMPTZ,
time_in_force VARCHAR(20) DEFAULT 'GTC', -- 'GTC', 'IOC', 'FOK', 'GTD'
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE broker_integration.trade_execution IS
'Registro de todas las ejecuciones de ordenes en brokers conectados';
COMMENT ON COLUMN broker_integration.trade_execution.slippage IS
'Diferencia entre precio solicitado y ejecutado en pips';
-- Indices
CREATE INDEX IF NOT EXISTS idx_trade_exec_tenant
ON broker_integration.trade_execution(tenant_id);
CREATE INDEX IF NOT EXISTS idx_trade_exec_user
ON broker_integration.trade_execution(user_id);
CREATE INDEX IF NOT EXISTS idx_trade_exec_account
ON broker_integration.trade_execution(broker_account_id);
CREATE INDEX IF NOT EXISTS idx_trade_exec_status
ON broker_integration.trade_execution(status);
CREATE INDEX IF NOT EXISTS idx_trade_exec_pending
ON broker_integration.trade_execution(status, created_at)
WHERE status IN ('pending', 'submitted');
CREATE INDEX IF NOT EXISTS idx_trade_exec_symbol
ON broker_integration.trade_execution(symbol, executed_at DESC);
CREATE INDEX IF NOT EXISTS idx_trade_exec_broker_order
ON broker_integration.trade_execution(broker_order_id)
WHERE broker_order_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_trade_exec_signal
ON broker_integration.trade_execution(signal_id)
WHERE signal_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_trade_exec_created
ON broker_integration.trade_execution(created_at DESC);
-- Trigger para updated_at
DROP TRIGGER IF EXISTS trade_exec_updated_at ON broker_integration.trade_execution;
CREATE TRIGGER trade_exec_updated_at
BEFORE UPDATE ON broker_integration.trade_execution
FOR EACH ROW
EXECUTE FUNCTION broker_integration.update_broker_timestamp();
-- Trigger para calcular latencias
CREATE OR REPLACE FUNCTION broker_integration.calculate_execution_latency()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.submitted_at IS NOT NULL AND NEW.submission_latency_ms IS NULL THEN
NEW.submission_latency_ms := EXTRACT(EPOCH FROM (NEW.submitted_at - NEW.created_at)) * 1000;
END IF;
IF NEW.executed_at IS NOT NULL AND NEW.submitted_at IS NOT NULL AND NEW.execution_latency_ms IS NULL THEN
NEW.execution_latency_ms := EXTRACT(EPOCH FROM (NEW.executed_at - NEW.submitted_at)) * 1000;
END IF;
IF NEW.executed_at IS NOT NULL AND NEW.total_latency_ms IS NULL THEN
NEW.total_latency_ms := EXTRACT(EPOCH FROM (NEW.executed_at - NEW.created_at)) * 1000;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trade_exec_latency ON broker_integration.trade_execution;
CREATE TRIGGER trade_exec_latency
BEFORE UPDATE OF submitted_at, executed_at ON broker_integration.trade_execution
FOR EACH ROW
EXECUTE FUNCTION broker_integration.calculate_execution_latency();
-- Vista de ejecuciones recientes
CREATE OR REPLACE VIEW broker_integration.v_recent_executions AS
SELECT
te.id,
te.user_id,
ba.broker_name,
te.symbol,
te.direction,
te.order_type,
te.lot_size,
te.status,
te.requested_price,
te.executed_price,
te.slippage,
te.total_latency_ms,
te.created_at,
te.executed_at
FROM broker_integration.trade_execution te
JOIN broker_integration.broker_accounts ba ON te.broker_account_id = ba.id
ORDER BY te.created_at DESC;
-- Vista de estadisticas de ejecucion
CREATE OR REPLACE VIEW broker_integration.v_execution_stats AS
SELECT
ba.broker_name,
te.symbol,
COUNT(*) AS total_executions,
COUNT(*) FILTER (WHERE te.status = 'filled') AS filled,
COUNT(*) FILTER (WHERE te.status = 'rejected') AS rejected,
ROUND((COUNT(*) FILTER (WHERE te.status = 'filled')::DECIMAL
/ NULLIF(COUNT(*), 0) * 100), 2) AS fill_rate,
ROUND(AVG(te.slippage) FILTER (WHERE te.slippage IS NOT NULL)::NUMERIC, 2) AS avg_slippage,
ROUND(AVG(te.total_latency_ms) FILTER (WHERE te.total_latency_ms IS NOT NULL)::NUMERIC, 0) AS avg_latency_ms
FROM broker_integration.trade_execution te
JOIN broker_integration.broker_accounts ba ON te.broker_account_id = ba.id
WHERE te.created_at >= NOW() - INTERVAL '30 days'
GROUP BY ba.broker_name, te.symbol
ORDER BY total_executions DESC;
-- RLS
ALTER TABLE broker_integration.trade_execution ENABLE ROW LEVEL SECURITY;
CREATE POLICY trade_exec_tenant ON broker_integration.trade_execution
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY trade_exec_user ON broker_integration.trade_execution
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
-- Grants
GRANT SELECT, INSERT, UPDATE ON broker_integration.trade_execution TO trading_app;
GRANT SELECT ON broker_integration.trade_execution TO trading_readonly;
GRANT SELECT ON broker_integration.v_recent_executions TO trading_app;
GRANT SELECT ON broker_integration.v_execution_stats TO trading_app;

View File

@ -0,0 +1,310 @@
-- ============================================================================
-- SCHEMA: portfolio_management
-- TABLE: portfolios, portfolio_accounts
-- DESCRIPTION: Gestion de portafolios de inversion
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Crear schema
CREATE SCHEMA IF NOT EXISTS portfolio_management;
COMMENT ON SCHEMA portfolio_management IS
'Gestion de portafolios, metas de inversion y proyecciones';
-- Enum para tipo de portafolio
DO $$ BEGIN
CREATE TYPE portfolio_management.portfolio_type AS ENUM (
'investment', -- Inversion a largo plazo
'trading', -- Trading activo
'retirement', -- Retiro
'education', -- Fondo educativo
'emergency', -- Fondo de emergencia
'custom' -- Personalizado
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para perfil de riesgo
DO $$ BEGIN
CREATE TYPE portfolio_management.risk_profile AS ENUM (
'conservative',
'moderately_conservative',
'moderate',
'moderately_aggressive',
'aggressive'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estrategia de rebalanceo
DO $$ BEGIN
CREATE TYPE portfolio_management.rebalance_strategy AS ENUM (
'threshold', -- Por umbral de desviacion
'calendar', -- Por calendario
'tactical', -- Tactico/oportunista
'never', -- Sin rebalanceo
'custom'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Portafolios
CREATE TABLE IF NOT EXISTS portfolio_management.portfolios (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
-- Informacion basica
name VARCHAR(100) NOT NULL,
description TEXT,
type portfolio_management.portfolio_type NOT NULL DEFAULT 'investment',
risk_profile portfolio_management.risk_profile NOT NULL DEFAULT 'moderate',
-- Valor
total_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
cash_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
invested_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'USD',
-- Alocacion objetivo
target_allocation JSONB DEFAULT '{}'::JSONB, -- {"stocks": 60, "bonds": 30, "cash": 10}
current_allocation JSONB DEFAULT '{}'::JSONB,
allocation_drift DECIMAL(5, 2), -- % de desviacion
-- Rebalanceo
rebalance_strategy portfolio_management.rebalance_strategy NOT NULL DEFAULT 'threshold',
rebalance_threshold DECIMAL(5, 2) DEFAULT 5, -- % de desviacion para trigger
rebalance_frequency VARCHAR(20), -- 'monthly', 'quarterly', 'annually'
last_rebalance_at TIMESTAMPTZ,
next_rebalance_at TIMESTAMPTZ,
-- Performance
total_return DECIMAL(15, 4) NOT NULL DEFAULT 0,
total_return_percent DECIMAL(10, 4) NOT NULL DEFAULT 0,
ytd_return DECIMAL(15, 4) NOT NULL DEFAULT 0,
ytd_return_percent DECIMAL(10, 4) NOT NULL DEFAULT 0,
inception_date DATE,
-- Metricas de riesgo
volatility_annual DECIMAL(10, 4),
sharpe_ratio DECIMAL(10, 4),
sortino_ratio DECIMAL(10, 4),
max_drawdown DECIMAL(10, 4),
max_drawdown_date DATE,
beta DECIMAL(10, 4),
alpha DECIMAL(10, 4),
-- Benchmark
benchmark_symbol VARCHAR(20) DEFAULT 'SPY',
benchmark_return DECIMAL(10, 4),
active_return DECIMAL(10, 4), -- Alpha vs benchmark
-- Dividendos
total_dividends DECIMAL(15, 4) NOT NULL DEFAULT 0,
dividend_yield DECIMAL(10, 4),
reinvest_dividends BOOLEAN NOT NULL DEFAULT TRUE,
-- Meta
goal_id UUID, -- FK a investment_goals
goal_progress_percent DECIMAL(5, 2),
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
is_default BOOLEAN NOT NULL DEFAULT FALSE,
is_archived BOOLEAN NOT NULL DEFAULT FALSE,
-- Configuracion
show_in_dashboard BOOLEAN NOT NULL DEFAULT TRUE,
notify_on_rebalance BOOLEAN NOT NULL DEFAULT TRUE,
auto_invest_enabled BOOLEAN NOT NULL DEFAULT FALSE,
auto_invest_amount DECIMAL(15, 2),
auto_invest_frequency VARCHAR(20),
-- Metadata
tags VARCHAR(50)[],
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT portfolios_name_unique UNIQUE (user_id, name)
);
COMMENT ON TABLE portfolio_management.portfolios IS
'Portafolios de inversion con tracking de performance y rebalanceo';
-- ============================================================================
-- TABLE: portfolio_accounts
-- ============================================================================
-- Tabla de Cuentas en Portafolio
CREATE TABLE IF NOT EXISTS portfolio_management.portfolio_accounts (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolio_management.portfolios(id) ON DELETE CASCADE,
-- Cuenta vinculada
account_type VARCHAR(50) NOT NULL, -- 'broker', 'investment', 'wallet'
account_id UUID NOT NULL, -- ID de la cuenta
account_name VARCHAR(100),
-- Valor en este portafolio
value DECIMAL(15, 2) NOT NULL DEFAULT 0,
allocation_percent DECIMAL(5, 2), -- % del portafolio
-- Posiciones
positions_count INTEGER NOT NULL DEFAULT 0,
positions_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Estado
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sync_enabled BOOLEAN NOT NULL DEFAULT TRUE,
last_sync_at TIMESTAMPTZ,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT portfolio_accounts_unique UNIQUE (portfolio_id, account_type, account_id)
);
COMMENT ON TABLE portfolio_management.portfolio_accounts IS
'Cuentas (broker, investment, wallet) vinculadas a un portafolio';
-- Indices para portfolios
CREATE INDEX IF NOT EXISTS idx_portfolios_tenant
ON portfolio_management.portfolios(tenant_id);
CREATE INDEX IF NOT EXISTS idx_portfolios_user
ON portfolio_management.portfolios(user_id);
CREATE INDEX IF NOT EXISTS idx_portfolios_type
ON portfolio_management.portfolios(type);
CREATE INDEX IF NOT EXISTS idx_portfolios_active
ON portfolio_management.portfolios(user_id, is_active)
WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_portfolios_default
ON portfolio_management.portfolios(user_id, is_default)
WHERE is_default = TRUE;
CREATE INDEX IF NOT EXISTS idx_portfolios_rebalance
ON portfolio_management.portfolios(next_rebalance_at)
WHERE is_active = TRUE AND next_rebalance_at IS NOT NULL;
-- Indices para portfolio_accounts
CREATE INDEX IF NOT EXISTS idx_portfolio_accounts_portfolio
ON portfolio_management.portfolio_accounts(portfolio_id);
CREATE INDEX IF NOT EXISTS idx_portfolio_accounts_account
ON portfolio_management.portfolio_accounts(account_type, account_id);
-- Funcion de timestamp
CREATE OR REPLACE FUNCTION portfolio_management.update_portfolio_timestamp()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Triggers
DROP TRIGGER IF EXISTS portfolio_updated_at ON portfolio_management.portfolios;
CREATE TRIGGER portfolio_updated_at
BEFORE UPDATE ON portfolio_management.portfolios
FOR EACH ROW
EXECUTE FUNCTION portfolio_management.update_portfolio_timestamp();
DROP TRIGGER IF EXISTS portfolio_account_updated_at ON portfolio_management.portfolio_accounts;
CREATE TRIGGER portfolio_account_updated_at
BEFORE UPDATE ON portfolio_management.portfolio_accounts
FOR EACH ROW
EXECUTE FUNCTION portfolio_management.update_portfolio_timestamp();
-- Trigger para asegurar solo un portafolio default
CREATE OR REPLACE FUNCTION portfolio_management.ensure_single_default_portfolio()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_default = TRUE THEN
UPDATE portfolio_management.portfolios
SET is_default = FALSE
WHERE user_id = NEW.user_id
AND id != NEW.id
AND is_default = TRUE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS portfolio_single_default ON portfolio_management.portfolios;
CREATE TRIGGER portfolio_single_default
BEFORE INSERT OR UPDATE OF is_default ON portfolio_management.portfolios
FOR EACH ROW
WHEN (NEW.is_default = TRUE)
EXECUTE FUNCTION portfolio_management.ensure_single_default_portfolio();
-- Vista de portafolios con resumen
CREATE OR REPLACE VIEW portfolio_management.v_portfolio_summary AS
SELECT
p.id,
p.user_id,
p.name,
p.type,
p.risk_profile,
p.total_value,
p.total_return_percent,
p.ytd_return_percent,
p.sharpe_ratio,
p.max_drawdown,
p.allocation_drift,
p.is_default,
(SELECT COUNT(*) FROM portfolio_management.portfolio_accounts pa WHERE pa.portfolio_id = p.id) AS accounts_count,
p.last_rebalance_at,
p.created_at
FROM portfolio_management.portfolios p
WHERE p.is_active = TRUE
ORDER BY p.is_default DESC, p.total_value DESC;
-- RLS
ALTER TABLE portfolio_management.portfolios ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_management.portfolio_accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY portfolios_tenant ON portfolio_management.portfolios
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY portfolios_user ON portfolio_management.portfolios
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
CREATE POLICY portfolio_accounts_via_portfolio ON portfolio_management.portfolio_accounts
FOR ALL
USING (
portfolio_id IN (
SELECT id FROM portfolio_management.portfolios
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
-- Grants
GRANT USAGE ON SCHEMA portfolio_management TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.portfolios TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.portfolio_accounts TO trading_app;
GRANT SELECT ON portfolio_management.portfolios TO trading_readonly;
GRANT SELECT ON portfolio_management.portfolio_accounts TO trading_readonly;
GRANT SELECT ON portfolio_management.v_portfolio_summary TO trading_app;

View File

@ -0,0 +1,333 @@
-- ============================================================================
-- SCHEMA: portfolio_management
-- TABLE: investment_goals, rebalance_suggestions
-- DESCRIPTION: Metas de inversion y sugerencias de rebalanceo
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Enum para tipo de meta
DO $$ BEGIN
CREATE TYPE portfolio_management.goal_type AS ENUM (
'retirement',
'house',
'education',
'emergency_fund',
'vacation',
'car',
'wedding',
'wealth_building',
'financial_independence',
'custom'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Enum para estado de meta
DO $$ BEGIN
CREATE TYPE portfolio_management.goal_status AS ENUM (
'planning',
'active',
'on_track',
'behind',
'ahead',
'achieved',
'abandoned'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Metas de Inversion
CREATE TABLE IF NOT EXISTS portfolio_management.investment_goals (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE,
portfolio_id UUID REFERENCES portfolio_management.portfolios(id) ON DELETE SET NULL,
-- Informacion
name VARCHAR(100) NOT NULL,
description TEXT,
type portfolio_management.goal_type NOT NULL,
status portfolio_management.goal_status NOT NULL DEFAULT 'planning',
-- Meta financiera
target_amount DECIMAL(15, 2) NOT NULL,
current_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
initial_amount DECIMAL(15, 2) NOT NULL DEFAULT 0,
currency VARCHAR(10) NOT NULL DEFAULT 'USD',
-- Timeline
start_date DATE NOT NULL DEFAULT CURRENT_DATE,
target_date DATE NOT NULL,
months_remaining INTEGER,
-- Contribuciones
monthly_contribution DECIMAL(15, 2),
contribution_frequency VARCHAR(20) DEFAULT 'monthly',
total_contributions DECIMAL(15, 2) NOT NULL DEFAULT 0,
next_contribution_date DATE,
-- Rendimiento
expected_return_percent DECIMAL(5, 2) DEFAULT 7, -- Retorno anual esperado
actual_return_percent DECIMAL(10, 4),
inflation_rate DECIMAL(5, 2) DEFAULT 3, -- Para ajuste real
-- Progreso
progress_percent DECIMAL(5, 2) NOT NULL DEFAULT 0,
projected_amount DECIMAL(15, 2), -- Monto proyectado al target_date
projected_status portfolio_management.goal_status,
on_track BOOLEAN,
-- Gap analysis
amount_gap DECIMAL(15, 2), -- Diferencia con target
monthly_gap DECIMAL(15, 2), -- Contribucion adicional necesaria
-- Alertas
alert_behind_threshold DECIMAL(5, 2) DEFAULT 10, -- % detras para alertar
last_alert_at TIMESTAMPTZ,
-- Milestones
milestones JSONB DEFAULT '[]'::JSONB, -- [{"percent": 25, "reached_at": "..."}]
-- Icono y color
icon VARCHAR(50),
color VARCHAR(7),
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
achieved_at TIMESTAMPTZ,
-- Constraints
CONSTRAINT goals_name_unique UNIQUE (user_id, name),
CONSTRAINT goals_valid_dates CHECK (target_date > start_date)
);
COMMENT ON TABLE portfolio_management.investment_goals IS
'Metas de inversion con tracking de progreso y proyecciones';
-- ============================================================================
-- TABLE: rebalance_suggestions
-- ============================================================================
-- Enum para tipo de accion
DO $$ BEGIN
CREATE TYPE portfolio_management.rebalance_action AS ENUM (
'buy',
'sell',
'hold',
'reduce',
'increase'
);
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
-- Tabla de Sugerencias de Rebalanceo
CREATE TABLE IF NOT EXISTS portfolio_management.rebalance_suggestions (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolio_management.portfolios(id) ON DELETE CASCADE,
-- Fecha de sugerencia
suggestion_date DATE NOT NULL DEFAULT CURRENT_DATE,
-- Asset
asset_class VARCHAR(50) NOT NULL, -- 'stocks', 'bonds', 'crypto'
symbol VARCHAR(20),
asset_name VARCHAR(100),
-- Alocacion
current_allocation DECIMAL(5, 2) NOT NULL,
target_allocation DECIMAL(5, 2) NOT NULL,
deviation DECIMAL(5, 2) NOT NULL, -- current - target
-- Accion sugerida
action portfolio_management.rebalance_action NOT NULL,
suggested_amount DECIMAL(15, 2),
suggested_units DECIMAL(15, 4),
-- Impacto
estimated_cost DECIMAL(15, 4), -- Comisiones, spreads
estimated_tax DECIMAL(15, 4), -- Impuestos estimados
net_amount DECIMAL(15, 2),
-- Prioridad
priority INTEGER NOT NULL DEFAULT 50, -- 1-100, mayor = mas urgente
is_urgent BOOLEAN NOT NULL DEFAULT FALSE,
-- Razon
reason TEXT,
analysis JSONB DEFAULT '{}'::JSONB,
-- Estado
is_applied BOOLEAN NOT NULL DEFAULT FALSE,
applied_at TIMESTAMPTZ,
applied_amount DECIMAL(15, 2),
-- Expiracion
valid_until TIMESTAMPTZ,
is_expired BOOLEAN NOT NULL DEFAULT FALSE,
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
COMMENT ON TABLE portfolio_management.rebalance_suggestions IS
'Sugerencias de rebalanceo generadas para mantener alocacion objetivo';
-- Indices para investment_goals
CREATE INDEX IF NOT EXISTS idx_goals_tenant
ON portfolio_management.investment_goals(tenant_id);
CREATE INDEX IF NOT EXISTS idx_goals_user
ON portfolio_management.investment_goals(user_id);
CREATE INDEX IF NOT EXISTS idx_goals_portfolio
ON portfolio_management.investment_goals(portfolio_id)
WHERE portfolio_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_goals_status
ON portfolio_management.investment_goals(status);
CREATE INDEX IF NOT EXISTS idx_goals_target_date
ON portfolio_management.investment_goals(target_date)
WHERE status IN ('active', 'on_track', 'behind');
-- Indices para rebalance_suggestions
CREATE INDEX IF NOT EXISTS idx_rebalance_portfolio
ON portfolio_management.rebalance_suggestions(portfolio_id, suggestion_date DESC);
CREATE INDEX IF NOT EXISTS idx_rebalance_pending
ON portfolio_management.rebalance_suggestions(portfolio_id, is_applied, priority DESC)
WHERE is_applied = FALSE AND is_expired = FALSE;
-- Triggers
DROP TRIGGER IF EXISTS goal_updated_at ON portfolio_management.investment_goals;
CREATE TRIGGER goal_updated_at
BEFORE UPDATE ON portfolio_management.investment_goals
FOR EACH ROW
EXECUTE FUNCTION portfolio_management.update_portfolio_timestamp();
-- Trigger para calcular progreso de meta
CREATE OR REPLACE FUNCTION portfolio_management.calculate_goal_progress()
RETURNS TRIGGER AS $$
BEGIN
-- Calcular progreso
IF NEW.target_amount > 0 THEN
NEW.progress_percent := (NEW.current_amount / NEW.target_amount * 100);
END IF;
-- Calcular meses restantes
NEW.months_remaining := EXTRACT(MONTH FROM AGE(NEW.target_date, CURRENT_DATE))
+ EXTRACT(YEAR FROM AGE(NEW.target_date, CURRENT_DATE)) * 12;
-- Calcular gap
NEW.amount_gap := NEW.target_amount - NEW.current_amount;
-- Determinar estado
IF NEW.current_amount >= NEW.target_amount THEN
NEW.status := 'achieved';
NEW.on_track := TRUE;
ELSIF NEW.progress_percent >= (100.0 * (CURRENT_DATE - NEW.start_date)
/ (NEW.target_date - NEW.start_date)) THEN
NEW.status := 'on_track';
NEW.on_track := TRUE;
ELSE
NEW.status := 'behind';
NEW.on_track := FALSE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS goal_progress ON portfolio_management.investment_goals;
CREATE TRIGGER goal_progress
BEFORE INSERT OR UPDATE OF current_amount, target_amount, target_date
ON portfolio_management.investment_goals
FOR EACH ROW
EXECUTE FUNCTION portfolio_management.calculate_goal_progress();
-- Vista de metas activas
CREATE OR REPLACE VIEW portfolio_management.v_active_goals AS
SELECT
id,
user_id,
name,
type,
status,
target_amount,
current_amount,
progress_percent,
target_date,
months_remaining,
monthly_contribution,
on_track,
amount_gap
FROM portfolio_management.investment_goals
WHERE status NOT IN ('achieved', 'abandoned')
ORDER BY target_date;
-- Vista de sugerencias pendientes
CREATE OR REPLACE VIEW portfolio_management.v_pending_rebalances AS
SELECT
rs.id,
rs.portfolio_id,
p.name AS portfolio_name,
rs.asset_class,
rs.symbol,
rs.action,
rs.current_allocation,
rs.target_allocation,
rs.deviation,
rs.suggested_amount,
rs.priority,
rs.is_urgent,
rs.suggestion_date
FROM portfolio_management.rebalance_suggestions rs
JOIN portfolio_management.portfolios p ON rs.portfolio_id = p.id
WHERE rs.is_applied = FALSE
AND rs.is_expired = FALSE
AND (rs.valid_until IS NULL OR rs.valid_until > NOW())
ORDER BY rs.priority DESC, rs.suggestion_date DESC;
-- RLS
ALTER TABLE portfolio_management.investment_goals ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_management.rebalance_suggestions ENABLE ROW LEVEL SECURITY;
CREATE POLICY goals_tenant ON portfolio_management.investment_goals
FOR ALL
USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID);
CREATE POLICY goals_user ON portfolio_management.investment_goals
FOR ALL
USING (user_id = current_setting('app.current_user_id', true)::UUID);
CREATE POLICY rebalance_via_portfolio ON portfolio_management.rebalance_suggestions
FOR ALL
USING (
portfolio_id IN (
SELECT id FROM portfolio_management.portfolios
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.investment_goals TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.rebalance_suggestions TO trading_app;
GRANT SELECT ON portfolio_management.investment_goals TO trading_readonly;
GRANT SELECT ON portfolio_management.rebalance_suggestions TO trading_readonly;
GRANT SELECT ON portfolio_management.v_active_goals TO trading_app;
GRANT SELECT ON portfolio_management.v_pending_rebalances TO trading_app;

View File

@ -0,0 +1,282 @@
-- ============================================================================
-- SCHEMA: portfolio_management
-- TABLE: portfolio_snapshots, monte_carlo_projections
-- DESCRIPTION: Snapshots historicos y proyecciones Monte Carlo
-- VERSION: 1.0.0
-- CREATED: 2026-01-16
-- SPRINT: Sprint 6 - DDL Implementation Roadmap Q1-2026
-- ============================================================================
-- Tabla de Snapshots de Portafolio
CREATE TABLE IF NOT EXISTS portfolio_management.portfolio_snapshots (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolio_management.portfolios(id) ON DELETE CASCADE,
-- Fecha del snapshot
snapshot_date DATE NOT NULL,
snapshot_type VARCHAR(20) NOT NULL DEFAULT 'daily', -- 'daily', 'weekly', 'monthly', 'quarterly'
-- Valores
total_value DECIMAL(15, 2) NOT NULL,
cash_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
invested_value DECIMAL(15, 2) NOT NULL DEFAULT 0,
-- Performance del dia
daily_change DECIMAL(15, 4),
daily_change_percent DECIMAL(10, 4),
-- Performance acumulado
total_return DECIMAL(15, 4),
total_return_percent DECIMAL(10, 4),
ytd_return DECIMAL(15, 4),
ytd_return_percent DECIMAL(10, 4),
-- Contribuciones/Retiros del dia
contributions DECIMAL(15, 2) DEFAULT 0,
withdrawals DECIMAL(15, 2) DEFAULT 0,
net_flow DECIMAL(15, 2) DEFAULT 0,
-- Dividendos
dividends_received DECIMAL(15, 4) DEFAULT 0,
-- Alocacion
allocation JSONB NOT NULL DEFAULT '{}'::JSONB,
-- Posiciones top
top_holdings JSONB DEFAULT '[]'::JSONB, -- [{"symbol": "AAPL", "value": 1000, "percent": 10}]
positions_count INTEGER NOT NULL DEFAULT 0,
-- Metricas de riesgo (al cierre)
volatility_30d DECIMAL(10, 4),
sharpe_ratio_30d DECIMAL(10, 4),
max_drawdown_30d DECIMAL(10, 4),
beta_30d DECIMAL(10, 4),
-- Benchmark
benchmark_value DECIMAL(15, 8),
benchmark_return DECIMAL(10, 4),
active_return DECIMAL(10, 4),
-- Metadata
metadata JSONB DEFAULT '{}'::JSONB,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT snapshot_unique UNIQUE (portfolio_id, snapshot_date, snapshot_type)
);
COMMENT ON TABLE portfolio_management.portfolio_snapshots IS
'Snapshots historicos diarios/semanales/mensuales del estado del portafolio';
-- ============================================================================
-- TABLE: monte_carlo_projections
-- ============================================================================
-- Tabla de Proyecciones Monte Carlo
CREATE TABLE IF NOT EXISTS portfolio_management.monte_carlo_projections (
-- Identificadores
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID REFERENCES portfolio_management.portfolios(id) ON DELETE CASCADE,
goal_id UUID REFERENCES portfolio_management.investment_goals(id) ON DELETE CASCADE,
-- Parametros de simulacion
simulation_date DATE NOT NULL DEFAULT CURRENT_DATE,
num_simulations INTEGER NOT NULL DEFAULT 10000,
time_horizon_months INTEGER NOT NULL,
confidence_levels DECIMAL(5, 2)[] DEFAULT ARRAY[10, 25, 50, 75, 90],
-- Inputs
initial_value DECIMAL(15, 2) NOT NULL,
monthly_contribution DECIMAL(15, 2) DEFAULT 0,
expected_return DECIMAL(10, 4) NOT NULL, -- Anual
volatility DECIMAL(10, 4) NOT NULL, -- Anual
inflation_rate DECIMAL(10, 4) DEFAULT 0.03,
-- Resultados por percentil
percentile_10 DECIMAL(15, 2), -- Pesimista
percentile_25 DECIMAL(15, 2),
percentile_50 DECIMAL(15, 2), -- Mediana
percentile_75 DECIMAL(15, 2),
percentile_90 DECIMAL(15, 2), -- Optimista
-- Valor real ajustado por inflacion
real_percentile_10 DECIMAL(15, 2),
real_percentile_50 DECIMAL(15, 2),
real_percentile_90 DECIMAL(15, 2),
-- Probabilidades
prob_reach_goal DECIMAL(5, 2), -- % de alcanzar meta
prob_positive_return DECIMAL(5, 2), -- % de retorno positivo
prob_beat_inflation DECIMAL(5, 2), -- % de vencer inflacion
-- Riesgo de ruina
prob_ruin DECIMAL(5, 2), -- % de perder >50%
worst_case DECIMAL(15, 2), -- Peor caso (percentil 1)
best_case DECIMAL(15, 2), -- Mejor caso (percentil 99)
-- Drawdown esperado
expected_max_drawdown DECIMAL(10, 4),
prob_drawdown_20_plus DECIMAL(5, 2), -- % de drawdown >20%
-- Paths de simulacion (muestra)
sample_paths JSONB DEFAULT '[]'::JSONB, -- 5-10 paths de ejemplo
-- Distribucion final
final_distribution JSONB DEFAULT '{}'::JSONB, -- Histograma de valores finales
-- Sensibilidad
sensitivity_return JSONB DEFAULT '{}'::JSONB, -- Impacto de cambio en return
sensitivity_volatility JSONB DEFAULT '{}'::JSONB, -- Impacto de cambio en vol
sensitivity_contribution JSONB DEFAULT '{}'::JSONB, -- Impacto de cambio en contribucion
-- Metadata
computation_time_ms INTEGER,
random_seed BIGINT,
metadata JSONB DEFAULT '{}'::JSONB,
notes TEXT,
-- Timestamps
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '7 days',
-- Constraints
CONSTRAINT monte_carlo_portfolio_or_goal CHECK (
portfolio_id IS NOT NULL OR goal_id IS NOT NULL
)
);
COMMENT ON TABLE portfolio_management.monte_carlo_projections IS
'Proyecciones Monte Carlo para portafolios y metas de inversion';
COMMENT ON COLUMN portfolio_management.monte_carlo_projections.prob_reach_goal IS
'Probabilidad de alcanzar el objetivo basado en simulaciones';
-- Indices para snapshots
CREATE INDEX IF NOT EXISTS idx_snapshots_portfolio
ON portfolio_management.portfolio_snapshots(portfolio_id, snapshot_date DESC);
CREATE INDEX IF NOT EXISTS idx_snapshots_date
ON portfolio_management.portfolio_snapshots(snapshot_date DESC);
CREATE INDEX IF NOT EXISTS idx_snapshots_type
ON portfolio_management.portfolio_snapshots(snapshot_type, snapshot_date DESC);
-- Indice BRIN para datos temporales
CREATE INDEX IF NOT EXISTS idx_snapshots_brin
ON portfolio_management.portfolio_snapshots USING BRIN (snapshot_date);
-- Indices para monte_carlo
CREATE INDEX IF NOT EXISTS idx_monte_carlo_portfolio
ON portfolio_management.monte_carlo_projections(portfolio_id, simulation_date DESC)
WHERE portfolio_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_monte_carlo_goal
ON portfolio_management.monte_carlo_projections(goal_id, simulation_date DESC)
WHERE goal_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_monte_carlo_recent
ON portfolio_management.monte_carlo_projections(simulation_date DESC);
-- Funcion para obtener performance historico
CREATE OR REPLACE FUNCTION portfolio_management.get_portfolio_performance(
p_portfolio_id UUID,
p_start_date DATE DEFAULT NULL,
p_end_date DATE DEFAULT NULL
)
RETURNS TABLE (
snapshot_date DATE,
total_value DECIMAL,
daily_change_percent DECIMAL,
total_return_percent DECIMAL,
cumulative_return DECIMAL
) AS $$
BEGIN
RETURN QUERY
SELECT
ps.snapshot_date,
ps.total_value,
ps.daily_change_percent,
ps.total_return_percent,
SUM(ps.daily_change_percent) OVER (ORDER BY ps.snapshot_date) AS cumulative_return
FROM portfolio_management.portfolio_snapshots ps
WHERE ps.portfolio_id = p_portfolio_id
AND (p_start_date IS NULL OR ps.snapshot_date >= p_start_date)
AND (p_end_date IS NULL OR ps.snapshot_date <= p_end_date)
ORDER BY ps.snapshot_date;
END;
$$ LANGUAGE plpgsql;
-- Vista de performance mensual
CREATE OR REPLACE VIEW portfolio_management.v_monthly_performance AS
SELECT
portfolio_id,
DATE_TRUNC('month', snapshot_date)::DATE AS month,
FIRST_VALUE(total_value) OVER w AS start_value,
LAST_VALUE(total_value) OVER w AS end_value,
SUM(contributions) AS total_contributions,
SUM(dividends_received) AS total_dividends,
MAX(total_return_percent) - MIN(total_return_percent) AS monthly_return_percent
FROM portfolio_management.portfolio_snapshots
WHERE snapshot_type = 'daily'
WINDOW w AS (PARTITION BY portfolio_id, DATE_TRUNC('month', snapshot_date)
ORDER BY snapshot_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING)
GROUP BY portfolio_id, DATE_TRUNC('month', snapshot_date), total_value, snapshot_date
ORDER BY portfolio_id, month DESC;
-- Vista de ultima proyeccion
CREATE OR REPLACE VIEW portfolio_management.v_latest_projections AS
SELECT DISTINCT ON (COALESCE(portfolio_id, goal_id))
id,
portfolio_id,
goal_id,
simulation_date,
time_horizon_months,
initial_value,
percentile_10,
percentile_50,
percentile_90,
prob_reach_goal,
prob_positive_return,
expected_max_drawdown
FROM portfolio_management.monte_carlo_projections
WHERE expires_at > NOW()
ORDER BY COALESCE(portfolio_id, goal_id), simulation_date DESC;
-- RLS
ALTER TABLE portfolio_management.portfolio_snapshots ENABLE ROW LEVEL SECURITY;
ALTER TABLE portfolio_management.monte_carlo_projections ENABLE ROW LEVEL SECURITY;
CREATE POLICY snapshots_via_portfolio ON portfolio_management.portfolio_snapshots
FOR ALL
USING (
portfolio_id IN (
SELECT id FROM portfolio_management.portfolios
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
CREATE POLICY monte_carlo_via_portfolio ON portfolio_management.monte_carlo_projections
FOR ALL
USING (
portfolio_id IN (
SELECT id FROM portfolio_management.portfolios
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
OR goal_id IN (
SELECT id FROM portfolio_management.investment_goals
WHERE user_id = current_setting('app.current_user_id', true)::UUID
)
);
-- Grants
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.portfolio_snapshots TO trading_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON portfolio_management.monte_carlo_projections TO trading_app;
GRANT SELECT ON portfolio_management.portfolio_snapshots TO trading_readonly;
GRANT SELECT ON portfolio_management.monte_carlo_projections TO trading_readonly;
GRANT SELECT ON portfolio_management.v_monthly_performance TO trading_app;
GRANT SELECT ON portfolio_management.v_latest_projections TO trading_app;
GRANT EXECUTE ON FUNCTION portfolio_management.get_portfolio_performance TO trading_app;