量化系统跑到一定阶段,CSV 一定会成为瓶颈。本文给出一个生产级的 PostgreSQL 建表方案。
为什么 CSV 不够用
| 场景 | CSV 耗时 | PostgreSQL 耗时 |
|---|---|---|
| 查茅台近 20 天收盘价 | ~30s | <0.1s |
| 对比 50 只股一年涨幅 | ~5min | <1s |
| 多策略并行读写 | 文件锁冲突 | ACID 保障 |
| 重复数据去重 | 手动 | UNIQUE INDEX |
1. K 线数据表
CREATE TABLE daily_prices (
id SERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
trade_date DATE NOT NULL,
open NUMERIC(12,4),
high NUMERIC(12,4),
low NUMERIC(12,4),
close NUMERIC(12,4),
volume BIGINT,
amount NUMERIC(16,2),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_symbol_date
ON daily_prices(symbol, trade_date);
CREATE INDEX idx_date ON daily_prices(trade_date);
类型选择
| 列 | 类型 | 为什么 |
|---|---|---|
| open/high/low/close | NUMERIC(12,4) | 定点数,无浮点误差。4 位兼容港股美股 |
| volume | BIGINT | 日成交可达数亿股,INTEGER 不够 |
| amount | NUMERIC(16,2) | 成交额可达数千亿,预留空间 |
| trade_date | DATE | 不带时区,A 股统一北京时间 |
为什么不用 FLOAT?
>>> 0.1 + 0.2 == 0.3
False
金融场景不允许这种误差。NUMERIC 是定点数,牺牲一点速度换绝对精度。
2. 信号表
CREATE TABLE signals (
id SERIAL PRIMARY KEY,
symbol VARCHAR(10) NOT NULL,
strategy VARCHAR(50) NOT NULL,
signal_type VARCHAR(10) NOT NULL CHECK (signal_type IN ('BUY','SELL','HOLD')),
price NUMERIC(12,4),
reason TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_signals_time ON signals(created_at DESC);
CREATE INDEX idx_signals_symbol ON signals(symbol);
CHECK 约束防止脏数据(如 signal_type = 'buy' 大小写不一致)。
3. Upsert:避免重复拉取产生脏数据
from sqlalchemy.dialects.postgresql import insert
def upsert_kline(engine, df):
"""插入或更新 K 线数据"""
rows = df.to_dict('records')
stmt = insert(DailyPrice).values(rows)
stmt = stmt.on_conflict_do_update(
constraint='idx_symbol_date',
set_={
'open': stmt.excluded.open,
'high': stmt.excluded.high,
'low': stmt.excluded.low,
'close': stmt.excluded.close,
'volume': stmt.excluded.volume,
'amount': stmt.excluded.amount,
}
)
engine.execute(stmt)
定时任务多次拉同一天数据 → ON CONFLICT DO UPDATE 只更新不复写。
4. Python 批量入库
import pandas as pd
from sqlalchemy import create_engine
engine = create_engine(
'postgresql://user:pass@localhost:5432/quantdb'
)
df = pd.read_csv('daily_data.csv')
df.to_sql('daily_prices', engine,
if_exists='append',
index=False,
method='multi', # 批量 INSERT
chunksize=1000) # 分批提交
method='multi' 把多行合并成一条 INSERT,比单条插入快 10 倍以上。
5. 常用查询
-- 最近 N 天 K 线
SELECT trade_date, open, high, low, close, volume
FROM daily_prices
WHERE symbol = '600519'
ORDER BY trade_date DESC
LIMIT 20;
-- 今日涨幅 TOP 10
SELECT symbol,
ROUND((close - prev_close) / prev_close * 100, 2) AS pct_change
FROM (
SELECT symbol, close,
LAG(close) OVER (PARTITION BY symbol ORDER BY trade_date) AS prev_close
FROM daily_prices
WHERE trade_date >= CURRENT_DATE - INTERVAL '2 days'
) t
WHERE trade_date = (
SELECT MAX(trade_date) FROM daily_prices WHERE symbol = t.symbol
)
ORDER BY pct_change DESC
LIMIT 10;
-- 连续上涨天数
SELECT symbol, COUNT(*) AS up_days
FROM (
SELECT symbol, trade_date,
close > LAG(close) OVER (PARTITION BY symbol ORDER BY trade_date) AS is_up
FROM daily_prices
WHERE trade_date > CURRENT_DATE - INTERVAL '10 days'
) t
WHERE is_up
GROUP BY symbol
HAVING COUNT(*) >= 3;
6. 数据量大了怎么办
| 数据量 | 方案 | 效果 |
|---|---|---|
| <100 万条 | 默认配置 | 无需优化 |
| 100 万 ~ 1000 万 | 加索引 + 调 work_mem | 查询毫秒级 |
| >1000 万 (分钟线) | TimescaleDB 插件 | 自动分区 + 10:1 压缩 |
-- TimescaleDB 一键转时序表
SELECT create_hypertable('daily_prices', 'trade_date');
-- 7 天后自动压缩
SELECT add_compression_policy('daily_prices',
INTERVAL '7 days');
选型速查
| 方案 | 适用 |
|---|---|
| CSV | 0-1 股回测,快速原型 |
| SQLite | 10-50 股,本地单机 |
| PostgreSQL | 全市场 5000+,生产环境 |
| +TimescaleDB | 分钟级 × 全市场,高频 |
早点切到 PG,省下的排查时间比写建表语句多得多。