feat(s2): 投研核心端到端跑通(IC 表 + tears 报告)

- Task 加 raw_result 字段;orchestrator get_raw_result(内存存 FactorReport)
- 路由 /factor/list、/task/{id}/ic-summary、/task/{id}/report/{factor}(query token 给 iframe)
- analyzer cfg=None 时加载 data_platform.yaml(修 API 路径 read_db_daily 崩)
- get_status 返回 error_msg(调试+前端 failed 展示)
- 前端 投研-新建(多因子/多标的/日期)+ 结果页(IC 表 + tears iframe)
- factor 冒烟通过:ma5 → IC 1D/5D/10D 真实数据
This commit is contained in:
2026-07-07 06:28:01 +08:00
parent 28aea67232
commit 212ad6426d
11 changed files with 416 additions and 8 deletions
+35
View File
@@ -0,0 +1,35 @@
import { apiClient } from './client'
import { useAuthStore } from '@/stores/auth'
export interface FactorItem {
name: string
category: string
}
export interface FactorSubmit {
symbols: string[]
factor_names: string[]
start: string
end: string
}
export async function getFactors(): Promise<FactorItem[]> {
const { data } = await apiClient.get<{ factors: FactorItem[] }>('/factor/list')
return data.factors
}
export async function submitFactor(req: FactorSubmit): Promise<string> {
const { data } = await apiClient.post<{ task_id: string }>('/factor/analyze', req)
return data.task_id
}
export async function getIcSummary(taskId: string): Promise<Record<string, unknown>> {
const { data } = await apiClient.get<{ ic_summary: Record<string, unknown> }>(`/task/${taskId}/ic-summary`)
return data.ic_summary
}
/** Report URL with token in query (iframe can't set Authorization header). */
export function reportUrl(taskId: string, factor: string): string {
const auth = useAuthStore()
return `/api/v1/task/${taskId}/report/${factor}?token=${encodeURIComponent(auth.token ?? '')}`
}
+1
View File
@@ -12,6 +12,7 @@ const routes: RouteRecordRaw[] = [
{ path: 'backtest/progress/:id', name: 'bt-progress', component: () => import('@/views/backtest/Progress.vue') },
{ path: 'backtest/result/:id', name: 'bt-result', component: () => import('@/views/backtest/Result.vue') },
{ path: 'factor/new', name: 'fc-new', component: () => import('@/views/factor/New.vue') },
{ path: 'factor/progress/:id', name: 'fc-progress', component: () => import('@/views/backtest/Progress.vue') },
{ path: 'factor/result/:id', name: 'fc-result', component: () => import('@/views/factor/Result.vue') },
],
},
+4 -1
View File
@@ -11,7 +11,10 @@ const { status, stage, start } = useTask(taskId)
start()
watch(status, (s) => {
if (s === 'done') router.push(`/backtest/result/${taskId}`)
if (s === 'done') {
const base = route.path.startsWith('/factor') ? '/factor' : '/backtest'
router.push(`${base}/result/${taskId}`)
}
})
function pct(): number {
+80 -2
View File
@@ -1,4 +1,82 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getFactors, submitFactor, type FactorItem } from '@/api/factor'
const router = useRouter()
const factors = ref<FactorItem[]>([])
const loading = ref(false)
const submitting = ref(false)
const form = reactive({
factor_names: [] as string[],
symbolsText: '600000\n000001\n300750',
start: '2024-01-01',
end: '2024-06-30',
})
onMounted(async () => {
loading.value = true
try {
factors.value = await getFactors()
} catch {
ElMessage.error('因子列表加载失败')
} finally {
loading.value = false
}
})
async function onSubmit(): Promise<void> {
const symbols = form.symbolsText
.split(/[\s,]+/)
.map((s) => s.trim())
.filter(Boolean)
if (!form.factor_names.length || symbols.length < 2) {
ElMessage.warning('至少选 1 个因子 + 2 个标的(IC 横截面需多标的)')
return
}
submitting.value = true
try {
const tid = await submitFactor({
symbols,
factor_names: form.factor_names,
start: form.start,
end: form.end,
})
ElMessage.success('因子分析已提交')
router.push(`/factor/progress/${tid}`)
} catch {
ElMessage.error('提交失败')
} finally {
submitting.value = false
}
}
</script>
<template>
<el-empty description="投研 - 新建因子分析(S2 切片实现)" />
<el-card v-loading="loading">
<template #header>
<h3>新建因子分析</h3>
</template>
<el-form label-width="140px">
<el-form-item label="因子">
<el-select v-model="form.factor_names" multiple placeholder="选择因子" style="width: 380px">
<el-option v-for="f in factors" :key="f.name" :label="f.name" :value="f.name" />
</el-select>
</el-form-item>
<el-form-item label="标的(≥2,每行一个)">
<el-input v-model="form.symbolsText" type="textarea" :rows="3" placeholder="600000&#10;000001&#10;300750" />
</el-form-item>
<el-form-item label="开始日期">
<el-date-picker v-model="form.start" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item label="结束日期">
<el-date-picker v-model="form.end" type="date" value-format="YYYY-MM-DD" style="width: 220px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交分析</el-button>
</el-form-item>
</el-form>
</el-card>
</template>
+87 -2
View File
@@ -1,4 +1,89 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getIcSummary, reportUrl } from '@/api/factor'
interface IcStats {
mean?: number
std?: number
icir?: number
t_stat?: number
count?: number
error?: string
}
interface FactorInfo {
status?: string
ic?: Record<string, IcStats>
error?: string
}
const route = useRoute()
const taskId = String(route.params.id)
const loading = ref(true)
const icSummary = ref<Record<string, FactorInfo>>({})
const rows = computed(() => {
const out: Array<Record<string, unknown>> = []
for (const [factor, info] of Object.entries(icSummary.value)) {
const ic = info?.ic || {}
for (const [period, s] of Object.entries(ic)) {
out.push({ factor, period, ...s })
}
}
return out
})
const factors = computed(() => Object.keys(icSummary.value))
function fmt(v: unknown): string {
if (typeof v === 'number') return (Math.round(v * 10000) / 10000).toString()
return v == null ? '' : String(v)
}
onMounted(async () => {
try {
icSummary.value = (await getIcSummary(taskId)) as Record<string, FactorInfo>
} catch {
ElMessage.error('IC 摘要加载失败')
} finally {
loading.value = false
}
})
</script>
<template>
<el-empty description="投研 - 结果(S2 切片实现)" />
<div v-loading="loading">
<el-card>
<template #header>
<h3>IC 统计</h3>
</template>
<el-table :data="rows" stripe size="small" empty-text=" IC 数据">
<el-table-column prop="factor" label="因子" width="120" />
<el-table-column prop="period" label="周期" width="80" />
<el-table-column label="IC 均值">
<template #default="{ row }">{{ fmt(row.mean) }}</template>
</el-table-column>
<el-table-column label="IC 标准差">
<template #default="{ row }">{{ fmt(row.std) }}</template>
</el-table-column>
<el-table-column label="ICIR">
<template #default="{ row }">{{ fmt(row.icir) }}</template>
</el-table-column>
<el-table-column label="t 统计">
<template #default="{ row }">{{ fmt(row.t_stat) }}</template>
</el-table-column>
<el-table-column label="样本数">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
</el-table>
</el-card>
<el-card v-for="f in factors" :key="f" style="margin-top: 16px">
<template #header>
<h3>tears 报告 {{ f }}</h3>
</template>
<iframe :src="reportUrl(taskId, f)" style="width: 100%; height: 600px; border: 0" />
</el-card>
</div>
</template>
+47 -3
View File
@@ -1,7 +1,9 @@
"""
FastAPI routes for Sanguo Quant API
"""
import os
from fastapi import APIRouter, HTTPException, Depends, WebSocket, Query, Header
from fastapi.responses import FileResponse
from pydantic import BaseModel
from .schemas import CtaBacktestRequest, OptimizeRequest, FactorAnalysisRequest
from .auth import verify_token as verify_token_impl, verify_password, create_token
@@ -111,11 +113,14 @@ def get_status(task_id: str):
s = get_orchestrator().get_status(task_id)
if s is None:
raise HTTPException(status_code=404, detail="task not found")
stage = get_orchestrator().pool.get_stage(task_id)
pool = get_orchestrator().pool
stage = pool.get_stage(task_id)
task = pool.get_task(task_id)
return {
"task_id": task_id,
"status": s.value if hasattr(s, "value") else str(s),
"stage": stage or ""
"stage": stage or "",
"error_msg": task.error_msg if task else None,
}
@@ -222,4 +227,43 @@ def kline(symbol: str, start: str, end: str):
try:
return {"symbol": symbol, "kline": load_kline(symbol, start, end)}
except Exception as e:
raise HTTPException(status_code=500, detail=f"kline load failed: {type(e).__name__}: {e}")
raise HTTPException(status_code=500, detail=f"kline load failed: {type(e).__name__}: {e}")
# ===== Factor (投研) endpoints (S2) =====
@router.get("/factor/list", dependencies=[Depends(verify_token)])
def factor_list():
"""List registered factors for the UI dropdown."""
from sanguo_factor.registry import list_factors
items = [{"name": f["name"], "category": f.get("category", "")} for f in list_factors()]
return {"factors": items}
@router.get("/task/{task_id}/ic-summary", dependencies=[Depends(verify_token)])
def ic_summary(task_id: str):
"""Factor IC summary (mean/std/icir/t_stat per period)."""
r = get_orchestrator().get_raw_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
ic = getattr(r, "ic_summary", None)
if ic is None:
raise HTTPException(status_code=404, detail="no ic_summary (not a factor result?)")
return {"task_id": task_id, "ic_summary": ic}
@router.get("/task/{task_id}/report/{factor}")
def factor_report(task_id: str, factor: str, token: str = Query(...)):
"""Serve the alphalens tears HTML report (token via query for iframe use)."""
try:
verify_token_impl(token)
except Exception:
raise HTTPException(status_code=401, detail="invalid token")
r = get_orchestrator().get_raw_result(task_id)
if r is None:
raise HTTPException(status_code=404, detail="result not ready")
paths = getattr(r, "report_paths", {}) or {}
path = paths.get(factor)
if not path or not os.path.exists(path):
raise HTTPException(status_code=404, detail=f"report for {factor} not found")
return FileResponse(path)
+6
View File
@@ -66,6 +66,12 @@ def run_factor_analysis(
"""
from .registry import get_factor
# API path passes cfg=None → load default data_platform.yaml (so read_db_daily
# and AlphaLabSession can find the A-share DB).
if cfg is None:
from sanguo_data.config import load_config, find_config_path
cfg = load_config(find_config_path())
# Check if alphalens is available
if get_clean_factor_and_forward_returns is None or create_full_tear_sheet is None or factor_information_coefficient is None:
return FactorReport(
+10
View File
@@ -135,6 +135,7 @@ class Orchestrator:
# S1.1: use the persisted DB row id (BacktestResult.id) so get_result can
# load_result(result.id). FactorReport (no .id) falls back to None until S2.
task.complete(result_id=getattr(result, "id", None))
task.raw_result = result # S2: keep in-memory result (FactorReport) for ic-summary/report
await self._notify_stage(task_id, "完成")
def get_status(self, task_id: str) -> TaskState | None:
@@ -150,6 +151,15 @@ class Orchestrator:
return load_result(task.result_id, self.db_path)
return None
def get_raw_result(self, task_id: str):
"""Get the raw in-memory result object (e.g. FactorReport) by task ID.
Used by factor endpoints (ic-summary, tears report) where the result
isn't a BacktestResult persisted to the DB.
"""
task = self.pool.get_task(task_id)
return task.raw_result if task else None
# Module-level worker functions (must be top-level for ProcessPoolExecutor pickle)
def _cta_worker(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, db_path: str) -> any:
+2
View File
@@ -4,6 +4,7 @@ Defines Task state machine and transitions
"""
import enum
from dataclasses import dataclass
from typing import Any
class TaskState(enum.Enum):
@@ -21,6 +22,7 @@ class Task:
task_type: str
status: TaskState = TaskState.PENDING
result_id: int | None = None
raw_result: Any = None # in-memory result object (e.g. FactorReport for factor tasks)
error_msg: str | None = None
stage: str = "" # Current stage (数据加载/算因子/回测中...)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Phase 3b S2 end-to-end smoke: login -> submit factor analysis (ma5,
multi-symbol) -> poll -> verify ic-summary non-empty.
"""
import json
import sys
import time
import urllib.request
BASE = "http://192.168.2.154:8000"
def _req(method, path, token=None, body=None):
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=headers, method=method)
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read())
def main() -> int:
tok = _req("POST", "/api/v1/auth/login", body={"username": "admin", "password": "admin"})["token"]
print("[1] login OK")
fl = _req("GET", "/api/v1/factor/list", token=tok)
print(f"[2] factors: {[f['name'] for f in fl['factors']]}")
sub = _req("POST", "/api/v1/factor/analyze", token=tok, body={
"symbols": ["600000", "000001", "300750"],
"factor_names": ["ma5"],
"start": "2024-01-01",
"end": "2024-06-30",
})
tid = sub["task_id"]
print(f"[3] submitted: {tid}")
status = "pending"
for i in range(60):
s = _req("GET", f"/api/v1/task/{tid}", token=tok)
status = s["status"]
print(f" [{i:02d}] status={status} stage={s.get('stage', '')}")
if status in ("done", "failed"):
break
time.sleep(3)
if status != "done":
print(f"[!] factor analysis did not complete: {status}")
return 1
ic = _req("GET", f"/api/v1/task/{tid}/ic-summary", token=tok)["ic_summary"]
print(f"[4] ic_summary keys: {list(ic.keys())}")
assert "ma5" in ic, "ma5 missing from ic_summary"
ma5 = ic["ma5"]
print(f" ma5 status: {ma5.get('status')}")
print(f" ma5 ic: {json.dumps(ma5.get('ic', {}), ensure_ascii=False)[:300]}")
assert ma5.get("ic"), "ma5 ic empty"
print("[5] SMOKE PASSED")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except AssertionError as e:
print(f"[SMOKE FAILED] {e}")
sys.exit(2)
except Exception as e:
print(f"[SMOKE ERROR] {type(e).__name__}: {e}")
sys.exit(3)
+73
View File
@@ -0,0 +1,73 @@
"""Tests for factor (投研) endpoints (S2): /factor/list, /ic-summary, /report."""
import pytest
from fastapi.testclient import TestClient
from sanguo_api.app import create_app
from sanguo_api.routes import set_orchestrator
from sanguo_api.auth import hash_password
class FakeReport:
"""Stand-in for FactorReport."""
def __init__(self, ic_summary: dict, report_paths: dict):
self.ic_summary = ic_summary
self.report_paths = report_paths
class FakeOrch:
def __init__(self, raw):
self._raw = raw
def get_raw_result(self, task_id):
return self._raw
@pytest.fixture(scope="module")
def client() -> TestClient:
app = create_app(
db_path="/tmp/test_fc_routes.db",
auth_config={
"username": "admin",
"password_hash": hash_password("admin"),
"jwt_secret": "test-secret",
"expire_minutes": 60,
},
max_workers=1,
)
set_orchestrator(FakeOrch(FakeReport(
ic_summary={"ma5": {"status": "success", "ic": {
"1D": {"mean": -0.12, "std": 0.5, "icir": -0.24, "t_stat": -1.1, "count": 49},
}}},
report_paths={"ma5": "/tmp/__definitely_absent_ma5.html"},
)))
return TestClient(app)
@pytest.fixture(scope="module")
def token(client) -> str:
return client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"}).json()["token"]
def test_factor_list_shape(client, token):
r = client.get("/api/v1/factor/list", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
assert isinstance(r.json()["factors"], list)
def test_ic_summary(client, token):
r = client.get("/api/v1/task/t/ic-summary", headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
ic = r.json()["ic_summary"]
assert "ma5" in ic
assert ic["ma5"]["ic"]["1D"]["mean"] == -0.12
def test_report_bad_token_401(client):
r = client.get("/api/v1/task/t/report/ma5?token=bad")
assert r.status_code == 401
def test_report_file_absent_404(client, token):
r = client.get(f"/api/v1/task/t/report/ma5?token={token}")
assert r.status_code == 404