8  课程总结与综合项目实践

经过前七章的学习,我们已经构建了一个完整的金融工程知识体系。从 Python 基础语法起步,我们学习了数据获取与清洗(Pandas/本地快照数据)、时间序列分析(ARIMA)、风险管理(VaR)、量化投资策略(均线/MPT)、机器学习应用(Random Forest)以及大数据处理初步(Dask)。

本章不再引入新的理论,而是通过一个综合项目,将上述知识点串联起来。我们将构建一个基于机器学习的“长三角科技优选”指数增强策略。

8.1 课程知识回顾

我们将本课程的核心能力归纳为以下“金融数据科学金字塔”:

Python 基础与数据获取 (Ch1, Ch2) 统计与时间序列分析 (Ch3) 风险管理与量化策略 (Ch4, Ch5) 机器学习与大数据 (Ch6, Ch7) 金融工程师能力栈
图 8.1: 金融工程师能力金字塔

8.2 综合项目:长三角科技优选策略

8.2.1 项目背景

长三角地区(上海、江苏、浙江、安徽)是中国科技创新的高地。我们的目标是构建一个量化投资策略,自动筛选该地区基本面良好且具有上涨潜力的科技股,通过组合投资分散风险,力争获得超越沪深300指数的收益。

8.2.2 策略逻辑 (Pipeline)

  1. 数据层 (Data):获取长三角地区 TMT(科技、传媒、通信)及医药生物行业股票池的日线行情。
  2. 因子层 (Factors):计算动量(Momentum)、波动率(Volatility)和相对强弱(RSI)等技术指标。
  3. 模型层 (Model):使用随机森林(Random Forest)预测未来 5 日的收益率方向(上涨/下跌)。
  4. 执行层 (Execution):选取预测上涨概率最高的 Top 5 股票等权重买入,并结合 5% 的止损线进行风险控制。
注意

综合项目的‘验收标准’:先保证口径对,再谈收益高低

这个项目把前面所有章节串起来,因此最容易在细节上“看起来能跑、其实口径错”。建议按下面顺序自检:

  1. 数据可复现:所有输入都来自 data/ 的本地快照,不在文档中联网取数。
  2. 标签无泄漏:预测未来 5 日方向时,特征只能使用当日及之前的信息(例如 future_ret_5shift(-5) 生成标签,但特征窗口不能跨到未来)。
  3. 训练/测试按时间切分:严禁随机打乱。
  4. 评价指标与交易逻辑一致:分类指标(AUC/混淆矩阵)只是‘预测质量’,回测净值才是‘策略质量’;两者要同时看。

做到这四点,你的结果哪怕不惊艳,也更接近真实可落地的策略研究流程。

8.2.3 代码实现

以下代码整合了本地数据快照读取、Pandas 特征工程和 Scikit-learn 模型预测(严格按时间顺序切分训练/测试集,避免前视偏差)。

import pandas as pd
import numpy as np
from pathlib import Path
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, roc_auc_score, classification_report, confusion_matrix
import matplotlib.pyplot as plt

print('启动‘长三角科技优选’综合策略项目...')

# 1. 数据读取:本地快照(离线、可复现)
df_all = pd.read_parquet(Path('data/cn_equity_daily_latest.parquet'))

# 2. 股票池定义:选取长三角且具代表性的科技/成长股,并显式包含 601018.SH 与 002142.SZ
# 示例:宁波港(浙江)、宁波银行(浙江)、恒瑞医药(江苏)、海康威视(浙江)、中芯国际(上海)
stock_pool = {
    '601018.SH': '宁波港',
    '002142.SZ': '宁波银行',
    '600276.SH': '恒瑞医药',
    '002415.SZ': '海康威视',
    '688981.SH': '中芯国际',
}
codes = list(stock_pool.keys())

# 3. 特征工程函数(对每只股票单独计算,避免跨股票混淆)
def add_features(pdf: pd.DataFrame) -> pd.DataFrame:
    pdf = pdf.sort_values('trade_date').copy()
    pdf['trade_date'] = pd.to_datetime(pdf['trade_date'])
    pdf['price'] = pdf['adj_close']
    pdf['log_ret'] = np.log(pdf['price'] / pdf['price'].shift(1))

    # 因子:动量、波动、乖离
    pdf['mom_5'] = pdf['price'] / pdf['price'].shift(5) - 1
    pdf['vol_10'] = pdf['log_ret'].rolling(10).std()
    pdf['ma_20'] = pdf['price'].rolling(20).mean()
    pdf['bias_20'] = pdf['price'] / pdf['ma_20'] - 1

    # 标签:未来 5 日收益方向
    pdf['future_ret_5'] = pdf['price'].shift(-5) / pdf['price'] - 1
    pdf['target'] = (pdf['future_ret_5'] > 0).astype(int)
    return pdf

# 4. 构建全市场数据集
all_data = []
for code in codes:
    sub = df_all[df_all['ts_code'].eq(code)].copy()
    if sub.empty:
        print(f'跳过 {code}:本地快照中未找到该股票数据')
        continue
    sub = sub.loc[(sub['trade_date'] >= '2021-01-01') & (sub['trade_date'] <= '2023-12-31')]
    sub = add_features(sub)
    sub['ts_code'] = code
    all_data.append(sub)

if not all_data:
    raise RuntimeError('本地快照中未找到股票池数据,请先运行数据获取脚本生成 data/*latest.parquet')

full_df = pd.concat(all_data, ignore_index=True)
full_df = full_df.dropna(subset=['mom_5', 'vol_10', 'bias_20', 'target'])

# 5. 机器学习模型训练
features = ['mom_5', 'vol_10', 'bias_20']
X = full_df[features]
y = full_df['target']

# 严格按“日期”切分,避免随机打乱带来的前视偏差
full_df['trade_date'] = pd.to_datetime(full_df['trade_date'])
unique_dates = np.sort(full_df['trade_date'].unique())
split_idx = int(len(unique_dates) * 0.8)
split_date = unique_dates[split_idx]

train_mask = full_df['trade_date'] <= split_date
test_mask = full_df['trade_date'] > split_date

X_train, y_train = full_df.loc[train_mask, features], full_df.loc[train_mask, 'target']
X_test, y_test = full_df.loc[test_mask, features], full_df.loc[test_mask, 'target']

print(f'训练集截止日期: {pd.to_datetime(split_date).date()}')
print(f'训练样本数: {len(X_train)} | 测试样本数: {len(X_test)}')

model = RandomForestClassifier(n_estimators=100, max_depth=5, random_state=42)
model.fit(X_train, y_train)

# 6. 模型评估
pred = model.predict(X_test)
proba = model.predict_proba(X_test)[:, 1]
acc = accuracy_score(y_test, pred)
auc = roc_auc_score(y_test, proba)
print(f'随机森林模型测试集准确率: {acc:.2%}')
print(f'随机森林模型测试集 AUC: {auc:.4f}')
启动‘长三角科技优选’综合策略项目...
训练集截止日期: 2023-06-05
训练样本数: 2829 | 测试样本数: 705
随机森林模型测试集准确率: 50.07%
随机森林模型测试集 AUC: 0.4774
注记

再次说明:如何读懂混淆矩阵(Confusion Matrix)

在二分类(上涨/下跌)问题中,混淆矩阵把预测结果拆成四类:TP/FP/TN/FN。它不仅告诉你“总体对了多少”(Accuracy),更重要的是告诉你“错在哪里”(是把下跌错判为上涨更多,还是把上涨错判为下跌更多)。

在量化交易中,这两类错误的经济含义不同:

  • FP 偏高:更容易产生‘追涨误判’,可能带来更多不必要的交易与回撤。
  • FN 偏高:更容易错过上涨机会,收益弹性不足。

因此评估时通常结合 Precision/Recall、AUC、以及回测中的交易成本与最大回撤一起看。

print('\n混淆矩阵:')
print(confusion_matrix(y_test, pred))
print('\n分类报告:')
print(classification_report(y_test, pred))

# 查看特征重要性
importances = pd.Series(model.feature_importances_, index=features)
print('\n特征重要性:\n', importances.sort_values(ascending=False))

# 7. 示例信号生成(针对本地快照中的最新数据)
print('\n----- 最新交易信号生成(示例)-----')
for code, name in stock_pool.items():
    sub = full_df[full_df['ts_code'].eq(code)].sort_values('trade_date')
    if sub.empty:
        continue
    last_row = sub.iloc[-1:]
    signal = int(model.predict(last_row[features])[0])
    prob = float(model.predict_proba(last_row[features])[0][1])
    action = '买入/持有' if signal == 1 else '卖出/空仓'
    dt = pd.to_datetime(last_row['trade_date'].iloc[0]).date()
    print(f'日期: {dt} | 股票: {name}{code}) | 预测上涨概率: {prob:.2%} | 建议: {action}')

混淆矩阵:
[[261 121]
 [231  92]]

分类报告:
              precision    recall  f1-score   support

           0       0.53      0.68      0.60       382
           1       0.43      0.28      0.34       323

    accuracy                           0.50       705
   macro avg       0.48      0.48      0.47       705
weighted avg       0.49      0.50      0.48       705


特征重要性:
 vol_10     0.355197
bias_20    0.334738
mom_5      0.310065
dtype: float64

----- 最新交易信号生成(示例)-----
日期: 2023-12-29 | 股票: 宁波港(601018.SH) | 预测上涨概率: 54.18% | 建议: 买入/持有
日期: 2023-12-29 | 股票: 宁波银行(002142.SZ) | 预测上涨概率: 38.65% | 建议: 卖出/空仓
日期: 2023-12-29 | 股票: 恒瑞医药(600276.SH) | 预测上涨概率: 39.44% | 建议: 卖出/空仓
日期: 2023-12-29 | 股票: 海康威视(002415.SZ) | 预测上涨概率: 37.49% | 建议: 卖出/空仓
日期: 2023-12-29 | 股票: 中芯国际(688981.SH) | 预测上涨概率: 40.35% | 建议: 卖出/空仓

8.3 练习题

题目1:前视偏差检查。解释为什么本章必须按日期切分训练/测试集;并给出一种更严格的验证方式(如 walk-forward / expanding window)。

题目2:交易成本敏感性。假设每次换仓的单边成本为 \(c\)(例如 10bp),写出净收益的计算公式,并用代码展示当 \(c\) 从 0bp 增加到 30bp 时,净收益如何变化。

题目3:稳定性分析。将标签从‘未来 5 日收益为正’改为‘未来 10 日收益为正’,比较模型 AUC 的变化,并解释原因。

8.4 练习题完整解答

本节给出三道练习题的完整解答。为保证可复现性,所有数据均从 data/ 目录的本地快照读取。

8.4.1 题目 1 解答:walk-forward(滚动前推)验证框架

walk-forward(滚动前推)验证的核心是:每个测试窗口之前的训练数据都严格早于测试窗口,且训练集可随时间扩张,从而更贴近真实‘上线后逐日积累数据’的过程。

import numpy as np
from sklearn.metrics import roc_auc_score
from sklearn.ensemble import RandomForestClassifier

# 复用上文 full_df 与 features;若未运行上文代码块,可取消注释重新构造 full_df

df_wf = full_df.sort_values('trade_date').copy()
dates = np.sort(df_wf['trade_date'].unique())

# 为节省计算时间,这里用较短窗口作演示;生产环境可增大 window/step 并做超参搜索
window = 60
step = 60

aucs = []
for start in range(window, len(dates) - window, step):
    train_end = dates[start - 1]
    test_start = dates[start]
    test_end = dates[min(start + window - 1, len(dates) - 1)]

    train_mask = df_wf['trade_date'] <= train_end
    test_mask = (df_wf['trade_date'] >= test_start) & (df_wf['trade_date'] <= test_end)

    X_train = df_wf.loc[train_mask, features]
    y_train = df_wf.loc[train_mask, 'target']
    X_test = df_wf.loc[test_mask, features]
    y_test = df_wf.loc[test_mask, 'target']

    m = RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42)
    m.fit(X_train, y_train)
    proba = m.predict_proba(X_test)[:, 1]
    aucs.append(roc_auc_score(y_test, proba))

print(f'walk-forward AUC({len(aucs)} 个窗口)均值: {np.mean(aucs):.4f} | 标准差: {np.std(aucs):.4f}')
walk-forward AUC(10 个窗口)均值: 0.5369 | 标准差: 0.0623

8.4.2 题目 2 解答:交易成本敏感性

若每次换仓发生单边成本 \(c\),在简单近似下,单期净收益可写为:

\[r_{t,\text{net}} = r_{t,\text{gross}} - c\cdot \text{turnover}_t\]

其中 \(\text{turnover}_t\) 表示当期换手率(买入金额与卖出金额的合计占组合市值的比例)。下面用‘信号变化次数’作为换手的粗略代理(示范用;严谨回测应基于实际持仓权重变化计算)。

import pandas as pd

one = full_df[full_df['ts_code'].eq('002142.SZ')].sort_values('trade_date').copy()
one = one.dropna(subset=features + ['future_ret_5']).copy()

proba_one = model.predict_proba(one[features])[:, 1]
signal = (proba_one >= 0.5).astype(int)

turnover_proxy = float(pd.Series(signal).diff().abs().fillna(0).sum() / len(signal))
gross_ret = float(one['future_ret_5'].mean())

print(f'换手率代理(002142.SZ, signal flip rate): {turnover_proxy:.4f}')
for c_bp in [0, 10, 20, 30]:
    c = c_bp / 10000
    net_ret = gross_ret - c * turnover_proxy
    print(f'c={c_bp:>2}bp | gross={gross_ret:.6f} | net={net_ret:.6f}')
换手率代理(002142.SZ, signal flip rate): 0.1220
c= 0bp | gross=-0.003247 | net=-0.003247
c=10bp | gross=-0.003247 | net=-0.003369
c=20bp | gross=-0.003247 | net=-0.003491
c=30bp | gross=-0.003247 | net=-0.003613

8.4.3 题目 3 解答:将标签改为未来 10 日

标签视野变长(5 日→10 日)往往会改变样本的‘可预测性’与‘噪声结构’,也会使动量/均线类特征与标签的相关性发生变化。

import pandas as pd
import numpy as np
from sklearn.metrics import roc_auc_score
from sklearn.ensemble import RandomForestClassifier

def add_features_target_10(pdf: pd.DataFrame) -> pd.DataFrame:
    pdf = add_features(pdf)
    pdf['future_ret_10'] = pdf['price'].shift(-10) / pdf['price'] - 1
    pdf['target_10'] = (pdf['future_ret_10'] > 0).astype(int)
    return pdf

all_data_10 = []
for code in codes:
    sub = df_all[df_all['ts_code'].eq(code)].copy()
    sub = sub.loc[(sub['trade_date'] >= '2021-01-01') & (sub['trade_date'] <= '2023-12-31')]
    sub = add_features_target_10(sub)
    sub['ts_code'] = code
    all_data_10.append(sub)

df10 = pd.concat(all_data_10, ignore_index=True)
df10 = df10.dropna(subset=features + ['target_10']).copy()
df10['trade_date'] = pd.to_datetime(df10['trade_date'])

dates10 = np.sort(df10['trade_date'].unique())
split_idx10 = int(len(dates10) * 0.8)
split_date10 = dates10[split_idx10]

train_mask10 = df10['trade_date'] <= split_date10
test_mask10 = df10['trade_date'] > split_date10

X_train10 = df10.loc[train_mask10, features]
y_train10 = df10.loc[train_mask10, 'target_10']
X_test10 = df10.loc[test_mask10, features]
y_test10 = df10.loc[test_mask10, 'target_10']

m10 = RandomForestClassifier(n_estimators=200, max_depth=5, random_state=42)
m10.fit(X_train10, y_train10)
proba10 = m10.predict_proba(X_test10)[:, 1]
auc10 = roc_auc_score(y_test10, proba10)
print(f'未来10日标签 AUC: {auc10:.4f}')
未来10日标签 AUC: 0.4861

8.5 8.3 结语与展望

本书通过八个章节的篇幅,带领大家完成了从 Python 编程入门到量化策略与大数据实战的旅程。但这仅仅是开始。金融市场瞬息万变,真正的金融工程师需要保持持续学习:

  1. 深入数学:加强随机过程、凸优化等数学基础。
  2. 关注前沿:探索深度学习(Transformer, LSTM)在时序预测中的应用。
  3. 立足本土:持续关注中国资本市场的制度创新(如全面注册制)带来的新机遇,特别是长三角等核心经济区的产业动态。

希望这本书能成为你金融工程职业生涯的一块坚实基石。祝大家在量化投资的道路上行稳致远!