diff --git a/ddl/schemas/trading/tables/001_symbols.sql b/ddl/schemas/trading/tables/001_symbols.sql new file mode 100644 index 0000000..e444d5f --- /dev/null +++ b/ddl/schemas/trading/tables/001_symbols.sql @@ -0,0 +1,229 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: symbols +-- DESCRIPTION: Simbolos/instrumentos habilitados para trading por tenant +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Crear schema si no existe +CREATE SCHEMA IF NOT EXISTS trading; + +-- Grant usage +GRANT USAGE ON SCHEMA trading TO trading_app; +GRANT USAGE ON SCHEMA trading TO trading_readonly; + +-- ============================================================================ +-- ENUMS COMPARTIDOS DEL SCHEMA TRADING +-- ============================================================================ + +-- Enum para direccion de trade +DO $$ BEGIN + CREATE TYPE trading.trade_direction AS ENUM ( + 'long', -- Compra / Buy + 'short' -- Venta / Sell + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de posicion +DO $$ BEGIN + CREATE TYPE trading.position_status AS ENUM ( + 'pending', -- Pendiente de ejecucion + 'open', -- Posicion abierta + 'closed', -- Cerrada manualmente + 'stopped', -- Cerrada por stop loss + 'target_hit', -- Cerrada por take profit + 'expired', -- Expirada + 'cancelled' -- Cancelada antes de ejecutar + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para tipo de orden +DO $$ BEGIN + CREATE TYPE trading.order_type AS ENUM ( + 'market', -- Orden de mercado + 'limit', -- Orden limitada + 'stop', -- Stop order + 'stop_limit' -- Stop limit order + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para timeframe +DO $$ BEGIN + CREATE TYPE trading.timeframe AS ENUM ( + 'M1', 'M5', 'M15', 'M30', -- Minutos + 'H1', 'H4', -- Horas + 'D1', 'W1', 'MN' -- Dia, Semana, Mes + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- ============================================================================ +-- TABLA: symbols +-- Simbolos habilitados por tenant (copia de market_data.tickers con config tenant) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS trading.symbols ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + ticker_id UUID NOT NULL REFERENCES market_data.tickers(id), + + -- Datos del simbolo (denormalizados para performance) + symbol VARCHAR(20) NOT NULL, + name VARCHAR(200), + type market_data.instrument_type, + + -- Configuracion por tenant + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + is_tradeable BOOLEAN NOT NULL DEFAULT TRUE, + is_visible BOOLEAN NOT NULL DEFAULT TRUE, + + -- Limites especificos del tenant + min_lot_size DECIMAL(10, 4), + max_lot_size DECIMAL(10, 4), + max_position_size DECIMAL(15, 2), -- Tamaño maximo de posicion en USD + max_daily_volume DECIMAL(15, 2), -- Volumen diario maximo + + -- Spread markup (si el tenant agrega spread) + spread_markup_pips DECIMAL(10, 4) DEFAULT 0, + + -- Comisiones del tenant + commission_per_lot DECIMAL(10, 4) DEFAULT 0, + commission_percent DECIMAL(5, 4) DEFAULT 0, + + -- Margen requerido (override del default) + margin_required_percent DECIMAL(5, 2), + max_leverage INTEGER, + + -- Trading hours override + custom_trading_hours JSONB, + + -- Categorias/tags del tenant + categories VARCHAR(50)[], + tags VARCHAR(50)[], + display_order INTEGER DEFAULT 999, + + -- Estadisticas del tenant + trade_count INTEGER NOT NULL DEFAULT 0, + total_volume DECIMAL(20, 4) NOT NULL DEFAULT 0, + last_trade_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT symbols_unique_per_tenant UNIQUE (tenant_id, ticker_id), + CONSTRAINT symbols_unique_symbol_per_tenant UNIQUE (tenant_id, symbol) +); + +COMMENT ON TABLE trading.symbols IS +'Simbolos habilitados para trading por tenant con configuracion personalizada'; + +COMMENT ON COLUMN trading.symbols.spread_markup_pips IS +'Pips adicionales de spread que el tenant agrega al precio del broker'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_symbols_tenant + ON trading.symbols(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_symbols_ticker + ON trading.symbols(ticker_id); + +CREATE INDEX IF NOT EXISTS idx_symbols_symbol + ON trading.symbols(tenant_id, symbol); + +CREATE INDEX IF NOT EXISTS idx_symbols_enabled + ON trading.symbols(tenant_id, is_enabled, is_tradeable) + WHERE is_enabled = TRUE AND is_tradeable = TRUE; + +CREATE INDEX IF NOT EXISTS idx_symbols_type + ON trading.symbols(tenant_id, type); + +-- GIN index para categorias +CREATE INDEX IF NOT EXISTS idx_symbols_categories_gin + ON trading.symbols USING GIN (categories); + +-- Trigger para updated_at +CREATE OR REPLACE FUNCTION trading.update_trading_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS symbol_updated_at ON trading.symbols; +CREATE TRIGGER symbol_updated_at + BEFORE UPDATE ON trading.symbols + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para sincronizar datos del ticker +CREATE OR REPLACE FUNCTION trading.sync_symbol_from_ticker() +RETURNS TRIGGER AS $$ +BEGIN + SELECT symbol, name, type + INTO NEW.symbol, NEW.name, NEW.type + FROM market_data.tickers + WHERE id = NEW.ticker_id; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS symbol_sync ON trading.symbols; +CREATE TRIGGER symbol_sync + BEFORE INSERT OR UPDATE OF ticker_id ON trading.symbols + FOR EACH ROW + EXECUTE FUNCTION trading.sync_symbol_from_ticker(); + +-- Vista de simbolos activos +CREATE OR REPLACE VIEW trading.v_active_symbols AS +SELECT + s.id, + s.tenant_id, + s.ticker_id, + s.symbol, + s.name, + s.type, + t.pip_size, + t.typical_spread_pips, + s.spread_markup_pips, + (COALESCE(t.typical_spread_pips, 0) + COALESCE(s.spread_markup_pips, 0)) AS total_spread_pips, + t.current_bid, + t.current_ask, + s.min_lot_size, + s.max_lot_size, + COALESCE(s.max_leverage, t.max_leverage) AS max_leverage, + s.display_order +FROM trading.symbols s +JOIN market_data.tickers t ON s.ticker_id = t.id +WHERE s.is_enabled = TRUE + AND s.is_tradeable = TRUE + AND t.status = 'active' +ORDER BY s.display_order, s.symbol; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.symbols ENABLE ROW LEVEL SECURITY; + +CREATE POLICY symbols_tenant_isolation ON trading.symbols + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.symbols TO trading_app; +GRANT SELECT ON trading.symbols TO trading_readonly; +GRANT SELECT ON trading.v_active_symbols TO trading_app; diff --git a/ddl/schemas/trading/tables/002_strategies.sql b/ddl/schemas/trading/tables/002_strategies.sql new file mode 100644 index 0000000..c53e96e --- /dev/null +++ b/ddl/schemas/trading/tables/002_strategies.sql @@ -0,0 +1,249 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: strategies +-- DESCRIPTION: Estrategias de trading definidas +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de estrategia +DO $$ BEGIN + CREATE TYPE trading.strategy_type AS ENUM ( + 'manual', -- Estrategia manual (usuario decide) + 'semi_auto', -- Semi-automatica (senales con confirmacion) + 'fully_auto', -- Totalmente automatizada + 'copy_trading' -- Copy trading de otro usuario/bot + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para metodologia de trading +DO $$ BEGIN + CREATE TYPE trading.trading_methodology AS ENUM ( + 'trend_following', -- Seguimiento de tendencia + 'mean_reversion', -- Reversion a la media + 'breakout', -- Rupturas + 'scalping', -- Scalping + 'swing', -- Swing trading + 'position', -- Position trading + 'arbitrage', -- Arbitraje + 'news_based', -- Basado en noticias + 'smc_ict', -- Smart Money Concepts / ICT + 'harmonic', -- Patrones armonicos + 'custom' -- Personalizada + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Estrategias +CREATE TABLE IF NOT EXISTS trading.strategies ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + created_by UUID REFERENCES users.users(id), + + -- Informacion basica + name VARCHAR(100) NOT NULL, + slug VARCHAR(100) NOT NULL, + description TEXT, + short_description VARCHAR(500), + + -- Clasificacion + type trading.strategy_type NOT NULL DEFAULT 'manual', + methodology trading.trading_methodology NOT NULL DEFAULT 'custom', + + -- Configuracion de riesgo + risk_config JSONB NOT NULL DEFAULT '{ + "risk_per_trade_percent": 2, + "max_daily_loss_percent": 5, + "max_drawdown_percent": 20, + "max_concurrent_trades": 3, + "risk_reward_min": 1.5 + }'::JSONB, + + -- Configuracion de entrada + entry_config JSONB NOT NULL DEFAULT '{ + "order_type": "market", + "slippage_pips": 2, + "max_spread_pips": 5, + "entry_confirmation_required": false + }'::JSONB, + + -- Configuracion de salida + exit_config JSONB NOT NULL DEFAULT '{ + "use_trailing_stop": false, + "trailing_stop_pips": 20, + "partial_close_enabled": false, + "partial_close_percent": 50, + "breakeven_enabled": false, + "breakeven_pips": 10 + }'::JSONB, + + -- Simbolos y timeframes + allowed_symbols VARCHAR(20)[], -- NULL = todos + allowed_timeframes trading.timeframe[], + primary_timeframe trading.timeframe DEFAULT 'H1', + + -- Horarios de trading + trading_hours JSONB DEFAULT '{ + "enabled": false, + "sessions": ["london", "new_york"], + "avoid_news": true, + "news_buffer_minutes": 30 + }'::JSONB, + + -- Indicadores utilizados + indicators_config JSONB DEFAULT '[]'::JSONB, + + -- Reglas de entrada (para estrategias automaticas) + entry_rules JSONB DEFAULT '[]'::JSONB, + + -- Reglas de salida + exit_rules JSONB DEFAULT '[]'::JSONB, + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_public BOOLEAN NOT NULL DEFAULT FALSE, -- Visible para otros usuarios + is_premium BOOLEAN NOT NULL DEFAULT FALSE, -- Requiere suscripcion + + -- Estadisticas + total_trades INTEGER NOT NULL DEFAULT 0, + winning_trades INTEGER NOT NULL DEFAULT 0, + losing_trades INTEGER NOT NULL DEFAULT 0, + win_rate DECIMAL(5, 2) DEFAULT 0, + profit_factor DECIMAL(10, 4) DEFAULT 0, + average_profit DECIMAL(15, 4) DEFAULT 0, + average_loss DECIMAL(15, 4) DEFAULT 0, + max_drawdown DECIMAL(15, 4) DEFAULT 0, + total_profit_loss DECIMAL(15, 4) DEFAULT 0, + + -- Backtesting + last_backtest_at TIMESTAMPTZ, + backtest_results JSONB, + + -- Version control + version INTEGER NOT NULL DEFAULT 1, + previous_version_id UUID, + + -- Metadata + tags VARCHAR(50)[], + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT strategies_unique_slug UNIQUE (tenant_id, slug), + CONSTRAINT strategies_win_rate_check CHECK (win_rate BETWEEN 0 AND 100) +); + +COMMENT ON TABLE trading.strategies IS +'Estrategias de trading con configuracion de riesgo, entrada y salida'; + +COMMENT ON COLUMN trading.strategies.risk_config IS +'Configuracion de gestion de riesgo de la estrategia'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_strategies_tenant + ON trading.strategies(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_strategies_creator + ON trading.strategies(created_by); + +CREATE INDEX IF NOT EXISTS idx_strategies_type + ON trading.strategies(type); + +CREATE INDEX IF NOT EXISTS idx_strategies_methodology + ON trading.strategies(methodology); + +CREATE INDEX IF NOT EXISTS idx_strategies_active + ON trading.strategies(tenant_id, is_active) + WHERE is_active = TRUE; + +CREATE INDEX IF NOT EXISTS idx_strategies_public + ON trading.strategies(is_public, win_rate DESC) + WHERE is_public = TRUE; + +-- GIN index para tags +CREATE INDEX IF NOT EXISTS idx_strategies_tags_gin + ON trading.strategies USING GIN (tags); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS strategy_updated_at ON trading.strategies; +CREATE TRIGGER strategy_updated_at + BEFORE UPDATE ON trading.strategies + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Funcion para calcular estadisticas de estrategia +CREATE OR REPLACE FUNCTION trading.recalculate_strategy_stats(p_strategy_id UUID) +RETURNS VOID AS $$ +DECLARE + v_stats RECORD; +BEGIN + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE profit_loss > 0) AS wins, + COUNT(*) FILTER (WHERE profit_loss < 0) AS losses, + COALESCE(AVG(profit_loss) FILTER (WHERE profit_loss > 0), 0) AS avg_profit, + COALESCE(AVG(ABS(profit_loss)) FILTER (WHERE profit_loss < 0), 0) AS avg_loss, + COALESCE(SUM(profit_loss), 0) AS total_pnl + INTO v_stats + FROM trading.positions + WHERE strategy_id = p_strategy_id + AND status IN ('closed', 'stopped', 'target_hit'); + + UPDATE trading.strategies + SET total_trades = v_stats.total, + winning_trades = v_stats.wins, + losing_trades = v_stats.losses, + win_rate = CASE WHEN v_stats.total > 0 + THEN (v_stats.wins::DECIMAL / v_stats.total * 100) + ELSE 0 END, + profit_factor = CASE WHEN v_stats.avg_loss > 0 + THEN v_stats.avg_profit / v_stats.avg_loss + ELSE 0 END, + average_profit = v_stats.avg_profit, + average_loss = v_stats.avg_loss, + total_profit_loss = v_stats.total_pnl + WHERE id = p_strategy_id; +END; +$$ LANGUAGE plpgsql; + +-- Vista de estrategias publicas +CREATE OR REPLACE VIEW trading.v_public_strategies AS +SELECT + id, + name, + description, + type, + methodology, + primary_timeframe, + total_trades, + win_rate, + profit_factor, + max_drawdown, + is_premium, + created_at +FROM trading.strategies +WHERE is_public = TRUE + AND is_active = TRUE + AND total_trades >= 10 -- Minimo de trades para mostrar +ORDER BY win_rate DESC, profit_factor DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.strategies ENABLE ROW LEVEL SECURITY; + +CREATE POLICY strategies_tenant_isolation ON trading.strategies + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.strategies TO trading_app; +GRANT SELECT ON trading.strategies TO trading_readonly; +GRANT SELECT ON trading.v_public_strategies TO trading_app; +GRANT EXECUTE ON FUNCTION trading.recalculate_strategy_stats TO trading_app; diff --git a/ddl/schemas/trading/tables/003_bots.sql b/ddl/schemas/trading/tables/003_bots.sql new file mode 100644 index 0000000..d2fc84d --- /dev/null +++ b/ddl/schemas/trading/tables/003_bots.sql @@ -0,0 +1,328 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: bots +-- DESCRIPTION: Agentes de trading automatizado (Atlas, Orion, Nova) +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para estado del bot +DO $$ BEGIN + CREATE TYPE trading.bot_status AS ENUM ( + 'inactive', -- Inactivo + 'starting', -- Iniciando + 'running', -- Ejecutando + 'paused', -- Pausado temporalmente + 'stopping', -- Deteniendo + 'error', -- Error + 'maintenance' -- En mantenimiento + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para nivel de riesgo del bot +DO $$ BEGIN + CREATE TYPE trading.bot_risk_level AS ENUM ( + 'conservative', -- Conservador (bajo riesgo) + 'moderate', -- Moderado + 'aggressive', -- Agresivo + 'ultra_aggressive' -- Ultra agresivo + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Bots de Trading +CREATE TABLE IF NOT EXISTS trading.bots ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + strategy_id UUID REFERENCES trading.strategies(id), + + -- Identificacion + code VARCHAR(20) NOT NULL, -- 'ATLAS', 'ORION', 'NOVA' + name VARCHAR(100) NOT NULL, + description TEXT, + avatar_url TEXT, + + -- Tipo y version + version VARCHAR(20) NOT NULL DEFAULT '1.0.0', + risk_level trading.bot_risk_level NOT NULL DEFAULT 'moderate', + + -- Estado + status trading.bot_status NOT NULL DEFAULT 'inactive', + status_message TEXT, + last_status_change TIMESTAMPTZ DEFAULT NOW(), + + -- Conexion MT4/MT5 + mt_account_id VARCHAR(50), + mt_server VARCHAR(100), + mt_connected BOOLEAN NOT NULL DEFAULT FALSE, + mt_last_heartbeat TIMESTAMPTZ, + + -- Configuracion de trading + trading_config JSONB NOT NULL DEFAULT '{ + "enabled": false, + "symbols": ["EURUSD", "GBPUSD", "XAUUSD"], + "timeframes": ["H1", "H4"], + "max_trades_per_day": 5, + "max_concurrent_positions": 3, + "trading_hours": { + "enabled": false, + "start": "08:00", + "end": "20:00", + "timezone": "America/New_York" + } + }'::JSONB, + + -- Configuracion de riesgo + risk_config JSONB NOT NULL DEFAULT '{ + "capital_allocation": 10000, + "risk_per_trade_percent": 1, + "max_daily_loss_percent": 3, + "max_drawdown_percent": 10, + "lot_size_mode": "fixed", + "fixed_lot_size": 0.1, + "use_compounding": false + }'::JSONB, + + -- Configuracion de senales + signal_config JSONB NOT NULL DEFAULT '{ + "min_confidence": 70, + "require_confirmation": true, + "confirmation_timeframe": "M15", + "filter_by_trend": true, + "filter_by_volatility": true + }'::JSONB, + + -- Capital y rendimiento + initial_capital DECIMAL(15, 2) NOT NULL DEFAULT 0, + current_capital DECIMAL(15, 2) NOT NULL DEFAULT 0, + allocated_capital DECIMAL(15, 2) NOT NULL DEFAULT 0, + available_margin DECIMAL(15, 2) NOT NULL DEFAULT 0, + + -- Estadisticas de rendimiento + total_trades INTEGER NOT NULL DEFAULT 0, + winning_trades INTEGER NOT NULL DEFAULT 0, + losing_trades INTEGER NOT NULL DEFAULT 0, + win_rate DECIMAL(5, 2) DEFAULT 0, + profit_factor DECIMAL(10, 4) DEFAULT 0, + sharpe_ratio DECIMAL(10, 4) DEFAULT 0, + max_drawdown DECIMAL(15, 4) DEFAULT 0, + max_drawdown_percent DECIMAL(5, 2) DEFAULT 0, + + -- P&L + total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0, + total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0, + net_profit DECIMAL(15, 4) NOT NULL DEFAULT 0, + daily_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + weekly_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + monthly_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Rendimiento porcentual + total_return_percent DECIMAL(10, 4) DEFAULT 0, + daily_return_percent DECIMAL(10, 4) DEFAULT 0, + weekly_return_percent DECIMAL(10, 4) DEFAULT 0, + monthly_return_percent DECIMAL(10, 4) DEFAULT 0, + + -- Posiciones actuales + open_positions_count INTEGER NOT NULL DEFAULT 0, + open_positions_value DECIMAL(15, 4) NOT NULL DEFAULT 0, + unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Ultima actividad + last_trade_at TIMESTAMPTZ, + last_signal_at TIMESTAMPTZ, + last_error_at TIMESTAMPTZ, + last_error_message TEXT, + + -- Subscribers (usuarios que invierten en este bot) + subscriber_count INTEGER NOT NULL DEFAULT 0, + total_aum DECIMAL(15, 2) NOT NULL DEFAULT 0, -- Assets Under Management + + -- Disponibilidad + is_public BOOLEAN NOT NULL DEFAULT FALSE, + is_premium BOOLEAN NOT NULL DEFAULT FALSE, + min_investment DECIMAL(15, 2) DEFAULT 100, + max_investment DECIMAL(15, 2), + + -- Comisiones + performance_fee_percent DECIMAL(5, 2) DEFAULT 20, -- Fee sobre ganancias + management_fee_percent DECIMAL(5, 2) DEFAULT 0, -- Fee anual sobre AUM + + -- Metadata + tags VARCHAR(50)[], + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + started_at TIMESTAMPTZ, + stopped_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT bots_unique_code UNIQUE (tenant_id, code), + CONSTRAINT bots_capital_check CHECK (current_capital >= 0), + CONSTRAINT bots_win_rate_check CHECK (win_rate BETWEEN 0 AND 100) +); + +COMMENT ON TABLE trading.bots IS +'Agentes de trading automatizado con configuracion y estadisticas'; + +COMMENT ON COLUMN trading.bots.code IS +'Codigo unico del bot: ATLAS (conservador), ORION (moderado), NOVA (agresivo)'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_bots_tenant + ON trading.bots(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_bots_code + ON trading.bots(tenant_id, code); + +CREATE INDEX IF NOT EXISTS idx_bots_status + ON trading.bots(status); + +CREATE INDEX IF NOT EXISTS idx_bots_running + ON trading.bots(tenant_id, status) + WHERE status = 'running'; + +CREATE INDEX IF NOT EXISTS idx_bots_public + ON trading.bots(is_public, net_profit DESC) + WHERE is_public = TRUE; + +CREATE INDEX IF NOT EXISTS idx_bots_performance + ON trading.bots(tenant_id, total_return_percent DESC); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS bot_updated_at ON trading.bots; +CREATE TRIGGER bot_updated_at + BEFORE UPDATE ON trading.bots + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para registrar cambios de estado +CREATE OR REPLACE FUNCTION trading.log_bot_status_change() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IS DISTINCT FROM OLD.status THEN + NEW.last_status_change := NOW(); + + IF NEW.status = 'running' AND OLD.status != 'running' THEN + NEW.started_at := NOW(); + ELSIF NEW.status IN ('inactive', 'stopped') AND OLD.status = 'running' THEN + NEW.stopped_at := NOW(); + ELSIF NEW.status = 'error' THEN + NEW.last_error_at := NOW(); + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS bot_status_change ON trading.bots; +CREATE TRIGGER bot_status_change + BEFORE UPDATE OF status ON trading.bots + FOR EACH ROW + EXECUTE FUNCTION trading.log_bot_status_change(); + +-- Funcion para actualizar estadisticas del bot +CREATE OR REPLACE FUNCTION trading.recalculate_bot_stats(p_bot_id UUID) +RETURNS VOID AS $$ +DECLARE + v_stats RECORD; +BEGIN + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE profit_loss > 0) AS wins, + COUNT(*) FILTER (WHERE profit_loss < 0) AS losses, + COALESCE(SUM(profit_loss) FILTER (WHERE profit_loss > 0), 0) AS total_profit, + COALESCE(SUM(ABS(profit_loss)) FILTER (WHERE profit_loss < 0), 0) AS total_loss, + COALESCE(SUM(profit_loss), 0) AS net_pnl + INTO v_stats + FROM trading.positions + WHERE bot_id = p_bot_id + AND status IN ('closed', 'stopped', 'target_hit'); + + UPDATE trading.bots + SET total_trades = v_stats.total, + winning_trades = v_stats.wins, + losing_trades = v_stats.losses, + win_rate = CASE WHEN v_stats.total > 0 + THEN (v_stats.wins::DECIMAL / v_stats.total * 100) + ELSE 0 END, + profit_factor = CASE WHEN v_stats.total_loss > 0 + THEN v_stats.total_profit / v_stats.total_loss + ELSE 0 END, + total_profit = v_stats.total_profit, + total_loss = v_stats.total_loss, + net_profit = v_stats.net_pnl, + total_return_percent = CASE WHEN initial_capital > 0 + THEN (v_stats.net_pnl / initial_capital * 100) + ELSE 0 END + WHERE id = p_bot_id; +END; +$$ LANGUAGE plpgsql; + +-- Vista de bots activos +CREATE OR REPLACE VIEW trading.v_active_bots AS +SELECT + id, + tenant_id, + code, + name, + description, + avatar_url, + risk_level, + status, + win_rate, + profit_factor, + total_return_percent, + max_drawdown_percent, + subscriber_count, + total_aum, + is_premium, + min_investment +FROM trading.bots +WHERE is_public = TRUE + AND status IN ('running', 'paused') +ORDER BY total_return_percent DESC; + +-- Vista de rendimiento de bots +CREATE OR REPLACE VIEW trading.v_bot_performance AS +SELECT + b.id, + b.code, + b.name, + b.status, + b.total_trades, + b.win_rate, + b.profit_factor, + b.sharpe_ratio, + b.net_profit, + b.total_return_percent, + b.daily_return_percent, + b.weekly_return_percent, + b.monthly_return_percent, + b.max_drawdown_percent, + b.open_positions_count, + b.unrealized_pnl, + b.last_trade_at +FROM trading.bots b +ORDER BY b.tenant_id, b.total_return_percent DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.bots ENABLE ROW LEVEL SECURITY; + +CREATE POLICY bots_tenant_isolation ON trading.bots + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.bots TO trading_app; +GRANT SELECT ON trading.bots TO trading_readonly; +GRANT SELECT ON trading.v_active_bots TO trading_app; +GRANT SELECT ON trading.v_bot_performance TO trading_app; +GRANT EXECUTE ON FUNCTION trading.recalculate_bot_stats TO trading_app; diff --git a/ddl/schemas/trading/tables/004_signals.sql b/ddl/schemas/trading/tables/004_signals.sql new file mode 100644 index 0000000..b1f9509 --- /dev/null +++ b/ddl/schemas/trading/tables/004_signals.sql @@ -0,0 +1,323 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: signals +-- DESCRIPTION: Senales de trading generadas por ML/bots +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de senal +DO $$ BEGIN + CREATE TYPE trading.signal_type AS ENUM ( + 'entry', -- Senal de entrada + 'exit', -- Senal de salida + 'scale_in', -- Agregar a posicion + 'scale_out', -- Reducir posicion + 'move_sl', -- Mover stop loss + 'move_tp', -- Mover take profit + 'alert' -- Alerta informativa + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de senal +DO $$ BEGIN + CREATE TYPE trading.signal_status AS ENUM ( + 'pending', -- Pendiente de validacion + 'active', -- Activa y disponible + 'executed', -- Ejecutada + 'expired', -- Expirada sin ejecutar + 'cancelled', -- Cancelada + 'invalidated' -- Invalidada por condiciones + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para fuente de senal +DO $$ BEGIN + CREATE TYPE trading.signal_source AS ENUM ( + 'ml_model', -- Modelo ML + 'technical', -- Analisis tecnico + 'fundamental', -- Analisis fundamental + 'sentiment', -- Analisis de sentimiento + 'manual', -- Analista manual + 'bot', -- Bot de trading + 'copy_trade' -- Copy trading + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Senales de Trading +CREATE TABLE IF NOT EXISTS trading.signals ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + bot_id UUID REFERENCES trading.bots(id), + strategy_id UUID REFERENCES trading.strategies(id), + + -- Clasificacion + type trading.signal_type NOT NULL DEFAULT 'entry', + source trading.signal_source NOT NULL DEFAULT 'ml_model', + status trading.signal_status NOT NULL DEFAULT 'pending', + + -- Instrumento + symbol_id UUID REFERENCES trading.symbols(id), + symbol VARCHAR(20) NOT NULL, + timeframe trading.timeframe DEFAULT 'H1', + + -- Direccion y precios + direction trading.trade_direction NOT NULL, + entry_price DECIMAL(15, 8) NOT NULL, + stop_loss DECIMAL(15, 8), + take_profit DECIMAL(15, 8), + take_profit_2 DECIMAL(15, 8), -- TP secundario + take_profit_3 DECIMAL(15, 8), -- TP terciario + + -- Risk/Reward + risk_pips DECIMAL(10, 4), + reward_pips DECIMAL(10, 4), + risk_reward_ratio DECIMAL(10, 4), + + -- Confianza y scoring + confidence DECIMAL(5, 2) NOT NULL DEFAULT 50 -- 0-100 + CHECK (confidence BETWEEN 0 AND 100), + strength VARCHAR(20), -- 'weak', 'moderate', 'strong', 'very_strong' + quality_score DECIMAL(5, 2), + + -- Modelo ML (si aplica) + model_id VARCHAR(100), + model_version VARCHAR(20), + model_prediction JSONB, -- Prediccion completa del modelo + + -- Analisis tecnico + technical_analysis JSONB DEFAULT '{}'::JSONB, -- Indicadores al momento de la senal + pattern_detected VARCHAR(100), -- Patron identificado + key_levels JSONB, -- Niveles clave S/R + + -- Contexto de mercado + market_context JSONB DEFAULT '{}'::JSONB, -- Condiciones de mercado + trend_direction VARCHAR(20), + volatility_level VARCHAR(20), + + -- Validez + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ, -- NULL = sin expiracion + invalidation_price DECIMAL(15, 8), -- Precio que invalida la senal + + -- Ejecucion + executed_at TIMESTAMPTZ, + executed_price DECIMAL(15, 8), + execution_slippage DECIMAL(10, 4), + position_id UUID, -- Posicion resultante + + -- Resultado + result VARCHAR(20), -- 'win', 'loss', 'breakeven', 'partial' + result_pips DECIMAL(10, 4), + result_amount DECIMAL(15, 4), + + -- Notificaciones + notification_sent BOOLEAN NOT NULL DEFAULT FALSE, + notification_sent_at TIMESTAMPTZ, + notification_channels JSONB DEFAULT '[]'::JSONB, + + -- Usuarios que vieron/ejecutaron + view_count INTEGER NOT NULL DEFAULT 0, + execution_count INTEGER NOT NULL DEFAULT 0, + + -- Metadata + tags VARCHAR(50)[], + notes TEXT, + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE trading.signals IS +'Senales de trading generadas por modelos ML, bots o analistas'; + +COMMENT ON COLUMN trading.signals.confidence IS +'Nivel de confianza de la senal (0-100%)'; + +COMMENT ON COLUMN trading.signals.invalidation_price IS +'Precio que invalida la senal antes de ser ejecutada'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_signals_tenant + ON trading.signals(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_signals_bot + ON trading.signals(bot_id); + +CREATE INDEX IF NOT EXISTS idx_signals_symbol + ON trading.signals(symbol, generated_at DESC); + +CREATE INDEX IF NOT EXISTS idx_signals_status + ON trading.signals(status); + +CREATE INDEX IF NOT EXISTS idx_signals_active + ON trading.signals(tenant_id, status, generated_at DESC) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_signals_type_direction + ON trading.signals(type, direction); + +CREATE INDEX IF NOT EXISTS idx_signals_confidence + ON trading.signals(confidence DESC) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_signals_valid_until + ON trading.signals(valid_until) + WHERE valid_until IS NOT NULL AND status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_signals_generated + ON trading.signals(generated_at DESC); + +-- GIN index para technical_analysis +CREATE INDEX IF NOT EXISTS idx_signals_ta_gin + ON trading.signals USING GIN (technical_analysis); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS signal_updated_at ON trading.signals; +CREATE TRIGGER signal_updated_at + BEFORE UPDATE ON trading.signals + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para calcular risk/reward +CREATE OR REPLACE FUNCTION trading.calculate_signal_rr() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.entry_price IS NOT NULL AND NEW.stop_loss IS NOT NULL THEN + IF NEW.direction = 'long' THEN + NEW.risk_pips := (NEW.entry_price - NEW.stop_loss) * 10000; -- Para forex + IF NEW.take_profit IS NOT NULL THEN + NEW.reward_pips := (NEW.take_profit - NEW.entry_price) * 10000; + END IF; + ELSE + NEW.risk_pips := (NEW.stop_loss - NEW.entry_price) * 10000; + IF NEW.take_profit IS NOT NULL THEN + NEW.reward_pips := (NEW.entry_price - NEW.take_profit) * 10000; + END IF; + END IF; + + IF NEW.risk_pips > 0 AND NEW.reward_pips IS NOT NULL THEN + NEW.risk_reward_ratio := NEW.reward_pips / NEW.risk_pips; + END IF; + END IF; + + -- Determinar strength basado en confidence + NEW.strength := CASE + WHEN NEW.confidence >= 85 THEN 'very_strong' + WHEN NEW.confidence >= 70 THEN 'strong' + WHEN NEW.confidence >= 55 THEN 'moderate' + ELSE 'weak' + END; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS signal_calc_rr ON trading.signals; +CREATE TRIGGER signal_calc_rr + BEFORE INSERT OR UPDATE OF entry_price, stop_loss, take_profit, confidence ON trading.signals + FOR EACH ROW + EXECUTE FUNCTION trading.calculate_signal_rr(); + +-- Trigger para expirar senales +CREATE OR REPLACE FUNCTION trading.expire_old_signals() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE trading.signals + SET status = 'expired' + WHERE status = 'active' + AND valid_until IS NOT NULL + AND valid_until < NOW(); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para actualizar estadisticas del bot cuando se procesa senal +CREATE OR REPLACE FUNCTION trading.update_bot_on_signal() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.bot_id IS NOT NULL THEN + UPDATE trading.bots + SET last_signal_at = NOW() + WHERE id = NEW.bot_id; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS signal_bot_update ON trading.signals; +CREATE TRIGGER signal_bot_update + AFTER INSERT ON trading.signals + FOR EACH ROW + EXECUTE FUNCTION trading.update_bot_on_signal(); + +-- Vista de senales activas +CREATE OR REPLACE VIEW trading.v_active_signals AS +SELECT + s.id, + s.tenant_id, + s.bot_id, + b.name AS bot_name, + s.symbol, + s.timeframe, + s.type, + s.direction, + s.entry_price, + s.stop_loss, + s.take_profit, + s.risk_reward_ratio, + s.confidence, + s.strength, + s.pattern_detected, + s.valid_until, + s.generated_at +FROM trading.signals s +LEFT JOIN trading.bots b ON s.bot_id = b.id +WHERE s.status = 'active' + AND (s.valid_until IS NULL OR s.valid_until > NOW()) +ORDER BY s.confidence DESC, s.generated_at DESC; + +-- Vista de rendimiento de senales por simbolo +CREATE OR REPLACE VIEW trading.v_signal_performance AS +SELECT + symbol, + COUNT(*) AS total_signals, + COUNT(*) FILTER (WHERE result = 'win') AS wins, + COUNT(*) FILTER (WHERE result = 'loss') AS losses, + ROUND(AVG(confidence)::NUMERIC, 2) AS avg_confidence, + ROUND(AVG(risk_reward_ratio)::NUMERIC, 2) AS avg_rr, + ROUND((COUNT(*) FILTER (WHERE result = 'win')::DECIMAL / NULLIF(COUNT(*) FILTER (WHERE result IN ('win', 'loss')), 0) * 100)::NUMERIC, 2) AS win_rate +FROM trading.signals +WHERE status IN ('executed', 'expired') + AND result IS NOT NULL +GROUP BY symbol +ORDER BY win_rate DESC NULLS LAST; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.signals ENABLE ROW LEVEL SECURITY; + +CREATE POLICY signals_tenant_isolation ON trading.signals + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON trading.signals TO trading_app; +GRANT SELECT ON trading.signals TO trading_readonly; +GRANT SELECT ON trading.v_active_signals TO trading_app; +GRANT SELECT ON trading.v_signal_performance TO trading_app; +GRANT EXECUTE ON FUNCTION trading.expire_old_signals TO trading_app; diff --git a/ddl/schemas/trading/tables/005_positions.sql b/ddl/schemas/trading/tables/005_positions.sql new file mode 100644 index 0000000..a0f86ca --- /dev/null +++ b/ddl/schemas/trading/tables/005_positions.sql @@ -0,0 +1,395 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: positions +-- DESCRIPTION: Posiciones de trading (abiertas y cerradas) +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Posiciones de Trading +CREATE TABLE IF NOT EXISTS trading.positions ( + -- 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 + bot_id UUID REFERENCES trading.bots(id), + strategy_id UUID REFERENCES trading.strategies(id), + signal_id UUID REFERENCES trading.signals(id), + symbol_id UUID REFERENCES trading.symbols(id), + + -- Identificadores externos + external_ticket VARCHAR(50), -- Ticket del broker/MT4 + external_order_id VARCHAR(50), + + -- Instrumento + symbol VARCHAR(20) NOT NULL, + + -- Direccion y estado + direction trading.trade_direction NOT NULL, + status trading.position_status NOT NULL DEFAULT 'pending', + order_type trading.order_type NOT NULL DEFAULT 'market', + + -- Tamaño + lot_size DECIMAL(10, 4) NOT NULL, + units INTEGER, -- Unidades/cantidad + + -- Precios de entrada + requested_price DECIMAL(15, 8), + entry_price DECIMAL(15, 8), + slippage DECIMAL(15, 8), + + -- Stop Loss + stop_loss DECIMAL(15, 8), + stop_loss_pips DECIMAL(10, 4), + original_stop_loss DECIMAL(15, 8), + trailing_stop_enabled BOOLEAN NOT NULL DEFAULT FALSE, + trailing_stop_distance DECIMAL(10, 4), + + -- Take Profit + take_profit DECIMAL(15, 8), + take_profit_pips DECIMAL(10, 4), + take_profit_2 DECIMAL(15, 8), + take_profit_3 DECIMAL(15, 8), + + -- Breakeven + breakeven_activated BOOLEAN NOT NULL DEFAULT FALSE, + breakeven_trigger_pips DECIMAL(10, 4), + breakeven_offset_pips DECIMAL(10, 4), + + -- Precios de cierre + exit_price DECIMAL(15, 8), + exit_reason VARCHAR(50), -- 'manual', 'stop_loss', 'take_profit', 'trailing_stop', 'margin_call' + + -- Profit/Loss + profit_loss DECIMAL(15, 4), -- P&L en moneda de cuenta + profit_loss_pips DECIMAL(10, 4), + profit_loss_percent DECIMAL(10, 4), + + -- Comisiones y costos + commission DECIMAL(15, 4) DEFAULT 0, + swap DECIMAL(15, 4) DEFAULT 0, -- Swap overnight + spread_cost DECIMAL(15, 4) DEFAULT 0, + total_cost DECIMAL(15, 4) DEFAULT 0, + net_profit DECIMAL(15, 4), -- P&L - costos + + -- Margen + margin_used DECIMAL(15, 4), + leverage_used INTEGER, + + -- Risk management + risk_amount DECIMAL(15, 4), -- Monto en riesgo + risk_percent DECIMAL(5, 2), -- % de cuenta en riesgo + reward_amount DECIMAL(15, 4), + risk_reward_ratio DECIMAL(10, 4), + + -- Tiempo + duration_seconds INTEGER, + duration_formatted VARCHAR(50), -- "2h 30m" + + -- Partial closes + partial_closes JSONB DEFAULT '[]'::JSONB, -- [{ "lot_size": 0.05, "price": 1.1234, "pnl": 50, "at": "..." }] + remaining_lot_size DECIMAL(10, 4), + + -- Escalado + scale_ins JSONB DEFAULT '[]'::JSONB, -- Adiciones a la posicion + average_entry_price DECIMAL(15, 8), + + -- Precio actual (para posiciones abiertas) + current_price DECIMAL(15, 8), + unrealized_pnl DECIMAL(15, 4), + unrealized_pnl_pips DECIMAL(10, 4), + price_updated_at TIMESTAMPTZ, + + -- Analisis post-trade + max_favorable_excursion DECIMAL(15, 4), -- MFE - max ganancia no realizada + max_adverse_excursion DECIMAL(15, 4), -- MAE - max perdida no realizada + mfe_price DECIMAL(15, 8), + mae_price DECIMAL(15, 8), + + -- Screenshots/Evidence + entry_screenshot_url TEXT, + exit_screenshot_url TEXT, + + -- Notas + notes TEXT, + tags VARCHAR(50)[], + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + requested_at TIMESTAMPTZ, + opened_at TIMESTAMPTZ, + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE trading.positions IS +'Posiciones de trading con tracking completo de entrada, salida y P&L'; + +COMMENT ON COLUMN trading.positions.max_favorable_excursion IS +'Maximum Favorable Excursion - maxima ganancia no realizada durante el trade'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_positions_tenant + ON trading.positions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_positions_user + ON trading.positions(user_id); + +CREATE INDEX IF NOT EXISTS idx_positions_bot + ON trading.positions(bot_id) + WHERE bot_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_positions_strategy + ON trading.positions(strategy_id) + WHERE strategy_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_positions_signal + ON trading.positions(signal_id) + WHERE signal_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_positions_symbol + ON trading.positions(symbol); + +CREATE INDEX IF NOT EXISTS idx_positions_status + ON trading.positions(status); + +CREATE INDEX IF NOT EXISTS idx_positions_open + ON trading.positions(tenant_id, user_id, status) + WHERE status = 'open'; + +CREATE INDEX IF NOT EXISTS idx_positions_closed + ON trading.positions(tenant_id, closed_at DESC) + WHERE status IN ('closed', 'stopped', 'target_hit'); + +CREATE INDEX IF NOT EXISTS idx_positions_external + ON trading.positions(external_ticket) + WHERE external_ticket IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_positions_pnl + ON trading.positions(profit_loss DESC) + WHERE status IN ('closed', 'stopped', 'target_hit'); + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS position_updated_at ON trading.positions; +CREATE TRIGGER position_updated_at + BEFORE UPDATE ON trading.positions + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para calcular campos al abrir posicion +CREATE OR REPLACE FUNCTION trading.calculate_position_on_open() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status = 'open' AND OLD.status = 'pending' THEN + NEW.opened_at := NOW(); + + -- Calcular slippage + IF NEW.requested_price IS NOT NULL AND NEW.entry_price IS NOT NULL THEN + NEW.slippage := ABS(NEW.entry_price - NEW.requested_price); + END IF; + + -- Calcular pips de SL/TP + IF NEW.stop_loss IS NOT NULL AND NEW.entry_price IS NOT NULL THEN + IF NEW.direction = 'long' THEN + NEW.stop_loss_pips := (NEW.entry_price - NEW.stop_loss) * 10000; + ELSE + NEW.stop_loss_pips := (NEW.stop_loss - NEW.entry_price) * 10000; + END IF; + END IF; + + IF NEW.take_profit IS NOT NULL AND NEW.entry_price IS NOT NULL THEN + IF NEW.direction = 'long' THEN + NEW.take_profit_pips := (NEW.take_profit - NEW.entry_price) * 10000; + ELSE + NEW.take_profit_pips := (NEW.entry_price - NEW.take_profit) * 10000; + END IF; + END IF; + + -- Calcular risk/reward ratio + IF NEW.stop_loss_pips > 0 AND NEW.take_profit_pips IS NOT NULL THEN + NEW.risk_reward_ratio := NEW.take_profit_pips / NEW.stop_loss_pips; + END IF; + + NEW.remaining_lot_size := NEW.lot_size; + NEW.average_entry_price := NEW.entry_price; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS position_on_open ON trading.positions; +CREATE TRIGGER position_on_open + BEFORE UPDATE OF status ON trading.positions + FOR EACH ROW + EXECUTE FUNCTION trading.calculate_position_on_open(); + +-- Trigger para calcular campos al cerrar posicion +CREATE OR REPLACE FUNCTION trading.calculate_position_on_close() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN + NEW.closed_at := NOW(); + + -- Calcular P&L en pips + IF NEW.exit_price IS NOT NULL AND NEW.entry_price IS NOT NULL THEN + IF NEW.direction = 'long' THEN + NEW.profit_loss_pips := (NEW.exit_price - NEW.entry_price) * 10000; + ELSE + NEW.profit_loss_pips := (NEW.entry_price - NEW.exit_price) * 10000; + END IF; + END IF; + + -- Calcular duracion + IF NEW.opened_at IS NOT NULL THEN + NEW.duration_seconds := EXTRACT(EPOCH FROM (NEW.closed_at - NEW.opened_at))::INTEGER; + + -- Formatear duracion + NEW.duration_formatted := + CASE + WHEN NEW.duration_seconds < 60 THEN NEW.duration_seconds || 's' + WHEN NEW.duration_seconds < 3600 THEN (NEW.duration_seconds / 60) || 'm' + WHEN NEW.duration_seconds < 86400 THEN (NEW.duration_seconds / 3600) || 'h ' || ((NEW.duration_seconds % 3600) / 60) || 'm' + ELSE (NEW.duration_seconds / 86400) || 'd ' || ((NEW.duration_seconds % 86400) / 3600) || 'h' + END; + END IF; + + -- Calcular costos totales + NEW.total_cost := COALESCE(NEW.commission, 0) + COALESCE(NEW.swap, 0) + COALESCE(NEW.spread_cost, 0); + NEW.net_profit := COALESCE(NEW.profit_loss, 0) - NEW.total_cost; + + -- Determinar exit_reason basado en status + IF NEW.exit_reason IS NULL THEN + NEW.exit_reason := CASE NEW.status + WHEN 'stopped' THEN 'stop_loss' + WHEN 'target_hit' THEN 'take_profit' + ELSE 'manual' + END; + END IF; + + -- Actualizar senal si existe + IF NEW.signal_id IS NOT NULL THEN + UPDATE trading.signals + SET status = 'executed', + executed_at = NEW.closed_at, + executed_price = NEW.exit_price, + result = CASE + WHEN NEW.profit_loss > 0 THEN 'win' + WHEN NEW.profit_loss < 0 THEN 'loss' + ELSE 'breakeven' + END, + result_pips = NEW.profit_loss_pips, + result_amount = NEW.profit_loss, + position_id = NEW.id + WHERE id = NEW.signal_id; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS position_on_close ON trading.positions; +CREATE TRIGGER position_on_close + BEFORE UPDATE OF status ON trading.positions + FOR EACH ROW + EXECUTE FUNCTION trading.calculate_position_on_close(); + +-- Trigger para actualizar estadisticas de bot/strategy +CREATE OR REPLACE FUNCTION trading.update_stats_on_position_close() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN + -- Actualizar bot stats + IF NEW.bot_id IS NOT NULL THEN + PERFORM trading.recalculate_bot_stats(NEW.bot_id); + END IF; + + -- Actualizar strategy stats + IF NEW.strategy_id IS NOT NULL THEN + PERFORM trading.recalculate_strategy_stats(NEW.strategy_id); + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS position_stats_update ON trading.positions; +CREATE TRIGGER position_stats_update + AFTER UPDATE OF status ON trading.positions + FOR EACH ROW + EXECUTE FUNCTION trading.update_stats_on_position_close(); + +-- Vista de posiciones abiertas +CREATE OR REPLACE VIEW trading.v_open_positions AS +SELECT + p.id, + p.tenant_id, + p.user_id, + p.bot_id, + b.name AS bot_name, + p.symbol, + p.direction, + p.lot_size, + p.entry_price, + p.stop_loss, + p.take_profit, + p.current_price, + p.unrealized_pnl, + p.unrealized_pnl_pips, + p.risk_reward_ratio, + p.opened_at, + NOW() - p.opened_at AS time_open +FROM trading.positions p +LEFT JOIN trading.bots b ON p.bot_id = b.id +WHERE p.status = 'open' +ORDER BY p.opened_at DESC; + +-- Vista de historial de trades +CREATE OR REPLACE VIEW trading.v_trade_history AS +SELECT + p.id, + p.tenant_id, + p.user_id, + p.symbol, + p.direction, + p.lot_size, + p.entry_price, + p.exit_price, + p.stop_loss, + p.take_profit, + p.profit_loss, + p.profit_loss_pips, + p.net_profit, + p.exit_reason, + p.duration_formatted, + p.opened_at, + p.closed_at +FROM trading.positions p +WHERE p.status IN ('closed', 'stopped', 'target_hit') +ORDER BY p.closed_at DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.positions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY positions_tenant_isolation ON trading.positions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +-- Los usuarios solo ven sus propias posiciones +CREATE POLICY positions_user_isolation ON trading.positions + FOR SELECT + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE ON trading.positions TO trading_app; +GRANT SELECT ON trading.positions TO trading_readonly; +GRANT SELECT ON trading.v_open_positions TO trading_app; +GRANT SELECT ON trading.v_trade_history TO trading_app; diff --git a/ddl/schemas/trading/tables/006_price_alerts.sql b/ddl/schemas/trading/tables/006_price_alerts.sql new file mode 100644 index 0000000..7de7d9b --- /dev/null +++ b/ddl/schemas/trading/tables/006_price_alerts.sql @@ -0,0 +1,260 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: price_alerts +-- DESCRIPTION: Alertas de precio configuradas por usuarios +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Enum para tipo de alerta +DO $$ BEGIN + CREATE TYPE trading.alert_type AS ENUM ( + 'price_above', -- Precio sube por encima de + 'price_below', -- Precio baja por debajo de + 'price_cross', -- Precio cruza (cualquier direccion) + 'percent_change', -- Cambio porcentual + 'volume_spike', -- Spike de volumen + 'volatility', -- Alerta de volatilidad + 'indicator', -- Alerta de indicador tecnico + 'pattern' -- Patron detectado + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Enum para estado de alerta +DO $$ BEGIN + CREATE TYPE trading.alert_status AS ENUM ( + 'active', -- Activa y monitoreando + 'triggered', -- Disparada + 'expired', -- Expirada sin disparar + 'paused', -- Pausada por usuario + 'deleted' -- Eliminada (soft delete) + ); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; + +-- Tabla de Alertas de Precio +CREATE TABLE IF NOT EXISTS trading.price_alerts ( + -- 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, + symbol_id UUID REFERENCES trading.symbols(id), + + -- Simbolo + symbol VARCHAR(20) NOT NULL, + + -- Tipo y estado + type trading.alert_type NOT NULL DEFAULT 'price_above', + status trading.alert_status NOT NULL DEFAULT 'active', + + -- Condicion + target_price DECIMAL(15, 8), -- Precio objetivo + trigger_value DECIMAL(15, 8), -- Valor que dispara (precio, %, etc) + comparison VARCHAR(10) DEFAULT 'gte', -- 'gt', 'gte', 'lt', 'lte', 'eq', 'cross' + + -- Para alertas de cambio porcentual + percent_change DECIMAL(10, 4), + time_window_minutes INTEGER, -- Ventana de tiempo para % change + base_price DECIMAL(15, 8), -- Precio base para calcular % + + -- Para alertas de indicadores + indicator_name VARCHAR(50), + indicator_config JSONB, + indicator_threshold DECIMAL(15, 8), + + -- Configuracion + is_recurring BOOLEAN NOT NULL DEFAULT FALSE, -- Se reactiva despues de disparar + cooldown_minutes INTEGER DEFAULT 60, -- Cooldown entre disparos + max_triggers INTEGER, -- Max veces que puede disparar (NULL = ilimitado) + trigger_count INTEGER NOT NULL DEFAULT 0, + + -- Notificacion + notify_email BOOLEAN NOT NULL DEFAULT TRUE, + notify_push BOOLEAN NOT NULL DEFAULT TRUE, + notify_sms BOOLEAN NOT NULL DEFAULT FALSE, + notification_message TEXT, -- Mensaje personalizado + + -- Sonido + play_sound BOOLEAN NOT NULL DEFAULT TRUE, + sound_name VARCHAR(50) DEFAULT 'default', + + -- Vigencia + valid_from TIMESTAMPTZ DEFAULT NOW(), + valid_until TIMESTAMPTZ, -- NULL = sin expiracion + + -- Ultimo trigger + last_triggered_at TIMESTAMPTZ, + last_triggered_price DECIMAL(15, 8), + + -- Precio actual (cache) + current_price DECIMAL(15, 8), + price_updated_at TIMESTAMPTZ, + + -- Notas + name VARCHAR(100), -- Nombre descriptivo opcional + notes TEXT, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE trading.price_alerts IS +'Alertas de precio configuradas por usuarios para monitorear instrumentos'; + +COMMENT ON COLUMN trading.price_alerts.is_recurring IS +'Si TRUE, la alerta se reactiva automaticamente despues de disparar'; + +-- Indices +CREATE INDEX IF NOT EXISTS idx_price_alerts_tenant + ON trading.price_alerts(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_price_alerts_user + ON trading.price_alerts(user_id); + +CREATE INDEX IF NOT EXISTS idx_price_alerts_symbol + ON trading.price_alerts(symbol); + +CREATE INDEX IF NOT EXISTS idx_price_alerts_status + ON trading.price_alerts(status); + +CREATE INDEX IF NOT EXISTS idx_price_alerts_active + ON trading.price_alerts(symbol, status, target_price) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_price_alerts_user_active + ON trading.price_alerts(user_id, status) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_price_alerts_expiring + ON trading.price_alerts(valid_until) + WHERE valid_until IS NOT NULL AND status = 'active'; + +-- Trigger para updated_at +DROP TRIGGER IF EXISTS price_alert_updated_at ON trading.price_alerts; +CREATE TRIGGER price_alert_updated_at + BEFORE UPDATE ON trading.price_alerts + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Funcion para verificar y disparar alertas +CREATE OR REPLACE FUNCTION trading.check_price_alerts( + p_symbol VARCHAR(20), + p_price DECIMAL(15, 8) +) +RETURNS TABLE ( + alert_id UUID, + user_id UUID, + alert_type trading.alert_type, + notification_message TEXT +) AS $$ +DECLARE + v_alert RECORD; +BEGIN + FOR v_alert IN + SELECT * FROM trading.price_alerts + WHERE symbol = p_symbol + AND status = 'active' + AND (valid_until IS NULL OR valid_until > NOW()) + AND (last_triggered_at IS NULL OR last_triggered_at + (cooldown_minutes || ' minutes')::INTERVAL < NOW()) + LOOP + -- Verificar condicion segun tipo + IF (v_alert.type = 'price_above' AND p_price >= v_alert.target_price) OR + (v_alert.type = 'price_below' AND p_price <= v_alert.target_price) OR + (v_alert.type = 'price_cross' AND + ((v_alert.current_price < v_alert.target_price AND p_price >= v_alert.target_price) OR + (v_alert.current_price > v_alert.target_price AND p_price <= v_alert.target_price))) + THEN + -- Disparar alerta + UPDATE trading.price_alerts + SET status = CASE WHEN is_recurring THEN 'active' ELSE 'triggered' END, + last_triggered_at = NOW(), + last_triggered_price = p_price, + trigger_count = trigger_count + 1 + WHERE id = v_alert.id; + + -- Verificar max_triggers + IF v_alert.max_triggers IS NOT NULL AND v_alert.trigger_count + 1 >= v_alert.max_triggers THEN + UPDATE trading.price_alerts SET status = 'triggered' WHERE id = v_alert.id; + END IF; + + RETURN QUERY SELECT + v_alert.id, + v_alert.user_id, + v_alert.type, + COALESCE(v_alert.notification_message, + v_alert.symbol || ' alcanzó ' || p_price::TEXT); + END IF; + END LOOP; + + -- Actualizar precio actual en todas las alertas del simbolo + UPDATE trading.price_alerts + SET current_price = p_price, + price_updated_at = NOW() + WHERE symbol = p_symbol + AND status = 'active'; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para expirar alertas +CREATE OR REPLACE FUNCTION trading.expire_old_alerts() +RETURNS INTEGER AS $$ +DECLARE + v_count INTEGER; +BEGIN + UPDATE trading.price_alerts + SET status = 'expired' + WHERE status = 'active' + AND valid_until IS NOT NULL + AND valid_until < NOW(); + + GET DIAGNOSTICS v_count = ROW_COUNT; + RETURN v_count; +END; +$$ LANGUAGE plpgsql; + +-- Vista de alertas activas del usuario +CREATE OR REPLACE VIEW trading.v_my_price_alerts AS +SELECT + id, + symbol, + type, + status, + target_price, + current_price, + name, + notify_email, + notify_push, + is_recurring, + trigger_count, + last_triggered_at, + valid_until, + created_at +FROM trading.price_alerts +WHERE status IN ('active', 'paused') +ORDER BY created_at DESC; + +-- RLS Policy para multi-tenancy +ALTER TABLE trading.price_alerts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY price_alerts_tenant_isolation ON trading.price_alerts + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY price_alerts_user_isolation ON trading.price_alerts + FOR ALL + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.price_alerts TO trading_app; +GRANT SELECT ON trading.price_alerts TO trading_readonly; +GRANT SELECT ON trading.v_my_price_alerts TO trading_app; +GRANT EXECUTE ON FUNCTION trading.check_price_alerts TO trading_app; +GRANT EXECUTE ON FUNCTION trading.expire_old_alerts TO trading_app; diff --git a/ddl/schemas/trading/tables/007_watchlists.sql b/ddl/schemas/trading/tables/007_watchlists.sql new file mode 100644 index 0000000..6128619 --- /dev/null +++ b/ddl/schemas/trading/tables/007_watchlists.sql @@ -0,0 +1,334 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLE: watchlists, watchlist_items +-- DESCRIPTION: Listas de seguimiento de simbolos +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- Tabla de Watchlists +CREATE TABLE IF NOT EXISTS trading.watchlists ( + -- 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 + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), -- Color hex para identificacion + icon VARCHAR(50), + + -- Configuracion + is_default BOOLEAN NOT NULL DEFAULT FALSE, -- Watchlist por defecto + is_public BOOLEAN NOT NULL DEFAULT FALSE, -- Compartida publicamente + is_pinned BOOLEAN NOT NULL DEFAULT FALSE, -- Fijada en UI + + -- Ordenamiento + display_order INTEGER NOT NULL DEFAULT 0, + sort_by VARCHAR(50) DEFAULT 'symbol', -- 'symbol', 'change', 'volume', 'custom' + sort_direction VARCHAR(4) DEFAULT 'asc', + + -- Columnas visibles + visible_columns JSONB DEFAULT '["symbol", "price", "change", "change_percent"]'::JSONB, + + -- Estadisticas + item_count INTEGER NOT NULL DEFAULT 0, + + -- Compartir + share_code VARCHAR(20), -- Codigo para compartir + shared_at TIMESTAMPTZ, + view_count INTEGER NOT NULL DEFAULT 0, + copy_count INTEGER NOT NULL DEFAULT 0, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT watchlists_unique_name UNIQUE (user_id, name) +); + +COMMENT ON TABLE trading.watchlists IS +'Listas de seguimiento de simbolos creadas por usuarios'; + +-- Tabla de Items de Watchlist +CREATE TABLE IF NOT EXISTS trading.watchlist_items ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + watchlist_id UUID NOT NULL REFERENCES trading.watchlists(id) ON DELETE CASCADE, + symbol_id UUID REFERENCES trading.symbols(id), + ticker_id UUID REFERENCES market_data.tickers(id), + + -- Simbolo + symbol VARCHAR(20) NOT NULL, + + -- Orden personalizado + display_order INTEGER NOT NULL DEFAULT 0, + + -- Notas del usuario + notes TEXT, + tags VARCHAR(50)[], + + -- Alertas rapidas + alert_price_above DECIMAL(15, 8), + alert_price_below DECIMAL(15, 8), + alert_enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- Precios de referencia del usuario + entry_price DECIMAL(15, 8), -- Precio de entrada planeado + target_price DECIMAL(15, 8), -- Precio objetivo + stop_price DECIMAL(15, 8), -- Stop loss planeado + + -- Precio actual (cache) + current_price DECIMAL(15, 8), + price_change DECIMAL(15, 8), + price_change_percent DECIMAL(10, 4), + price_updated_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT watchlist_items_unique UNIQUE (watchlist_id, symbol) +); + +COMMENT ON TABLE trading.watchlist_items IS +'Simbolos individuales dentro de una watchlist'; + +-- Indices para watchlists +CREATE INDEX IF NOT EXISTS idx_watchlists_tenant + ON trading.watchlists(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_watchlists_user + ON trading.watchlists(user_id); + +CREATE INDEX IF NOT EXISTS idx_watchlists_default + ON trading.watchlists(user_id, is_default) + WHERE is_default = TRUE; + +CREATE INDEX IF NOT EXISTS idx_watchlists_public + ON trading.watchlists(is_public, view_count DESC) + WHERE is_public = TRUE; + +CREATE INDEX IF NOT EXISTS idx_watchlists_share_code + ON trading.watchlists(share_code) + WHERE share_code IS NOT NULL; + +-- Indices para watchlist_items +CREATE INDEX IF NOT EXISTS idx_watchlist_items_watchlist + ON trading.watchlist_items(watchlist_id, display_order); + +CREATE INDEX IF NOT EXISTS idx_watchlist_items_symbol + ON trading.watchlist_items(symbol); + +CREATE INDEX IF NOT EXISTS idx_watchlist_items_alerts + ON trading.watchlist_items(alert_enabled, symbol) + WHERE alert_enabled = TRUE; + +-- Trigger para updated_at en watchlists +DROP TRIGGER IF EXISTS watchlist_updated_at ON trading.watchlists; +CREATE TRIGGER watchlist_updated_at + BEFORE UPDATE ON trading.watchlists + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para updated_at en watchlist_items +DROP TRIGGER IF EXISTS watchlist_item_updated_at ON trading.watchlist_items; +CREATE TRIGGER watchlist_item_updated_at + BEFORE UPDATE ON trading.watchlist_items + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para actualizar item_count en watchlist +CREATE OR REPLACE FUNCTION trading.update_watchlist_count() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'INSERT' THEN + UPDATE trading.watchlists + SET item_count = item_count + 1 + WHERE id = NEW.watchlist_id; + ELSIF TG_OP = 'DELETE' THEN + UPDATE trading.watchlists + SET item_count = item_count - 1 + WHERE id = OLD.watchlist_id; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS watchlist_item_count ON trading.watchlist_items; +CREATE TRIGGER watchlist_item_count + AFTER INSERT OR DELETE ON trading.watchlist_items + FOR EACH ROW + EXECUTE FUNCTION trading.update_watchlist_count(); + +-- Trigger para asegurar solo una watchlist default por usuario +CREATE OR REPLACE FUNCTION trading.ensure_single_default_watchlist() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_default = TRUE THEN + UPDATE trading.watchlists + 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 watchlist_single_default ON trading.watchlists; +CREATE TRIGGER watchlist_single_default + BEFORE INSERT OR UPDATE OF is_default ON trading.watchlists + FOR EACH ROW + WHEN (NEW.is_default = TRUE) + EXECUTE FUNCTION trading.ensure_single_default_watchlist(); + +-- Funcion para copiar watchlist +CREATE OR REPLACE FUNCTION trading.copy_watchlist( + p_watchlist_id UUID, + p_new_user_id UUID, + p_new_name VARCHAR(100) DEFAULT NULL +) +RETURNS UUID AS $$ +DECLARE + v_source RECORD; + v_new_id UUID; + v_tenant_id UUID; +BEGIN + -- Obtener watchlist origen + SELECT * INTO v_source FROM trading.watchlists WHERE id = p_watchlist_id; + + IF v_source IS NULL THEN + RAISE EXCEPTION 'Watchlist not found'; + END IF; + + -- Obtener tenant del nuevo usuario + SELECT tenant_id INTO v_tenant_id FROM users.users WHERE id = p_new_user_id; + + -- Crear nueva watchlist + INSERT INTO trading.watchlists ( + tenant_id, user_id, name, description, color, icon, + sort_by, sort_direction, visible_columns + ) VALUES ( + v_tenant_id, + p_new_user_id, + COALESCE(p_new_name, v_source.name || ' (Copy)'), + v_source.description, + v_source.color, + v_source.icon, + v_source.sort_by, + v_source.sort_direction, + v_source.visible_columns + ) + RETURNING id INTO v_new_id; + + -- Copiar items + INSERT INTO trading.watchlist_items ( + watchlist_id, symbol_id, ticker_id, symbol, + display_order, notes, tags, + entry_price, target_price, stop_price + ) + SELECT + v_new_id, symbol_id, ticker_id, symbol, + display_order, notes, tags, + entry_price, target_price, stop_price + FROM trading.watchlist_items + WHERE watchlist_id = p_watchlist_id; + + -- Incrementar contador de copias + UPDATE trading.watchlists + SET copy_count = copy_count + 1 + WHERE id = p_watchlist_id; + + RETURN v_new_id; +END; +$$ LANGUAGE plpgsql; + +-- Vista de watchlists con items +CREATE OR REPLACE VIEW trading.v_watchlists_with_items AS +SELECT + w.id, + w.user_id, + w.name, + w.description, + w.color, + w.is_default, + w.is_pinned, + w.item_count, + w.display_order, + ( + SELECT jsonb_agg(jsonb_build_object( + 'symbol', wi.symbol, + 'current_price', wi.current_price, + 'price_change_percent', wi.price_change_percent, + 'notes', wi.notes + ) ORDER BY wi.display_order) + FROM trading.watchlist_items wi + WHERE wi.watchlist_id = w.id + ) AS items +FROM trading.watchlists w +ORDER BY w.is_pinned DESC, w.display_order, w.name; + +-- Vista de items con precios actualizados +CREATE OR REPLACE VIEW trading.v_watchlist_items_live AS +SELECT + wi.id, + wi.watchlist_id, + wi.symbol, + t.name AS symbol_name, + t.type AS symbol_type, + t.current_bid, + t.current_ask, + wi.current_price, + wi.price_change_percent, + wi.entry_price, + wi.target_price, + wi.stop_price, + wi.notes, + wi.display_order +FROM trading.watchlist_items wi +LEFT JOIN market_data.tickers t ON wi.symbol = t.symbol +ORDER BY wi.watchlist_id, wi.display_order; + +-- RLS Policy para multi-tenancy en watchlists +ALTER TABLE trading.watchlists ENABLE ROW LEVEL SECURITY; + +CREATE POLICY watchlists_tenant_isolation ON trading.watchlists + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY watchlists_user_isolation ON trading.watchlists + FOR ALL + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- RLS para watchlist_items (hereda de watchlist via JOIN) +ALTER TABLE trading.watchlist_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY watchlist_items_via_watchlist ON trading.watchlist_items + FOR ALL + USING ( + watchlist_id IN ( + SELECT id FROM trading.watchlists + WHERE user_id = current_setting('app.current_user_id', true)::UUID + ) + ); + +-- Grants +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.watchlists TO trading_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.watchlist_items TO trading_app; +GRANT SELECT ON trading.watchlists TO trading_readonly; +GRANT SELECT ON trading.watchlist_items TO trading_readonly; +GRANT SELECT ON trading.v_watchlists_with_items TO trading_app; +GRANT SELECT ON trading.v_watchlist_items_live TO trading_app; +GRANT EXECUTE ON FUNCTION trading.copy_watchlist TO trading_app; diff --git a/ddl/schemas/trading/tables/008_paper_trading.sql b/ddl/schemas/trading/tables/008_paper_trading.sql new file mode 100644 index 0000000..adbdfb7 --- /dev/null +++ b/ddl/schemas/trading/tables/008_paper_trading.sql @@ -0,0 +1,464 @@ +-- ============================================================================ +-- SCHEMA: trading +-- TABLES: paper_trading_accounts, paper_trading_positions +-- DESCRIPTION: Sistema de paper trading (cuentas demo) +-- VERSION: 1.0.0 +-- CREATED: 2026-01-16 +-- SPRINT: Sprint 3 - DDL Implementation Roadmap Q1-2026 +-- ============================================================================ + +-- ============================================================================ +-- TABLA: paper_trading_accounts +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS trading.paper_trading_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, + + -- Informacion de cuenta + name VARCHAR(100) NOT NULL DEFAULT 'Paper Account', + description TEXT, + + -- Capital + initial_balance DECIMAL(15, 2) NOT NULL DEFAULT 10000, + current_balance DECIMAL(15, 2) NOT NULL DEFAULT 10000, + currency VARCHAR(3) NOT NULL DEFAULT 'USD', + + -- Configuracion + leverage INTEGER NOT NULL DEFAULT 100, + margin_call_level DECIMAL(5, 2) DEFAULT 50, -- % de margen para margin call + stop_out_level DECIMAL(5, 2) DEFAULT 20, -- % de margen para stop out + + -- Estado + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Margen + used_margin DECIMAL(15, 2) NOT NULL DEFAULT 0, + free_margin DECIMAL(15, 2) NOT NULL DEFAULT 10000, + margin_level DECIMAL(10, 2), -- (Equity / Used Margin) * 100 + + -- Equity y P&L + equity DECIMAL(15, 2) NOT NULL DEFAULT 10000, -- Balance + Unrealized P&L + unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + realized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Estadisticas + total_trades INTEGER NOT NULL DEFAULT 0, + winning_trades INTEGER NOT NULL DEFAULT 0, + losing_trades INTEGER NOT NULL DEFAULT 0, + win_rate DECIMAL(5, 2) DEFAULT 0, + profit_factor DECIMAL(10, 4) DEFAULT 0, + max_drawdown DECIMAL(15, 4) DEFAULT 0, + max_drawdown_percent DECIMAL(5, 2) DEFAULT 0, + + -- Rendimiento + total_profit DECIMAL(15, 4) NOT NULL DEFAULT 0, + total_loss DECIMAL(15, 4) NOT NULL DEFAULT 0, + net_profit DECIMAL(15, 4) NOT NULL DEFAULT 0, + return_percent DECIMAL(10, 4) DEFAULT 0, + + -- Posiciones actuales + open_positions_count INTEGER NOT NULL DEFAULT 0, + + -- Resets + reset_count INTEGER NOT NULL DEFAULT 0, + last_reset_at TIMESTAMPTZ, + + -- Metadata + metadata JSONB DEFAULT '{}'::JSONB, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Constraints + CONSTRAINT paper_accounts_balance_check CHECK (initial_balance > 0) +); + +COMMENT ON TABLE trading.paper_trading_accounts IS +'Cuentas de paper trading (demo) para practicar sin dinero real'; + +-- Indices para paper_trading_accounts +CREATE INDEX IF NOT EXISTS idx_paper_accounts_tenant + ON trading.paper_trading_accounts(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_paper_accounts_user + ON trading.paper_trading_accounts(user_id); + +CREATE INDEX IF NOT EXISTS idx_paper_accounts_active + ON trading.paper_trading_accounts(user_id, is_active) + WHERE is_active = TRUE; + +CREATE INDEX IF NOT EXISTS idx_paper_accounts_default + ON trading.paper_trading_accounts(user_id, is_default) + WHERE is_default = TRUE; + +-- ============================================================================ +-- TABLA: paper_trading_positions +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS trading.paper_trading_positions ( + -- Identificadores + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES trading.paper_trading_accounts(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants.tenants(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users.users(id) ON DELETE CASCADE, + + -- Simbolo + symbol VARCHAR(20) NOT NULL, + symbol_id UUID REFERENCES trading.symbols(id), + + -- Direccion y estado + direction trading.trade_direction NOT NULL, + status trading.position_status NOT NULL DEFAULT 'open', + order_type trading.order_type NOT NULL DEFAULT 'market', + + -- Tamaño + lot_size DECIMAL(10, 4) NOT NULL, + + -- Precios + entry_price DECIMAL(15, 8) NOT NULL, + current_price DECIMAL(15, 8), + exit_price DECIMAL(15, 8), + + -- Stop Loss / Take Profit + stop_loss DECIMAL(15, 8), + take_profit DECIMAL(15, 8), + trailing_stop_enabled BOOLEAN NOT NULL DEFAULT FALSE, + trailing_stop_distance DECIMAL(10, 4), + + -- P&L + profit_loss DECIMAL(15, 4), + profit_loss_pips DECIMAL(10, 4), + unrealized_pnl DECIMAL(15, 4) NOT NULL DEFAULT 0, + + -- Margen + margin_used DECIMAL(15, 4), + + -- Comisiones simuladas + commission DECIMAL(15, 4) DEFAULT 0, + swap DECIMAL(15, 4) DEFAULT 0, + + -- Analisis + max_favorable_excursion DECIMAL(15, 4), + max_adverse_excursion DECIMAL(15, 4), + + -- Notas + notes TEXT, + tags VARCHAR(50)[], + + -- Timestamps + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + closed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMENT ON TABLE trading.paper_trading_positions IS +'Posiciones de paper trading asociadas a cuentas demo'; + +-- Indices para paper_trading_positions +CREATE INDEX IF NOT EXISTS idx_paper_positions_account + ON trading.paper_trading_positions(account_id); + +CREATE INDEX IF NOT EXISTS idx_paper_positions_user + ON trading.paper_trading_positions(user_id); + +CREATE INDEX IF NOT EXISTS idx_paper_positions_tenant + ON trading.paper_trading_positions(tenant_id); + +CREATE INDEX IF NOT EXISTS idx_paper_positions_symbol + ON trading.paper_trading_positions(symbol); + +CREATE INDEX IF NOT EXISTS idx_paper_positions_status + ON trading.paper_trading_positions(status); + +CREATE INDEX IF NOT EXISTS idx_paper_positions_open + ON trading.paper_trading_positions(account_id, status) + WHERE status = 'open'; + +-- ============================================================================ +-- TRIGGERS +-- ============================================================================ + +-- Trigger para updated_at en accounts +DROP TRIGGER IF EXISTS paper_account_updated_at ON trading.paper_trading_accounts; +CREATE TRIGGER paper_account_updated_at + BEFORE UPDATE ON trading.paper_trading_accounts + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para updated_at en positions +DROP TRIGGER IF EXISTS paper_position_updated_at ON trading.paper_trading_positions; +CREATE TRIGGER paper_position_updated_at + BEFORE UPDATE ON trading.paper_trading_positions + FOR EACH ROW + EXECUTE FUNCTION trading.update_trading_timestamp(); + +-- Trigger para asegurar solo una cuenta default +CREATE OR REPLACE FUNCTION trading.ensure_single_default_paper_account() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.is_default = TRUE THEN + UPDATE trading.paper_trading_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 paper_account_single_default ON trading.paper_trading_accounts; +CREATE TRIGGER paper_account_single_default + BEFORE INSERT OR UPDATE OF is_default ON trading.paper_trading_accounts + FOR EACH ROW + WHEN (NEW.is_default = TRUE) + EXECUTE FUNCTION trading.ensure_single_default_paper_account(); + +-- Trigger para actualizar cuenta al abrir posicion +CREATE OR REPLACE FUNCTION trading.update_paper_account_on_position_open() +RETURNS TRIGGER AS $$ +DECLARE + v_margin DECIMAL(15, 4); + v_account RECORD; +BEGIN + IF NEW.status = 'open' AND (OLD IS NULL OR OLD.status = 'pending') THEN + SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = NEW.account_id; + + -- Calcular margen requerido + v_margin := (NEW.lot_size * 100000 * NEW.entry_price) / v_account.leverage; + NEW.margin_used := v_margin; + + -- Actualizar cuenta + UPDATE trading.paper_trading_accounts + SET used_margin = used_margin + v_margin, + free_margin = current_balance - (used_margin + v_margin), + open_positions_count = open_positions_count + 1, + margin_level = CASE WHEN (used_margin + v_margin) > 0 + THEN (equity / (used_margin + v_margin)) * 100 + ELSE NULL END + WHERE id = NEW.account_id; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS paper_position_open ON trading.paper_trading_positions; +CREATE TRIGGER paper_position_open + BEFORE INSERT OR UPDATE OF status ON trading.paper_trading_positions + FOR EACH ROW + EXECUTE FUNCTION trading.update_paper_account_on_position_open(); + +-- Trigger para actualizar cuenta al cerrar posicion +CREATE OR REPLACE FUNCTION trading.update_paper_account_on_position_close() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.status IN ('closed', 'stopped', 'target_hit') AND OLD.status = 'open' THEN + NEW.closed_at := NOW(); + + -- Calcular P&L + IF NEW.direction = 'long' THEN + NEW.profit_loss_pips := (NEW.exit_price - NEW.entry_price) * 10000; + ELSE + NEW.profit_loss_pips := (NEW.entry_price - NEW.exit_price) * 10000; + END IF; + NEW.profit_loss := NEW.profit_loss_pips * NEW.lot_size * 10; -- Aproximacion simplificada + + -- Actualizar cuenta + UPDATE trading.paper_trading_accounts + SET current_balance = current_balance + COALESCE(NEW.profit_loss, 0), + used_margin = used_margin - COALESCE(NEW.margin_used, 0), + free_margin = (current_balance + COALESCE(NEW.profit_loss, 0)) - (used_margin - COALESCE(NEW.margin_used, 0)), + open_positions_count = open_positions_count - 1, + total_trades = total_trades + 1, + winning_trades = winning_trades + CASE WHEN NEW.profit_loss > 0 THEN 1 ELSE 0 END, + losing_trades = losing_trades + CASE WHEN NEW.profit_loss < 0 THEN 1 ELSE 0 END, + total_profit = total_profit + CASE WHEN NEW.profit_loss > 0 THEN NEW.profit_loss ELSE 0 END, + total_loss = total_loss + CASE WHEN NEW.profit_loss < 0 THEN ABS(NEW.profit_loss) ELSE 0 END, + realized_pnl = realized_pnl + COALESCE(NEW.profit_loss, 0) + WHERE id = NEW.account_id; + + -- Recalcular estadisticas + PERFORM trading.recalculate_paper_account_stats(NEW.account_id); + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS paper_position_close ON trading.paper_trading_positions; +CREATE TRIGGER paper_position_close + BEFORE UPDATE OF status ON trading.paper_trading_positions + FOR EACH ROW + EXECUTE FUNCTION trading.update_paper_account_on_position_close(); + +-- ============================================================================ +-- FUNCIONES +-- ============================================================================ + +-- Funcion para recalcular estadisticas de cuenta +CREATE OR REPLACE FUNCTION trading.recalculate_paper_account_stats(p_account_id UUID) +RETURNS VOID AS $$ +DECLARE + v_account RECORD; +BEGIN + SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = p_account_id; + + UPDATE trading.paper_trading_accounts + SET win_rate = CASE WHEN total_trades > 0 + THEN (winning_trades::DECIMAL / total_trades * 100) + ELSE 0 END, + profit_factor = CASE WHEN total_loss > 0 + THEN total_profit / total_loss + ELSE 0 END, + net_profit = total_profit - total_loss, + return_percent = CASE WHEN initial_balance > 0 + THEN ((current_balance - initial_balance) / initial_balance * 100) + ELSE 0 END, + equity = current_balance + unrealized_pnl + WHERE id = p_account_id; +END; +$$ LANGUAGE plpgsql; + +-- Funcion para resetear cuenta paper trading +CREATE OR REPLACE FUNCTION trading.reset_paper_account( + p_account_id UUID, + p_new_balance DECIMAL(15, 2) DEFAULT NULL +) +RETURNS BOOLEAN AS $$ +DECLARE + v_account RECORD; +BEGIN + SELECT * INTO v_account FROM trading.paper_trading_accounts WHERE id = p_account_id; + + IF v_account IS NULL THEN + RETURN FALSE; + END IF; + + -- Cerrar todas las posiciones abiertas + UPDATE trading.paper_trading_positions + SET status = 'cancelled', + closed_at = NOW() + WHERE account_id = p_account_id + AND status = 'open'; + + -- Resetear cuenta + UPDATE trading.paper_trading_accounts + SET current_balance = COALESCE(p_new_balance, initial_balance), + equity = COALESCE(p_new_balance, initial_balance), + used_margin = 0, + free_margin = COALESCE(p_new_balance, initial_balance), + margin_level = NULL, + unrealized_pnl = 0, + realized_pnl = 0, + total_trades = 0, + winning_trades = 0, + losing_trades = 0, + win_rate = 0, + profit_factor = 0, + max_drawdown = 0, + max_drawdown_percent = 0, + total_profit = 0, + total_loss = 0, + net_profit = 0, + return_percent = 0, + open_positions_count = 0, + reset_count = reset_count + 1, + last_reset_at = NOW() + WHERE id = p_account_id; + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- VISTAS +-- ============================================================================ + +-- Vista de cuentas paper trading +CREATE OR REPLACE VIEW trading.v_paper_accounts AS +SELECT + pa.id, + pa.user_id, + pa.name, + pa.initial_balance, + pa.current_balance, + pa.currency, + pa.leverage, + pa.equity, + pa.unrealized_pnl, + pa.realized_pnl, + pa.return_percent, + pa.win_rate, + pa.profit_factor, + pa.total_trades, + pa.open_positions_count, + pa.is_default, + pa.is_active +FROM trading.paper_trading_accounts pa +WHERE pa.is_active = TRUE +ORDER BY pa.is_default DESC, pa.created_at DESC; + +-- Vista de posiciones abiertas paper trading +CREATE OR REPLACE VIEW trading.v_paper_open_positions AS +SELECT + pp.id, + pp.account_id, + pa.name AS account_name, + pp.symbol, + pp.direction, + pp.lot_size, + pp.entry_price, + pp.current_price, + pp.stop_loss, + pp.take_profit, + pp.unrealized_pnl, + pp.margin_used, + pp.opened_at +FROM trading.paper_trading_positions pp +JOIN trading.paper_trading_accounts pa ON pp.account_id = pa.id +WHERE pp.status = 'open' +ORDER BY pp.opened_at DESC; + +-- ============================================================================ +-- RLS POLICIES +-- ============================================================================ + +ALTER TABLE trading.paper_trading_accounts ENABLE ROW LEVEL SECURITY; + +CREATE POLICY paper_accounts_tenant_isolation ON trading.paper_trading_accounts + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY paper_accounts_user_isolation ON trading.paper_trading_accounts + FOR ALL + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +ALTER TABLE trading.paper_trading_positions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY paper_positions_tenant_isolation ON trading.paper_trading_positions + FOR ALL + USING (tenant_id = current_setting('app.current_tenant_id', true)::UUID); + +CREATE POLICY paper_positions_user_isolation ON trading.paper_trading_positions + FOR ALL + USING (user_id = current_setting('app.current_user_id', true)::UUID); + +-- ============================================================================ +-- GRANTS +-- ============================================================================ + +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.paper_trading_accounts TO trading_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON trading.paper_trading_positions TO trading_app; +GRANT SELECT ON trading.paper_trading_accounts TO trading_readonly; +GRANT SELECT ON trading.paper_trading_positions TO trading_readonly; +GRANT SELECT ON trading.v_paper_accounts TO trading_app; +GRANT SELECT ON trading.v_paper_open_positions TO trading_app; +GRANT EXECUTE ON FUNCTION trading.recalculate_paper_account_stats TO trading_app; +GRANT EXECUTE ON FUNCTION trading.reset_paper_account TO trading_app;