learning_ai_invt_trdg/backend/schema/012_entry_atomic_lifecycle.sql

217 lines
6.2 KiB
PL/PgSQL

-- ============================================================
-- Migration 012: Atomic ENTRY Lifecycle Persistence
-- Date: 2026-02-16
-- Purpose:
-- 1) Add explicit trade_lifecycle and positions tables for traceability.
-- 2) Harden orders/trade_history uniqueness to drive idempotent RPC inserts.
-- 3) Expose fn_persist_entry_lifecycle for exchange-first persistence.
-- ============================================================
BEGIN;
-- --------------------------------------------------------------------------------
-- Ensure lifecycle table captures profile + trade scope
-- --------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS trade_lifecycle (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL REFERENCES trade_profiles(id) ON DELETE CASCADE,
trade_id text NOT NULL,
entry_order_id text NOT NULL,
current_stage text NOT NULL DEFAULT 'ENTRY',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT trade_lifecycle_profile_trade_unique UNIQUE (profile_id, trade_id)
);
CREATE INDEX IF NOT EXISTS idx_trade_lifecycle_entry_order
ON trade_lifecycle(entry_order_id);
-- --------------------------------------------------------------------------------
-- Positions table for lifecycle-driven snapshots
-- --------------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS positions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_id uuid NOT NULL,
trade_id text NOT NULL,
entry_order_id text NOT NULL,
symbol text NOT NULL,
side text NOT NULL,
quantity numeric NOT NULL DEFAULT 0,
avg_price numeric NOT NULL DEFAULT 0,
status text NOT NULL DEFAULT 'OPEN',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT positions_profile_trade_unique UNIQUE (profile_id, trade_id),
CONSTRAINT positions_lifecycle_fk FOREIGN KEY (profile_id, trade_id)
REFERENCES trade_lifecycle(profile_id, trade_id) ON DELETE CASCADE,
CONSTRAINT chk_positions_side CHECK (side IN ('BUY','SELL'))
);
CREATE INDEX IF NOT EXISTS idx_positions_profile_trade
ON positions (profile_id, trade_id);
CREATE INDEX IF NOT EXISTS idx_positions_symbol ON positions (symbol);
-- --------------------------------------------------------------------------------
-- Harden orders + trade_history uniqueness for idempotent child inserts
-- --------------------------------------------------------------------------------
CREATE UNIQUE INDEX IF NOT EXISTS uq_orders_order_id ON orders (order_id) WHERE order_id IS NOT NULL;
ALTER TABLE IF EXISTS trade_history
ADD COLUMN IF NOT EXISTS lifecycle_marker text;
CREATE UNIQUE INDEX IF NOT EXISTS uq_trade_history_lifecycle_marker
ON trade_history (lifecycle_marker)
WHERE lifecycle_marker IS NOT NULL;
COMMIT;
-- --------------------------------------------------------------------------------
-- Entry persistence RPC
-- --------------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_persist_entry_lifecycle(
p_profile_id uuid,
p_trade_id text,
p_order_id text,
p_symbol text,
p_side text,
p_type text,
p_quantity numeric,
p_price numeric,
p_status text,
p_timestamp bigint,
p_stop_loss numeric DEFAULT NULL,
p_take_profit numeric DEFAULT NULL,
p_source text DEFAULT 'BOT',
p_reason text DEFAULT 'Entry lifecycle persisted'
) RETURNS jsonb AS $$
DECLARE
lifecycle_row trade_lifecycle%ROWTYPE;
entry_marker text := format('entry:%s', p_trade_id);
BEGIN
BEGIN
INSERT INTO trade_lifecycle (
profile_id,
trade_id,
entry_order_id,
current_stage,
created_at,
updated_at
) VALUES (
p_profile_id,
p_trade_id,
p_order_id,
'ENTRY',
now(),
now()
)
RETURNING * INTO lifecycle_row;
EXCEPTION WHEN unique_violation THEN
UPDATE trade_lifecycle
SET updated_at = now()
WHERE profile_id = p_profile_id
AND trade_id = p_trade_id;
END;
INSERT INTO orders (
order_id,
profile_id,
trade_id,
symbol,
type,
side,
qty,
quantity,
price,
status,
timestamp,
stop_loss,
take_profit,
action,
source
) VALUES (
p_order_id,
p_profile_id,
p_trade_id,
p_symbol,
p_type,
upper(p_side),
COALESCE(p_quantity, 0),
COALESCE(p_quantity, 0),
COALESCE(p_price, 0),
p_status,
p_timestamp,
p_stop_loss,
p_take_profit,
'ENTRY',
p_source
) ON CONFLICT (order_id) DO NOTHING;
INSERT INTO positions (
profile_id,
trade_id,
entry_order_id,
symbol,
side,
quantity,
avg_price,
status,
created_at,
updated_at
) VALUES (
p_profile_id,
p_trade_id,
p_order_id,
p_symbol,
upper(p_side),
0,
COALESCE(p_price, 0),
'OPEN',
now(),
now()
) ON CONFLICT (profile_id, trade_id) DO NOTHING;
INSERT INTO trade_history (
trade_id,
profile_id,
symbol,
side,
size,
entry_price,
exit_price,
pnl,
pnl_percent,
reason,
timestamp,
stop_loss,
take_profit,
source,
lifecycle_marker
) VALUES (
p_trade_id,
p_profile_id,
p_symbol,
upper(p_side),
COALESCE(p_quantity, 0),
COALESCE(p_price, 0),
NULL,
0,
0,
p_reason,
p_timestamp,
p_stop_loss,
p_take_profit,
p_source,
entry_marker
) ON CONFLICT (lifecycle_marker) DO NOTHING;
IF lifecycle_row IS NULL THEN
SELECT * INTO lifecycle_row
FROM trade_lifecycle
WHERE profile_id = p_profile_id
AND trade_id = p_trade_id;
END IF;
RETURN row_to_json(lifecycle_row);
END;
$$ LANGUAGE plpgsql;