// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ==================== USER MANAGEMENT ====================
model User {
id String @id @default(uuid())
email String @unique
password String // hashed
name String?
phone String?
telegramId String? @unique
emailVerified DateTime?
image String?
role UserRole @default(USER)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
alerts Alert[]
watchlists Watchlist[]
portfolios Portfolio[]
notifications Notification[]
sessions Session[]
@@map("users")
}
enum UserRole {
USER
PREMIUM
ADMIN
}
model Session {
id String @id @default(uuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("sessions")
}
// ==================== MARKET DATA ====================
// Giá chứng khoán theo thời gian (TimescaleDB hypertable)
model StockPrice {
id BigInt @id @default(autoincrement())
symbol String @db.VarChar(10)
timestamp DateTime @db.Timestamptz(3)
// OHLC
open Decimal @db.Decimal(12, 2)
high Decimal @db.Decimal(12, 2)
low Decimal @db.Decimal(12, 2)
close Decimal @db.Decimal(12, 2)
// Volume & Value
volume BigInt
value BigInt
// Bid/Ask (mảng 10 mức giá)
bidPrice Decimal[] @db.Decimal(12, 2)
bidVolume BigInt[]
askPrice Decimal[] @db.Decimal(12, 2)
askVolume BigInt[]
// Thông tin thêm
reference Decimal? @db.Decimal(12, 2) // Giá tham chiếu
ceiling Decimal? @db.Decimal(12, 2) // Giá trần
floor Decimal? @db.Decimal(12, 2) // Giá sàn
// Metadata
source String @default("MBS") // MBS, VNDIRECT, SSI
createdAt DateTime @default(now())
@@unique([symbol, timestamp])
@@index([symbol, timestamp(sort: Desc)])
@@index([timestamp])
@@map("stock_prices")
}
// Chi tiết giao dịch (tape) - TimescaleDB hypertable
model Trade {
id BigInt @id @default(autoincrement())
symbol String @db.VarChar(10)
timestamp DateTime @db.Timestamptz(3)
price Decimal @db.Decimal(12, 2)
volume BigInt
side TradeSide? // B: Buy, S: Sell, null: unknown
// Thông tin giao dịch
matchType String? @db.VarChar(10) // LO, MP, ATC, PLO, etc.
orderId String? @db.VarChar(50) // ID để trace
createdAt DateTime @default(now())
@@index([symbol, timestamp(sort: Desc)])
@@index([timestamp])
@@map("trades")
}
enum TradeSide {
BUY
SELL
}
// Chỉ số thị trường (VN-Index, VN30, etc.)
model MarketIndex {
id BigInt @id @default(autoincrement())
symbol String @db.VarChar(20) // VNINDEX, VN30, HNXINDEX, etc.
timestamp DateTime @db.Timestamptz(3)
value Decimal @db.Decimal(12, 2)
change Decimal @db.Decimal(12, 2)
changePercent Decimal @db.Decimal(5, 2)
// Thông tin thị trường
advance Int // Số mã tăng
decline Int // Số mã giảm
unchanged Int // Số mã đứng giá
totalVolume BigInt // Tổng khối lượng
totalValue BigInt // Tổng giá trị
createdAt DateTime @default(now())
@@unique([symbol, timestamp])
@@index([symbol, timestamp(sort: Desc)])
@@map("market_indices")
}
// Thông tin công ty
model Company {
id String @id @default(uuid())
symbol String @unique @db.VarChar(10)
// Thông tin cơ bản
companyName String
shortName String?
industry String?
sector String?
exchange String @db.VarChar(10) // HOSE, HNX, UPCOM
// Thông tin tài chính (cập nhật định kỳ)
marketCap BigInt?
sharesOutstanding BigInt?
eps Decimal? @db.Decimal(12, 2)
pe Decimal? @db.Decimal(8, 2)
pb Decimal? @db.Decimal(8, 2)
roe Decimal? @db.Decimal(5, 2)
roa Decimal? @db.Decimal(5, 2)
dividendYield Decimal? @db.Decimal(5, 2)
// Thông tin liên hệ
website String?
address String?
employees Int?
// Metadata
lastUpdated DateTime @updatedAt
createdAt DateTime @default(now())
@@map("companies")
}
// ==================== ALERT SYSTEM ====================
model Alert {
id String @id @default(uuid())
userId String
// Cấu hình alert
symbol String @db.VarChar(10)
conditionType AlertCondition
threshold Decimal @db.Decimal(12, 4)
// Thông báo
notificationChannels NotificationChannel[]
message String?
// Trạng thái
isActive Boolean @default(true)
triggeredAt DateTime?
triggerCount Int @default(0)
maxTriggers Int @default(1) // Số lần trigger tối đa
// Thời hạn
expiryDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isActive])
@@index([symbol, isActive])
@@map("alerts")
}
enum AlertCondition {
PRICE_ABOVE
PRICE_BELOW
CHANGE_PERCENT_ABOVE
CHANGE_PERCENT_BELOW
VOLUME_ABOVE
BREAKOUT_HIGH
BREAKOUT_LOW
RSI_ABOVE
RSI_BELOW
}
enum NotificationChannel {
WEB
EMAIL
TELEGRAM
SMS
}
// Lịch sử alert đã trigger
model AlertHistory {
id String @id @default(uuid())
alertId String
userId String
// Dữ liệu khi trigger
symbol String @db.VarChar(10)
price Decimal @db.Decimal(12, 2)
value Decimal @db.Decimal(12, 4) // Giá trị tại thời điểm trigger
// Thông báo đã gửi
channels NotificationChannel[]
sentAt DateTime
createdAt DateTime @default(now())
@@index([userId, createdAt(sort: Desc)])
@@map("alert_history")
}
// ==================== WATCHLIST & PORTFOLIO ====================
model Watchlist {
id String @id @default(uuid())
userId String
name String
description String?
isDefault Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items WatchlistItem[]
@@unique([userId, name])
@@map("watchlists")
}
model WatchlistItem {
id String @id @default(uuid())
watchlistId String
symbol String @db.VarChar(10)
order Int @default(0)
notes String?
addedAt DateTime @default(now())
watchlist Watchlist @relation(fields: [watchlistId], references: [id], onDelete: Cascade)
@@unique([watchlistId, symbol])
@@map("watchlist_items")
}
// Danh mục đầu tư
model Portfolio {
id String @id @default(uuid())
userId String
name String
description String?
currency String @default("VND")
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
holdings Holding[]
transactions Transaction[]
@@map("portfolios")
}
// Vị thế nắm giữ
model Holding {
id String @id @default(uuid())
portfolioId String
symbol String @db.VarChar(10)
// Số lượng
quantity Decimal @db.Decimal(15, 2)
averageCost Decimal @db.Decimal(12, 2)
// Thông tin cập nhật
lastPrice Decimal? @db.Decimal(12, 2)
marketValue Decimal? @db.Decimal(15, 2)
unrealizedPnl Decimal? @db.Decimal(15, 2)
unrealizedPnlPercent Decimal? @db.Decimal(5, 2)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@unique([portfolioId, symbol])
@@map("holdings")
}
// Lịch sử giao dịch
model Transaction {
id String @id @default(uuid())
portfolioId String
symbol String @db.VarChar(10)
type TransactionType
quantity Decimal @db.Decimal(15, 2)
price Decimal @db.Decimal(12, 2)
fees Decimal @db.Decimal(12, 2) @default(0)
taxes Decimal @db.Decimal(12, 2) @default(0)
total Decimal @db.Decimal(15, 2)
date DateTime @db.Date
notes String?
createdAt DateTime @default(now())
portfolio Portfolio @relation(fields: [portfolioId], references: [id], onDelete: Cascade)
@@index([portfolioId, date(sort: Desc)])
@@map("transactions")
}
enum TransactionType {
BUY
SELL
DIVIDEND
SPLIT
BONUS
RIGHTS
}
// ==================== NOTIFICATIONS ====================
model Notification {
id String @id @default(uuid())
userId String
type NotificationType
title String
body String
data Json? // Additional payload
isRead Boolean @default(false)
readAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, isRead])
@@index([userId, createdAt(sort: Desc)])
@@map("notifications")
}
enum NotificationType {
ALERT
PRICE_UPDATE
SYSTEM
NEWS
}
// ==================== SYSTEM ====================
// Cấu hình hệ thống
model SystemConfig {
id String @id @default(uuid())
key String @unique
value Json
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
@@map("system_config")
}
// Log lỗi và sự kiện
model SystemLog {
id BigInt @id @default(autoincrement())
level LogLevel
service String @db.VarChar(50)
message String
metadata Json?
timestamp DateTime @default(now())
@@index([timestamp(sort: Desc)])
@@index([level, timestamp(sort: Desc)])
@@map("system_logs")
}
enum LogLevel {
DEBUG
INFO
WARN
ERROR
FATAL
}
-- migrations/001_initial_schema.sql
-- Enable TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- ==================== USER MANAGEMENT ====================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(255),
phone VARCHAR(20),
telegram_id VARCHAR(50) UNIQUE,
email_verified TIMESTAMPTZ,
image VARCHAR(500),
role VARCHAR(20) DEFAULT 'USER' CHECK (role IN ('USER', 'PREMIUM', 'ADMIN')),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ==================== MARKET DATA ====================
CREATE TABLE stock_prices (
id BIGSERIAL,
symbol VARCHAR(10) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
open DECIMAL(12, 2) NOT NULL,
high DECIMAL(12, 2) NOT NULL,
low DECIMAL(12, 2) NOT NULL,
close DECIMAL(12, 2) NOT NULL,
volume BIGINT NOT NULL,
value BIGINT NOT NULL,
bid_price DECIMAL(12, 2)[] DEFAULT '{}',
bid_volume BIGINT[] DEFAULT '{}',
ask_price DECIMAL(12, 2)[] DEFAULT '{}',
ask_volume BIGINT[] DEFAULT '{}',
reference DECIMAL(12, 2),
ceiling DECIMAL(12, 2),
floor DECIMAL(12, 2),
source VARCHAR(20) DEFAULT 'MBS',
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (symbol, timestamp)
);
-- Convert to hypertable for time-series data
SELECT create_hypertable('stock_prices', 'timestamp',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
-- Indexes for stock_prices
CREATE INDEX idx_stock_prices_symbol_time_desc ON stock_prices (symbol, timestamp DESC);
CREATE INDEX idx_stock_prices_timestamp ON stock_prices (timestamp DESC);
CREATE TABLE trades (
id BIGSERIAL,
symbol VARCHAR(10) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
price DECIMAL(12, 2) NOT NULL,
volume BIGINT NOT NULL,
side VARCHAR(4) CHECK (side IN ('BUY', 'SELL')),
match_type VARCHAR(10),
order_id VARCHAR(50),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (id, timestamp)
);
-- Convert to hypertable
SELECT create_hypertable('trades', 'timestamp',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
CREATE INDEX idx_trades_symbol_time ON trades (symbol, timestamp DESC);
CREATE TABLE market_indices (
id BIGSERIAL,
symbol VARCHAR(20) NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
value DECIMAL(12, 2) NOT NULL,
change DECIMAL(12, 2) NOT NULL,
change_percent DECIMAL(5, 2) NOT NULL,
advance INT DEFAULT 0,
decline INT DEFAULT 0,
unchanged INT DEFAULT 0,
total_volume BIGINT DEFAULT 0,
total_value BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (symbol, timestamp)
);
SELECT create_hypertable('market_indices', 'timestamp',
chunk_time_interval => INTERVAL '1 day',
if_not_exists => TRUE
);
CREATE TABLE companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
symbol VARCHAR(10) UNIQUE NOT NULL,
company_name VARCHAR(255) NOT NULL,
short_name VARCHAR(100),
industry VARCHAR(100),
sector VARCHAR(100),
exchange VARCHAR(10) NOT NULL CHECK (exchange IN ('HOSE', 'HNX', 'UPCOM')),
market_cap BIGINT,
shares_outstanding BIGINT,
eps DECIMAL(12, 2),
pe DECIMAL(8, 2),
pb DECIMAL(8, 2),
roe DECIMAL(5, 2),
roa DECIMAL(5, 2),
dividend_yield DECIMAL(5, 2),
website VARCHAR(255),
address TEXT,
employees INT,
last_updated TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ==================== ALERT SYSTEM ====================
CREATE TYPE alert_condition AS ENUM (
'PRICE_ABOVE', 'PRICE_BELOW',
'CHANGE_PERCENT_ABOVE', 'CHANGE_PERCENT_BELOW',
'VOLUME_ABOVE', 'BREAKOUT_HIGH', 'BREAKOUT_LOW',
'RSI_ABOVE', 'RSI_BELOW'
);
CREATE TYPE notification_channel AS ENUM ('WEB', 'EMAIL', 'TELEGRAM', 'SMS');
CREATE TABLE alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
symbol VARCHAR(10) NOT NULL,
condition_type alert_condition NOT NULL,
threshold DECIMAL(12, 4) NOT NULL,
notification_channels notification_channel[] DEFAULT '{}',
message TEXT,
is_active BOOLEAN DEFAULT true,
triggered_at TIMESTAMPTZ,
trigger_count INT DEFAULT 0,
max_triggers INT DEFAULT 1,
expiry_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_alerts_user_active ON alerts(user_id, is_active);
CREATE INDEX idx_alerts_symbol_active ON alerts(symbol, is_active);
CREATE TABLE alert_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
alert_id UUID NOT NULL,
user_id UUID NOT NULL,
symbol VARCHAR(10) NOT NULL,
price DECIMAL(12, 2) NOT NULL,
value DECIMAL(12, 4) NOT NULL,
channels notification_channel[] DEFAULT '{}',
sent_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_alert_history_user ON alert_history(user_id, created_at DESC);
-- ==================== WATCHLIST & PORTFOLIO ====================
CREATE TABLE watchlists (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
is_default BOOLEAN DEFAULT false,
"order" INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name)
);
CREATE TABLE watchlist_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
watchlist_id UUID NOT NULL REFERENCES watchlists(id) ON DELETE CASCADE,
symbol VARCHAR(10) NOT NULL,
"order" INT DEFAULT 0,
notes TEXT,
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(watchlist_id, symbol)
);
CREATE TABLE portfolios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
currency VARCHAR(3) DEFAULT 'VND',
is_default BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TYPE transaction_type AS ENUM ('BUY', 'SELL', 'DIVIDEND', 'SPLIT', 'BONUS', 'RIGHTS');
CREATE TABLE holdings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
symbol VARCHAR(10) NOT NULL,
quantity DECIMAL(15, 2) NOT NULL,
average_cost DECIMAL(12, 2) NOT NULL,
last_price DECIMAL(12, 2),
market_value DECIMAL(15, 2),
unrealized_pnl DECIMAL(15, 2),
unrealized_pnl_percent DECIMAL(5, 2),
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(portfolio_id, symbol)
);
CREATE TABLE transactions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
portfolio_id UUID NOT NULL REFERENCES portfolios(id) ON DELETE CASCADE,
symbol VARCHAR(10) NOT NULL,
type transaction_type NOT NULL,
quantity DECIMAL(15, 2) NOT NULL,
price DECIMAL(12, 2) NOT NULL,
fees DECIMAL(12, 2) DEFAULT 0,
taxes DECIMAL(12, 2) DEFAULT 0,
total DECIMAL(15, 2) NOT NULL,
date DATE NOT NULL,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_transactions_portfolio_date ON transactions(portfolio_id, date DESC);
-- ==================== NOTIFICATIONS ====================
CREATE TYPE notification_type AS ENUM ('ALERT', 'PRICE_UPDATE', 'SYSTEM', 'NEWS');
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type notification_type NOT NULL,
title VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
data JSONB,
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_notifications_user_unread ON notifications(user_id, is_read);
CREATE INDEX idx_notifications_user_created ON notifications(user_id, created_at DESC);
-- ==================== SYSTEM ====================
CREATE TABLE system_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key VARCHAR(100) UNIQUE NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TYPE log_level AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL');
CREATE TABLE system_logs (
id BIGSERIAL PRIMARY KEY,
level log_level NOT NULL,
service VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
metadata JSONB,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_system_logs_timestamp ON system_logs(timestamp DESC);
CREATE INDEX idx_system_logs_level_timestamp ON system_logs(level, timestamp DESC);
-- Continuous aggregates for fast queries
CREATE MATERIALIZED VIEW stock_prices_1h
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', timestamp) AS bucket,
symbol,
first(open, timestamp) as open,
max(high) as high,
min(low) as low,
last(close, timestamp) as close,
sum(volume) as volume,
sum(value) as value
FROM stock_prices
GROUP BY bucket, symbol;
-- Retention policy (giữ data 1 năm cho raw data, aggregate giữ lâu hơn)
SELECT add_retention_policy('stock_prices', INTERVAL '1 year');
SELECT add_retention_policy('trades', INTERVAL '6 months');
-- migrations/002_functions_triggers.sql
-- Function để tự động cập nhật updated_at
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Apply cho các bảng cần updated_at
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_alerts_updated_at BEFORE UPDATE ON alerts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_watchlists_updated_at BEFORE UPDATE ON watchlists
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_portfolios_updated_at BEFORE UPDATE ON portfolios
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_holdings_updated_at BEFORE UPDATE ON holdings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Function để tính toán P&L tự động khi cập nhật holding
CREATE OR REPLACE FUNCTION calculate_holding_pnl()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.last_price IS NOT NULL THEN
NEW.market_value := NEW.quantity * NEW.last_price;
NEW.unrealized_pnl := (NEW.last_price - NEW.average_cost) * NEW.quantity;
IF NEW.average_cost > 0 THEN
NEW.unrealized_pnl_percent := ((NEW.last_price - NEW.average_cost) / NEW.average_cost) * 100;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_calculate_holding_pnl
BEFORE INSERT OR UPDATE ON holdings
FOR EACH ROW
EXECUTE FUNCTION calculate_holding_pnl();
-- Function để tính total khi insert transaction
CREATE OR REPLACE FUNCTION calculate_transaction_total()
RETURNS TRIGGER AS $$
BEGIN
NEW.total := (NEW.quantity * NEW.price) + NEW.fees + NEW.taxes;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_calculate_transaction_total
BEFORE INSERT ON transactions
FOR EACH ROW
EXECUTE FUNCTION calculate_transaction_total();
-- Function để cập nhật holding khi có transaction mới
CREATE OR REPLACE FUNCTION update_holding_on_transaction()
RETURNS TRIGGER AS $$
DECLARE
existing_holding_id UUID;
current_qty DECIMAL(15,2);
current_avg DECIMAL(12,2);
BEGIN
-- Tìm holding hiện tại
SELECT id, quantity, average_cost
INTO existing_holding_id, current_qty, current_avg
FROM holdings
WHERE portfolio_id = NEW.portfolio_id AND symbol = NEW.symbol;
IF NEW.type = 'BUY' THEN
IF existing_holding_id IS NOT NULL THEN
-- Cập nhật holding hiện có
UPDATE holdings SET
quantity = current_qty + NEW.quantity,
average_cost = ((current_qty * current_avg) + (NEW.quantity * NEW.price)) / (current_qty + NEW.quantity),
updated_at = NOW()
WHERE id = existing_holding_id;
ELSE
-- Tạo holding mới
INSERT INTO holdings (portfolio_id, symbol, quantity, average_cost)
VALUES (NEW.portfolio_id, NEW.symbol, NEW.quantity, NEW.price);
END IF;
ELSIF NEW.type = 'SELL' THEN
IF existing_holding_id IS NOT NULL THEN
IF current_qty <= NEW.quantity THEN
-- Xóa holding nếu bán hết
DELETE FROM holdings WHERE id = existing_holding_id;
ELSE
-- Giảm số lượng (không đổi average cost)
UPDATE holdings SET
quantity = current_qty - NEW.quantity,
updated_at = NOW()
WHERE id = existing_holding_id;
END IF;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_holding_on_transaction
AFTER INSERT ON transactions
FOR EACH ROW
EXECUTE FUNCTION update_holding_on_transaction();
-- Function để lấy top movers
CREATE OR REPLACE FUNCTION get_top_movers(
p_exchange VARCHAR(10) DEFAULT NULL,
p_limit INT DEFAULT 10,
p_order_by VARCHAR(20) DEFAULT 'change_percent' -- 'change_percent', 'volume', 'value'
)
RETURNS TABLE (
symbol VARCHAR(10),
company_name VARCHAR(255),
last_price DECIMAL(12,2),
change DECIMAL(12,2),
change_percent DECIMAL(5,2),
volume BIGINT,
value BIGINT,
market_cap BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
sp.symbol,
c.company_name,
sp.close as last_price,
sp.close - sp.reference as change,
((sp.close - sp.reference) / sp.reference * 100)::DECIMAL(5,2) as change_percent,
sp.volume,
sp.value,
c.market_cap
FROM stock_prices sp
JOIN companies c ON sp.symbol = c.symbol
WHERE sp.timestamp = (
SELECT MAX(timestamp) FROM stock_prices
)
AND (p_exchange IS NULL OR c.exchange = p_exchange)
ORDER BY
CASE p_order_by
WHEN 'change_percent' THEN ABS((sp.close - sp.reference) / sp.reference * 100)
WHEN 'volume' THEN sp.volume
WHEN 'value' THEN sp.value
END DESC
LIMIT p_limit;
END;
$$ LANGUAGE plpgsql;
-- Function để lấy market overview
CREATE OR REPLACE FUNCTION get_market_overview()
RETURNS TABLE (
exchange VARCHAR(10),
index_value DECIMAL(12,2),
index_change DECIMAL(12,2),
index_change_percent DECIMAL(5,2),
advance INT,
decline INT,
unchanged INT,
total_volume BIGINT,
total_value BIGINT
) AS $$
BEGIN
RETURN QUERY
SELECT
CASE
WHEN mi.symbol = 'VNINDEX' THEN 'HOSE'
WHEN mi.symbol = 'HNXINDEX' THEN 'HNX'
WHEN mi.symbol = 'UPCOMINDEX' THEN 'UPCOM'
END as exchange,
mi.value as index_value,
mi.change as index_change,
mi.change_percent as index_change_percent,
mi.advance,
mi.decline,
mi.unchanged,
mi.total_volume,
mi.total_value
FROM market_indices mi
WHERE mi.timestamp = (
SELECT MAX(timestamp) FROM market_indices
)
AND mi.symbol IN ('VNINDEX', 'HNXINDEX', 'UPCOMINDEX');
END;
$$ LANGUAGE plpgsql;
-- migrations/003_seed_data.sql
-- Insert các công ty mẫu (top cổ phiếu VN30)
INSERT INTO companies (symbol, company_name, short_name, industry, sector, exchange) VALUES
('VNM', 'Vinamilk', 'Vinamilk', 'Thực phẩm', 'Tiêu dùng', 'HOSE'),
('VIC', 'Vingroup', 'Vingroup', 'Bất động sản', 'Bất động sản', 'HOSE'),
('HPG', 'Hòa Phát', 'Hòa Phát', 'Thép', 'Vật liệu', 'HOSE'),
('MWG', 'Thế Giới Di Động', 'TGDD', 'Bán lẻ', 'Tiêu dùng', 'HOSE'),
('FPT', 'FPT Corporation', 'FPT', 'Công nghệ', 'Công nghệ', 'HOSE'),
('VCB', 'Vietcombank', 'Vietcombank', 'Ngân hàng', 'Tài chính', 'HOSE'),
('VHM', 'Vinhomes', 'Vinhomes', 'Bất động sản', 'Bất động sản', 'HOSE'),
('GAS', 'PV Gas', 'PV Gas', 'Dầu khí', 'Năng lượng', 'HOSE'),
('MSN', 'Masan Group', 'Masan', 'Tiêu dùng', 'Tiêu dùng', 'HOSE'),
('TCH', 'Techcombank', 'Techcombank', 'Ngân hàng', 'Tài chính', 'HOSE');
-- Insert system config mặc định
INSERT INTO system_config (key, value) VALUES
('market_hours', '{"open": "09:00", "close": "15:00", "lunch_start": "11:30", "lunch_end": "13:00"}'::jsonb),
('data_sources', '{"primary": "MBS", "fallbacks": ["VNDIRECT", "SSI"]}'::jsonb),
('alert_limits', '{"free": 5, "premium": 50}'::jsonb),
('websocket_config', '{"reconnect_interval": 5000, "max_reconnect": 10}'::jsonb);
-- Insert sample data cho testing (giả lập 1 ngày giao dịch)
DO $$
DECLARE
v_date DATE := CURRENT_DATE;
v_time TIME;
v_base_price DECIMAL(12,2);
v_price DECIMAL(12,2);
BEGIN
-- Tạo dữ liệu cho VNM
v_base_price := 78500;
v_time := '09:00:00';
WHILE v_time <= '15:00:00' LOOP
-- Random walk price
v_price := v_base_price + (random() - 0.5) * 1000;
INSERT INTO stock_prices (
symbol, timestamp, open, high, low, close,
volume, value, bid_price, bid_volume, ask_price, ask_volume,
reference, ceiling, floor
) VALUES (
'VNM',
v_date + v_time,
v_price - 50,
v_price + 100,
v_price - 100,
v_price,
(random() * 100000)::BIGINT,
(random() * 1000000000)::BIGINT,
ARRAY[v_price-100, v_price-200, v_price-300],
ARRAY[(random()*1000)::BIGINT, (random()*1000)::BIGINT, (random()*1000)::BIGINT],
ARRAY[v_price+100, v_price+200, v_price+300],
ARRAY[(random()*1000)::BIGINT, (random()*1000)::BIGINT, (random()*1000)::BIGINT],
78500,
86300,
70700
);
v_time := v_time + INTERVAL '5 minutes';
v_base_price := v_price;
END LOOP;
END $$;