diff --git a/.gitignore b/.gitignore index 3ed152b..447aa00 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..fb00bc5 --- /dev/null +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/factors.py b/backend/app/api/factors.py new file mode 100644 index 0000000..1cbdba3 --- /dev/null +++ b/backend/app/api/factors.py @@ -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() + ] diff --git a/backend/app/api/research.py b/backend/app/api/research.py new file mode 100644 index 0000000..6f8422b --- /dev/null +++ b/backend/app/api/research.py @@ -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"] diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 1326fd0..933db7c 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/stocks.py b/backend/app/api/stocks.py new file mode 100644 index 0000000..edd5dbc --- /dev/null +++ b/backend/app/api/stocks.py @@ -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 diff --git a/backend/app/domain/repositories/market.py b/backend/app/domain/repositories/market.py index 59fef5a..bb124ff 100644 --- a/backend/app/domain/repositories/market.py +++ b/backend/app/domain/repositories/market.py @@ -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: """断点续传用:该股票本地已有数据的最新交易日。""" diff --git a/backend/app/infrastructure/persistence/sqlalchemy/repositories/market_impl.py b/backend/app/infrastructure/persistence/sqlalchemy/repositories/market_impl.py index 23093a0..98943c0 100644 --- a/backend/app/infrastructure/persistence/sqlalchemy/repositories/market_impl.py +++ b/backend/app/infrastructure/persistence/sqlalchemy/repositories/market_impl.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index 7e68af2..82affca 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/quant/local_engine.py b/backend/app/quant/local_engine.py index f466a4b..ee89254 100644 --- a/backend/app/quant/local_engine.py +++ b/backend/app/quant/local_engine.py @@ -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} diff --git a/backend/app/quant/service.py b/backend/app/quant/service.py index aedafe1..a4e7a35 100644 --- a/backend/app/quant/service.py +++ b/backend/app/quant/service.py @@ -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) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..b8e799b --- /dev/null +++ b/backend/tests/test_api.py @@ -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 diff --git a/frontend/web/.env.local.example b/frontend/web/.env.local.example new file mode 100644 index 0000000..0786c06 --- /dev/null +++ b/frontend/web/.env.local.example @@ -0,0 +1,2 @@ +# 后端 API 基础地址(开发期) +NEXT_PUBLIC_API_BASE=http://127.0.0.1:8000/api diff --git a/frontend/web/app/backtest/page.tsx b/frontend/web/app/backtest/page.tsx new file mode 100644 index 0000000..f765dea --- /dev/null +++ b/frontend/web/app/backtest/page.tsx @@ -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([]); + 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(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + apiGet("/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("/backtests", spec) + .then(setResult) + .catch((e: Error) => setError(e.message)) + .finally(() => setRunning(false)); + } + + const s = result?.summary; + return ( + <> +

选股回测

+
+

研究参数(Research Specification)

+
+ + + + + + + +
+ {error &&
{error}
} +
+ + {result && s && ( + <> +
+

+ 结果 · {s.total_return_pct.toFixed(2)}%{" "} + = 0 ? "good" : "bad"}> + {s.total_return_pct >= 0 ? "▲" : "▼"} + +

+
+
+
期末净值
+
{s.final_equity.toLocaleString()}
+
+
+
年化收益
+
{s.annual_return_pct.toFixed(2)}%
+
+
+
Sharpe
+
{s.sharpe.toFixed(2)}
+
+
+
最大回撤
+
{s.max_drawdown_pct.toFixed(2)}%
+
+
+
胜率(按交易)
+
{s.win_rate_pct.toFixed(1)}%
+
+
+
交易笔数 / 换手
+
+ {s.total_trades} / {s.avg_turnover_pct.toFixed(0)}% +
+
+
+
+ +
+ +
+ +
+

月度收益(%)

+ + + + + + + + + {result.monthly_returns.map((m) => ( + + + + + ))} + +
月份收益
+ {m.year}-{String(m.month).padStart(2, "0")} + = 0 ? "good" : "bad"}> + {m.return_pct.toFixed(2)}% +
+
+ +
+

最新持仓

+ {(() => { + const last = new Map(); + 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 ( +
+ {pos.map((p) => ( + + {p.symbol} · {(p.weight * 100).toFixed(0)}% + + ))} +
+ ); + })()} +
+ +
+

未建模约束(如实标注)

+
    + {result.unimplemented.map((u, i) => ( +
  • {u}
  • + ))} +
+
+ + )} + + ); +} diff --git a/frontend/web/app/factors/page.tsx b/frontend/web/app/factors/page.tsx new file mode 100644 index 0000000..81a349c --- /dev/null +++ b/frontend/web/app/factors/page.tsx @@ -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([]); + const [name, setName] = useState("momentum_60"); + const [start, setStart] = useState("2024-03-01"); + const [end, setEnd] = useState("2024-12-31"); + const [report, setReport] = useState(null); + const [running, setRunning] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + apiGet("/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("/factor-tests", spec) + .then(setReport) + .catch((e: Error) => setError(e.message)) + .finally(() => setRunning(false)); + } + + return ( + <> +

因子研究

+
+

因子目录(行情因子 · 无未来函数)

+ + + + + + + + + + + + {factors.map((f) => ( + + + + + + + + ))} + +
名称描述公式回看方向
{f.name}{f.description}{f.formula}{f.lookback}{f.direction === "higher_is_better" ? "高为好" : "低为好"}
+
+ +
+

运行单因子测试(IC / RankIC / 分层)

+
+ + + + +
+ {error &&
{error}
} + + {report && ( +
+
+
+
IC 均值
+
{report.ic_mean.toFixed(4)}
+
+
+
RankIC 均值
+
{report.rank_ic_mean.toFixed(4)}
+
+
+
ICIR
+
{report.icir.toFixed(3)}
+
+
+
正收益占比
+
{report.positive_ratio_pct.toFixed(1)}%
+
+
+
样本日数
+
{report.sample_days}
+
+
+ {report.quantile_returns.length > 0 && ( + + + + + {report.quantile_returns.map((q) => ( + + ))} + + + + + + {report.quantile_returns.map((q) => ( + + ))} + + +
分层(1 最低 → 5 最高)Q{q.quantile + 1}
未来 21 日平均收益{q.return_pct.toFixed(2)}%
+ )} +
+ )} +
+ + ); +} diff --git a/frontend/web/app/globals.css b/frontend/web/app/globals.css new file mode 100644 index 0000000..edfb01c --- /dev/null +++ b/frontend/web/app/globals.css @@ -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; +} diff --git a/frontend/web/app/layout.tsx b/frontend/web/app/layout.tsx new file mode 100644 index 0000000..83c370c --- /dev/null +++ b/frontend/web/app/layout.tsx @@ -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 ( + + + +
{children}
+ + + ); +} diff --git a/frontend/web/app/page.tsx b/frontend/web/app/page.tsx new file mode 100644 index 0000000..38564dd --- /dev/null +++ b/frontend/web/app/page.tsx @@ -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(null); + const [factorCount, setFactorCount] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + Promise.all([ + apiGet("/stocks?limit=500"), + apiGet("/factors"), + ]) + .then(([stocks, factors]) => { + setStockCount(stocks.length); + setFactorCount(factors.length); + }) + .catch((e: Error) => setError(e.message)); + }, []); + + return ( + <> +

总览

+ {error &&
后端不可达:{error}
} +
+
+
股票池
+
{stockCount ?? "—"}
+
+
+
内置因子
+
{factorCount ?? "—"}
+
+
+
+

说明

+
+ 个人 A 股量化研究平台(Phase 3 Web)。 + 顶部导航进入「股票池 / 因子研究 / 回测」;研究基于本地 SQLite 行情 + (Tushare 同步,20 只权重股 2023–2024 日线),因子测试与回测均由后端 + Research Specification 驱动并返回标准化结果。 +
+
+ + ); +} diff --git a/frontend/web/app/stocks/page.tsx b/frontend/web/app/stocks/page.tsx new file mode 100644 index 0000000..5c3a7c0 --- /dev/null +++ b/frontend/web/app/stocks/page.tsx @@ -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([]); + const [q, setQ] = useState(""); + const [error, setError] = useState(""); + + useEffect(() => { + apiGet(`/stocks?limit=300&q=${encodeURIComponent(q)}`) + .then(setStocks) + .catch((e: Error) => setError(e.message)); + }, [q]); + + return ( + <> +

股票池

+
+ setQ(e.target.value)} + /> + 共 {stocks.length} 条 +
+ {error &&
{error}
} +
+ + + + + + + + + + + + + {stocks.map((s) => ( + + + + + + + + + ))} + +
代码名称行业地区上市日状态
{s.symbol}{s.name}{s.industry ?? "—"}{s.area ?? "—"}{s.list_date}{s.status}
+
+ + ); +} diff --git a/frontend/web/components/LineChart.tsx b/frontend/web/components/LineChart.tsx new file mode 100644 index 0000000..458dac9 --- /dev/null +++ b/frontend/web/components/LineChart.tsx @@ -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(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
暂无数据
; + return
; +} diff --git a/frontend/web/lib/api.ts b/frontend/web/lib/api.ts new file mode 100644 index 0000000..626b425 --- /dev/null +++ b/frontend/web/lib/api.ts @@ -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(path: string): Promise { + 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(path: string, body: unknown): Promise { + 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; +} diff --git a/frontend/web/lib/types.ts b/frontend/web/lib/types.ts new file mode 100644 index 0000000..e5c7b04 --- /dev/null +++ b/frontend/web/lib/types.ts @@ -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; +} + +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[]; +} diff --git a/frontend/web/next-env.d.ts b/frontend/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/frontend/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/web/next.config.ts b/frontend/web/next.config.ts new file mode 100644 index 0000000..2f6491c --- /dev/null +++ b/frontend/web/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + reactStrictMode: true, +}; + +export default nextConfig; diff --git a/frontend/web/package.json b/frontend/web/package.json new file mode 100644 index 0000000..29da5ad --- /dev/null +++ b/frontend/web/package.json @@ -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" + } +} diff --git a/frontend/web/pnpm-lock.yaml b/frontend/web/pnpm-lock.yaml new file mode 100644 index 0000000..db9b2d7 --- /dev/null +++ b/frontend/web/pnpm-lock.yaml @@ -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 diff --git a/frontend/web/tsconfig.json b/frontend/web/tsconfig.json new file mode 100644 index 0000000..afedc74 --- /dev/null +++ b/frontend/web/tsconfig.json @@ -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"] +}