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:
@@ -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 ?? '')}`
|
||||
}
|
||||
@@ -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') },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 000001 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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (数据加载/算因子/回测中...)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user