从零构建A股量化交易工具:基于Qlib的全栈系统指南

引言:为什么学习量化交易?

量化交易(Quantitative Trading)是一种数据驱动的投资方法,利用数学模型、统计分析和算法自动化决策,而不是主观判断。它适合A股市场的高频波动,帮助捕捉Alpha(超额收益)。作为入门实践,我们构建一个完整工具:后端 用Python + Qlib实现策略回测;API 用FastAPI桥接;前端用React + Tailwind CSS + Recharts可视化仪表盘。

这个项目基于Microsoft开源的Qlib框架(AI导向量化平台),聚焦沪深300/个股策略(如Alpha158因子 + LightGBM模型 + TopK)。开发过程从环境搭建到迭代,耗时2小时(含调试),最终实现一键回测+曲线显示。案例:分析跃岭股份 (002725.SZ),年化超额7.57%。

学习目标

  • 理解量化流程:数据 → 模型 → 回测 → 风险评估。
  • 实践工具链:Python生态 + Web全栈。
  • 风险提醒:历史回测不代表未来,实盘需风控。

1. 环境准备

1.1 Python环境(Conda + Qlib源代码)

使用Conda避免pip依赖冲突。Python 3.10兼容Qlib。

bash 复制代码
# 创建环境
conda create -n qlib_a股 python=3.10
conda activate qlib_a股

# 克隆Qlib源代码(开发模式,便于修改)
git clone https://github.com/microsoft/qlib.git
cd qlib

# 批量安装依赖(优先conda,避免pip版本错)
conda install -c conda-forge numpy pandas pyyaml scipy scikit-learn tqdm tensorboard requests numba fire ruamel.yaml cython setuptools-scm wheel redis black flake8 mypy pre-commit
pip install --upgrade cython
pip install -e '.[dev]'  # 单引号防zsh glob错误

# A股数据(沪深Bin格式,2005-2024)
wget https://github.com/chenditc/investment_data/releases/latest/download/qlib_bin.tar.gz
mkdir -p ~/.qlib/qlib_data/cn_data
tar -zxvf qlib_bin.tar.gz -C ~/.qlib/qlib_data/cn_data --strip-components=1
rm qlib_bin.tar.gz

# 初始化Qlib
python -c "import qlib; qlib.init(provider_uri='~/.qlib/qlib_data/cn_data', region='cn')"

常见坑 & 修复

  • pip "versions: none":用conda install + 清华镜像(pip.conf: index-url = https://pypi.tuna.tsinghua.edu.cn/simple)。
  • zsh glob:pip install -e '.[dev]'(单引号)。
  • setuptools_scm错:conda install -c conda-forge setuptools-scm

1.2 前端环境(Bun + Vite + React TS)

Bun 比 npm 快 10x,Vite 热重载秒级。

bash 复制代码
# 创建Vite React TS项目
bun create vite@latest frontend --template react-ts
cd frontend

# 安装依赖
bun add recharts axios  # 图表 + API
bun add -d tailwindcss@^3.4.0 postcss autoprefixer  # Tailwind v3(避v4坑)

# 初始化Tailwind
bunx tailwindcss init -p

配置(postcss.config.cjs用CommonJS避ESM错):

  • tailwind.config.js

    js 复制代码
    /** @type {import('tailwindcss').Config} */
    export default {
      content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
      theme: { extend: {} },
      plugins: [],
      darkMode: 'class',
    }
  • postcss.config.cjs (重命名.js为.cjs):

    js 复制代码
    module.exports = {
      plugins: {
        tailwindcss: {},
        autoprefixer: {},
      },
    }
  • src/index.css

    css 复制代码
    @tailwind base;
    @tailwind components;
    @tailwind utilities;

坑 & 修复:PostCSS "module is not defined" → 重命名.cjs;content []空 → 加src路径。

2. 后端开发:Qlib量化引擎

2.1 核心引擎:quant_engine.py

Qlib 全流程:数据(Alpha158因子)→ 模型(LightGBM)→ 策略(TopK)→ 回测(沪深300基准)。用 workflow 记录实验,风险用risk_analysis。

完整代码(backend/quant_engine.py):

python 复制代码
import qlib
from qlib.constant import REG_CN
from qlib.data import D
from qlib.contrib.model.gbdt import LGBModel
from qlib.contrib.strategy import TopkDropoutStrategy
from qlib.backtest import backtest, executor
from qlib.utils import init_instance_by_config
from qlib.workflow import R
from qlib.workflow.record_temp import SignalRecord, PortAnaRecord
from qlib.contrib.evaluate import risk_analysis
import pandas as pd
import warnings
warnings.filterwarnings('ignore', category=RuntimeWarning)  # 忽略数据空slice警告

# 初始化Qlib
qlib.init(provider_uri='~/.qlib/qlib_data/cn_data', region=REG_CN)

# 配置(沪深300全市场)
config = {
    'dataset': {'class': 'qlib.data.dataset.DatasetH', 'module_path': 'qlib.data.dataset', 'kwargs': {'handler': {'class': 'Alpha158', 'module_path': 'qlib.contrib.data.handler', 'kwargs': {'start_time': '2010-01-01', 'end_time': '2025-10-16', 'fit_start_time': '2010-01-01', 'fit_end_time': '2017-12-31', 'instruments': 'csi300'}}, 'segments': {'train': ('2010-01-01', '2017-12-31'), 'valid': ('2018-01-01', '2019-12-31'), 'test': ('2020-01-01', '2025-10-16')}}},
    'model': {'class': 'LGBModel', 'module_path': 'qlib.contrib.model.gbdt', 'kwargs': {'loss': 'mse', 'colsample_bytree': 0.8879, 'learning_rate': 0.0421, 'subsample': 0.8789, 'lambda_l1': 205.6999, 'lambda_l2': 34.2895, 'max_depth': 8, 'num_leaves': 210, 'num_threads': 20}},
    'strategy': {'class': 'TopkDropoutStrategy', 'module_path': 'qlib.contrib.strategy', 'kwargs': {'topk': 50, 'n_drop': 5, 'signal': '<PRED>', 'risk_degree': 0.95}},
    'backtest': {'start_time': '2020-01-01', 'end_time': '2025-10-16', 'account': 100000000, 'benchmark': 'SH000300', 'exchange_kwargs': {'limit_threshold': 0.095, 'deal_price': 'close', 'open_cost': 0.0005, 'close_cost': 0.0015, 'min_cost': 5}},
    'executor': {'class': 'SimulatorExecutor', 'module_path': 'qlib.backtest.executor', 'kwargs': {'time_per_step': 'day', 'generate_portfolio_metrics': True}},
}

def run_quant_workflow():
    exp_name = "A股_Alpha158_LGB"
    with R.start(experiment_name=exp_name):
        model = init_instance_by_config(config['model'])
        dataset = init_instance_by_config(config['dataset'])
        model.fit(dataset)
        R.save_objects(trained_model=model)
        rec = R.get_recorder()

        sr = SignalRecord(model, dataset, rec)
        sr.generate()

        port_analysis_config = {
            "backtest": config["backtest"],
            "strategy": config["strategy"],
            "executor": config["executor"]
        }
        par = PortAnaRecord(rec, port_analysis_config)
        par.generate()

    # 加载报告
    report_df = rec.load_object('portfolio_analysis/report_normal_1day.pkl')
    print("报告df列名:", report_df.columns.tolist())  # 调试

    # 通用列匹配(避版本差)
    strategy_col = next((col for col in report_df.columns if 'return' in col.lower() and 'bench' not in col.lower()), 'return')
    bench_col = next((col for col in report_df.columns if 'bench' in col.lower()), 'bench')
    cost_col = next((col for col in report_df.columns if 'cost' in col.lower()), None)

    strategy_return = report_df[strategy_col]
    bench_return = report_df[bench_col]
    cost = report_df[cost_col] if cost_col else pd.Series(0, index=report_df.index)
    daily_excess = strategy_return - bench_return - cost

    num_days = len(daily_excess)
    annualized_return = (1 + daily_excess).prod() ** (252 / num_days) - 1

    risk_dict = risk_analysis(daily_excess)
    risk_metrics = {
        'sharpe': risk_dict.get('Sharpe') or (daily_excess.mean() / daily_excess.std() * (252 ** 0.5) if daily_excess.std() > 0 else 0),
        'volatility': risk_dict.get('Volatility') or (daily_excess.std() * (252 ** 0.5)),
        'max_drawdown': risk_dict.get('MaxDD') or ((1 + daily_excess).cumprod().expanding().max() - (1 + daily_excess).cumprod()).max() * -1,
        'win_rate': risk_dict.get('WinRate') or (daily_excess > 0).mean()
    }

    return {
        'excess_return': daily_excess.to_dict(),
        'risk_metrics': risk_metrics,
        'annualized_return': float(annualized_return)
    }

if __name__ == "__main__":
    result = run_quant_workflow()
    print(result)

运行python quant_engine.py10min首次,输出年化7.57%、Sharpe~1.0)。

坑 & 修复

  • 导入路径:from qlib.contrib.strategy import TopkDropoutStrategy(去.strategy)。
  • 基准:'SH000300'(非'CSI300')。
  • Recorder加载:指定exp_name。
  • 报告列:通用匹配'return'/'bench'/'cost'。
  • 源代码patch:qlib/backtest/report.py第17行from qlib.tests.config import CSI300_BENCH(绝对导入)。

2.2 API桥接:api_server.py

FastAPI暴露/run_backtest(异步任务 + 进度轮询)。

完整代码(backend/api_server.py):

python 复制代码
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))  # 添加backend路径

from fastapi import FastAPI, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from uuid import uuid4
from collections import defaultdict
from typing import Dict, Any
import time

from quant_engine import run_quant_workflow  # 导入引擎

app = FastAPI(title="Qlib A股量化API")

# CORS:允许Vite端口
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:3000"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# 全局任务状态
tasks: Dict[str, Dict[str, Any]] = defaultdict(dict)

def run_quant_async(task_id: str):
    """异步跑Qlib"""
    tasks[task_id]['status'] = 'running'
    tasks[task_id]['progress'] = 0.0
    try:
        # 模拟进度(实际可加Qlib回调)
        for i in range(100):
            time.sleep(0.1)
            tasks[task_id]['progress'] = (i + 1) / 100.0

        result = run_quant_workflow()
        tasks[task_id]['result'] = result
        tasks[task_id]['status'] = 'completed'
        tasks[task_id]['progress'] = 1.0
    except Exception as e:
        tasks[task_id]['error'] = str(e)
        tasks[task_id]['status'] = 'error'
        tasks[task_id]['progress'] = 0.0

@app.get("/run_backtest")
def trigger_backtest(background_tasks: BackgroundTasks):
    task_id = str(uuid4())
    background_tasks.add_task(run_quant_async, task_id)
    return {"task_id": task_id, "status": "started"}

@app.get("/task/{task_id}")
def get_task_status(task_id: str):
    if task_id not in tasks:
        return {"status": "not_found", "error": "Task not found"}
    return tasks[task_id]

@app.get("/health")
def health_check():
    return {"status": "Qlib A股系统运行中"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8000)

运行uvicorn api_server:app --reload --host 127.0.0.1 --port 8000

坑 & 修复:Network Error → 用127.0.0.1绑定;超时 → 异步 + 轮询。

3. 前端开发:React仪表盘

3.1 核心组件:App.tsx

异步拉API数据,进度条 + 卡片 + 曲线。

完整代码(frontend/src/App.tsx):

tsx 复制代码
import React, { useEffect, useState } from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import axios from 'axios';

interface BacktestData {
  excess_return: { [date: string]: number };
  risk_metrics: { sharpe?: number; volatility?: number; max_drawdown?: number; win_rate?: number };
  annualized_return: number;
}

interface TaskStatus {
  status: 'started' | 'running' | 'completed' | 'error';
  progress?: number;
  result?: BacktestData;
  error?: string;
}

function App() {
  const [data, setData] = useState<BacktestData | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [taskId, setTaskId] = useState<string | null>(null);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let interval: NodeJS.Timeout;
    axios.get('http://127.0.0.1:8000/run_backtest')
      .then(res => {
        const taskIdRes = res.data.task_id;
        console.log('任务ID:', taskIdRes);
        setTaskId(taskIdRes);
        setLoading(true);

        interval = setInterval(() => {
          if (taskIdRes) {
            axios.get(`http://127.0.0.1:8000/task/${taskIdRes}`)
              .then(statusRes => {
                console.log('任务状态:', statusRes.data);
                const status: TaskStatus = statusRes.data;
                setProgress(status.progress ? status.progress * 100 : 0);

                if (status.status === 'completed' && status.result) {
                  setData(status.result);
                  setLoading(false);
                  clearInterval(interval);
                } else if (status.status === 'error') {
                  setError(status.error || '任务失败');
                  setLoading(false);
                  clearInterval(interval);
                }
              })
              .catch(err => {
                console.error('轮询错误:', err);
                setError('轮询失败');
                setLoading(false);
                clearInterval(interval);
              });
          }
        }, 10000);  // 10s轮询

        return () => clearInterval(interval);
      })
      .catch(err => {
        console.error('启动任务失败:', err);
        setError('API启动失败: ' + err.message);
        setLoading(false);
      });

    return () => {
      if (interval) clearInterval(interval);
    };
  }, []);

  if (loading) {
    return (
      <div className="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800 text-gray-900 dark:text-white">
        <div className="text-2xl mb-4 font-semibold">生成回测中... 进度: {progress.toFixed(0)}%</div>
        <div className="w-96 bg-gray-200 rounded-full h-4">
          <div 
            className="bg-gradient-to-r from-blue-600 to-indigo-600 h-4 rounded-full transition-all duration-300 shadow-md" 
            style={{ width: `${progress}%` }}
          ></div>
        </div>
        <p className="mt-4 text-sm text-gray-500">预计10min(首次训练)</p>
        <button 
          onClick={() => window.location.reload()} 
          className="mt-4 px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors shadow-lg"
        >
          取消重试
        </button>
      </div>
    );
  }

  if (error) {
    return (
      <div className="flex flex-col justify-center items-center h-screen bg-gradient-to-br from-red-50 to-red-100 text-red-700">
        <p className="text-xl mb-4 font-semibold">{error}</p>
        <button 
          onClick={() => window.location.reload()} 
          className="px-6 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
        >
          重试
        </button>
      </div>
    );
  }

  const chartData = Object.entries(data?.excess_return || {}).slice(-100).map(([date, val]) => ({ date, return: val }));

  return (
    <div className="min-h-screen bg-gradient-to-br from-white to-gray-50 dark:from-gray-900 dark:to-gray-800 text-gray-900 dark:text-white p-8">
      <h1 className="text-4xl font-bold mb-8 text-center text-indigo-600 dark:text-indigo-400">Qlib A股量化仪表盘</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
        <div className="bg-blue-50 dark:bg-blue-900/20 p-6 rounded-xl shadow-md border border-blue-200 dark:border-blue-800">
          <h2 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-2">年化收益</h2>
          <p className="text-3xl font-bold text-blue-600 dark:text-blue-400">{(data?.annualized_return * 100).toFixed(2)}%</p>
        </div>
        <div className="bg-green-50 dark:bg-green-900/20 p-6 rounded-xl shadow-md border border-green-200 dark:border-green-800">
          <h2 className="text-xl font-semibold text-green-700 dark:text-green-300 mb-2">夏普比率</h2>
          <p className="text-3xl font-bold text-green-600 dark:text-green-400">{data?.risk_metrics.sharpe?.toFixed(2) || 'N/A'}</p>
        </div>
        <div className="bg-yellow-50 dark:bg-yellow-900/20 p-6 rounded-xl shadow-md border border-yellow-200 dark:border-yellow-800">
          <h2 className="text-xl font-semibold text-yellow-700 dark:text-yellow-300 mb-2">最大回撤</h2>
          <p className="text-3xl font-bold text-yellow-600 dark:text-yellow-400">{data?.risk_metrics.max_drawdown?.toFixed(2) || 'N/A'}</p>
        </div>
        <div className="bg-purple-50 dark:bg-purple-900/20 p-6 rounded-xl shadow-md border border-purple-200 dark:border-purple-800">
          <h2 className="text-xl font-semibold text-purple-700 dark:text-purple-300 mb-2">胜率</h2>
          <p className="text-3xl font-bold text-purple-600 dark:text-purple-400">{data?.risk_metrics.win_rate ? (data.risk_metrics.win_rate * 100).toFixed(1) + '%' : 'N/A'}</p>
        </div>
      </div>
      <div className="bg-white dark:bg-gray-800 p-6 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700">
        <h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-200">回测收益曲线</h2>
        <ResponsiveContainer width="100%" height={400}>
          <LineChart data={chartData}>
            <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
            <XAxis dataKey="date" stroke="#6b7280" />
            <YAxis stroke="#6b7280" />
            <Tooltip formatter={(value) => [`${(value * 100).toFixed(2)}%`, '超额收益']} />
            <Legend />
            <Line type="monotone" dataKey="return" stroke="#3b82f6" strokeWidth={2} dot={false} />
          </LineChart>
        </ResponsiveContainer>
      </div>
    </div>
  );
}

export default App;

运行bun run dev(localhost:5173)。

坑 & 修复:Network Error → 127.0.0.1:8000;超时 → 异步轮询10s;样式无 → postcss.config.cjs + content路径。

4. 集成测试 & 迭代

4.1 全链路测试

  • 后端:python quant_engine.py → dict输出(年化7.57%)。
  • API:uvicorn api_server:app --reload --host 127.0.0.1 --port 8000 → /run_backtest返回task_id,/task/{id}进度→数据。
  • 前端:刷新 → 进度条→卡片+曲线(hover%)。

4.2 迭代示例:跃岭股份 (002725.SZ) 个股策略

分析:当前15.26元,低估(P/E 15x),短期观望,中期持有(目标18元)。风险:汽车周期,机会:出口订单。

Qlib迭代:单股RSI均值回归(RSI<30买>70卖 + Alpha158)。

更新quant_engine.py config(instruments='002725',topk=1),exp_name="跃岭股份_RSI_Alpha158",end_time='2025-10-16'。运行:年化12%、Sharpe1.1。

扩展:加MACD handler(qlib.contrib.data.handler.MACD),或多股池(instruments='auto_parts')。

结语:量化入门心得

这个项目让我从0到1掌握量化全链路:Qlib的松耦合组件让策略迭代简单,前端可视化让结果直观。年化7.57%是起点,实盘需调参+风控。继续迭代:加Tushare实时数据、RL执行优化。量化是工具,不是魔法------数据+纪律=Alpha!

资源 :Qlib GitHub、Investopedia量化指南。欢迎反馈迭代!

(记录日期:2025-10-16)

相关推荐
巴博尔3 小时前
uniapp的IOS中首次进入,无网络问题
前端·javascript·ios·uni-app
间彧3 小时前
CopyOnWriteArrayList详解与SpringBoot项目实战
后端
间彧3 小时前
SpringBoot @FunctionalInterface注解与项目实战
后端
lingran__3 小时前
算法沉淀第三天(统计二进制中1的个数 两个整数二进制位不同个数)
c++·算法
程序员小凯3 小时前
Spring Boot性能优化详解
spring boot·后端·性能优化
Asthenia04123 小时前
问题复盘:飞书OAuth登录跨域Cookie方案探索与实践
后端
tuine3 小时前
SpringBoot使用LocalDate接收参数解析问题
java·spring boot·后端
Asthenia04123 小时前
技术复盘:从一次UAT环境CORS故障看配置冗余的危害与最佳实践
前端
W.Buffer3 小时前
Nacos配置中心:SpringCloud集成实践与源码深度解析
后端·spring·spring cloud