Files
QuantDinger/backend_api_python/migrations/init.sql
T
TIANHE 3a72490ca7 v5.0.3
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-07-19 13:45:34 +08:00

2026 lines
93 KiB
SQL

-- QuantDinger PostgreSQL Schema Initialization
-- This script runs automatically when PostgreSQL container starts for the first time.
-- =============================================================================
-- 1. Users & Authentication
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email VARCHAR(100) UNIQUE,
nickname VARCHAR(50),
avatar VARCHAR(255) DEFAULT '/avatar2.jpg',
status VARCHAR(20) DEFAULT 'active', -- active/disabled/pending
role VARCHAR(20) DEFAULT 'user', -- admin/manager/user/viewer
credits DECIMAL(20,2) DEFAULT 0,
vip_expires_at TIMESTAMP, -- VIP杩囨湡鏃堕棿
vip_plan VARCHAR(20) DEFAULT '',
vip_is_lifetime BOOLEAN DEFAULT FALSE,
vip_monthly_credits_last_grant TIMESTAMP,
email_verified BOOLEAN DEFAULT FALSE,
referred_by INTEGER, -- 閭€璇蜂汉ID
notification_settings TEXT DEFAULT '',
chart_templates TEXT DEFAULT '', -- 鐢ㄦ埛鍥捐〃妯℃澘 JSON锛堟寚鏍囧竷灞€/鏍峰紡锛?
timezone VARCHAR(64) DEFAULT '',
token_version INTEGER DEFAULT 1,
password_changed_at TIMESTAMP, -- NULL only prompts when bootstrap password is still 123456
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_referred_by ON qd_users(referred_by);
-- Note: Admin user is created automatically by the application on startup
-- using ADMIN_USER and ADMIN_PASSWORD from environment variables
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_credits_log (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL, -- recharge/consume/refund/admin_adjust/vip_grant
amount DECIMAL(20,2) NOT NULL,
balance_after DECIMAL(20,2) NOT NULL, -- 鍙樺姩鍚庝綑棰?
feature VARCHAR(50) DEFAULT '', -- 娑堣垂鐨勫姛鑳斤細ai_analysis/strategy_run/backtest 绛?
reference_id VARCHAR(100) DEFAULT '',
remark TEXT DEFAULT '', -- 澶囨敞
operator_id INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_credits_log_user_id ON qd_credits_log(user_id);
CREATE INDEX IF NOT EXISTS idx_credits_log_action ON qd_credits_log(action);
CREATE INDEX IF NOT EXISTS idx_credits_log_created_at ON qd_credits_log(created_at);
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_membership_orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
plan VARCHAR(20) NOT NULL, -- monthly/yearly/lifetime
price_usd DECIMAL(10,2) DEFAULT 0,
status VARCHAR(20) DEFAULT 'paid',
created_at TIMESTAMP DEFAULT NOW(),
paid_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_membership_orders_user_id ON qd_membership_orders(user_id);
-- =============================================================================
-- 1.56. USDT Orders (multi-chain single-receiving-address + amount-suffix model)
-- =============================================================================
--
-- v3.0.6 reset: replaced xpub-derived per-order addresses with a single fixed
-- receiving address per chain. Orders are identified on-chain by a unique
-- amount suffix in the low decimals (e.g. 19.991234 -> suffix 0.001234).
-- This eliminates the consolidation step (funds land directly in the main
-- wallet) and removes per-sweep TRX/gas costs.
--
-- Supported chains: TRC20 (TRON), BEP20 (BSC), ERC20 (Ethereum), SOL (Solana SPL).
-- Each chain's address is configured via USDT_{CHAIN}_ADDRESS env var.
CREATE TABLE IF NOT EXISTS qd_usdt_orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
plan VARCHAR(20) NOT NULL, -- monthly/yearly/lifetime
chain VARCHAR(20) NOT NULL DEFAULT 'TRC20', -- TRC20/BEP20/ERC20/SOL
currency VARCHAR(10) NOT NULL DEFAULT 'USDT',
amount_usdt DECIMAL(20,8) NOT NULL DEFAULT 0, -- final amount = base + suffix (6 dp typical)
amount_suffix DECIMAL(20,8) NOT NULL DEFAULT 0, -- the unique suffix portion used for matching
address VARCHAR(120) NOT NULL DEFAULT '', -- fixed receiving address (per chain)
payment_uri TEXT NOT NULL DEFAULT '', -- full deep link (EIP-681 / Solana Pay / tron URI)
matched_via VARCHAR(20) NOT NULL DEFAULT 'amount_suffix',
status VARCHAR(20) NOT NULL DEFAULT 'pending', -- pending/paid/confirmed/expired/cancelled/failed
tx_hash VARCHAR(120) DEFAULT '',
paid_at TIMESTAMP,
confirmed_at TIMESTAMP,
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_usdt_orders_user_id ON qd_usdt_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_usdt_orders_status ON qd_usdt_orders(status);
-- v3.0.6 cleanup: drop the legacy unique index on (chain, address) that
-- was used by the per-order xpub-derived address scheme. In the current
-- "single fixed receiving address per chain + amount-suffix matching"
-- model, every active order on the same chain shares the same address,
-- so this old index would falsely reject every second pending order
-- (UniqueViolation on idx_usdt_orders_address_unique). Safe & idempotent.
DROP INDEX IF EXISTS idx_usdt_orders_address_unique;
-- Prevent two active orders on the same chain from claiming the same amount,
-- which is the foundation of the amount-suffix matching scheme.
CREATE UNIQUE INDEX IF NOT EXISTS idx_usdt_orders_amount_active
ON qd_usdt_orders(chain, amount_usdt)
WHERE status IN ('pending', 'paid');
-- One-shot cleanup for installs that pre-date v3.0.6. address_index is no
-- longer used; we keep the column where it already exists to avoid breaking
-- old rows, but new installs do not need it. The DO block is idempotent and
-- safe to re-run.
DO $$
BEGIN
-- amount_suffix
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='qd_usdt_orders' AND column_name='amount_suffix'
) THEN
ALTER TABLE qd_usdt_orders ADD COLUMN amount_suffix DECIMAL(20,8) NOT NULL DEFAULT 0;
END IF;
-- payment_uri
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='qd_usdt_orders' AND column_name='payment_uri'
) THEN
ALTER TABLE qd_usdt_orders ADD COLUMN payment_uri TEXT NOT NULL DEFAULT '';
END IF;
-- currency
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='qd_usdt_orders' AND column_name='currency'
) THEN
ALTER TABLE qd_usdt_orders ADD COLUMN currency VARCHAR(10) NOT NULL DEFAULT 'USDT';
END IF;
-- matched_via
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='qd_usdt_orders' AND column_name='matched_via'
) THEN
ALTER TABLE qd_usdt_orders ADD COLUMN matched_via VARCHAR(20) NOT NULL DEFAULT 'amount_suffix';
END IF;
-- widen amount_usdt to (20,8) so suffix at 6+ decimals fits exactly
BEGIN
ALTER TABLE qd_usdt_orders ALTER COLUMN amount_usdt TYPE DECIMAL(20,8);
EXCEPTION WHEN others THEN NULL;
END;
-- widen address (TRC20 base58 ~34, Solana ~44; old col was 80)
BEGIN
ALTER TABLE qd_usdt_orders ALTER COLUMN address TYPE VARCHAR(120);
EXCEPTION WHEN others THEN NULL;
END;
END
$$;
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_oauth_states (
state VARCHAR(128) PRIMARY KEY,
provider VARCHAR(20) NOT NULL,
redirect TEXT,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON qd_oauth_states(expires_at);
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_verification_codes (
id SERIAL PRIMARY KEY,
email VARCHAR(100) NOT NULL,
code VARCHAR(10) NOT NULL,
type VARCHAR(20) NOT NULL, -- register/login/reset_password/change_email/change_password
expires_at TIMESTAMP NOT NULL,
used_at TIMESTAMP,
ip_address VARCHAR(45),
attempts INTEGER DEFAULT 0, -- Failed verification attempts (anti-brute-force)
last_attempt_at TIMESTAMP, -- Last attempt time
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_verification_codes_email ON qd_verification_codes(email);
CREATE INDEX IF NOT EXISTS idx_verification_codes_type ON qd_verification_codes(type);
CREATE INDEX IF NOT EXISTS idx_verification_codes_expires ON qd_verification_codes(expires_at);
-- =============================================================================
-- 1.7. Login Attempts (鐧诲綍灏濊瘯璁板綍 - 闃茬垎鐮?
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_login_attempts (
id SERIAL PRIMARY KEY,
identifier VARCHAR(100) NOT NULL, -- IP address or username
identifier_type VARCHAR(10) NOT NULL, -- 'ip' or 'account'
attempt_time TIMESTAMP DEFAULT NOW(),
success BOOLEAN DEFAULT FALSE,
ip_address VARCHAR(45),
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_login_attempts_identifier ON qd_login_attempts(identifier, identifier_type);
CREATE INDEX IF NOT EXISTS idx_login_attempts_time ON qd_login_attempts(attempt_time);
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_oauth_links (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES qd_users(id) ON DELETE CASCADE,
provider VARCHAR(20) NOT NULL, -- 'google' or 'github'
provider_user_id VARCHAR(100) NOT NULL,
provider_email VARCHAR(100),
provider_name VARCHAR(100),
provider_avatar VARCHAR(255),
access_token TEXT,
refresh_token TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(provider, provider_user_id)
);
CREATE INDEX IF NOT EXISTS idx_oauth_links_user_id ON qd_oauth_links(user_id);
CREATE INDEX IF NOT EXISTS idx_oauth_links_provider ON qd_oauth_links(provider);
-- =============================================================================
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_security_logs (
id SERIAL PRIMARY KEY,
user_id INTEGER,
action VARCHAR(50) NOT NULL, -- login/logout/register/reset_password/oauth_login/etc
ip_address VARCHAR(45),
user_agent TEXT,
details TEXT, -- JSON with additional info
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_security_logs_user_id ON qd_security_logs(user_id);
CREATE INDEX IF NOT EXISTS idx_security_logs_action ON qd_security_logs(action);
CREATE INDEX IF NOT EXISTS idx_security_logs_created_at ON qd_security_logs(created_at);
-- =============================================================================
-- 1.10. User MFA (TOTP / Authenticator App)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_user_mfa (
user_id INTEGER PRIMARY KEY REFERENCES qd_users(id) ON DELETE CASCADE,
enabled BOOLEAN DEFAULT FALSE,
secret_encrypted TEXT NOT NULL,
recovery_codes_hash TEXT DEFAULT '',
last_used_counter BIGINT DEFAULT 0,
confirmed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS qd_mfa_challenges (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
challenge_hash VARCHAR(128) UNIQUE NOT NULL,
reason VARCHAR(50) DEFAULT 'risk_login',
ip_address VARCHAR(45),
user_agent TEXT,
attempts INTEGER DEFAULT 0,
expires_at TIMESTAMP NOT NULL,
consumed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_user_id ON qd_mfa_challenges(user_id);
CREATE INDEX IF NOT EXISTS idx_mfa_challenges_expires ON qd_mfa_challenges(expires_at);
-- =============================================================================
-- 2. Trading Strategies
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategies_trading (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_name VARCHAR(255) NOT NULL,
strategy_type VARCHAR(50) DEFAULT 'StrategyV2',
market_category VARCHAR(50) DEFAULT 'Crypto',
execution_mode VARCHAR(20) NOT NULL DEFAULT 'signal',
notification_config JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(20) DEFAULT 'stopped',
symbol VARCHAR(50),
symbol_canonical VARCHAR(50) DEFAULT '',
timeframe VARCHAR(10),
initial_capital DECIMAL(20,8) DEFAULT 1000,
leverage INTEGER DEFAULT 1,
market_type VARCHAR(20) DEFAULT 'swap',
exchange_config JSONB NOT NULL DEFAULT '{}'::jsonb,
trading_config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategies_user_id ON qd_strategies_trading(user_id);
CREATE INDEX IF NOT EXISTS idx_strategies_status ON qd_strategies_trading(status);
-- Script source library: reusable code assets separated from live/runtime strategy rows.
CREATE TABLE IF NOT EXISTS qd_script_sources (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
code TEXT NOT NULL DEFAULT '',
asset_type VARCHAR(32) NOT NULL DEFAULT 'script',
template_key VARCHAR(80) DEFAULT '',
param_schema JSONB DEFAULT '{}'::jsonb,
source_marketplace_indicator_id INTEGER,
source_script_source_id INTEGER,
visibility VARCHAR(32) DEFAULT 'private',
status VARCHAR(32) DEFAULT 'draft',
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_script_sources_user_id ON qd_script_sources(user_id);
CREATE INDEX IF NOT EXISTS idx_script_sources_marketplace ON qd_script_sources(source_marketplace_indicator_id);
CREATE INDEX IF NOT EXISTS idx_script_sources_asset_type ON qd_script_sources(user_id, asset_type);
CREATE TABLE IF NOT EXISTS qd_script_source_versions (
id SERIAL PRIMARY KEY,
source_id INTEGER NOT NULL REFERENCES qd_script_sources(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
version_no INTEGER NOT NULL,
name VARCHAR(255) NOT NULL DEFAULT '',
description TEXT DEFAULT '',
code TEXT NOT NULL DEFAULT '',
template_key VARCHAR(80) DEFAULT '',
param_schema JSONB DEFAULT '{}'::jsonb,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(source_id, version_no)
);
CREATE INDEX IF NOT EXISTS idx_script_source_versions_source
ON qd_script_source_versions(source_id, version_no DESC);
CREATE INDEX IF NOT EXISTS idx_script_source_versions_user
ON qd_script_source_versions(user_id);
CREATE TABLE IF NOT EXISTS qd_script_templates (
id SERIAL PRIMARY KEY,
template_key VARCHAR(80) UNIQUE NOT NULL,
asset_type VARCHAR(40) NOT NULL DEFAULT 'script',
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
code TEXT NOT NULL DEFAULT '',
param_schema JSONB DEFAULT '{}'::jsonb,
tags JSONB DEFAULT '[]'::jsonb,
icon VARCHAR(64) DEFAULT 'appstore',
accent VARCHAR(32) DEFAULT 'blue',
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_script_templates_active
ON qd_script_templates(is_active, sort_order);
-- =============================================================================
-- 3. Strategy Positions
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_positions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
symbol VARCHAR(50),
symbol_canonical VARCHAR(50) DEFAULT '',
side VARCHAR(10), -- long/short
size DECIMAL(20,8),
entry_price DECIMAL(20,8),
current_price DECIMAL(20,8),
highest_price DECIMAL(20,8) DEFAULT 0,
lowest_price DECIMAL(20,8) DEFAULT 0,
unrealized_pnl DECIMAL(20,8) DEFAULT 0,
pnl_percent DECIMAL(10,4) DEFAULT 0,
equity DECIMAL(20,8) DEFAULT 0,
market_type VARCHAR(20) DEFAULT 'swap',
credential_id INTEGER DEFAULT 0,
inst_id VARCHAR(80) DEFAULT '',
strategy_run_id INTEGER DEFAULT 0,
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(strategy_id, symbol, side)
);
CREATE INDEX IF NOT EXISTS idx_positions_user_id ON qd_strategy_positions(user_id);
CREATE INDEX IF NOT EXISTS idx_positions_strategy_id ON qd_strategy_positions(strategy_id);
-- =============================================================================
-- 4. Strategy Trades
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_trades (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
symbol VARCHAR(50),
symbol_canonical VARCHAR(50) DEFAULT '',
type VARCHAR(30), -- open_long, close_short, etc.
price DECIMAL(20,8),
amount DECIMAL(20,8),
value DECIMAL(20,8),
commission DECIMAL(20,8) DEFAULT 0,
commission_ccy VARCHAR(20) DEFAULT '',
commission_quote DECIMAL(24,8),
profit DECIMAL(20,8) DEFAULT 0,
close_reason VARCHAR(64) DEFAULT '',
matched_entry_price DECIMAL(20,8) DEFAULT 0,
grid_matched_profit DECIMAL(20,8) DEFAULT 0,
market_type VARCHAR(20) DEFAULT 'swap',
credential_id INTEGER DEFAULT 0,
inst_id VARCHAR(80) DEFAULT '',
fill_source VARCHAR(32) DEFAULT '',
pending_order_id INTEGER DEFAULT 0,
strategy_run_id INTEGER DEFAULT 0,
order_intent_id INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_trades_user_id ON qd_strategy_trades(user_id);
CREATE INDEX IF NOT EXISTS idx_trades_strategy_id ON qd_strategy_trades(strategy_id);
CREATE INDEX IF NOT EXISTS idx_trades_created_at ON qd_strategy_trades(created_at);
CREATE INDEX IF NOT EXISTS idx_trades_strategy_symbol_canon ON qd_strategy_trades (strategy_id, market_type, symbol_canonical);
CREATE INDEX IF NOT EXISTS idx_positions_strategy_leg ON qd_strategy_positions (strategy_id, market_type, symbol_canonical, side);
-- Strategy AI review report history.
CREATE TABLE IF NOT EXISTS qd_strategy_review_reports (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
lookback_days INTEGER NOT NULL DEFAULT 30,
language VARCHAR(20) DEFAULT 'zh-CN',
include_ai BOOLEAN DEFAULT TRUE,
ai_status VARCHAR(32) DEFAULT '',
summary TEXT DEFAULT '',
total_net_pnl DECIMAL(20,8) DEFAULT 0,
total_return_pct DECIMAL(20,8) DEFAULT 0,
win_rate DECIMAL(20,8) DEFAULT 0,
profit_factor DECIMAL(20,8) DEFAULT 0,
max_drawdown_pct DECIMAL(20,8) DEFAULT 0,
report_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_review_reports_strategy
ON qd_strategy_review_reports(strategy_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_strategy_review_reports_user
ON qd_strategy_review_reports(user_id, created_at DESC);
-- L1 account position mirror (exchange truth per credential + inst_id + side)
CREATE TABLE IF NOT EXISTS qd_account_positions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
credential_id INTEGER NOT NULL DEFAULT 0,
exchange_id VARCHAR(40) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'swap',
inst_id VARCHAR(80) NOT NULL DEFAULT '',
symbol VARCHAR(50) NOT NULL DEFAULT '',
side VARCHAR(10) NOT NULL DEFAULT '',
size DECIMAL(24, 8) NOT NULL DEFAULT 0,
entry_price DECIMAL(24, 8) DEFAULT 0,
mark_price DECIMAL(24, 8) DEFAULT 0,
unrealized_pnl DECIMAL(24, 8) DEFAULT 0,
raw_json JSONB DEFAULT '{}'::jsonb,
synced_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (credential_id, market_type, inst_id, side)
);
CREATE INDEX IF NOT EXISTS idx_account_pos_user ON qd_account_positions(user_id);
CREATE INDEX IF NOT EXISTS idx_account_pos_cred ON qd_account_positions(credential_id, market_type);
-- Grid cell ladder state (P2). Pre-placed limit orders / user-stream driven
-- fills will land here; today only the scaffolding lives in code (see
-- app.services.live_trading.grid_cells).
CREATE TABLE IF NOT EXISTS qd_grid_cells (
id SERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
symbol VARCHAR(50) NOT NULL,
cell_index INTEGER NOT NULL,
lower_price DECIMAL(20,8) NOT NULL,
upper_price DECIMAL(20,8) NOT NULL,
state VARCHAR(24) NOT NULL DEFAULT 'idle',
leg_size DECIMAL(20,8) DEFAULT 0,
leg_entry_price DECIMAL(20,8) DEFAULT 0,
working_order_id VARCHAR(64) DEFAULT '',
last_event_ts TIMESTAMP DEFAULT NOW(),
extra JSONB DEFAULT '{}'::jsonb,
CONSTRAINT uniq_grid_cell UNIQUE(strategy_id, symbol, cell_index)
);
CREATE INDEX IF NOT EXISTS idx_grid_cells_strategy ON qd_grid_cells(strategy_id);
CREATE INDEX IF NOT EXISTS idx_grid_cells_state ON qd_grid_cells(strategy_id, state);
CREATE TABLE IF NOT EXISTS qd_grid_resting_orders (
id SERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
symbol VARCHAR(50) NOT NULL,
cell_index INTEGER NOT NULL DEFAULT 0,
purpose VARCHAR(24) NOT NULL,
side VARCHAR(8) NOT NULL,
pos_side VARCHAR(8) NOT NULL DEFAULT '',
reduce_only BOOLEAN NOT NULL DEFAULT FALSE,
price DECIMAL(24, 8) NOT NULL,
quantity DECIMAL(24, 8) NOT NULL DEFAULT 0,
quote_amount DECIMAL(24, 8) NOT NULL DEFAULT 0,
client_order_id VARCHAR(64) NOT NULL DEFAULT '',
exchange_order_id VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(20) NOT NULL DEFAULT 'pending',
filled_quantity DECIMAL(24, 8) NOT NULL DEFAULT 0,
avg_fill_price DECIMAL(24, 8) NOT NULL DEFAULT 0,
processed_fill_qty DECIMAL(24, 8) NOT NULL DEFAULT 0,
extra JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_grid_resting_strategy ON qd_grid_resting_orders(strategy_id, status);
-- =============================================================================
-- 5. Pending Orders Queue
-- =============================================================================
CREATE TABLE IF NOT EXISTS pending_orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE SET NULL,
symbol VARCHAR(50) NOT NULL,
signal_type VARCHAR(30) NOT NULL,
signal_ts BIGINT,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
order_intent_id INTEGER NOT NULL DEFAULT 0,
idempotency_key VARCHAR(180) NOT NULL,
market_type VARCHAR(20) DEFAULT 'swap',
order_type VARCHAR(20) DEFAULT 'market',
amount DECIMAL(20,8) DEFAULT 0,
price DECIMAL(20,8) DEFAULT 0,
execution_mode VARCHAR(20) DEFAULT 'signal',
status VARCHAR(20) DEFAULT 'pending',
priority INTEGER DEFAULT 0,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 10,
last_error TEXT DEFAULT '',
payload_json TEXT DEFAULT '',
dispatch_note TEXT DEFAULT '',
exchange_id VARCHAR(50) DEFAULT '',
credential_id INTEGER NOT NULL DEFAULT 0,
inst_id VARCHAR(80) NOT NULL DEFAULT '',
exchange_order_id VARCHAR(100) DEFAULT '',
exchange_response_json TEXT DEFAULT '',
filled DECIMAL(20,8) DEFAULT 0,
avg_price DECIMAL(20,8) DEFAULT 0,
executed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
processed_at TIMESTAMP,
sent_at TIMESTAMP
);
UPDATE pending_orders
SET idempotency_key = 'pending-order-' || id::text
WHERE idempotency_key IS NULL OR idempotency_key = '';
ALTER TABLE pending_orders ALTER COLUMN idempotency_key SET NOT NULL;
ALTER TABLE pending_orders ALTER COLUMN idempotency_key DROP DEFAULT;
CREATE UNIQUE INDEX IF NOT EXISTS uq_pending_orders_idempotency_key ON pending_orders(idempotency_key);
CREATE INDEX IF NOT EXISTS idx_pending_orders_user_id ON pending_orders(user_id);
CREATE INDEX IF NOT EXISTS idx_pending_orders_status ON pending_orders(status);
CREATE INDEX IF NOT EXISTS idx_pending_orders_strategy_id ON pending_orders(strategy_id);
CREATE INDEX IF NOT EXISTS idx_pending_orders_strategy_run_id ON pending_orders(strategy_run_id);
-- =============================================================================
-- 6. Strategy Notifications
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
symbol VARCHAR(50) DEFAULT '',
signal_type VARCHAR(30) DEFAULT '',
channels VARCHAR(255) DEFAULT '',
title VARCHAR(255) DEFAULT '',
message TEXT DEFAULT '',
payload_json TEXT DEFAULT '',
is_read INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON qd_strategy_notifications(user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_strategy_id ON qd_strategy_notifications(strategy_id);
CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON qd_strategy_notifications(is_read);
-- =============================================================================
-- 6a. Indicator Signal Alerts
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_indicator_signal_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
indicator_id INTEGER NOT NULL,
indicator_name VARCHAR(160) DEFAULT '',
market VARCHAR(32) NOT NULL,
symbol VARCHAR(64) NOT NULL,
symbol_name VARCHAR(128) DEFAULT '',
timeframe VARCHAR(16) NOT NULL DEFAULT '1D',
signal_keys TEXT DEFAULT '[]',
channels TEXT DEFAULT '["browser"]',
target_json TEXT DEFAULT '{}',
param_json TEXT DEFAULT '{}',
status VARCHAR(16) NOT NULL DEFAULT 'running',
last_bar_time VARCHAR(64) DEFAULT '',
last_fingerprint VARCHAR(255) DEFAULT '',
last_signal_payload TEXT DEFAULT '{}',
last_error TEXT DEFAULT '',
check_count INTEGER NOT NULL DEFAULT 0,
trigger_count INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMP DEFAULT NOW(),
last_checked_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_indicator_signal_alerts_user_id ON qd_indicator_signal_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_indicator_signal_alerts_status_next ON qd_indicator_signal_alerts(status, next_check_at);
CREATE INDEX IF NOT EXISTS idx_indicator_signal_alerts_indicator_id ON qd_indicator_signal_alerts(indicator_id);
-- =============================================================================
-- 6b. Strategy runtime logs (dashboard / API)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_logs (
id SERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
level VARCHAR(20) DEFAULT 'info',
message TEXT NOT NULL,
timestamp TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_strategy_id ON qd_strategy_logs(strategy_id);
CREATE INDEX IF NOT EXISTS idx_strategy_logs_timestamp ON qd_strategy_logs(timestamp);
-- =============================================================================
-- 7. Indicator Codes
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_indicator_codes (
id serial4 NOT NULL,
user_id int4 DEFAULT 1 NOT NULL,
is_buy int4 DEFAULT 0 NOT NULL,
end_time int8 DEFAULT 1 NOT NULL,
name varchar(255) DEFAULT ''::character varying NOT NULL,
code text NULL,
description text DEFAULT ''::text NULL,
publish_to_community int4 DEFAULT 0 NOT NULL,
pricing_type varchar(20) DEFAULT 'free'::character varying NOT NULL,
price numeric(10, 2) DEFAULT 0 NOT NULL,
is_encrypted int4 DEFAULT 0 NOT NULL,
preview_image varchar(500) DEFAULT ''::character varying NULL,
vip_free boolean DEFAULT false,
createtime int8 NULL,
updatetime int8 NULL,
created_at timestamp DEFAULT now(),
updated_at timestamp DEFAULT now(),
purchase_count int4 DEFAULT 0 NULL,
avg_rating numeric(3, 2) DEFAULT 0 NULL,
rating_count int4 DEFAULT 0 NULL,
view_count int4 DEFAULT 0 NULL,
review_status varchar(20) DEFAULT 'approved'::character varying NULL,
review_note text DEFAULT ''::text NULL,
reviewed_at timestamp NULL,
reviewed_by int4 NULL,
asset_type varchar(32) DEFAULT 'indicator'::character varying NULL,
source_indicator_id int4 NULL,
source_script_source_id int4 NULL,
source_strategy_id int4 NULL,
-- (zh-CN / en-US / ja-JP 绛?锛沶ame_i18n / description_i18n 鏄?LLM 缈昏瘧鐢熸垚鐨?
-- JSONB锛岀粨鏋勫舰濡?{"en-US": "...", "zh-CN": "...", ...}銆?
-- 瑙?app/services/indicator_translator.py 涓?community_service.py:_localize_indicator銆?
source_language varchar(16) DEFAULT NULL,
name_i18n jsonb DEFAULT NULL,
description_i18n jsonb DEFAULT NULL,
CONSTRAINT qd_indicator_codes_pkey PRIMARY KEY (id),
CONSTRAINT qd_indicator_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES qd_users(id) ON DELETE CASCADE
);
-- Upgrade existing installations created before marketplace asset metadata was
-- added. CREATE TABLE IF NOT EXISTS does not add columns to an existing table.
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS vip_free boolean DEFAULT false;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS review_status varchar(20) DEFAULT 'approved';
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS review_note text DEFAULT '';
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS reviewed_at timestamp NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS reviewed_by int4 NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS asset_type varchar(32) DEFAULT 'indicator';
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS source_indicator_id int4 NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS source_script_source_id int4 NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS source_strategy_id int4 NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS source_language varchar(16) DEFAULT NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS name_i18n jsonb DEFAULT NULL;
ALTER TABLE qd_indicator_codes ADD COLUMN IF NOT EXISTS description_i18n jsonb DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_indicator_codes_user_id ON qd_indicator_codes USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_indicator_review_status ON qd_indicator_codes USING btree (review_status);
CREATE INDEX IF NOT EXISTS idx_indicator_codes_source ON qd_indicator_codes USING btree (source_indicator_id);
CREATE INDEX IF NOT EXISTS idx_indicator_codes_source_script ON qd_indicator_codes USING btree (source_script_source_id);
CREATE INDEX IF NOT EXISTS idx_indicator_codes_source_strategy ON qd_indicator_codes USING btree (source_strategy_id);
CREATE TABLE IF NOT EXISTS qd_indicator_code_versions (
id serial4 NOT NULL,
indicator_id int4 NOT NULL,
user_id int4 NOT NULL,
version_no int4 NOT NULL,
name varchar(255) DEFAULT ''::character varying NOT NULL,
description text DEFAULT ''::text NULL,
code text NOT NULL,
created_at timestamp DEFAULT now(),
CONSTRAINT qd_indicator_code_versions_pkey PRIMARY KEY (id),
CONSTRAINT qd_indicator_code_versions_indicator_fkey FOREIGN KEY (indicator_id) REFERENCES qd_indicator_codes(id) ON DELETE CASCADE,
CONSTRAINT qd_indicator_code_versions_user_fkey FOREIGN KEY (user_id) REFERENCES qd_users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_indicator_code_versions_indicator ON qd_indicator_code_versions USING btree (indicator_id, version_no DESC);
CREATE INDEX IF NOT EXISTS idx_indicator_code_versions_user ON qd_indicator_code_versions USING btree (user_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_indicator_code_versions_no ON qd_indicator_code_versions USING btree (indicator_id, version_no);
-- =============================================================================
-- 10. Watchlist
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_watchlist (
id SERIAL PRIMARY KEY,
user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
name VARCHAR(100) DEFAULT '',
exchange_id VARCHAR(50) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'spot',
instrument_id VARCHAR(120) NOT NULL DEFAULT '',
settle_currency VARCHAR(20) NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT uq_watchlist_asset UNIQUE(user_id, market, symbol)
);
CREATE INDEX IF NOT EXISTS idx_watchlist_user_id ON qd_watchlist(user_id);
ALTER TABLE qd_watchlist ADD COLUMN IF NOT EXISTS exchange_id VARCHAR(50) NOT NULL DEFAULT '';
ALTER TABLE qd_watchlist ADD COLUMN IF NOT EXISTS market_type VARCHAR(20) NOT NULL DEFAULT 'spot';
ALTER TABLE qd_watchlist ADD COLUMN IF NOT EXISTS instrument_id VARCHAR(120) NOT NULL DEFAULT '';
ALTER TABLE qd_watchlist ADD COLUMN IF NOT EXISTS settle_currency VARCHAR(20) NOT NULL DEFAULT '';
ALTER TABLE qd_watchlist DROP CONSTRAINT IF EXISTS qd_watchlist_user_id_market_symbol_key;
DELETE FROM qd_watchlist newer
USING qd_watchlist older
WHERE newer.user_id = older.user_id
AND newer.market = older.market
AND newer.symbol = older.symbol
AND newer.id < older.id;
UPDATE qd_watchlist
SET exchange_id = '', market_type = 'spot', instrument_id = ''
WHERE exchange_id <> '' OR market_type <> 'spot' OR instrument_id <> '';
DROP INDEX IF EXISTS uq_watchlist_market_context;
CREATE UNIQUE INDEX IF NOT EXISTS uq_watchlist_asset
ON qd_watchlist(user_id, market, symbol);
-- =============================================================================
-- 10A. Strategy universes and point-in-time membership
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_universes (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES qd_users(id) ON DELETE CASCADE,
code VARCHAR(80) NOT NULL,
name VARCHAR(160) NOT NULL DEFAULT '',
name_i18n_key VARCHAR(160) NOT NULL DEFAULT '',
market VARCHAR(50) NOT NULL DEFAULT '',
universe_type VARCHAR(32) NOT NULL,
source VARCHAR(50) NOT NULL DEFAULT 'manual',
source_ref VARCHAR(160) NOT NULL DEFAULT '',
is_system BOOLEAN NOT NULL DEFAULT FALSE,
status VARCHAR(24) NOT NULL DEFAULT 'active',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_universes_system_code
ON qd_universes(code) WHERE is_system = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS uq_universes_user_code
ON qd_universes(user_id, code) WHERE is_system = FALSE;
CREATE INDEX IF NOT EXISTS idx_universes_user
ON qd_universes(user_id, updated_at DESC);
CREATE TABLE IF NOT EXISTS qd_universe_members (
id BIGSERIAL PRIMARY KEY,
universe_id INTEGER NOT NULL REFERENCES qd_universes(id) ON DELETE CASCADE,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(80) NOT NULL,
name VARCHAR(160) NOT NULL DEFAULT '',
exchange_id VARCHAR(50) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'spot',
instrument_id VARCHAR(120) NOT NULL DEFAULT '',
settle_currency VARCHAR(20) NOT NULL DEFAULT '',
valid_from DATE NOT NULL DEFAULT DATE '1900-01-01',
valid_to DATE,
member_weight DOUBLE PRECISION,
member_rank INTEGER,
source_version VARCHAR(120) NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT ck_universe_member_valid_range
CHECK (valid_to IS NULL OR valid_to > valid_from)
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_universe_member_interval
ON qd_universe_members(
universe_id, market, symbol, exchange_id, market_type, instrument_id, valid_from
);
CREATE INDEX IF NOT EXISTS idx_universe_members_asof
ON qd_universe_members(universe_id, valid_from, valid_to);
CREATE INDEX IF NOT EXISTS idx_universe_members_symbol
ON qd_universe_members(market, symbol);
CREATE TABLE IF NOT EXISTS qd_universe_snapshots (
snapshot_id VARCHAR(36) PRIMARY KEY,
universe_id INTEGER NOT NULL REFERENCES qd_universes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
as_of_date DATE NOT NULL,
source_version VARCHAR(120) NOT NULL DEFAULT '',
content_hash VARCHAR(64) NOT NULL,
member_count INTEGER NOT NULL DEFAULT 0,
members_json JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_universe_snapshot_content
ON qd_universe_snapshots(universe_id, user_id, as_of_date, content_hash);
CREATE INDEX IF NOT EXISTS idx_universe_snapshots_lookup
ON qd_universe_snapshots(user_id, universe_id, as_of_date DESC);
CREATE TABLE IF NOT EXISTS qd_fundamental_snapshots (
id BIGSERIAL PRIMARY KEY,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(80) NOT NULL,
period_end DATE NOT NULL,
available_at DATE NOT NULL,
frequency VARCHAR(20) NOT NULL DEFAULT 'quarterly',
currency VARCHAR(20) NOT NULL DEFAULT '',
revenue DOUBLE PRECISION,
net_income DOUBLE PRECISION,
book_value DOUBLE PRECISION,
shareholder_equity DOUBLE PRECISION,
total_debt DOUBLE PRECISION,
free_cash_flow DOUBLE PRECISION,
shares_outstanding DOUBLE PRECISION,
market_cap DOUBLE PRECISION,
pe_ratio DOUBLE PRECISION,
pb_ratio DOUBLE PRECISION,
return_on_equity DOUBLE PRECISION,
revenue_growth DOUBLE PRECISION,
debt_to_equity DOUBLE PRECISION,
source VARCHAR(80) NOT NULL DEFAULT 'manual',
source_version VARCHAR(120) NOT NULL DEFAULT '',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (market, symbol, period_end, available_at, source)
);
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS revenue DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS net_income DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS book_value DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS shareholder_equity DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS total_debt DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS free_cash_flow DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS shares_outstanding DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS market_cap DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS pe_ratio DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS pb_ratio DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS return_on_equity DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS revenue_growth DOUBLE PRECISION;
ALTER TABLE qd_fundamental_snapshots ADD COLUMN IF NOT EXISTS debt_to_equity DOUBLE PRECISION;
CREATE INDEX IF NOT EXISTS idx_fundamental_snapshots_pit
ON qd_fundamental_snapshots (market, symbol, available_at, period_end);
CREATE TABLE IF NOT EXISTS qd_portfolio_rebalance_plans (
plan_id VARCHAR(36) PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE SET NULL,
portfolio_id VARCHAR(96) NOT NULL DEFAULT '',
universe_id INTEGER REFERENCES qd_universes(id) ON DELETE SET NULL,
universe_snapshot_id VARCHAR(36) NOT NULL DEFAULT '',
rebalance_group_id VARCHAR(128) NOT NULL,
execution_mode VARCHAR(24) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'planned',
signal_time TIMESTAMP NOT NULL,
scheduled_execution_time TIMESTAMP,
equity DOUBLE PRECISION NOT NULL DEFAULT 0,
cash DOUBLE PRECISION NOT NULL DEFAULT 0,
target_weights_json JSONB NOT NULL DEFAULT '{}'::jsonb,
current_weights_json JSONB NOT NULL DEFAULT '{}'::jsonb,
diagnostics_json JSONB NOT NULL DEFAULT '[]'::jsonb,
notification_id INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT ck_portfolio_execution_mode
CHECK (execution_mode IN ('live', 'notify_only'))
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_rebalance_group
ON qd_portfolio_rebalance_plans(user_id, strategy_id, rebalance_group_id);
CREATE INDEX IF NOT EXISTS idx_portfolio_rebalance_plans_user
ON qd_portfolio_rebalance_plans(user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS qd_portfolio_rebalance_orders (
id BIGSERIAL PRIMARY KEY,
plan_id VARCHAR(36) NOT NULL REFERENCES qd_portfolio_rebalance_plans(plan_id) ON DELETE CASCADE,
idempotency_key VARCHAR(180) NOT NULL,
market VARCHAR(50) NOT NULL DEFAULT '',
symbol VARCHAR(80) NOT NULL,
exchange_id VARCHAR(50) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'spot',
side VARCHAR(10) NOT NULL,
action VARCHAR(24) NOT NULL,
quantity DOUBLE PRECISION NOT NULL DEFAULT 0,
reference_price DOUBLE PRECISION NOT NULL DEFAULT 0,
estimated_notional DOUBLE PRECISION NOT NULL DEFAULT 0,
estimated_fee DOUBLE PRECISION NOT NULL DEFAULT 0,
current_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
target_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL DEFAULT 'planned',
order_intent_id INTEGER NOT NULL DEFAULT 0,
pending_order_id BIGINT NOT NULL DEFAULT 0,
actual_quantity DOUBLE PRECISION NOT NULL DEFAULT 0,
actual_price DOUBLE PRECISION NOT NULL DEFAULT 0,
acknowledged_at TIMESTAMP NULL,
error_code VARCHAR(120) NOT NULL DEFAULT '',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_portfolio_rebalance_order_key
ON qd_portfolio_rebalance_orders(idempotency_key);
CREATE INDEX IF NOT EXISTS idx_portfolio_rebalance_orders_plan
ON qd_portfolio_rebalance_orders(plan_id, status);
INSERT INTO qd_universes
(code, name_i18n_key, market, universe_type, source, source_ref, is_system, status)
VALUES
('watchlist', 'universe.catalog.watchlist', 'Mixed', 'watchlist', 'watchlist', 'current_user', TRUE, 'active'),
('csi300', 'universe.catalog.csi300', 'CNStock', 'index', 'provider', '000300.SH', TRUE, 'data_required'),
('csi500', 'universe.catalog.csi500', 'CNStock', 'index', 'provider', '000905.SH', TRUE, 'data_required'),
('sp500', 'universe.catalog.sp500', 'USStock', 'index', 'provider', 'SPX', TRUE, 'data_required'),
('nasdaq100', 'universe.catalog.nasdaq100', 'USStock', 'index', 'provider', 'NDX', TRUE, 'data_required'),
('etf_pool', 'universe.catalog.etfPool', 'Mixed', 'etf', 'provider', 'etf_pool', TRUE, 'data_required'),
('crypto_top100', 'universe.catalog.cryptoTop100', 'Crypto', 'market_cap', 'provider', 'top100', TRUE, 'data_required'),
('hk_equities', 'universe.catalog.hkEquities', 'HKStock', 'market', 'provider', 'hk_equities', TRUE, 'data_required')
ON CONFLICT DO NOTHING;
INSERT INTO qd_universes
(code, name_i18n_key, market, universe_type, source, source_ref, is_system, status)
VALUES
('hk_core', 'universe.catalog.hkCore', 'HKStock', 'market', 'symbol_master', 'HKStock:hot:equity', TRUE, 'active'),
('hk_etf', 'universe.catalog.hkEtf', 'HKStock', 'etf', 'symbol_master', 'HKStock:hot:etf', TRUE, 'active'),
('us_etf', 'universe.catalog.usEtf', 'USStock', 'etf', 'symbol_master', 'USStock:hot:etf', TRUE, 'active')
ON CONFLICT DO NOTHING;
INSERT INTO qd_universes
(code, name, name_i18n_key, market, universe_type, source, source_ref, is_system, status)
VALUES
('hk_hsi_core50', 'Hang Seng Index Core 50', 'universe.catalog.hkHsiCore50', 'HKStock', 'index', 'public_snapshot', 'HSI_CORE50', TRUE, 'data_required'),
('hk_tech30', 'Hang Seng TECH 30', 'universe.catalog.hkTech30', 'HKStock', 'index', 'public_snapshot', 'HSTECH', TRUE, 'data_required'),
('hk_china_enterprises50', 'Hang Seng China Enterprises 50', 'universe.catalog.hkChinaEnterprises50', 'HKStock', 'index', 'public_snapshot', 'HSCEI', TRUE, 'data_required'),
('hk_high_dividend50', 'Hang Seng High Dividend Yield 50', 'universe.catalog.hkHighDividend50', 'HKStock', 'index', 'public_snapshot', 'HSHDYI', TRUE, 'data_required')
ON CONFLICT DO NOTHING;
UPDATE qd_universes SET source_ref = 'USStock:hot:etf', updated_at = NOW()
WHERE code = 'us_etf' AND is_system = TRUE;
UPDATE qd_universes SET source_ref = 'HKStock:hot:etf', updated_at = NOW()
WHERE code = 'hk_etf' AND is_system = TRUE;
UPDATE qd_universes SET status = 'deprecated', updated_at = NOW()
WHERE code IN ('etf_pool', 'hk_equities', 'hk_core') AND is_system = TRUE;
-- =============================================================================
-- 11. Analysis Tasks
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_analysis_tasks (
id SERIAL PRIMARY KEY,
user_id INTEGER DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
model VARCHAR(100) DEFAULT '',
language VARCHAR(20) DEFAULT 'en-US',
status VARCHAR(20) DEFAULT 'completed',
result_json TEXT DEFAULT '',
error_message TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
completed_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_analysis_tasks_user_id ON qd_analysis_tasks(user_id);
CREATE TABLE IF NOT EXISTS qd_ai_strategy_decisions (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER REFERENCES qd_strategies_trading(id) ON DELETE CASCADE,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
decision_key VARCHAR(64) NOT NULL,
profile_name VARCHAR(80) NOT NULL DEFAULT '',
model_id VARCHAR(160) NOT NULL DEFAULT '',
prompt_version VARCHAR(80) NOT NULL DEFAULT '',
prompt_hash VARCHAR(64) NOT NULL,
input_hash VARCHAR(64) NOT NULL,
symbol VARCHAR(80) NOT NULL DEFAULT '',
as_of_time VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(24) NOT NULL,
output_json JSONB NOT NULL DEFAULT '{}'::jsonb,
error_code VARCHAR(120) NOT NULL DEFAULT '',
latency_ms INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_ai_strategy_decision_key
ON qd_ai_strategy_decisions(user_id, strategy_id, decision_key);
CREATE INDEX IF NOT EXISTS idx_ai_strategy_decisions_lookup
ON qd_ai_strategy_decisions(user_id, strategy_id, symbol, created_at DESC);
-- =============================================================================
-- 12. Backtest Runs
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_backtest_runs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER,
source_id INTEGER NOT NULL,
strategy_name VARCHAR(255) DEFAULT '',
market VARCHAR(50) NOT NULL DEFAULT '',
symbol VARCHAR(50) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'spot',
timeframe VARCHAR(10) NOT NULL DEFAULT '',
start_date VARCHAR(20) NOT NULL DEFAULT '',
end_date VARCHAR(20) NOT NULL DEFAULT '',
initial_capital DECIMAL(20,8) DEFAULT 10000,
commission DECIMAL(10,6) DEFAULT 0.001,
slippage DECIMAL(10,6) DEFAULT 0,
leverage INTEGER DEFAULT 1,
params_json TEXT NOT NULL DEFAULT '{}',
manifest_json TEXT NOT NULL DEFAULT '{}',
engine_version VARCHAR(50) DEFAULT '',
code_hash VARCHAR(128) DEFAULT '',
status VARCHAR(20) DEFAULT 'success',
error_message TEXT DEFAULT '',
result_json TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_user_id ON qd_backtest_runs(user_id);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_strategy_id ON qd_backtest_runs(strategy_id);
CREATE INDEX IF NOT EXISTS idx_backtest_runs_source_id ON qd_backtest_runs(source_id);
CREATE TABLE IF NOT EXISTS qd_backtest_trades (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
strategy_id INTEGER,
trade_index INTEGER DEFAULT 0,
trade_time VARCHAR(64) DEFAULT '',
trade_type VARCHAR(64) DEFAULT '',
side VARCHAR(32) DEFAULT '',
price DOUBLE PRECISION DEFAULT 0,
amount DOUBLE PRECISION DEFAULT 0,
profit DOUBLE PRECISION DEFAULT 0,
balance DOUBLE PRECISION DEFAULT 0,
reason VARCHAR(64) DEFAULT '',
payload_json TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_backtest_trades_run_id ON qd_backtest_trades(run_id);
CREATE TABLE IF NOT EXISTS qd_backtest_equity_points (
id SERIAL PRIMARY KEY,
run_id INTEGER NOT NULL,
point_index INTEGER DEFAULT 0,
point_time VARCHAR(64) DEFAULT '',
point_value DOUBLE PRECISION DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_backtest_equity_points_run_id ON qd_backtest_equity_points(run_id);
CREATE TABLE IF NOT EXISTS qd_factor_research_runs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
source_id INTEGER NOT NULL,
source_name VARCHAR(255) DEFAULT '',
market VARCHAR(100) DEFAULT '',
timeframe VARCHAR(10) DEFAULT '',
start_date VARCHAR(20) NOT NULL DEFAULT '',
end_date VARCHAR(20) NOT NULL DEFAULT '',
factor_id VARCHAR(64) NOT NULL DEFAULT '',
groups_count INTEGER NOT NULL DEFAULT 5,
holding_period INTEGER NOT NULL DEFAULT 5,
commission DECIMAL(10,6) DEFAULT 0.001,
slippage DECIMAL(10,6) DEFAULT 0,
neutralize_industry BOOLEAN NOT NULL DEFAULT FALSE,
universe_size INTEGER NOT NULL DEFAULT 0,
manifest_json TEXT NOT NULL DEFAULT '{}',
code_hash VARCHAR(128) DEFAULT '',
result_json TEXT NOT NULL DEFAULT '{}',
status VARCHAR(20) DEFAULT 'success',
error_message TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_factor_research_runs_user_id
ON qd_factor_research_runs(user_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_factor_research_runs_source_id
ON qd_factor_research_runs(source_id, id DESC);
-- =============================================================================
-- 13. Exchange Credentials
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_exchange_credentials (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
name VARCHAR(100) DEFAULT '',
exchange_id VARCHAR(50) NOT NULL,
api_key_hint VARCHAR(50) DEFAULT '',
encrypted_config TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_exchange_credentials_user_id ON qd_exchange_credentials(user_id);
-- =============================================================================
-- 14. Manual Positions
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_manual_positions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
name VARCHAR(100) DEFAULT '',
side VARCHAR(10) DEFAULT 'long',
quantity DECIMAL(20,8) NOT NULL DEFAULT 0,
entry_price DECIMAL(20,8) NOT NULL DEFAULT 0,
entry_time BIGINT,
notes TEXT DEFAULT '',
tags TEXT DEFAULT '',
group_name VARCHAR(100) DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, market, symbol, side, group_name)
);
CREATE INDEX IF NOT EXISTS idx_manual_positions_user_id ON qd_manual_positions(user_id);
-- =============================================================================
-- 15. Position Alerts
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_position_alerts (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
position_id INTEGER,
market VARCHAR(50) DEFAULT '',
symbol VARCHAR(50) DEFAULT '',
alert_type VARCHAR(30) NOT NULL,
threshold DECIMAL(20,8) NOT NULL DEFAULT 0,
notification_config TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
is_triggered INTEGER DEFAULT 0,
last_triggered_at TIMESTAMP,
trigger_count INTEGER DEFAULT 0,
repeat_interval INTEGER DEFAULT 0,
notes TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_position_alerts_user_id ON qd_position_alerts(user_id);
CREATE INDEX IF NOT EXISTS idx_position_alerts_position_id ON qd_position_alerts(position_id);
-- =============================================================================
-- 16. Position Monitors
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_position_monitors (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
name VARCHAR(100) DEFAULT '',
position_ids TEXT DEFAULT '',
monitor_type VARCHAR(20) DEFAULT 'ai',
config TEXT DEFAULT '',
notification_config TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
last_run_at TIMESTAMP,
next_run_at TIMESTAMP,
last_result TEXT DEFAULT '',
run_count INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_position_monitors_user_id ON qd_position_monitors(user_id);
-- =============================================================================
-- 17. Market Symbols (Seed Data)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_market_symbols (
id SERIAL PRIMARY KEY,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
name VARCHAR(255) DEFAULT '',
exchange VARCHAR(50) DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'spot',
instrument_id VARCHAR(120) NOT NULL DEFAULT '',
settle_currency VARCHAR(20) NOT NULL DEFAULT '',
asset_class VARCHAR(20) NOT NULL DEFAULT 'crypto',
currency VARCHAR(10) DEFAULT '',
is_active INTEGER DEFAULT 1,
is_hot INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(market, symbol, exchange, market_type, instrument_id)
);
CREATE INDEX IF NOT EXISTS idx_market_symbols_market ON qd_market_symbols(market);
CREATE INDEX IF NOT EXISTS idx_market_symbols_is_hot ON qd_market_symbols(market, is_hot);
CREATE INDEX IF NOT EXISTS idx_market_symbols_market_upper_symbol
ON qd_market_symbols(market, UPPER(symbol));
CREATE TABLE IF NOT EXISTS qd_market_sync_runs (
id BIGSERIAL PRIMARY KEY,
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual',
status VARCHAR(20) NOT NULL DEFAULT 'running',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
result JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_market_sync_runs_running
ON qd_market_sync_runs ((status)) WHERE status = 'running';
CREATE INDEX IF NOT EXISTS idx_market_sync_runs_started
ON qd_market_sync_runs(started_at DESC);
ALTER TABLE qd_market_symbols ADD COLUMN IF NOT EXISTS market_type VARCHAR(20) NOT NULL DEFAULT 'spot';
ALTER TABLE qd_market_symbols ADD COLUMN IF NOT EXISTS instrument_id VARCHAR(120) NOT NULL DEFAULT '';
ALTER TABLE qd_market_symbols ADD COLUMN IF NOT EXISTS settle_currency VARCHAR(20) NOT NULL DEFAULT '';
ALTER TABLE qd_market_symbols ADD COLUMN IF NOT EXISTS asset_class VARCHAR(20) NOT NULL DEFAULT 'crypto';
UPDATE qd_market_symbols SET asset_class = 'equity'
WHERE market IN ('CNStock', 'HKStock', 'USStock', 'MOEX') AND asset_class = 'crypto';
UPDATE qd_market_symbols SET asset_class = 'forex'
WHERE market = 'Forex' AND asset_class = 'crypto';
UPDATE qd_market_symbols SET asset_class = 'futures'
WHERE market = 'Futures' AND asset_class = 'crypto';
UPDATE qd_market_symbols SET is_hot = 1, sort_order = GREATEST(sort_order, 80)
WHERE market = 'HKStock' AND asset_class = 'etf' AND symbol IN (
'02800','02801','02823','02828','02840','02846','03032','03033','03037',
'03040','03067','03075','03088','03110','03188','03191','03416','03437'
);
UPDATE qd_market_symbols SET is_hot = 1, sort_order = GREATEST(sort_order, 80)
WHERE market = 'USStock' AND asset_class = 'etf' AND symbol IN (
'SPY','QQQ','IWM','DIA','VTI','VOO','IVV','EFA','EEM','AGG','BND','TLT','IEF',
'GLD','SLV','USO','XLF','XLK','XLE','XLV','XLI','XLY','XLP','XLU','VNQ','ARKK',
'HYG','LQD','SCHD','VUG','VTV'
);
ALTER TABLE qd_market_symbols DROP CONSTRAINT IF EXISTS qd_market_symbols_market_symbol_key;
CREATE UNIQUE INDEX IF NOT EXISTS uq_market_symbols_venue_instrument
ON qd_market_symbols(market, symbol, exchange, market_type, instrument_id);
UPDATE qd_market_symbols
SET is_active = 0
WHERE market = 'Crypto'
AND exchange <> ''
AND exchange NOT IN ('binance', 'bitget', 'bybit', 'okx', 'gate', 'htx');
CREATE TABLE IF NOT EXISTS qd_market_symbol_aliases (
id SERIAL PRIMARY KEY,
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
alias VARCHAR(255) NOT NULL,
is_active INTEGER DEFAULT 1,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(market, symbol, alias)
);
CREATE INDEX IF NOT EXISTS idx_market_symbol_aliases_lookup
ON qd_market_symbol_aliases(market, alias);
CREATE INDEX IF NOT EXISTS idx_market_symbol_aliases_upper_lookup
ON qd_market_symbol_aliases(market, UPPER(alias));
-- Seed data: Hot symbols for each market
INSERT INTO qd_market_symbols (market, symbol, name, exchange, currency, is_active, is_hot, sort_order) VALUES
-- USStock (US Stocks)
('USStock', 'AAPL', 'Apple Inc.', 'NASDAQ', 'USD', 1, 1, 100),
('USStock', 'MSFT', 'Microsoft Corporation', 'NASDAQ', 'USD', 1, 1, 99),
('USStock', 'GOOGL', 'Alphabet Inc.', 'NASDAQ', 'USD', 1, 1, 98),
('USStock', 'AMZN', 'Amazon.com Inc.', 'NASDAQ', 'USD', 1, 1, 97),
('USStock', 'TSLA', 'Tesla, Inc.', 'NASDAQ', 'USD', 1, 1, 96),
('USStock', 'META', 'Meta Platforms Inc.', 'NASDAQ', 'USD', 1, 1, 95),
('USStock', 'NVDA', 'NVIDIA Corporation', 'NASDAQ', 'USD', 1, 1, 94),
('USStock', 'JPM', 'JPMorgan Chase & Co.', 'NYSE', 'USD', 1, 1, 93),
('USStock', 'V', 'Visa Inc.', 'NYSE', 'USD', 1, 1, 92),
('USStock', 'JNJ', 'Johnson & Johnson', 'NYSE', 'USD', 1, 1, 91),
-- Crypto (major + popular altcoins)
('Crypto', 'BTC/USDT', 'Bitcoin', 'Binance', 'USDT', 1, 1, 100),
('Crypto', 'ETH/USDT', 'Ethereum', 'Binance', 'USDT', 1, 1, 99),
('Crypto', 'BNB/USDT', 'BNB', 'Binance', 'USDT', 1, 1, 98),
('Crypto', 'SOL/USDT', 'Solana', 'Binance', 'USDT', 1, 1, 97),
('Crypto', 'XRP/USDT', 'Ripple', 'Binance', 'USDT', 1, 1, 96),
('Crypto', 'ADA/USDT', 'Cardano', 'Binance', 'USDT', 1, 1, 95),
('Crypto', 'DOGE/USDT', 'Dogecoin', 'Binance', 'USDT', 1, 1, 94),
('Crypto', 'DOT/USDT', 'Polkadot', 'Binance', 'USDT', 1, 1, 93),
('Crypto', 'POL/USDT', 'Polygon', 'Binance', 'USDT', 1, 1, 92),
('Crypto', 'AVAX/USDT', 'Avalanche', 'Binance', 'USDT', 1, 1, 91),
-- Layer 1 / Layer 2
('Crypto', 'LINK/USDT', 'Chainlink', 'Binance', 'USDT', 1, 1, 90),
('Crypto', 'UNI/USDT', 'Uniswap', 'Binance', 'USDT', 1, 1, 89),
('Crypto', 'ATOM/USDT', 'Cosmos', 'Binance', 'USDT', 1, 1, 88),
('Crypto', 'LTC/USDT', 'Litecoin', 'Binance', 'USDT', 1, 1, 87),
('Crypto', 'FIL/USDT', 'Filecoin', 'Binance', 'USDT', 1, 1, 86),
('Crypto', 'NEAR/USDT', 'NEAR Protocol', 'Binance', 'USDT', 1, 1, 85),
('Crypto', 'APT/USDT', 'Aptos', 'Binance', 'USDT', 1, 1, 84),
('Crypto', 'SUI/USDT', 'Sui', 'Binance', 'USDT', 1, 1, 83),
('Crypto', 'ARB/USDT', 'Arbitrum', 'Binance', 'USDT', 1, 1, 82),
('Crypto', 'OP/USDT', 'Optimism', 'Binance', 'USDT', 1, 1, 81),
('Crypto', 'SEI/USDT', 'Sei', 'Binance', 'USDT', 1, 1, 80),
('Crypto', 'TIA/USDT', 'Celestia', 'Binance', 'USDT', 1, 1, 79),
('Crypto', 'INJ/USDT', 'Injective', 'Binance', 'USDT', 1, 1, 78),
('Crypto', 'FTM/USDT', 'Fantom', 'Binance', 'USDT', 1, 1, 77),
('Crypto', 'ALGO/USDT', 'Algorand', 'Binance', 'USDT', 1, 1, 76),
('Crypto', 'HBAR/USDT', 'Hedera', 'Binance', 'USDT', 1, 1, 75),
('Crypto', 'ICP/USDT', 'Internet Computer', 'Binance', 'USDT', 1, 1, 74),
('Crypto', 'VET/USDT', 'VeChain', 'Binance', 'USDT', 1, 1, 73),
('Crypto', 'SAND/USDT', 'The Sandbox', 'Binance', 'USDT', 1, 1, 72),
('Crypto', 'MANA/USDT', 'Decentraland', 'Binance', 'USDT', 1, 1, 71),
-- DeFi
('Crypto', 'AAVE/USDT', 'Aave', 'Binance', 'USDT', 1, 1, 70),
('Crypto', 'MKR/USDT', 'Maker', 'Binance', 'USDT', 1, 1, 69),
('Crypto', 'CRV/USDT', 'Curve DAO', 'Binance', 'USDT', 1, 1, 68),
('Crypto', 'COMP/USDT', 'Compound', 'Binance', 'USDT', 1, 1, 67),
('Crypto', 'SNX/USDT', 'Synthetix', 'Binance', 'USDT', 1, 1, 66),
('Crypto', 'SUSHI/USDT', 'SushiSwap', 'Binance', 'USDT', 1, 1, 65),
('Crypto', 'DYDX/USDT', 'dYdX', 'Binance', 'USDT', 1, 1, 64),
('Crypto', 'LDO/USDT', 'Lido DAO', 'Binance', 'USDT', 1, 1, 63),
('Crypto', 'PENDLE/USDT', 'Pendle', 'Binance', 'USDT', 1, 1, 62),
('Crypto', 'JUP/USDT', 'Jupiter', 'Binance', 'USDT', 1, 1, 61),
-- Meme coins
('Crypto', 'SHIB/USDT', 'Shiba Inu', 'Binance', 'USDT', 1, 1, 60),
('Crypto', 'PEPE/USDT', 'Pepe', 'Binance', 'USDT', 1, 1, 59),
('Crypto', 'WIF/USDT', 'dogwifhat', 'Binance', 'USDT', 1, 1, 58),
('Crypto', 'FLOKI/USDT', 'Floki', 'Binance', 'USDT', 1, 1, 57),
('Crypto', 'BONK/USDT', 'Bonk', 'Binance', 'USDT', 1, 1, 56),
('Crypto', 'MEME/USDT', 'Memecoin', 'Binance', 'USDT', 1, 1, 55),
('Crypto', 'TURBO/USDT', 'Turbo', 'Binance', 'USDT', 1, 1, 54),
('Crypto', 'NEIRO/USDT', 'Neiro', 'Binance', 'USDT', 1, 1, 53),
-- AI / Infra
('Crypto', 'RENDER/USDT', 'Render', 'Binance', 'USDT', 1, 1, 52),
('Crypto', 'FET/USDT', 'Fetch.ai', 'Binance', 'USDT', 1, 1, 51),
('Crypto', 'RNDR/USDT', 'Render Network', 'Binance', 'USDT', 1, 1, 50),
('Crypto', 'TAO/USDT', 'Bittensor', 'Binance', 'USDT', 1, 1, 49),
('Crypto', 'WLD/USDT', 'Worldcoin', 'Binance', 'USDT', 1, 1, 48),
('Crypto', 'AR/USDT', 'Arweave', 'Binance', 'USDT', 1, 1, 47),
('Crypto', 'STX/USDT', 'Stacks', 'Binance', 'USDT', 1, 1, 46),
('Crypto', 'ORDI/USDT', 'ORDI', 'Binance', 'USDT', 1, 1, 45),
-- Others
('Crypto', 'TRX/USDT', 'Tron', 'Binance', 'USDT', 1, 1, 44),
('Crypto', 'ETC/USDT', 'Ethereum Classic', 'Binance', 'USDT', 1, 1, 43),
('Crypto', 'THETA/USDT', 'Theta Network', 'Binance', 'USDT', 1, 1, 42),
('Crypto', 'EOS/USDT', 'EOS', 'Binance', 'USDT', 1, 1, 41),
('Crypto', 'XLM/USDT', 'Stellar', 'Binance', 'USDT', 1, 1, 40),
('Crypto', 'GALA/USDT', 'Gala', 'Binance', 'USDT', 1, 1, 39),
('Crypto', 'IMX/USDT', 'Immutable X', 'Binance', 'USDT', 1, 1, 38),
('Crypto', 'CFX/USDT', 'Conflux', 'Binance', 'USDT', 1, 1, 37),
('Crypto', 'JASMY/USDT', 'JasmyCoin', 'Binance', 'USDT', 1, 1, 36),
('Crypto', 'CHZ/USDT', 'Chiliz', 'Binance', 'USDT', 1, 1, 35),
('Crypto', 'GMT/USDT', 'STEPN', 'Binance', 'USDT', 1, 1, 34),
('Crypto', 'CAKE/USDT', 'PancakeSwap', 'Binance', 'USDT', 1, 1, 33),
('Crypto', '1INCH/USDT', '1inch', 'Binance', 'USDT', 1, 1, 32),
('Crypto', 'ENS/USDT', 'Ethereum Name Service', 'Binance', 'USDT', 1, 1, 31),
('Crypto', 'BLUR/USDT', 'Blur', 'Binance', 'USDT', 1, 1, 30),
-- Forex
('Forex', 'XAUUSD', 'Gold/USD', 'Forex', 'USD', 1, 1, 100),
('Forex', 'XAGUSD', 'Silver/USD', 'Forex', 'USD', 1, 1, 99),
('Forex', 'EURUSD', 'Euro/US Dollar', 'Forex', 'USD', 1, 1, 98),
('Forex', 'GBPUSD', 'British Pound/US Dollar', 'Forex', 'USD', 1, 1, 97),
('Forex', 'USDJPY', 'US Dollar/Japanese Yen', 'Forex', 'USD', 1, 1, 96),
('Forex', 'AUDUSD', 'Australian Dollar/US Dollar', 'Forex', 'USD', 1, 1, 95),
('Forex', 'USDCAD', 'US Dollar/Canadian Dollar', 'Forex', 'USD', 1, 1, 94),
('Forex', 'NZDUSD', 'New Zealand Dollar/US Dollar', 'Forex', 'USD', 1, 1, 93),
('Forex', 'USDCHF', 'US Dollar/Swiss Franc', 'Forex', 'EUR', 1, 1, 92),
('Forex', 'EURJPY', 'Euro/Japanese Yen', 'Forex', 'EUR', 1, 1, 91),
-- Futures
('Futures', 'CL', 'WTI Crude Oil', 'NYMEX', 'USD', 1, 1, 100),
('Futures', 'GC', 'Gold', 'COMEX', 'USD', 1, 1, 99),
('Futures', 'SI', 'Silver', 'COMEX', 'USD', 1, 1, 98),
('Futures', 'NG', 'Natural Gas', 'NYMEX', 'USD', 1, 1, 97),
('Futures', 'HG', 'Copper', 'COMEX', 'USD', 1, 1, 96),
('Futures', 'ZC', 'Corn', 'CBOT', 'USD', 1, 1, 95),
('Futures', 'ZS', 'Soybeans', 'CBOT', 'USD', 1, 1, 94),
('Futures', 'ZW', 'Wheat', 'CBOT', 'USD', 1, 1, 93),
('Futures', 'ES', 'S&P 500 E-mini', 'CME', 'USD', 1, 1, 92),
('Futures', 'NQ', 'NASDAQ 100 E-mini', 'CME', 'USD', 1, 1, 91),
-- A-share hot symbols use the canonical exchange identifier from the symbol master.
('CNStock', '600519', '贵州茅台', 'CN', 'CNY', 1, 1, 100),
('CNStock', '600036', '招商银行', 'CN', 'CNY', 1, 1, 99),
('CNStock', '601318', '中国平安', 'CN', 'CNY', 1, 1, 98),
('CNStock', '600900', '长江电力', 'CN', 'CNY', 1, 1, 97),
('CNStock', '601899', '紫金矿业', 'CN', 'CNY', 1, 1, 96),
('CNStock', '000858', '五粮液', 'CN', 'CNY', 1, 1, 95),
('CNStock', '000333', '美的集团', 'CN', 'CNY', 1, 1, 94),
('CNStock', '002594', '比亚迪', 'CN', 'CNY', 1, 1, 93),
('CNStock', '300750', '宁德时代', 'CN', 'CNY', 1, 1, 92),
('CNStock', '000001', '平安银行', 'CN', 'CNY', 1, 1, 91),
-- Hong Kong hot symbols.
('HKStock', '00700', '腾讯控股', 'HKEX', 'HKD', 1, 1, 100),
('HKStock', '09988', '阿里巴巴-W', 'HKEX', 'HKD', 1, 1, 99),
('HKStock', '03690', '美团-W', 'HKEX', 'HKD', 1, 1, 98),
('HKStock', '01810', '小米集团-W', 'HKEX', 'HKD', 1, 1, 97),
('HKStock', '00939', '建设银行', 'HKEX', 'HKD', 1, 1, 96),
('HKStock', '01299', '友邦保险', 'HKEX', 'HKD', 1, 1, 95),
('HKStock', '02318', '中国平安', 'HKEX', 'HKD', 1, 1, 94),
('HKStock', '00388', '香港交易所', 'HKEX', 'HKD', 1, 1, 93),
('HKStock', '00883', '中国海洋石油', 'HKEX', 'HKD', 1, 1, 92),
('HKStock', '01398', '工商银行', 'HKEX', 'HKD', 1, 1, 91),
-- MOEX (Moscow Exchange) blue chips
-- Tickers are the MOEX ISS instrument codes; resolve_symbol_name() upgrades
-- the display name from MOEX ISS securities/<sym>.json on first lookup.
('MOEX', 'SBER', 'Sberbank', 'MOEX', 'RUB', 1, 1, 100),
('MOEX', 'GAZP', 'Gazprom', 'MOEX', 'RUB', 1, 1, 99),
('MOEX', 'LKOH', 'Lukoil', 'MOEX', 'RUB', 1, 1, 98),
('MOEX', 'ROSN', 'Rosneft', 'MOEX', 'RUB', 1, 1, 97),
('MOEX', 'GMKN', 'Nornickel', 'MOEX', 'RUB', 1, 1, 96),
('MOEX', 'NVTK', 'Novatek', 'MOEX', 'RUB', 1, 1, 95),
('MOEX', 'TATN', 'Tatneft', 'MOEX', 'RUB', 1, 1, 94),
('MOEX', 'VTBR', 'VTB Bank', 'MOEX', 'RUB', 1, 1, 93),
('MOEX', 'MGNT', 'Magnit', 'MOEX', 'RUB', 1, 1, 92),
('MOEX', 'YNDX', 'Yandex', 'MOEX', 'RUB', 1, 1, 91),
('MOEX', 'SBERP', 'Sberbank Preferred','MOEX', 'RUB', 1, 1, 90),
('MOEX', 'PLZL', 'Polyus', 'MOEX', 'RUB', 1, 1, 89),
('MOEX', 'CHMF', 'Severstal', 'MOEX', 'RUB', 1, 1, 88),
('MOEX', 'ALRS', 'Alrosa', 'MOEX', 'RUB', 1, 1, 87),
('MOEX', 'MOEX', 'Moscow Exchange', 'MOEX', 'RUB', 1, 1, 86)
ON CONFLICT (market, symbol, exchange, market_type, instrument_id) DO NOTHING;
-- Remove legacy A-share rows that used venue-specific exchange identifiers.
-- Canonical symbol-master rows use exchange = 'CN'; the old identifiers caused
-- duplicate search results because exchange is part of the uniqueness key.
UPDATE qd_market_symbols canonical
SET is_hot = GREATEST(canonical.is_hot, legacy.is_hot),
sort_order = GREATEST(canonical.sort_order, legacy.sort_order)
FROM qd_market_symbols legacy
WHERE canonical.market = 'CNStock'
AND canonical.exchange = 'CN'
AND legacy.market = canonical.market
AND legacy.symbol = canonical.symbol
AND legacy.exchange IN ('SSE', 'SZSE')
AND legacy.market_type = canonical.market_type
AND legacy.instrument_id = canonical.instrument_id;
DELETE FROM qd_market_symbols legacy
USING qd_market_symbols canonical
WHERE legacy.market = 'CNStock'
AND legacy.exchange IN ('SSE', 'SZSE')
AND canonical.market = legacy.market
AND canonical.symbol = legacy.symbol
AND canonical.exchange = 'CN'
AND canonical.market_type = legacy.market_type
AND canonical.instrument_id = legacy.instrument_id;
-- =============================================================================
-- 19.5. Analysis Memory (Fast AI Analysis Memory System)
-- =============================================================================
-- Stores AI analysis results for history, feedback, and learning.
CREATE TABLE IF NOT EXISTS qd_analysis_memory (
id SERIAL PRIMARY KEY,
user_id INT, -- User who created this analysis (for filtering)
market VARCHAR(50) NOT NULL,
symbol VARCHAR(50) NOT NULL,
decision VARCHAR(10) NOT NULL,
confidence INT DEFAULT 50,
price_at_analysis DECIMAL(24, 8),
summary TEXT,
reasons JSONB,
scores JSONB,
indicators_snapshot JSONB,
raw_result JSONB, -- Full analysis result for history replay
consensus_score DECIMAL(24, 8),
consensus_abs DECIMAL(24, 8),
agreement_ratio DECIMAL(10, 6),
quality_multiplier DECIMAL(10, 6),
created_at TIMESTAMP DEFAULT NOW(),
validated_at TIMESTAMP,
actual_outcome VARCHAR(20),
actual_return_pct DECIMAL(10, 4),
was_correct BOOLEAN,
user_feedback VARCHAR(20), -- helpful/not_helpful
feedback_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_symbol ON qd_analysis_memory(market, symbol);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_created ON qd_analysis_memory(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_analysis_memory_validated ON qd_analysis_memory(validated_at) WHERE validated_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_analysis_memory_user ON qd_analysis_memory(user_id);
-- Migration: Add user_id column to existing qd_analysis_memory table
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_analysis_memory' AND column_name = 'user_id'
) THEN
ALTER TABLE qd_analysis_memory ADD COLUMN user_id INT;
CREATE INDEX IF NOT EXISTS idx_analysis_memory_user ON qd_analysis_memory(user_id);
RAISE NOTICE 'Added user_id column to qd_analysis_memory';
END IF;
END $$;
-- =============================================================================
-- 20. Migration: Add token_version for single-client login
-- =============================================================================
-- This migration adds token_version column for enforcing single-client login.
-- When a user logs in from a new device, the token_version is incremented,
-- invalidating all previous tokens and forcing other sessions to logout.
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_users' AND column_name = 'token_version'
) THEN
ALTER TABLE qd_users ADD COLUMN token_version INTEGER DEFAULT 1;
RAISE NOTICE 'Added token_version column to qd_users table';
END IF;
END $$;
-- =============================================================================
-- 20b. Migration: user profile timezone (IANA)
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_users' AND column_name = 'timezone'
) THEN
ALTER TABLE qd_users ADD COLUMN timezone VARCHAR(64) DEFAULT '';
RAISE NOTICE 'Added timezone column to qd_users table';
END IF;
END $$;
-- =============================================================================
-- 20c. Migration: password_changed_at (initial password reminder)
-- =============================================================================
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_users' AND column_name = 'password_changed_at'
) THEN
ALTER TABLE qd_users ADD COLUMN password_changed_at TIMESTAMP NULL;
-- One-time backfill when upgrading old DBs (skip on fresh installs after bootstrap user exists)
UPDATE qd_users
SET password_changed_at = COALESCE(updated_at, created_at, NOW())
WHERE password_changed_at IS NULL;
RAISE NOTICE 'Added password_changed_at column to qd_users table (existing users backfilled)';
END IF;
END $$;
-- =============================================================================
-- 20e. Stateful Strategy API runtime and order intent infrastructure
-- =============================================================================
CREATE TABLE IF NOT EXISTS strategy_runs (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1,
strategy_id INTEGER NOT NULL,
source_version_id VARCHAR(64) NOT NULL DEFAULT '',
code_hash VARCHAR(128) NOT NULL DEFAULT '',
parameter_snapshot_json JSONB NOT NULL DEFAULT '{}'::jsonb,
account_id VARCHAR(64) NOT NULL DEFAULT '',
exchange_id VARCHAR(50) NOT NULL DEFAULT '',
credential_id INTEGER NOT NULL DEFAULT 0,
symbol VARCHAR(80) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'swap',
position_mode VARCHAR(20) NOT NULL DEFAULT '',
runtime_status VARCHAR(32) NOT NULL DEFAULT 'running',
runtime_epoch BIGINT NOT NULL DEFAULT 1,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
stopped_at TIMESTAMP,
stop_reason TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_strategy_runs_strategy ON strategy_runs(strategy_id, runtime_status);
CREATE INDEX IF NOT EXISTS idx_strategy_runs_started ON strategy_runs(started_at DESC);
CREATE TABLE IF NOT EXISTS strategy_runtime_state (
id SERIAL PRIMARY KEY,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
strategy_id INTEGER NOT NULL,
state_key VARCHAR(128) NOT NULL,
state_json JSONB NOT NULL DEFAULT '{}'::jsonb,
version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(strategy_run_id, strategy_id, state_key)
);
CREATE INDEX IF NOT EXISTS idx_strategy_runtime_state_strategy ON strategy_runtime_state(strategy_id);
CREATE TABLE IF NOT EXISTS strategy_order_intents (
id SERIAL PRIMARY KEY,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
strategy_id INTEGER NOT NULL,
idempotency_key VARCHAR(180) NOT NULL,
symbol VARCHAR(80) NOT NULL,
market_type VARCHAR(20) NOT NULL DEFAULT 'swap',
side VARCHAR(10) NOT NULL,
position_side VARCHAR(10) NOT NULL DEFAULT '',
reduce_only BOOLEAN NOT NULL DEFAULT FALSE,
order_type VARCHAR(24) NOT NULL DEFAULT 'market',
quantity DECIMAL(28, 12) NOT NULL DEFAULT 0,
notional DECIMAL(28, 12) NOT NULL DEFAULT 0,
limit_price DECIMAL(28, 12) NOT NULL DEFAULT 0,
execution_algo VARCHAR(32) NOT NULL DEFAULT 'market',
portfolio_id VARCHAR(96) NOT NULL DEFAULT '',
universe_id VARCHAR(96) NOT NULL DEFAULT '',
rebalance_group_id VARCHAR(128) NOT NULL DEFAULT '',
target_weight DECIMAL(18, 10),
target_notional DECIMAL(28, 12),
target_position_qty DECIMAL(28, 12),
status VARCHAR(32) NOT NULL DEFAULT 'intent_created',
client_order_id VARCHAR(100) NOT NULL DEFAULT '',
exchange_order_id VARCHAR(100) NOT NULL DEFAULT '',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(strategy_run_id, idempotency_key)
);
CREATE INDEX IF NOT EXISTS idx_strategy_order_intents_strategy ON strategy_order_intents(strategy_id, status);
CREATE TABLE IF NOT EXISTS strategy_order_fills (
id SERIAL PRIMARY KEY,
order_intent_id INTEGER NOT NULL DEFAULT 0,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
strategy_id INTEGER NOT NULL DEFAULT 0,
exchange_id VARCHAR(50) NOT NULL DEFAULT '',
exchange_order_id VARCHAR(100) NOT NULL DEFAULT '',
exchange_fill_id VARCHAR(128) NOT NULL DEFAULT '',
side VARCHAR(10) NOT NULL DEFAULT '',
position_side VARCHAR(10) NOT NULL DEFAULT '',
price DECIMAL(28, 12) NOT NULL DEFAULT 0,
quantity DECIMAL(28, 12) NOT NULL DEFAULT 0,
notional DECIMAL(28, 12) NOT NULL DEFAULT 0,
fee DECIMAL(28, 12) NOT NULL DEFAULT 0,
fee_ccy VARCHAR(20) NOT NULL DEFAULT '',
filled_at TIMESTAMP NOT NULL DEFAULT NOW(),
raw_json JSONB NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS idx_strategy_order_fills_intent ON strategy_order_fills(order_intent_id);
CREATE INDEX IF NOT EXISTS idx_strategy_order_fills_strategy ON strategy_order_fills(strategy_id, filled_at DESC);
CREATE TABLE IF NOT EXISTS strategy_runtime_events (
id SERIAL PRIMARY KEY,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
strategy_id INTEGER NOT NULL DEFAULT 0,
event_type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
message TEXT NOT NULL DEFAULT '',
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_runtime_events_run ON strategy_runtime_events(strategy_run_id, created_at DESC);
CREATE TABLE IF NOT EXISTS strategy_runtime_locks (
lock_key VARCHAR(180) PRIMARY KEY,
strategy_run_id INTEGER NOT NULL DEFAULT 0,
runtime_epoch BIGINT NOT NULL DEFAULT 1,
owner VARCHAR(100) NOT NULL DEFAULT '',
expires_at TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- =============================================================================
-- Durable process roles, strategy commands, runtime leases, and worker health
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_strategy_commands (
id BIGSERIAL PRIMARY KEY,
strategy_id INTEGER NOT NULL,
user_id INTEGER NOT NULL DEFAULT 0,
command_type VARCHAR(24) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'pending',
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
payload_json JSONB NOT NULL DEFAULT '{}'::jsonb,
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
error_message TEXT NOT NULL DEFAULT '',
attempts INTEGER NOT NULL DEFAULT 0,
available_at TIMESTAMP NOT NULL DEFAULT NOW(),
claimed_by VARCHAR(160) NOT NULL DEFAULT '',
claimed_at TIMESTAMP,
lease_expires_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (command_type IN ('start', 'stop', 'restart', 'reconcile')),
CHECK (status IN ('pending', 'processing', 'succeeded', 'failed', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_strategy_commands_claim
ON qd_strategy_commands(status, available_at, id);
CREATE INDEX IF NOT EXISTS idx_strategy_commands_strategy
ON qd_strategy_commands(strategy_id, id DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_strategy_commands_active_action
ON qd_strategy_commands(strategy_id, command_type)
WHERE status IN ('pending', 'processing');
CREATE TABLE IF NOT EXISTS qd_strategy_runtime_leases (
strategy_id INTEGER PRIMARY KEY,
owner_id VARCHAR(160) NOT NULL,
fencing_token BIGINT NOT NULL DEFAULT 1,
lease_expires_at TIMESTAMP NOT NULL,
heartbeat_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_strategy_runtime_leases_expiry
ON qd_strategy_runtime_leases(lease_expires_at);
CREATE TABLE IF NOT EXISTS qd_worker_heartbeats (
worker_id VARCHAR(160) PRIMARY KEY,
role VARCHAR(32) NOT NULL,
status VARCHAR(24) NOT NULL DEFAULT 'running',
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
started_at TIMESTAMP NOT NULL DEFAULT NOW(),
heartbeat_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CHECK (role IN ('api', 'trading', 'scheduler', 'celery', 'celery-beat')),
CHECK (status IN ('running', 'stopped', 'failed'))
);
CREATE INDEX IF NOT EXISTS idx_worker_heartbeats_role
ON qd_worker_heartbeats(role, heartbeat_at DESC);
CREATE TABLE IF NOT EXISTS qd_process_leases (
lease_key VARCHAR(128) PRIMARY KEY,
owner_id VARCHAR(160) NOT NULL,
lease_expires_at TIMESTAMP NOT NULL,
heartbeat_at TIMESTAMP NOT NULL DEFAULT NOW(),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_process_leases_expiry
ON qd_process_leases(lease_expires_at);
CREATE TABLE IF NOT EXISTS qd_account_positions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 1 REFERENCES qd_users(id) ON DELETE CASCADE,
credential_id INTEGER NOT NULL DEFAULT 0,
exchange_id VARCHAR(40) NOT NULL DEFAULT '',
market_type VARCHAR(20) NOT NULL DEFAULT 'swap',
inst_id VARCHAR(80) NOT NULL DEFAULT '',
symbol VARCHAR(50) NOT NULL DEFAULT '',
side VARCHAR(10) NOT NULL DEFAULT '',
size DECIMAL(24, 8) NOT NULL DEFAULT 0,
entry_price DECIMAL(24, 8) DEFAULT 0,
mark_price DECIMAL(24, 8) DEFAULT 0,
unrealized_pnl DECIMAL(24, 8) DEFAULT 0,
raw_json JSONB DEFAULT '{}'::jsonb,
synced_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE (credential_id, market_type, inst_id, side)
);
CREATE INDEX IF NOT EXISTS idx_account_pos_user ON qd_account_positions(user_id);
CREATE INDEX IF NOT EXISTS idx_account_pos_cred ON qd_account_positions(credential_id, market_type);
-- =============================================================================
-- 21. Indicator Community Tables
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_indicator_purchases (
id SERIAL PRIMARY KEY,
indicator_id INTEGER NOT NULL REFERENCES qd_indicator_codes(id) ON DELETE CASCADE,
buyer_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
seller_id INTEGER NOT NULL REFERENCES qd_users(id),
price DECIMAL(10,2) NOT NULL DEFAULT 0,
gross_price DECIMAL(10,2),
platform_fee DECIMAL(10,2) DEFAULT 0,
seller_amount DECIMAL(10,2),
fee_rate DECIMAL(10,6) DEFAULT 0,
asset_name_snapshot VARCHAR(255),
asset_description_snapshot TEXT,
asset_code_snapshot TEXT,
asset_type_snapshot VARCHAR(32),
asset_preview_image_snapshot VARCHAR(500),
asset_is_encrypted_snapshot INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(indicator_id, buyer_id)
);
CREATE INDEX IF NOT EXISTS idx_purchases_indicator ON qd_indicator_purchases(indicator_id);
CREATE INDEX IF NOT EXISTS idx_purchases_buyer ON qd_indicator_purchases(buyer_id);
CREATE INDEX IF NOT EXISTS idx_purchases_seller ON qd_indicator_purchases(seller_id);
CREATE TABLE IF NOT EXISTS qd_indicator_comments (
id SERIAL PRIMARY KEY,
indicator_id INTEGER NOT NULL REFERENCES qd_indicator_codes(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
rating INTEGER DEFAULT 5 CHECK (rating >= 1 AND rating <= 5),
content TEXT DEFAULT '',
parent_id INTEGER REFERENCES qd_indicator_comments(id) ON DELETE CASCADE,
is_deleted INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comments_indicator ON qd_indicator_comments(indicator_id);
CREATE INDEX IF NOT EXISTS idx_comments_user ON qd_indicator_comments(user_id);
-- =============================================================================
-- Quick Trades (manual / discretionary orders from Quick Trade Panel)
-- =============================================================================
CREATE TABLE IF NOT EXISTS qd_quick_trades (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
credential_id INTEGER DEFAULT 0,
exchange_id VARCHAR(40) NOT NULL DEFAULT '',
symbol VARCHAR(60) NOT NULL DEFAULT '',
side VARCHAR(10) NOT NULL DEFAULT '', -- buy / sell
order_type VARCHAR(20) NOT NULL DEFAULT 'market', -- market / limit
amount DECIMAL(24, 8) DEFAULT 0,
price DECIMAL(24, 8) DEFAULT 0,
leverage INTEGER DEFAULT 1,
market_type VARCHAR(20) DEFAULT 'swap', -- swap / spot
tp_price DECIMAL(24, 8) DEFAULT 0,
sl_price DECIMAL(24, 8) DEFAULT 0,
status VARCHAR(20) DEFAULT 'submitted', -- submitted / filled / failed / cancelled
exchange_order_id VARCHAR(120) DEFAULT '',
filled_amount DECIMAL(24, 8) DEFAULT 0,
avg_fill_price DECIMAL(24, 8) DEFAULT 0,
commission DECIMAL(24, 8) DEFAULT 0, -- realised trading fee for this fill (best-effort)
commission_ccy VARCHAR(16) DEFAULT '', -- e.g. 'USDT' / 'BNB'; empty when unknown
commission_quote DECIMAL(24, 8),
error_msg TEXT DEFAULT '',
source VARCHAR(40) DEFAULT 'manual', -- ai_radar / ai_analysis / indicator / manual
raw_result JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_quick_trades_user ON qd_quick_trades(user_id);
CREATE INDEX IF NOT EXISTS idx_quick_trades_created ON qd_quick_trades(created_at DESC);
-- Migration: Add commission tracking columns to existing qd_quick_trades.
-- (Introduced in v3.0.8. Pre-existing rows default to 0 / '' which is the
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'qd_quick_trades' AND column_name = 'commission'
) THEN
ALTER TABLE qd_quick_trades ADD COLUMN commission DECIMAL(24, 8) DEFAULT 0;
ALTER TABLE qd_quick_trades ADD COLUMN commission_ccy VARCHAR(16) DEFAULT '';
RAISE NOTICE 'Added commission / commission_ccy columns to qd_quick_trades';
END IF;
END $$;
ALTER TABLE qd_strategy_trades ADD COLUMN IF NOT EXISTS commission_quote DECIMAL(24,8);
ALTER TABLE qd_quick_trades ADD COLUMN IF NOT EXISTS commission DECIMAL(24,8) DEFAULT 0;
ALTER TABLE qd_quick_trades ADD COLUMN IF NOT EXISTS commission_ccy VARCHAR(16) DEFAULT '';
ALTER TABLE qd_quick_trades ADD COLUMN IF NOT EXISTS commission_quote DECIMAL(24,8);
UPDATE qd_strategy_trades
SET commission_quote = commission
WHERE commission_quote IS NULL
AND UPPER(COALESCE(commission_ccy, '')) IN ('USD', 'USDT', 'USDC', 'BUSD', 'FDUSD', 'TUSD');
UPDATE qd_quick_trades
SET commission_quote = commission
WHERE commission_quote IS NULL
AND UPPER(COALESCE(commission_ccy, '')) IN ('USD', 'USDT', 'USDC', 'BUSD', 'FDUSD', 'TUSD');
-- =============================================================================
-- Polymarket (宸茬Щ闄?/ removed in v3.0.7)
-- =============================================================================
DROP TABLE IF EXISTS qd_polymarket_asset_opportunities CASCADE;
DROP TABLE IF EXISTS qd_polymarket_ai_analysis CASCADE;
DROP TABLE IF EXISTS qd_polymarket_markets CASCADE;
-- =============================================================================
-- =============================================================================
-- These tables back the multi-agent runtime (see docs/agent/AI_INTEGRATION_DESIGN.md).
-- They are tenant-scoped via user_id and stay isolated from human JWT sessions.
CREATE TABLE IF NOT EXISTS qd_agent_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
name VARCHAR(80) NOT NULL,
token_prefix VARCHAR(24) NOT NULL, -- e.g. "qd_agent_AbCdEf12" (shown to humans/audit only)
token_hash VARCHAR(128) NOT NULL, -- sha256(token) hex
scopes TEXT NOT NULL DEFAULT 'R', -- comma-separated subset of R,W,B,N,C,T
markets TEXT NOT NULL DEFAULT '*', -- comma-separated allowlist or '*'
instruments TEXT NOT NULL DEFAULT '*', -- comma-separated allowlist or '*'
paper_only BOOLEAN NOT NULL DEFAULT TRUE, -- T-class always starts paper-only
rate_limit_per_min INTEGER NOT NULL DEFAULT 60,
status VARCHAR(20) NOT NULL DEFAULT 'active',-- active/revoked/expired
expires_at TIMESTAMP,
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_tokens_hash ON qd_agent_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_agent_tokens_user ON qd_agent_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_agent_tokens_status ON qd_agent_tokens(status);
CREATE TABLE IF NOT EXISTS qd_agent_jobs (
id BIGSERIAL PRIMARY KEY,
job_id VARCHAR(40) NOT NULL UNIQUE, -- public id (uuid4 hex)
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
agent_token_id INTEGER REFERENCES qd_agent_tokens(id) ON DELETE SET NULL,
kind VARCHAR(40) NOT NULL, -- backtest
status VARCHAR(20) NOT NULL DEFAULT 'queued',-- queued/running/succeeded/failed/cancelled
request JSONB NOT NULL DEFAULT '{}'::jsonb,
result JSONB,
error TEXT,
progress JSONB,
idempotency_key VARCHAR(120),
created_at TIMESTAMP DEFAULT NOW(),
started_at TIMESTAMP,
finished_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_user ON qd_agent_jobs(user_id);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_status ON qd_agent_jobs(status);
CREATE INDEX IF NOT EXISTS idx_agent_jobs_kind ON qd_agent_jobs(kind);
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_jobs_idem
ON qd_agent_jobs(agent_token_id, kind, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS qd_agent_audit (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
agent_token_id INTEGER,
agent_name VARCHAR(80),
route VARCHAR(160) NOT NULL,
method VARCHAR(8) NOT NULL,
scope_class VARCHAR(4) NOT NULL, -- R / W / B / N / C / T
status_code INTEGER NOT NULL,
idempotency_key VARCHAR(120),
request_summary JSONB, -- redacted (no secrets)
response_summary JSONB,
duration_ms INTEGER,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_audit_user ON qd_agent_audit(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_audit_token ON qd_agent_audit(agent_token_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_audit_class ON qd_agent_audit(scope_class);
-- Paper-only ledger so trading-class tokens can simulate without ever touching
-- live exchange credentials. Real-money execution stays gated by paper_only=false
-- AND the existing TradingExecutor code path.
CREATE TABLE IF NOT EXISTS qd_agent_paper_orders (
id BIGSERIAL PRIMARY KEY,
order_uid VARCHAR(40) NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES qd_users(id) ON DELETE CASCADE,
agent_token_id INTEGER REFERENCES qd_agent_tokens(id) ON DELETE SET NULL,
market VARCHAR(40) NOT NULL,
symbol VARCHAR(60) NOT NULL,
side VARCHAR(8) NOT NULL, -- buy / sell
order_type VARCHAR(16) NOT NULL DEFAULT 'market',
qty DECIMAL(28,10) NOT NULL,
limit_price DECIMAL(28,10),
fill_price DECIMAL(28,10),
fill_value DECIMAL(28,10),
status VARCHAR(16) NOT NULL DEFAULT 'filled',-- filled / cancelled / rejected
note TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_agent_paper_orders_user ON qd_agent_paper_orders(user_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_agent_paper_orders_token ON qd_agent_paper_orders(agent_token_id);
-- Jobs created before progress JSONB existed (Agent Gateway v3.1)
ALTER TABLE qd_agent_jobs ADD COLUMN IF NOT EXISTS progress JSONB;
-- Strategy API V2 templates are seeded by strategy_v2_templates.sql.
-- =============================================================================
-- Completion Notice
-- =============================================================================
DO $$
BEGIN
RAISE NOTICE 'QuantDinger PostgreSQL schema initialized successfully!';
END $$;