4. SQL基础回顾:SELECT、JOIN、GROUP BY、窗口函数在金融数据清洗中的基础用法
在组合风险归因系统中,原始数据通常来自交易系统、估值引擎和外部市场数据源。这些数据存在重复、缺失、格式不一致等问题。SQL作为数据清洗的核心工具,能够高效地将原始数据转化为可用于风险计算的干净数据集。本节回顾四个最常用的SQL操作,并展示它们在金融数据清洗中的典型场景。
4.1 SELECT:字段筛选与数据探查
SELECT是SQL中最基本的操作,用于从表中选取特定字段。在金融数据清洗中,SELECT通常与DISTINCT、WHERE和CAST配合使用,完成数据探查和类型转换。
典型场景:从交易流水表中提取特定日期的交易记录,并检查字段完整性。
-- 探查交易表结构:检查日期格式和空值比例
SELECT
trade_date,
COUNT(*) AS total_trades,
SUM(CASE WHEN security_id IS NULL THEN 1 ELSE 0 END) AS missing_security,
SUM(CASE WHEN quantity IS NULL THEN 1 ELSE 0 END) AS missing_qty,
SUM(CASE WHEN price IS NULL THEN 1 ELSE 0 END) AS missing_price
FROM raw_trades
WHERE trade_date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY trade_date
ORDER BY trade_date;
| 字段 | 清洗前常见问题 | SELECT处理方式 |
|---|---|---|
| trade_date | 字符串格式不统一(20240101 vs 2024-01-01) | CAST(trade_date AS DATE) 统一格式 |
| price | 包含空值或非数字字符 | WHERE price IS NOT NULL AND price REGEXP '^[0-9.]+$' |
| currency | 大小写不一致(usd/USD/Usd) | UPPER(currency) 统一大写 |
4.2 JOIN:多表关联与数据补全
风险归因需要将交易数据与参考数据(证券主表、汇率表、因子暴露表)关联。JOIN操作是数据整合的核心,但需要警惕关联键重复和空值扩散问题。
实践建议:在金融数据清洗中,优先使用LEFT JOIN保留主表所有记录,然后通过CASE语句标记缺失的关联数据,而不是直接使用INNER JOIN丢弃记录。
-- 交易数据关联证券主表和行业分类
SELECT
t.trade_id,
t.security_id,
s.security_name,
s.asset_class,
s.industry_sector,
t.quantity * t.price AS notional,
CASE WHEN s.industry_sector IS NULL THEN 'UNKNOWN' ELSE s.industry_sector END AS sector_clean
FROM raw_trades t
LEFT JOIN security_master s
ON t.security_id = s.security_id
AND s.effective_date <= t.trade_date
AND (s.expiry_date IS NULL OR s.expiry_date >= t.trade_date);
4.3 GROUP BY:聚合计算与异常检测
GROUP BY用于按维度汇总数据,在清洗阶段主要用于发现异常值、重复记录和边界情况。结合HAVING子句可以快速定位数据质量问题。
-- 检测重复交易:同一组合、证券、日期出现多次
SELECT
portfolio_id,
security_id,
trade_date,
COUNT(*) AS record_count,
SUM(quantity) AS total_qty,
SUM(notional) AS total_notional
FROM trades_clean
GROUP BY portfolio_id, security_id, trade_date
HAVING COUNT(*) > 1
ORDER BY record_count DESC;
| 聚合维度 | 检测目标 | 清洗动作 |
|---|---|---|
| portfolio_id + security_id + trade_date | 重复交易记录 | 保留一条或按规则合并 |
| portfolio_id + trade_date | 日末持仓汇总与外部对账差异 | 标记差异并人工核查 |
| security_id + month | 价格序列缺失或异常跳变 | 插值或剔除异常点 |
4.4 窗口函数:时序计算与排名
窗口函数在不改变行数的情况下进行分组排序和累计计算,特别适合金融时间序列数据的清洗,如去重保留最新记录、计算日收益率、标记异常波动。
-- 使用ROW_NUMBER去重:保留每个证券每个交易日的最新一条价格记录
WITH price_dedup AS (
SELECT
security_id,
price_date,
close_price,
source,
ROW_NUMBER() OVER (
PARTITION BY security_id, price_date
ORDER BY update_timestamp DESC
) AS rn
FROM raw_prices
)
SELECT security_id, price_date, close_price, source
FROM price_dedup
WHERE rn = 1;
常见陷阱:窗口函数中的ORDER BY方向直接影响结果。在金融数据中,通常使用DESC排序保留最新记录;但计算累计收益率时,必须使用ASC按时间正序排列。
-- 计算日收益率并标记异常波动(超过3倍标准差)
WITH daily_returns AS (
SELECT
security_id,
trade_date,
close_price,
LAG(close_price) OVER (
PARTITION BY security_id
ORDER BY trade_date
) AS prev_close,
(close_price / LAG(close_price) OVER (
PARTITION BY security_id
ORDER BY trade_date
) - 1) AS daily_return
FROM prices_clean
),
volatility_stats AS (
SELECT
security_id,
AVG(daily_return) AS avg_ret,
STDDEV(daily_return) AS std_ret
FROM daily_returns
WHERE daily_return IS NOT NULL
GROUP BY security_id
)
SELECT
d.security_id,
d.trade_date,
d.daily_return,
CASE
WHEN ABS(d.daily_return - v.avg_ret) > 3 * v.std_ret
THEN 'OUTLIER'
ELSE 'NORMAL'
END AS flag
FROM daily_returns d
JOIN volatility_stats v ON d.security_id = v.security_id;
4.5 四种操作的组合使用策略
在实际清洗流程中,四种操作通常按以下顺序组合:先用SELECT和WHERE做初步过滤,再用JOIN关联参考数据,然后用窗口函数处理时序逻辑,最后用GROUP BY做质量校验。下表总结了每种操作在清洗中的核心用途和性能注意事项。
| 操作 | 核心用途 | 金融清洗典型场景 | 性能注意事项 |
|---|---|---|---|
| SELECT | 字段投影、类型转换、空值检查 | 统一日期格式、过滤无效价格 | 避免SELECT *,只取必要字段 |
| JOIN | 多表关联、数据补全、维度扩展 | 交易关联证券主表、汇率转换 | 关联键建索引,警惕笛卡尔积 |
| GROUP BY | 聚合汇总、重复检测、统计校验 | 日末持仓汇总、对账差异发现 | 合理使用HAVING过滤聚合结果 |
| 窗口函数 | 排序去重、累计计算、滑动统计 | 保留最新价格、计算收益率、标记异常 | PARTITION BY字段不宜过多 |
清洗流程示例:以下SQL片段展示了四种操作的组合使用,完成从原始交易数据到干净风险计算表的完整清洗。
WITH
-- 第一步:SELECT + 窗口函数去重
dedup_trades AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY trade_id
ORDER BY update_ts DESC
) AS rn
FROM raw_trades
WHERE trade_date >= '2024-01-01'
),
-- 第二步:JOIN关联参考数据
trades_with_ref AS (
SELECT
d.*,
s.asset_class,
s.industry_sector,
s.currency AS security_ccy,
fx.rate AS usd_rate
FROM dedup_trades d
LEFT JOIN security_master s
ON d.security_id = s.security_id
LEFT JOIN fx_rates fx
ON fx.ccy_pair = CONCAT(s.currency, 'USD')
AND fx.rate_date = d.trade_date
WHERE d.rn = 1
),
-- 第三步:窗口函数计算累计指标
trades_enriched AS (
SELECT *,
quantity * price * usd_rate AS notional_usd,
SUM(quantity * price * usd_rate) OVER (
PARTITION BY portfolio_id, trade_date
) AS daily_portfolio_notional
FROM trades_with_ref
)
-- 第四步:GROUP BY质量校验
SELECT
trade_date,
COUNT(*) AS trade_count,
SUM(notional_usd) AS total_notional,
COUNT(CASE WHEN asset_class IS NULL THEN 1 END) AS missing_asset_class
FROM trades_enriched
GROUP BY trade_date
HAVING COUNT(CASE WHEN asset_class IS NULL THEN 1 END) > 0
ORDER BY trade_date;