feat: Phase 3 Web — 业务 API(stocks/factors/backtests)+ Next.js 前端

- 后端业务 API:GET /api/stocks(搜索/分页)、GET /api/factors(因子目录)、POST /api/factor-tests 与 /api/backtests(Research Spec 驱动同步执行)、GET /api/backtests/last;Annotated 依赖注入 + CORS(dev)
- Repository 批量查询 get_range_many(研究装配一次查询,避免逐只拉取)
- 前端 frontend/web:Next.js 15(TS) + ECharts —— 总览 / 股票池 / 因子研究(IC·RankIC·分层展示) / 回测(净值·回撤·月度·持仓·未建模标注)
- 前端只消费业务 API 与标准化 BacktestResult,无 Qlib/SQL 概念泄漏
- 真实数据:同步 20 只权重股 2023-2024 日线(9680 根)支撑截面研究
- 验证:API 集成测试 8 项(DTO 校验/装配/引擎/标准结果,内存 repo 全链路)+ 全量 pytest 68 passed;前端 tsc + next build 通过;无头浏览器端到端(factors/backtest 页面渲染后端数据)
- ruff clean
This commit is contained in:
Simon
2026-09-06 17:14:33 +08:00
parent e9f59d3cf8
commit 92627f5b6b
27 changed files with 1863 additions and 7 deletions
+2
View File
@@ -17,6 +17,7 @@ build/
# ================= 密钥 / 凭证 =================
# 真实密钥只放根目录 .env(复制自 .env.example),严禁提交
.env
.env.local
*.pem
*.key
@@ -48,6 +49,7 @@ experiments/*
.idea/
.vscode/
*.swp
*.tsbuildinfo
# ================= archify 视觉验证副产物(重新生成即可) =================
docs/diagrams/*.visual-check.*
+51
View File
@@ -0,0 +1,51 @@
"""API 依赖注入:Repository / 研究服务的装配点(composition root 的一部分)。
路由层统一使用 Annotated 注入(FastAPI 推荐写法,配合 ruff B008 无冲突)。
"""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends
from sqlalchemy.orm import Session
from app.domain.repositories.market import (
DailyBarRepository,
StockRepository,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyDailyBarRepository,
SqlAlchemyStockRepository,
)
from app.infrastructure.persistence.sqlalchemy.session import get_session
from app.quant.engine import LocalEngine, QuantEngine
from app.quant.service import ResearchService
DbSession = Annotated[Session, Depends(get_session)]
def _stock_repo_factory(session: DbSession) -> StockRepository:
return SqlAlchemyStockRepository(session)
def _daily_repo_factory(session: DbSession) -> DailyBarRepository:
return SqlAlchemyDailyBarRepository(session)
def _engine_factory() -> QuantEngine:
return LocalEngine()
def _service_factory(
stock_repo: Annotated[StockRepository, Depends(_stock_repo_factory)],
daily_repo: Annotated[DailyBarRepository, Depends(_daily_repo_factory)],
engine: Annotated[QuantEngine, Depends(_engine_factory)],
) -> ResearchService:
return ResearchService(stock_repo, daily_repo, engine)
StockRepoDep = Annotated[StockRepository, Depends(_stock_repo_factory)]
DailyRepoDep = Annotated[DailyBarRepository, Depends(_daily_repo_factory)]
EngineDep = Annotated[QuantEngine, Depends(_engine_factory)]
ResearchServiceDep = Annotated[ResearchService, Depends(_service_factory)]
+25
View File
@@ -0,0 +1,25 @@
"""因子目录 API:/api/factors。"""
from __future__ import annotations
from fastapi import APIRouter
from app.quant.factors import list_factors
router = APIRouter(prefix="/factors", tags=["factors"])
@router.get("", summary="因子目录(含元数据)")
def list_factor_catalog() -> list[dict]:
return [
{
"name": d.name,
"description": d.description,
"formula": d.formula,
"frequency": d.frequency,
"lookback": d.lookback,
"direction": d.direction,
"requires": list(d.requires),
}
for d in list_factors()
]
+56
View File
@@ -0,0 +1,56 @@
"""研究执行 API:/api/factor-tests 与 /api/backtests。
Phase 3 为同步执行(样本有限);Phase 4 将改为 Job + SSE 异步(接口契约不变)。
最近一次结果在内存中可读,便于前端展示;持久化实验归档在 Phase 4。
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.api.deps import ResearchServiceDep
from app.domain.entities.research import (
BacktestResult,
FactorTestReport,
ResearchSpec,
)
from app.quant.factors import FactorError
router = APIRouter(tags=["research"])
# 内存中的最近结果(Phase 4 迁移到 Experiment 表)
_LAST_BACKTEST: dict[str, BacktestResult] = {}
_LAST_FACTOR_TEST: dict[str, FactorTestReport] = {}
@router.post("/factor-tests", response_model=FactorTestReport, summary="运行单因子测试(同步)")
def run_factor_test(
spec: ResearchSpec,
service: ResearchServiceDep,
) -> FactorTestReport:
try:
report = service.run_factor_test(spec)
except (ValueError, FactorError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_LAST_FACTOR_TEST["default"] = report
return report
@router.post("/backtests", response_model=BacktestResult, summary="运行回测(同步)")
def run_backtest(
spec: ResearchSpec,
service: ResearchServiceDep,
) -> BacktestResult:
try:
result = service.run_backtest(spec)
except (ValueError, FactorError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
_LAST_BACKTEST["default"] = result
return result
@router.get("/backtests/last", response_model=BacktestResult, summary="最近一次回测结果")
def last_backtest() -> BacktestResult:
if "default" not in _LAST_BACKTEST:
raise HTTPException(status_code=404, detail="尚无回测结果,请先 POST /api/backtests")
return _LAST_BACKTEST["default"]
+6 -3
View File
@@ -1,14 +1,17 @@
"""API 路由聚合。
后续业务路由按 AGENT.md §17 面向业务对象挂载:
/api/stocks /api/universes /api/factors /api/strategies /api/backtests /api/experiments /api/jobs
业务路由面向业务对象(AGENT.md §17):/api/stocks /api/factors
/api/factor-tests /api/backtests /api/experiments(Phase4) /api/jobs(Phase4) /api/agent(Phase5)
"""
from __future__ import annotations
from fastapi import APIRouter
from app.api import health
from app.api import factors, health, research, stocks
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(stocks.router)
api_router.include_router(factors.router)
api_router.include_router(research.router)
+34
View File
@@ -0,0 +1,34 @@
"""股票查询 API:/api/stocks。"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from app.api.deps import StockRepoDep
from app.domain.entities.market import Stock
router = APIRouter(prefix="/stocks", tags=["stocks"])
@router.get("", response_model=list[Stock], summary="股票列表")
def list_stocks(
repo: StockRepoDep,
q: str | None = None,
limit: int = 50,
offset: int = 0,
) -> list[Stock]:
if limit > 500:
limit = 500
stocks = repo.list()
if q:
needle = q.upper()
stocks = [s for s in stocks if needle in s.symbol or needle in s.name.upper()]
return stocks[offset : offset + limit]
@router.get("/{symbol}", response_model=Stock, summary="按代码查询")
def get_stock(symbol: str, repo: StockRepoDep) -> Stock:
stock = repo.get_by_symbol(symbol)
if stock is None:
raise HTTPException(status_code=404, detail=f"未找到股票 {symbol}")
return stock
@@ -42,6 +42,9 @@ class DailyBarRepository(Protocol):
def get_range(self, symbol: str, start: date, end: date) -> list[DailyBar]: ...
def get_range_many(self, symbols: Sequence[str], start: date, end: date) -> list[DailyBar]:
"""批量区间查询(研究服务装配面板用,避免逐只查询)。"""
def latest_date(self, symbol: str) -> date | None:
"""断点续传用:该股票本地已有数据的最新交易日。"""
@@ -140,6 +140,18 @@ class SqlAlchemyDailyBarRepository:
).all()
return [DailyBar.model_validate(r, from_attributes=True) for r in rows]
def get_range_many(self, symbols: Sequence[str], start: date, end: date) -> list[DailyBar]:
rows = self._session.scalars(
select(StockDailyModel)
.where(
StockDailyModel.symbol.in_(list(symbols)),
StockDailyModel.trade_date >= start,
StockDailyModel.trade_date <= end,
)
.order_by(StockDailyModel.trade_date)
).all()
return [DailyBar.model_validate(r, from_attributes=True) for r in rows]
def latest_date(self, symbol: str) -> date | None:
return self._session.scalar(
select(StockDailyModel.trade_date)
+11 -1
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.router import api_router
from app.core.config import get_settings
@@ -15,7 +16,16 @@ settings = get_settings()
app = FastAPI(
title=settings.app_name,
version=settings.app_version,
description="A股个人量化研究平台 API(Qlib 引擎 / Tushare 数据源)",
description="A股个人量化研究平台 API(研究引擎 / Tushare 数据源)",
)
# 开发期允许本地前端跨域(Phase 3 前端 dev server;上线收紧为白名单)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(api_router, prefix=settings.api_prefix)
+1
View File
@@ -326,6 +326,7 @@ def run_spec_factor_test(
panels = build_factor_panels(daily, spec.factors)
panel = panels[0][1]
close = daily.pivot(index="trade_date", columns="symbol", values="close").sort_index()
close.index = pd.to_datetime(close.index)
forward = close.shift(-horizon_days) / close - 1.0
report = run_factor_test(panel, forward, factor_name=factor_name)
return report, {factor_name: panel}
+9 -3
View File
@@ -87,7 +87,13 @@ class ResearchService:
# 回测前预留因子 warmup(lookback≤120 交易日,取 300 自然日余量)
data_start = start - timedelta(days=300)
stocks = filter_stocks(self._stock_repo.list(), spec.universe, as_of=start)
bars: list = []
for s in stocks:
bars.extend(self._daily_repo.get_range(s.symbol, data_start, end))
if not stocks:
return pd.DataFrame()
get_many = getattr(self._daily_repo, "get_range_many", None)
if get_many is not None:
bars = list(get_many([s.symbol for s in stocks], data_start, end))
else: # 兜底:逐只查询
bars = []
for s in stocks:
bars.extend(self._daily_repo.get_range(s.symbol, data_start, end))
return bars_to_daily_df(bars)
+141
View File
@@ -0,0 +1,141 @@
"""API 集成测试:/api/stocks、/api/factors、/api/backtests、/api/factor-tests。
使用内存 Repository / 合成行情替换真实 DB 依赖(override 装配工厂),
引擎为真实 LocalEngine —— 覆盖「DTO 校验 → 装配 → 引擎 → 标准结果」链路。
"""
from __future__ import annotations
from datetime import date
import pytest
from app.api import deps
from app.domain.entities.market import Stock
from app.main import app
from app.quant.engine import LocalEngine
from app.quant.service import ResearchService
from fastapi.testclient import TestClient
from conftest_quant import bars_dataframe_to_daily_bars, synthetic_daily
class _MemStockRepo:
def __init__(self, stocks: list[Stock]) -> None:
self._stocks = stocks
def get_by_symbol(self, symbol: str) -> Stock | None:
return next((s for s in self._stocks if s.symbol == symbol), None)
def list(self) -> list[Stock]:
return self._stocks
_SYMS = ["60000" + str(i) + ".SH" for i in range(5)] # 600000~600004
def _mem_stocks() -> list[Stock]:
return [
Stock(symbol=sym, name=f"测试股份{i}", list_date=date(1999, 11, 10))
for i, sym in enumerate(_SYMS)
]
@pytest.fixture()
def client() -> TestClient:
drifts = {sym: 0.003 - 0.0015 * i for i, sym in enumerate(_SYMS)}
daily_df = synthetic_daily(drifts, n=300)
bars = bars_dataframe_to_daily_bars(daily_df)
class _MemDailyRepo:
def get_range_many(self, symbols, start, end):
out = []
for b in bars:
if b.symbol in symbols and start <= b.trade_date <= end:
out.append(b)
return out
def get_range(self, symbol, start, end):
return [b for b in bars if b.symbol == symbol and start <= b.trade_date <= end]
service = ResearchService(_MemStockRepo(_mem_stocks()), _MemDailyRepo(), LocalEngine())
app.dependency_overrides[deps._stock_repo_factory] = lambda: _MemStockRepo(_mem_stocks()) # noqa: SLF001
app.dependency_overrides[deps._service_factory] = lambda: service # noqa: SLF001
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
_BACKTEST_BODY = {
"type": "backtest",
"universe": {"exclude_st": False, "min_listing_days": 0},
"factors": [{"name": "momentum_20", "weight": 1.0}],
"selection": {"top_n": 1},
"rebalance": "monthly",
"period": ["2024-03-01", "2024-10-31"],
}
class TestStocksApi:
def test_list(self, client: TestClient) -> None:
resp = client.get("/api/stocks?limit=10")
assert resp.status_code == 200
body = resp.json()
assert len(body) == 5
assert body[0]["symbol"]
assert body[0]["name"]
def test_list_search(self, client: TestClient) -> None:
resp = client.get("/api/stocks?q=600000")
assert resp.status_code == 200
assert len(resp.json()) == 1
assert resp.json()[0]["symbol"] == "600000.SH"
def test_get_one_and_missing(self, client: TestClient) -> None:
assert client.get("/api/stocks/600000.SH").status_code == 200
assert client.get("/api/stocks/999999.SZ").status_code == 404
class TestFactorsApi:
def test_catalog(self, client: TestClient) -> None:
resp = client.get("/api/factors")
assert resp.status_code == 200
names = {f["name"] for f in resp.json()}
assert "momentum_20" in names
meta = next(f for f in resp.json() if f["name"] == "momentum_60")
assert meta["lookback"] == 60
assert meta["direction"] in {"higher_is_better", "lower_is_better"}
class TestResearchApi:
def test_backtest_roundtrip(self, client: TestClient) -> None:
resp = client.post("/api/backtests", json=_BACKTEST_BODY)
assert resp.status_code == 200
body = resp.json()
assert body["summary"]["total_return_pct"] > 0
assert body["equity_curve"]
assert body["unimplemented"]
# 最近结果可读
last = client.get("/api/backtests/last")
assert last.status_code == 200
assert last.json()["summary"] == body["summary"]
def test_factor_test_roundtrip(self, client: TestClient) -> None:
body = dict(_BACKTEST_BODY)
body["type"] = "factor_test"
resp = client.post("/api/factor-tests", json=body)
assert resp.status_code == 200
report = resp.json()
assert report["factor_name"] == "momentum_20"
assert report["sample_days"] > 5
assert report["ic_mean"] > 0 # 合成数据为强趋势
def test_invalid_spec_422(self, client: TestClient) -> None:
bad = dict(_BACKTEST_BODY)
bad["period"] = ["2024-10-01", "2024-03-01"] # start > end
assert client.post("/api/backtests", json=bad).status_code == 422
def test_unknown_factor_400(self, client: TestClient) -> None:
bad = dict(_BACKTEST_BODY)
bad["factors"] = [{"name": "no_such_factor", "weight": 1.0}]
resp = client.post("/api/backtests", json=bad)
assert resp.status_code == 400
+2
View File
@@ -0,0 +1,2 @@
# 后端 API 基础地址(开发期)
NEXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { LineChart } from "@/components/LineChart";
export default function BacktestPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [factor, setFactor] = useState("momentum_60");
const [topN, setTopN] = useState(5);
const [rebalance, setRebalance] = useState<"monthly" | "weekly">("monthly");
const [start, setStart] = useState("2024-03-01");
const [end, setEnd] = useState("2024-12-31");
const [excludeSt, setExcludeSt] = useState(true);
const [result, setResult] = useState<BacktestResult | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
apiGet<FactorMeta[]>("/factors")
.then(setFactors)
.catch((e: Error) => setError(e.message));
}, []);
function run() {
setRunning(true);
setError("");
const spec: ResearchSpec = {
type: "backtest",
universe: { exclude_st: excludeSt, min_listing_days: 0 },
factors: [{ name: factor, weight: 1 }],
selection: { top_n: topN },
rebalance,
period: [start, end],
};
apiPost<BacktestResult>("/backtests", spec)
.then(setResult)
.catch((e: Error) => setError(e.message))
.finally(() => setRunning(false));
}
const s = result?.summary;
return (
<>
<h1>选股回测</h1>
<div className="card">
<h2>研究参数(Research Specification)</h2>
<div className="form-grid">
<label className="muted">
因子
<select value={factor} onChange={(e) => setFactor(e.target.value)}>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
</label>
<label className="muted">
Top N
<input
type="number"
min={1}
max={50}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</label>
<label className="muted">
调仓
<select
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</label>
<label className="muted">
开始
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="muted">
结束
<input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
<label className="muted">
<input
type="checkbox"
checked={excludeSt}
onChange={(e) => setExcludeSt(e.target.checked)}
/>{" "}
剔除 ST
</label>
<button className="primary" onClick={run} disabled={running}>
{running ? "运行中…" : "运行回测"}
</button>
</div>
{error && <div className="error">{error}</div>}
</div>
{result && s && (
<>
<div className="card">
<h2>
结果 · {s.total_return_pct.toFixed(2)}%{" "}
<span className={s.total_return_pct >= 0 ? "good" : "bad"}>
{s.total_return_pct >= 0 ? "▲" : "▼"}
</span>
</h2>
<div className="stat-grid">
<div className="stat">
<div className="label">期末净值</div>
<div className="value">{s.final_equity.toLocaleString()}</div>
</div>
<div className="stat">
<div className="label">年化收益</div>
<div className="value">{s.annual_return_pct.toFixed(2)}%</div>
</div>
<div className="stat">
<div className="label">Sharpe</div>
<div className="value">{s.sharpe.toFixed(2)}</div>
</div>
<div className="stat">
<div className="label">最大回撤</div>
<div className="value bad">{s.max_drawdown_pct.toFixed(2)}%</div>
</div>
<div className="stat">
<div className="label">胜率(按交易)</div>
<div className="value">{s.win_rate_pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="label">交易笔数 / 换手</div>
<div className="value">
{s.total_trades} / {s.avg_turnover_pct.toFixed(0)}%
</div>
</div>
</div>
<div style={{ marginTop: 12 }}>
<LineChart title="净值曲线" data={result.equity_curve} color="#34d399" />
</div>
<LineChart
title="回撤(%)"
data={result.drawdown}
color="#f87171"
/>
</div>
<div className="card">
<h2>月度收益(%)</h2>
<table>
<thead>
<tr>
<th>月份</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.monthly_returns.map((m) => (
<tr key={`${m.year}-${m.month}`}>
<td>
{m.year}-{String(m.month).padStart(2, "0")}
</td>
<td className={m.return_pct >= 0 ? "good" : "bad"}>
{m.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>最新持仓</h2>
{(() => {
const last = new Map<string, number>();
for (const p of result.positions) last.set(p.date, 0);
const lastDate = [...last.keys()].at(-1);
const pos = result.positions.filter((p) => p.date === lastDate);
return (
<div className="row">
{pos.map((p) => (
<span key={p.symbol} className="stat">
{p.symbol} · {(p.weight * 100).toFixed(0)}%
</span>
))}
</div>
);
})()}
</div>
<div className="card">
<h2>未建模约束(如实标注)</h2>
<ul className="muted">
{result.unimplemented.map((u, i) => (
<li key={i}>{u}</li>
))}
</ul>
</div>
</>
)}
</>
);
}
+139
View File
@@ -0,0 +1,139 @@
"use client";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
export default function FactorsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [name, setName] = useState("momentum_60");
const [start, setStart] = useState("2024-03-01");
const [end, setEnd] = useState("2024-12-31");
const [report, setReport] = useState<FactorTestReport | null>(null);
const [running, setRunning] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
apiGet<FactorMeta[]>("/factors")
.then(setFactors)
.catch((e: Error) => setError(e.message));
}, []);
function run() {
setRunning(true);
setError("");
const spec: ResearchSpec = {
type: "factor_test",
universe: { exclude_st: true, min_listing_days: 0 },
factors: [{ name, weight: 1 }],
selection: { top_n: 10 },
rebalance: "monthly",
period: [start, end],
};
apiPost<FactorTestReport>("/factor-tests", spec)
.then(setReport)
.catch((e: Error) => setError(e.message))
.finally(() => setRunning(false));
}
return (
<>
<h1>因子研究</h1>
<div className="card">
<h2>因子目录(行情因子 · 无未来函数)</h2>
<table>
<thead>
<tr>
<th>名称</th>
<th>描述</th>
<th>公式</th>
<th>回看</th>
<th>方向</th>
</tr>
</thead>
<tbody>
{factors.map((f) => (
<tr key={f.name}>
<td>{f.name}</td>
<td>{f.description}</td>
<td className="muted">{f.formula}</td>
<td>{f.lookback}</td>
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="card">
<h2>运行单因子测试(IC / RankIC / 分层)</h2>
<div className="row">
<select value={name} onChange={(e) => setName(e.target.value)}>
{factors.map((f) => (
<option key={f.name} value={f.name}>
{f.name}
</option>
))}
</select>
<label className="muted">
开始 <input type="date" value={start} onChange={(e) => setStart(e.target.value)} />
</label>
<label className="muted">
结束 <input type="date" value={end} onChange={(e) => setEnd(e.target.value)} />
</label>
<button className="primary" onClick={run} disabled={running}>
{running ? "运行中…" : "运行因子测试"}
</button>
</div>
{error && <div className="error">{error}</div>}
{report && (
<div style={{ marginTop: 16 }}>
<div className="stat-grid">
<div className="stat">
<div className="label">IC 均值</div>
<div className="value">{report.ic_mean.toFixed(4)}</div>
</div>
<div className="stat">
<div className="label">RankIC 均值</div>
<div className="value">{report.rank_ic_mean.toFixed(4)}</div>
</div>
<div className="stat">
<div className="label">ICIR</div>
<div className="value">{report.icir.toFixed(3)}</div>
</div>
<div className="stat">
<div className="label">正收益占比</div>
<div className="value">{report.positive_ratio_pct.toFixed(1)}%</div>
</div>
<div className="stat">
<div className="label">样本日数</div>
<div className="value">{report.sample_days}</div>
</div>
</div>
{report.quantile_returns.length > 0 && (
<table style={{ marginTop: 12 }}>
<thead>
<tr>
<th>分层(1 最低 → 5 最高)</th>
{report.quantile_returns.map((q) => (
<th key={q.quantile}>Q{q.quantile + 1}</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td>未来 21 日平均收益</td>
{report.quantile_returns.map((q) => (
<td key={q.quantile}>{q.return_pct.toFixed(2)}%</td>
))}
</tr>
</tbody>
</table>
)}
</div>
)}
</div>
</>
);
}
+168
View File
@@ -0,0 +1,168 @@
@import url("https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap");
:root {
--bg: #0f172a;
--panel: #1e293b;
--panel-2: #273449;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
--good: #34d399;
--bad: #f87171;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Noto Sans SC", system-ui, sans-serif;
}
nav {
display: flex;
gap: 18px;
align-items: center;
padding: 12px 24px;
background: var(--panel);
border-bottom: 1px solid #334155;
position: sticky;
top: 0;
}
nav a {
color: var(--muted);
text-decoration: none;
font-weight: 500;
}
nav a:hover,
nav a.active {
color: var(--accent);
}
nav .brand {
color: var(--text);
font-weight: 700;
margin-right: 8px;
}
main {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.card {
background: var(--panel);
border: 1px solid #334155;
border-radius: 10px;
padding: 18px;
margin-bottom: 16px;
}
h1 {
font-size: 22px;
margin: 0 0 16px;
}
h2 {
font-size: 16px;
margin: 0 0 12px;
}
.muted {
color: var(--muted);
font-size: 13px;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.stat {
background: var(--panel-2);
border-radius: 8px;
padding: 14px;
}
.stat .label {
color: var(--muted);
font-size: 12px;
}
.stat .value {
font-size: 20px;
font-weight: 700;
margin-top: 4px;
}
.good { color: var(--good); }
.bad { color: var(--bad); }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th,
td {
text-align: left;
padding: 8px 10px;
border-bottom: 1px solid #334155;
}
th {
color: var(--muted);
font-weight: 500;
}
input,
select,
button {
background: var(--panel-2);
color: var(--text);
border: 1px solid #475569;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
margin: 2px 6px 2px 0;
}
button.primary {
background: var(--accent);
color: #0f172a;
font-weight: 700;
border: none;
cursor: pointer;
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: var(--bad);
font-size: 13px;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
align-items: end;
}
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "qlib-platform · A股量化研究",
description: "个人 A 股量化研究平台:股票池 / 因子 / 回测",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<nav>
<span className="brand">qlib-platform</span>
<Link href="/">总览</Link>
<Link href="/stocks">股票池</Link>
<Link href="/factors">因子研究</Link>
<Link href="/backtest">回测</Link>
</nav>
<main>{children}</main>
</body>
</html>
);
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import type { Stock, FactorMeta } from "@/lib/types";
export default function DashboardPage() {
const [stockCount, setStockCount] = useState<number | null>(null);
const [factorCount, setFactorCount] = useState<number | null>(null);
const [error, setError] = useState("");
useEffect(() => {
Promise.all([
apiGet<Stock[]>("/stocks?limit=500"),
apiGet<FactorMeta[]>("/factors"),
])
.then(([stocks, factors]) => {
setStockCount(stocks.length);
setFactorCount(factors.length);
})
.catch((e: Error) => setError(e.message));
}, []);
return (
<>
<h1>总览</h1>
{error && <div className="error">后端不可达:{error}</div>}
<div className="stat-grid">
<div className="stat">
<div className="label">股票池</div>
<div className="value">{stockCount ?? "—"}</div>
</div>
<div className="stat">
<div className="label">内置因子</div>
<div className="value">{factorCount ?? "—"}</div>
</div>
</div>
<div className="card" style={{ marginTop: 16 }}>
<h2>说明</h2>
<div className="muted">
个人 A 股量化研究平台(Phase 3 Web)。
顶部导航进入「股票池 / 因子研究 / 回测」;研究基于本地 SQLite 行情
(Tushare 同步,20 只权重股 2023–2024 日线),因子测试与回测均由后端
Research Specification 驱动并返回标准化结果。
</div>
</div>
</>
);
}
+58
View File
@@ -0,0 +1,58 @@
"use client";
import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api";
import type { Stock } from "@/lib/types";
export default function StocksPage() {
const [stocks, setStocks] = useState<Stock[]>([]);
const [q, setQ] = useState("");
const [error, setError] = useState("");
useEffect(() => {
apiGet<Stock[]>(`/stocks?limit=300&q=${encodeURIComponent(q)}`)
.then(setStocks)
.catch((e: Error) => setError(e.message));
}, [q]);
return (
<>
<h1>股票池</h1>
<div className="row" style={{ marginBottom: 12 }}>
<input
placeholder="按代码 / 名称搜索(如 600519 或 茅台)"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">共 {stocks.length} 条</span>
</div>
{error && <div className="error">{error}</div>}
<div className="card">
<table>
<thead>
<tr>
<th>代码</th>
<th>名称</th>
<th>行业</th>
<th>地区</th>
<th>上市日</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{stocks.map((s) => (
<tr key={s.symbol}>
<td>{s.symbol}</td>
<td>{s.name}</td>
<td>{s.industry ?? "—"}</td>
<td>{s.area ?? "—"}</td>
<td>{s.list_date}</td>
<td>{s.status}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef } from "react";
import * as echarts from "echarts";
export interface XY {
date: string;
value: number;
}
export function LineChart({
title,
data,
color = "#38bdf8",
}: {
title: string;
data: XY[];
color?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
chart.setOption({
title: { text: title, textStyle: { color: "#e2e8f0", fontSize: 13 } },
tooltip: { trigger: "axis" },
grid: { left: 60, right: 20, top: 40, bottom: 30 },
xAxis: {
type: "category",
data: data.map((p) => p.date),
axisLabel: { color: "#94a3b8", fontSize: 10 },
},
yAxis: {
type: "value",
scale: true,
axisLabel: { color: "#94a3b8" },
splitLine: { lineStyle: { color: "#334155" } },
},
series: [
{
type: "line",
showSymbol: false,
data: data.map((p) => p.value),
lineStyle: { color, width: 1.6 },
itemStyle: { color },
},
],
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, color, title]);
if (data.length === 0) return <div className="muted">暂无数据</div>;
return <div ref={ref} style={{ height: 360, width: "100%" }} />;
}
+26
View File
@@ -0,0 +1,26 @@
/** 后端 API 客户端(开发期指向本地 FastAPI,地址见 .env.local)。 */
const BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://127.0.0.1:8000/api";
export async function apiGet<T>(path: string): Promise<T> {
const resp = await fetch(`${BASE}${path}`);
if (!resp.ok) throw new Error(`GET ${path} → ${resp.status}: ${await resp.text()}`);
return (await resp.json()) as T;
}
export async function apiPost<T>(path: string, body: unknown): Promise<T> {
const resp = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
const text = await resp.text();
throw new Error(`POST ${path} → ${resp.status}: ${text.slice(0, 300)}`);
}
return (await resp.json()) as T;
}
export interface ApiError {
status: number;
message: string;
}
+74
View File
@@ -0,0 +1,74 @@
/** 与后端 domain/entities/research.py 对应的类型(DTO 镜像)。 */
export interface Stock {
symbol: string;
name: string;
industry?: string | null;
area?: string | null;
list_date: string;
delist_date?: string | null;
status: string;
}
export interface FactorMeta {
name: string;
description: string;
formula: string;
frequency: string;
lookback: number;
direction: "higher_is_better" | "lower_is_better";
}
export interface ResearchSpec {
type: "factor_test" | "backtest";
universe: { exclude_st?: boolean; min_listing_days?: number };
factors: { name: string; weight: number }[];
selection: { top_n: number };
rebalance: "weekly" | "monthly";
period: [string, string];
}
export interface CurvePoint {
date: string;
value: number;
}
export interface BacktestSummary {
start: string;
end: string;
initial_capital: number;
final_equity: number;
total_return_pct: number;
annual_return_pct: number;
sharpe: number;
max_drawdown_pct: number;
volatility_pct: number;
win_rate_pct: number;
total_trades: number;
avg_turnover_pct: number;
}
export interface BacktestResult {
summary: BacktestSummary;
equity_curve: CurvePoint[];
drawdown: CurvePoint[];
monthly_returns: { year: number; month: number; return_pct: number }[];
yearly_returns: { year: number; return_pct: number }[];
positions: { date: string; symbol: string; weight: number }[];
trades: { entry_date: string; exit_date: string; symbol: string; return_pct: number }[];
turnover_pct: number;
unimplemented: string[];
config_snapshot: Record<string, unknown>;
}
export interface FactorTestReport {
factor_name: string;
ic_mean: number;
icir: number;
rank_ic_mean: number;
positive_ratio_pct: number;
quantile_returns: { quantile: number; return_pct: number }[];
spread_quantile?: number | null;
sample_days: number;
unimplemented: string[];
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
+23
View File
@@ -0,0 +1,23 @@
{
"name": "qlib-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"build": "next build",
"start": "next start -p 3000",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"echarts": "^5.5.1",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
}
+649
View File
@@ -0,0 +1,649 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
echarts:
specifier: ^5.5.1
version: 5.6.0
next:
specifier: ^15.1.0
version: 15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
react:
specifier: ^19.0.0
version: 19.2.8
react-dom:
specifier: ^19.0.0
version: 19.2.8(react@19.2.8)
devDependencies:
'@types/node':
specifier: ^22.10.0
version: 22.20.1
'@types/react':
specifier: ^19.0.0
version: 19.2.18
'@types/react-dom':
specifier: ^19.0.0
version: 19.2.7(@types/react@19.2.18)
typescript:
specifier: ^5.7.0
version: 5.9.3
packages:
'@emnapi/runtime@1.11.3':
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.4':
resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.35.4':
resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.4':
resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'}
os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.3':
resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.3':
resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.3':
resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.3.3':
resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.3.3':
resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.3.3':
resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.3.3':
resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.3.3':
resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.35.4':
resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.35.4':
resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.35.4':
resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.35.4':
resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.35.4':
resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.35.4':
resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.35.4':
resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.35.4':
resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.35.4':
resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.4':
resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.4':
resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.35.4':
resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.35.4':
resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'}
cpu: [x64]
os: [win32]
'@next/env@15.5.25':
resolution: {integrity: sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==}
'@next/swc-darwin-arm64@15.5.25':
resolution: {integrity: sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.5.25':
resolution: {integrity: sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.5.25':
resolution: {integrity: sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@next/swc-linux-arm64-musl@15.5.25':
resolution: {integrity: sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@next/swc-linux-x64-gnu@15.5.25':
resolution: {integrity: sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@next/swc-linux-x64-musl@15.5.25':
resolution: {integrity: sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@next/swc-win32-arm64-msvc@15.5.25':
resolution: {integrity: sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.5.25':
resolution: {integrity: sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@types/node@22.20.1':
resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==}
'@types/react-dom@19.2.7':
resolution: {integrity: sha512-I8bPpDLcHBv1qiIiXDCy71Rt8eQDKJP0sMSWJphDdAcdqiJ1sGpZamavoEIRZmYzjia9LuEb2HlYdDpmoENpvQ==}
peerDependencies:
'@types/react': ^19.2.0
'@types/react@19.2.18':
resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==}
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
echarts@5.6.0:
resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next@15.5.25:
resolution: {integrity: sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
sass: ^1.3.0
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@playwright/test':
optional: true
babel-plugin-react-compiler:
optional: true
sass:
optional: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14}
react-dom@19.2.8:
resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==}
peerDependencies:
react: ^19.2.8
react@19.2.8:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
hasBin: true
sharp@0.35.4:
resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'}
peerDependencies:
'@types/node': '*'
peerDependenciesMeta:
'@types/node':
optional: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
peerDependencies:
'@babel/core': '*'
babel-plugin-macros: '*'
react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0'
peerDependenciesMeta:
'@babel/core':
optional: true
babel-plugin-macros:
optional: true
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
zrender@5.6.1:
resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==}
snapshots:
'@emnapi/runtime@1.11.3':
dependencies:
tslib: 2.8.1
optional: true
'@img/colour@1.1.0':
optional: true
'@img/sharp-darwin-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true
'@img/sharp-darwin-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.3
optional: true
'@img/sharp-freebsd-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true
'@img/sharp-libvips-darwin-x64@1.3.3':
optional: true
'@img/sharp-libvips-linux-arm64@1.3.3':
optional: true
'@img/sharp-libvips-linux-arm@1.3.3':
optional: true
'@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true
'@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true
'@img/sharp-libvips-linux-s390x@1.3.3':
optional: true
'@img/sharp-libvips-linux-x64@1.3.3':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true
'@img/sharp-linux-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.3
optional: true
'@img/sharp-linux-arm@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.3
optional: true
'@img/sharp-linux-ppc64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true
'@img/sharp-linux-riscv64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true
'@img/sharp-linux-s390x@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.3
optional: true
'@img/sharp-linux-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.3
optional: true
'@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true
'@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true
'@img/sharp-wasm32@0.35.4':
dependencies:
'@emnapi/runtime': 1.11.3
optional: true
'@img/sharp-webcontainers-wasm32@0.35.4':
dependencies:
'@img/sharp-wasm32': 0.35.4
optional: true
'@img/sharp-win32-arm64@0.35.4':
optional: true
'@img/sharp-win32-ia32@0.35.4':
optional: true
'@img/sharp-win32-x64@0.35.4':
optional: true
'@next/env@15.5.25': {}
'@next/swc-darwin-arm64@15.5.25':
optional: true
'@next/swc-darwin-x64@15.5.25':
optional: true
'@next/swc-linux-arm64-gnu@15.5.25':
optional: true
'@next/swc-linux-arm64-musl@15.5.25':
optional: true
'@next/swc-linux-x64-gnu@15.5.25':
optional: true
'@next/swc-linux-x64-musl@15.5.25':
optional: true
'@next/swc-win32-arm64-msvc@15.5.25':
optional: true
'@next/swc-win32-x64-msvc@15.5.25':
optional: true
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
'@types/node@22.20.1':
dependencies:
undici-types: 6.21.0
'@types/react-dom@19.2.7(@types/react@19.2.18)':
dependencies:
'@types/react': 19.2.18
'@types/react@19.2.18':
dependencies:
csstype: 3.2.3
caniuse-lite@1.0.30001810: {}
client-only@0.0.1: {}
csstype@3.2.3: {}
detect-libc@2.1.2:
optional: true
echarts@5.6.0:
dependencies:
tslib: 2.3.0
zrender: 5.6.1
nanoid@3.3.18: {}
next@15.5.25(@types/node@22.20.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
dependencies:
'@next/env': 15.5.25
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001810
postcss: 8.4.31
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
styled-jsx: 5.1.6(react@19.2.8)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.25
'@next/swc-darwin-x64': 15.5.25
'@next/swc-linux-arm64-gnu': 15.5.25
'@next/swc-linux-arm64-musl': 15.5.25
'@next/swc-linux-x64-gnu': 15.5.25
'@next/swc-linux-x64-musl': 15.5.25
'@next/swc-win32-arm64-msvc': 15.5.25
'@next/swc-win32-x64-msvc': 15.5.25
sharp: 0.35.4(@types/node@22.20.1)
transitivePeerDependencies:
- '@babel/core'
- '@types/node'
- babel-plugin-macros
picocolors@1.1.1: {}
postcss@8.4.31:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
react-dom@19.2.8(react@19.2.8):
dependencies:
react: 19.2.8
scheduler: 0.27.0
react@19.2.8: {}
scheduler@0.27.0: {}
semver@7.8.5:
optional: true
sharp@0.35.4(@types/node@22.20.1):
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.8.5
optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.4
'@img/sharp-darwin-x64': 0.35.4
'@img/sharp-freebsd-wasm32': 0.35.4
'@img/sharp-libvips-darwin-arm64': 1.3.3
'@img/sharp-libvips-darwin-x64': 1.3.3
'@img/sharp-libvips-linux-arm': 1.3.3
'@img/sharp-libvips-linux-arm64': 1.3.3
'@img/sharp-libvips-linux-ppc64': 1.3.3
'@img/sharp-libvips-linux-riscv64': 1.3.3
'@img/sharp-libvips-linux-s390x': 1.3.3
'@img/sharp-libvips-linux-x64': 1.3.3
'@img/sharp-libvips-linuxmusl-arm64': 1.3.3
'@img/sharp-libvips-linuxmusl-x64': 1.3.3
'@img/sharp-linux-arm': 0.35.4
'@img/sharp-linux-arm64': 0.35.4
'@img/sharp-linux-ppc64': 0.35.4
'@img/sharp-linux-riscv64': 0.35.4
'@img/sharp-linux-s390x': 0.35.4
'@img/sharp-linux-x64': 0.35.4
'@img/sharp-linuxmusl-arm64': 0.35.4
'@img/sharp-linuxmusl-x64': 0.35.4
'@img/sharp-webcontainers-wasm32': 0.35.4
'@img/sharp-win32-arm64': 0.35.4
'@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.4
'@types/node': 22.20.1
optional: true
source-map-js@1.2.1: {}
styled-jsx@5.1.6(react@19.2.8):
dependencies:
client-only: 0.0.1
react: 19.2.8
tslib@2.3.0: {}
tslib@2.8.1: {}
typescript@5.9.3: {}
undici-types@6.21.0: {}
zrender@5.6.1:
dependencies:
tslib: 2.3.0
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}