-- ============================================================================ -- 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;