Compare commits

..
4 Commits
Author SHA1 Message Date
Simon a3fabf9ae9 docs: 新增架构文档 v2(ARCHITECTURE_v2) 2026-09-08 21:48:23 +08:00
Simon db520d0430 build(web): Noto Sans SC 本地自托管,移除 Google Fonts 在线依赖
- 经 3128 代理下载可变字体(400-700)101 个 unicode-range 子集到 public/fonts
- app/fonts.css 生成本地 @font-face 表,globals.css 改用 @import './fonts.css'
- 页面运行期不再请求 fonts.googleapis.com / fonts.gstatic.com
2026-09-08 21:48:23 +08:00
Simon 24f98e90c6 feat(web): 按 ui-ux-pro-max 设计系统重构前端(侧边导航 + 组件库 + 图标 + 可视化)
- 设计令牌与组件体系:语义色/间距/圆角/状态 + Card/Metric/Pill/Btn/Field/Empty/Skeleton/Progress 等
- 桌面左侧导航 + 窄屏抽屉(usePathname 高亮、ESC/遮罩关闭);修复抽屉/遮罩占据网格把 main 挤到第二行的问题
- 自绘内联 SVG 图标集替代 emoji;图标/表格/按钮等交互态齐全,空态/加载/错误/禁用态补齐
- 因子研究分层柱状、因子组合与回测净值/回撤双图 + 月度收益热力表、实验页任务状态徽标
- LineChart 主题化并按需引入 echarts(回测/组合首载 JS 448k→281k);next.config 放行局域网 dev 来源
2026-09-08 21:48:20 +08:00
Simon 442999f701 feat(data): 财务/日线同步增量 + 新浪「两边一致」校验兜底 + 逐只进度
- financial 默认增量:按 A 股披露节奏判断已最新并跳过;--full 强制全量重拉
- Tushare fina_indicator 增加报告期窗口与 100 条/请求自动分页(修复老报告期静默截断)
- 新浪兜底收紧为校验兜底:两源重叠历史一致才导入缺失键,行标记 source=sina;
  财务可比字段取 eps/销售毛利率(ROE 两端口径不同不作依据),日线只比较最近重叠交易日
- CLI 输出逐只进度与导入内容描述(来源/行数/报告期与公告区间),失败股票留待重跑
- financial_indicator 增 source 列(迁移 d3f6c9a21b04);新增一致性/分页/服务测试
2026-09-08 21:48:09 +08:00
133 changed files with 7417 additions and 756 deletions
@@ -0,0 +1,611 @@
"""数据同步服务:增量 + 新浪「两边一致」校验兜底(financial / daily)。
背景(AGENT.md §5/§7/§8):
- Tushare 是首选源;新浪财经只作备用。任何切源都必须可追溯(写 sync_log),
且禁止静默把未经核验的备用源数据并入主库。
- 本模块把「切到新浪」从 FailoverProvider 的『主源报错即兜底』收紧为
『校验兜底』:只有当某只股票**两边重叠的历史数据一致**时,才允许把新浪
的**新数据**(本地缺失键的行)导入;无本地历史或校验不一致 → 拒绝并告警,
留待 Tushare 恢复后重跑补齐(数据真实性优先)。
校验口径(经验证,见仓库数据):
- 财务可比字段只有 eps / gross_margin —— 两源同报告期数值逐位一致;
ROE 两边口径不同(Tushare 摊薄 vs 新浪加权),不作为一致性依据。
- 日线新浪为前复权,与本地不复权行仅「最近无除权区间」相等,因此只拿
两源重叠的最近若干个交易日做一致性校验(通道可信 → 才允许补缺)。
"""
from __future__ import annotations
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import date, timedelta
from decimal import Decimal
from app.domain.entities.market import DailyBar, FinancialIndicator, SyncLog
from app.domain.providers import MarketDataProvider
from app.domain.repositories.market import (
AdjustFactorRepository,
DailyBarRepository,
FinancialRepository,
)
from app.infrastructure.data_sources.errors import DataSourceAuthenticationError
# 财务两源可比字段(其余字段两端口径不一致 / 单侧缺失,不能作校验依据)
FINANCIAL_COMPARE_FIELDS = ("eps", "gross_margin")
DAILY_COMPARE_FIELDS = ("open", "high", "low", "close")
# 新浪日 K 可达窗口(getKLineData datalen=320 自然日)
SINA_KLINE_DAYS = 320
# 校验回看:请求新浪时额外回看 begin 之前的天数,确保与本地近期历史有重叠可比
SINA_VERIFY_LOOKBACK_DAYS = 45
_EPOCH = date(1990, 1, 1)
# ---------------------------------------------------------------- 一致性校验(纯函数)
@dataclass
class OverlapVerdict:
"""两边重叠一致性结论。ok=True 才允许导入新浪新数据。"""
ok: bool
shared: int = 0 # 重叠报告期 / 重叠交易日数量
compared: int = 0 # 实际参与数值比较的行/日数量
mismatches: list[str] = field(default_factory=list)
def summary(self) -> str:
if self.ok:
return f"重叠 {self.shared} 项,数值一致(比较 {self.compared} 项)"
why = f"重叠 {self.shared} 项不足/为空"
if self.mismatches:
why = ";".join(self.mismatches[:3])
return f"校验未通过:{why}"
def _close_enough(a: Decimal, b: Decimal, *, rel_tol: float, abs_tol: float) -> bool:
if a is None or b is None:
return False
diff = abs(a - b)
if diff <= Decimal(str(abs_tol)):
return True
scale = max(abs(a), abs(b))
return diff <= Decimal(str(rel_tol)) * scale
def financial_overlap_consistent(
local_rows: Sequence[FinancialIndicator],
sina_rows: Sequence[FinancialIndicator],
*,
min_shared: int = 2,
rel_tol: float = 1e-4,
abs_tol: float = 1e-3,
) -> OverlapVerdict:
"""新浪财务行与本地(Tushare)行按报告期重叠校验。
新浪每个报告期只保留最新一版(getFinanceReport2022 的 report_list 按
报告期一份);本地同报告期可能有多版公告,取公告日最新者比较。
要求:重叠报告期数 >= min_shared,且全部可比字段(两源都非空)一致。
"""
local_latest: dict[date, FinancialIndicator] = {}
for row in local_rows:
cur = local_latest.get(row.report_date)
if cur is None or row.announce_date > cur.announce_date:
local_latest[row.report_date] = row
sina_by_report = {row.report_date: row for row in sina_rows}
verdict = OverlapVerdict(ok=False)
shared_dates = sorted(set(local_latest) & set(sina_by_report), reverse=True)
verdict.shared = len(shared_dates)
for report in shared_dates:
a = local_latest[report]
b = sina_by_report[report]
day_mismatch: list[str] = []
compared = 0
for f in FINANCIAL_COMPARE_FIELDS:
va, vb = getattr(a, f), getattr(b, f)
if va is None or vb is None:
continue
compared += 1
if not _close_enough(va, vb, rel_tol=rel_tol, abs_tol=abs_tol):
day_mismatch.append(f"{report}: {f} {va}≠{vb}")
verdict.compared += compared
verdict.mismatches.extend(day_mismatch)
verdict.ok = (
verdict.shared >= min_shared and verdict.compared > 0 and not verdict.mismatches
)
return verdict
def daily_overlap_consistent(
local_bars: Sequence[DailyBar],
sina_bars: Sequence[DailyBar],
*,
min_shared: int = 3,
max_recent: int = 8,
rel_tol: float = 1e-4,
abs_tol: float = Decimal("0.02"),
) -> OverlapVerdict:
"""新浪日 K(前复权)与本地(不复权)重叠校验。
前复权锚定最新价:仅「最近一次除权之后」的交易日两源数值相等,因此只
比较两源重叠的、最近的 max_recent 个交易日(此时若有除权发生在该段,
校验会判不一致 → 拒绝兜底,安全方向)。vol/amount 两源单位/口径不同,
不参与比较。
"""
local_by_day = {b.trade_date: b for b in local_bars}
sina_by_day = {b.trade_date: b for b in sina_bars}
shared = sorted(set(local_by_day) & set(sina_by_day), reverse=True)
verdict = OverlapVerdict(ok=False)
verdict.shared = len(shared)
for day in shared[:max_recent]:
a, b = local_by_day[day], sina_by_day[day]
day_mismatch: list[str] = []
compared = 0
for f in DAILY_COMPARE_FIELDS:
va, vb = getattr(a, f), getattr(b, f)
if va is None or vb is None:
continue
compared += 1
if not _close_enough(va, vb, rel_tol=rel_tol, abs_tol=abs_tol):
day_mismatch.append(f"{day}: {f} {va}≠{vb}")
verdict.compared += compared
verdict.mismatches.extend(day_mismatch)
checked = len(shared[:max_recent])
verdict.ok = (
checked >= min_shared and verdict.compared > 0 and not verdict.mismatches
)
return verdict
# ---------------------------------------------------------------- 报告期披露节奏
def latest_expected_report_period(today: date) -> date:
"""当前「应已披露」的最新报告期(报告期结束日)。
用作财务增量的已最新判断:本地已含该报告期 → 该股票已跟进到最新一季,
跳过(避免每轮全量重拉;--full 强制)。窗口按 A 股披露节奏划分:
- 1/1~2/14:年报季未开 → 上年三季报(09-30)
- 2/15~6/30:年报+一季报季 → 本年一季报(03-31)
- 7/1~10/15:半年报季 → 本年半年报(06-30)
- 10/16~12/31:三季报季 → 本年三季报(09-30)
"""
y = today.year
md = (today.month, today.day)
if md <= (2, 14):
return date(y - 1, 9, 30)
if md <= (6, 30):
return date(y, 3, 31)
if md <= (10, 15):
return date(y, 6, 30)
return date(y, 9, 30)
# ---------------------------------------------------------------- 审计
def _audit_sync(
audit: Callable[[SyncLog], None],
*,
source: str,
api: str,
success: bool,
row_count: int = 0,
reason: str | None = None,
start: date | None = None,
end: date | None = None,
) -> None:
audit(
SyncLog(
source=source,
api=api,
success=success,
failure_reason=reason,
row_count=row_count,
data_start=start,
data_end=end,
)
)
# ---------------------------------------------------------------- 结果结构
@dataclass
class FinancialSymbolResult:
"""单只股票财务同步结果(status: skip|ok|sina|failed)。"""
symbol: str
status: str
source: str | None = None # tushare | sina
fetched: int = 0 # 数据源返回行数
written: int = 0 # 实际落库行数(新增;--full 时含更新)
updated: int = 0 # --full 下覆盖的既有行数
report_first: date | None = None
report_last: date | None = None
announce_first: date | None = None
announce_last: date | None = None
notes: list[str] = field(default_factory=list)
@dataclass
class DailySymbolResult:
"""单只股票日线同步结果(status: skip|ok|sina|failed)。"""
symbol: str
status: str
source: str | None = None # tushare | sina
bars_fetched: int = 0
bars_written: int = 0
day_first: date | None = None
day_last: date | None = None
factors_written: int | None = None # None=未尝试(新浪兜底无因子)
notes: list[str] = field(default_factory=list)
# ---------------------------------------------------------------- 财务同步服务
class VerifiedFinancialSyncer:
"""财务指标增量同步:Tushare 窗口化拉取 → 失败则新浪校验兜底。"""
def __init__(
self,
*,
primary: MarketDataProvider,
fallback: MarketDataProvider | None,
repo: FinancialRepository,
audit: Callable[[SyncLog], None],
today: date | None = None,
min_shared: int = 2,
) -> None:
self.primary = primary
self.fallback = fallback
self.repo = repo
self.audit = audit
self.today = today or date.today()
self.min_shared = min_shared
def sync_symbol(self, symbol: str, *, force_full: bool = False) -> FinancialSymbolResult:
local = self.repo.list_symbol(symbol)
local_keys = {(r.symbol, r.report_date, r.announce_date) for r in local}
due = latest_expected_report_period(self.today)
if not force_full and local and any(r.report_date == due for r in local):
return FinancialSymbolResult(
symbol=symbol,
status="skip",
notes=[f"本地已含最新报告期 {due.isoformat()},跳过(--full 强制重拉)"],
)
# 拉取窗口:有本地行则从最早本地报告期起(含更正/补缺),无则全历史;
# 上限到最新应披露报告期。
hi = due
lo = min((r.report_date for r in local), default=None) or _EPOCH
try:
rows = self.primary.get_financial(symbol, lo, hi)
except DataSourceAuthenticationError:
# 凭证无效/接口无权限:属全局性故障,快速失败让用户修 token,
# 不要对全市场逐只做无意义的新浪试探
raise
except Exception as exc: # noqa: BLE001 —— 与 FailoverProvider 一致,统一走审计
_audit_sync(
self.audit,
source=self.primary.name,
api="get_financial",
success=False,
reason=str(exc),
start=lo,
end=hi,
)
return self._sina_fallback(symbol, local, local_keys, primary_error=str(exc))
_audit_sync(
self.audit,
source=self.primary.name,
api="get_financial",
success=True,
row_count=len(rows),
start=lo,
end=hi,
)
if force_full:
to_write = rows
updated = sum(1 for r in rows if _fin_key(r) in local_keys)
else:
to_write = [r for r in rows if _fin_key(r) not in local_keys]
updated = 0
written = self.repo.upsert_many(to_write)
return _fin_result(symbol, status="ok", source="tushare", written_rows=to_write,
written=written, updated=updated)
# ---- 新浪校验兜底 ----
def _sina_fallback(
self,
symbol: str,
local: list[FinancialIndicator],
local_keys: set[tuple],
*,
primary_error: str,
) -> FinancialSymbolResult:
if self.fallback is None:
return FinancialSymbolResult(
symbol=symbol,
status="failed",
notes=[f"Tushare 失败且未配置新浪兜底: {primary_error}"],
)
if not local:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"无本地历史可做两边一致性校验,跳过待 Tushare 恢复重试({primary_error})",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[
f"Tushare 失败且本地无历史({symbol}),无法确认新浪数据真实性,"
f"跳过待重试。primary: {primary_error}"
],
)
try:
sina_rows = self.fallback.get_financial(symbol)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"primary: {primary_error}; fallback: {exc}",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[f"主备数据源均失败: primary={primary_error}; sina={exc}"],
)
verdict = financial_overlap_consistent(local, sina_rows, min_shared=self.min_shared)
if not verdict.ok:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=False,
reason=f"{verdict.summary()}(新浪返回 {len(sina_rows)} 行; primary={primary_error})",
)
return FinancialSymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[
f"新浪数据与本地历史不一致/无法校验({symbol}),拒绝导入。"
f"primary: {primary_error};{verdict.summary()}"
],
)
new_rows = [r for r in sina_rows if _fin_key(r) not in local_keys]
written = self.repo.upsert_many(new_rows)
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_financial",
success=True,
row_count=written,
)
return _fin_result(symbol, status="sina", source="sina", written_rows=new_rows,
written=written, updated=0,
note=f"新浪校验通过后补入 {written} 行(仅本地缺失键,source=sina)")
# ---------------------------------------------------------------- 日线同步服务
class VerifiedDailySyncer:
"""日线同步:Tushare 失败 → 新浪校验兜底(仅补缺失交易日、无复权因子)。"""
def __init__(
self,
*,
primary: MarketDataProvider,
fallback: MarketDataProvider | None,
bars: DailyBarRepository,
factors: AdjustFactorRepository,
audit: Callable[[SyncLog], None],
today: date | None = None,
min_shared: int = 3,
) -> None:
self.primary = primary
self.fallback = fallback
self.bars = bars
self.factors = factors
self.audit = audit
self.today = today or date.today()
self.min_shared = min_shared
def sync_symbol(self, symbol: str, begin: date, end: date) -> DailySymbolResult:
try:
bars = self.primary.get_daily(symbol, begin, end)
except DataSourceAuthenticationError:
raise # 凭证/权限故障 → 快速失败(见财务同步注释)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.primary.name,
api="get_daily",
success=False,
reason=str(exc),
start=begin,
end=end,
)
return self._sina_fallback(symbol, begin, end, primary_error=str(exc))
_audit_sync(
self.audit,
source=self.primary.name,
api="get_daily",
success=True,
row_count=len(bars),
start=begin,
end=end,
)
try:
factors = self.primary.get_adjust_factor(symbol, begin, end)
except DataSourceAuthenticationError:
raise # 凭证/权限故障 → 快速失败(见财务同步注释)
except Exception as exc: # noqa: BLE001
# 复权因子是日线配套:缺因子不写本段,避免 resume 按日线已最新而跳过、因子永远补不上
_audit_sync(
self.audit,
source=self.primary.name,
api="get_adjust_factor",
success=False,
reason=str(exc),
start=begin,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="tushare",
bars_fetched=len(bars),
notes=[f"日线拉取成功但复权因子失败,本段未落库(防因子缺口): {exc}"],
)
_audit_sync(
self.audit,
source=self.primary.name,
api="get_adjust_factor",
success=True,
row_count=len(factors),
start=begin,
end=end,
)
self.bars.upsert_many(bars)
self.factors.upsert_many(factors)
return DailySymbolResult(
symbol=symbol,
status="ok",
source="tushare",
bars_fetched=len(bars),
bars_written=len(bars),
factors_written=len(factors),
day_first=min((b.trade_date for b in bars), default=None),
day_last=max((b.trade_date for b in bars), default=None),
)
# ---- 新浪校验兜底 ----
def _sina_fallback(
self, symbol: str, begin: date, end: date, *, primary_error: str
) -> DailySymbolResult:
if self.fallback is None:
return DailySymbolResult(
symbol=symbol,
status="failed",
notes=[f"Tushare 失败且未配置新浪兜底: {primary_error}"],
)
# 新浪只有最近 SINA_KLINE_DAYS 自然日数据;为拿到「本地近期历史」重叠做
# 校验,请求窗口需回看 begin 之前 SINA_VERIFY_LOOKBACK_DAYS 天
# (见 daily_overlap_consistent:只比较两源重叠的最近交易日)。
q_start = max(
self.today - timedelta(days=SINA_KLINE_DAYS - 1),
begin - timedelta(days=SINA_VERIFY_LOOKBACK_DAYS),
)
try:
sina_bars = self.fallback.get_daily(symbol, q_start, end)
except Exception as exc: # noqa: BLE001
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=False,
reason=f"primary: {primary_error}; fallback: {exc}",
start=q_start,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="sina",
notes=[f"主备数据源均失败: primary={primary_error}; sina={exc}"],
)
local_recent = self.bars.get_range(symbol, q_start, end)
verdict = daily_overlap_consistent(local_recent, sina_bars, min_shared=self.min_shared)
if not verdict.ok:
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=False,
reason=f"{verdict.summary()}(新浪返回 {len(sina_bars)} 行; primary={primary_error})",
start=q_start,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="failed",
source="sina",
bars_fetched=len(sina_bars),
notes=[
f"新浪数据与本地历史不一致/无法校验({symbol}),拒绝兜底补缺。"
f"primary: {primary_error};{verdict.summary()}"
],
)
local_dates = {b.trade_date for b in local_recent}
new_bars = [
b
for b in sina_bars
if begin <= b.trade_date <= end and b.trade_date not in local_dates
]
written = self.bars.upsert_many(new_bars)
_audit_sync(
self.audit,
source=self.fallback.name,
api="get_daily",
success=True,
row_count=written,
start=begin,
end=end,
)
return DailySymbolResult(
symbol=symbol,
status="sina",
source="sina",
bars_fetched=len(sina_bars),
bars_written=written,
day_first=min((b.trade_date for b in new_bars), default=None),
day_last=max((b.trade_date for b in new_bars), default=None),
factors_written=None,
notes=[
f"新浪校验通过,仅补本地缺失交易日 {written} 根(前复权 source=sina,"
f"无复权因子;Tushare 恢复后 --resume 会按日覆盖回不复权口径)"
],
)
# ---------------------------------------------------------------- 小工具
def _fin_key(row: FinancialIndicator) -> tuple:
return (row.symbol, row.report_date, row.announce_date)
def _fin_result(
symbol: str,
*,
status: str,
source: str,
written_rows: Sequence[FinancialIndicator],
written: int,
updated: int,
note: str | None = None,
) -> FinancialSymbolResult:
reports = [r.report_date for r in written_rows]
announces = [r.announce_date for r in written_rows]
notes = [note] if note else []
return FinancialSymbolResult(
symbol=symbol,
status=status,
source=source,
fetched=len(written_rows),
written=written,
updated=updated,
report_first=min(reports, default=None),
report_last=max(reports, default=None),
announce_first=min(announces, default=None),
announce_last=max(announces, default=None),
notes=notes,
)
+173 -40
View File
@@ -1,28 +1,43 @@
"""Phase 1 数据同步 CLI(Tushare 首选 → SQLite)。 """Phase 1 数据同步 CLI(Tushare 首选 → SQLite,新浪校验兜底)。
用法(cd backend): 用法(cd backend):
uv run python -m app.cli.sync basic uv run python -m app.cli.sync basic
uv run python -m app.cli.sync calendar --start 20240101 --end 20241231 uv run python -m app.cli.sync calendar --start 20240101 --end 20241231
uv run python -m app.cli.sync daily --symbols 600519.SH,000001.SZ --start 20240101 uv run python -m app.cli.sync daily --symbols 600519.SH,000001.SZ --start 20240101
uv run python -m app.cli.sync daily --all --start 20240101 # 全市场 uv run python -m app.cli.sync daily --all --start 20240101 # 全市场
uv run python -m app.cli.sync financial --all uv run python -m app.cli.sync financial --all # 财务指标(增量)
uv run python -m app.cli.sync financial --all --full # 财务指标(强制全量重拉)
uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证 uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证
增量与兜底:
- daily --resume:从本地最新交易日续传(已有);Tushare 失败时走新浪校验兜底,
只有「两源重叠历史一致」才用新浪补本地缺失交易日(source=sina/前复权)。
- financial:默认增量——本地已含最新应披露报告期则跳过;Tushare 失败时新浪
数据须通过「两边一致」校验(重叠报告期 eps/销售毛利率逐期一致)才允许补入
本地缺失键(source=sina)。失败股票留待下轮重跑补齐,不会静默导入未核验数据。
- 每次拉取写入 sync_log 审计(来源 / 成功与否 / 行数 / 区间),禁止静默切源。
本模块是组装层(composition root):在此装配 Provider / Repository / Session, 本模块是组装层(composition root):在此装配 Provider / Repository / Session,
业务层代码仍只依赖抽象(domain.repositories / domain.providers)。 业务逻辑在 application.services.data_sync,业务层仍只依赖抽象。
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import sys import sys
import time
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from app.application.services.data_sync import (
DailySymbolResult,
FinancialSymbolResult,
VerifiedDailySyncer,
VerifiedFinancialSyncer,
)
from app.core.config import get_settings from app.core.config import get_settings
from app.infrastructure.data_sources.errors import DataSourceError from app.infrastructure.data_sources.errors import DataSourceError
from app.infrastructure.data_sources.failover import FailoverProvider
from app.infrastructure.data_sources.sina import SinaProvider from app.infrastructure.data_sources.sina import SinaProvider
from app.infrastructure.data_sources.tushare import TushareProvider from app.infrastructure.data_sources.tushare import TushareProvider
from app.infrastructure.persistence.sqlalchemy.models.market import StockModel from app.infrastructure.persistence.sqlalchemy.models.market import StockModel
@@ -44,12 +59,12 @@ def _parse_day(text: str) -> date:
def _failover_provider(session): def _failover_provider(session):
"""Tushare 首选 + 新浪兜底(FailoverProvider 每次尝试写 sync_log,AGENT.md §7)。 """Tushare 首选 + 新浪兜底(basic/calendar 用;daily/financial 走校验兜底服务)。
能力矩阵:新浪仅提供日线(前复权,标记 source=sina/adjust=qfq);其余接口 FailoverProvider 每次尝试写 sync_log(AGENT.md §7)。能力矩阵:新浪仅提供
(adj_factor/financial/basic/calendar)新浪不支持 → 抛错保留单源语义,日志可见。 日线/财务,basic/calendar 新浪不支持 → 抛错保留单源语义,日志可见。
Tushare 恢复后重跑 --resume 会把新浪兜底日按同键覆盖回不复权口径。
""" """
from app.infrastructure.data_sources.failover import FailoverProvider
from app.infrastructure.data_sources.sina import SinaProvider from app.infrastructure.data_sources.sina import SinaProvider
audit_repo = SqlAlchemySyncLogRepository(session) audit_repo = SqlAlchemySyncLogRepository(session)
@@ -97,6 +112,23 @@ def _symbols_of(args) -> list[str]:
return [s.strip() for s in args.symbols.split(",") if s.strip()] return [s.strip() for s in args.symbols.split(",") if s.strip()]
def _stock_names(session, symbols: list[str]) -> dict[str, str]:
"""一次性取出股票名称(进度描述用);批量查询避开 SQLite 变量上限。"""
names: dict[str, str] = {}
for i in range(0, len(symbols), 500):
chunk = symbols[i : i + 500]
rows = session.execute(
select(StockModel.symbol, StockModel.name).where(StockModel.symbol.in_(chunk))
)
names.update({sym: nm for sym, nm in rows})
return names
def _warn_notes(notes: list[str]) -> None:
for note in notes:
print(f" [warn] {note}", file=sys.stderr)
def cmd_daily(args) -> int: def cmd_daily(args) -> int:
from sqlalchemy import func from sqlalchemy import func
@@ -105,12 +137,20 @@ def cmd_daily(args) -> int:
symbols = _symbols_of(args) symbols = _symbols_of(args)
start = _parse_day(args.start) if args.start else date(2005, 1, 1) start = _parse_day(args.start) if args.start else date(2005, 1, 1)
end = _parse_day(args.end) if args.end else date.today() end = _parse_day(args.end) if args.end else date.today()
total = 0 started = time.monotonic()
skipped = 0 n_ok = n_sina = n_failed = n_skip = 0
rows_tushare = rows_sina = 0
with _session_ctx() as session: with _session_ctx() as session:
provider = _failover_provider(session) names = _stock_names(session, symbols)
audit = SqlAlchemySyncLogRepository(session).add
syncer = VerifiedDailySyncer(
primary=TushareProvider(token=get_settings().tushare_token),
fallback=SinaProvider(),
bars=SqlAlchemyDailyBarRepository(session),
factors=SqlAlchemyAdjustFactorRepository(session),
audit=audit,
)
bar_repo = SqlAlchemyDailyBarRepository(session) bar_repo = SqlAlchemyDailyBarRepository(session)
factor_repo = SqlAlchemyAdjustFactorRepository(session)
# 增量基准:本地数据已到该日期即视为「已最新」,resume 时不再调 API # 增量基准:本地数据已到该日期即视为「已最新」,resume 时不再调 API
global_latest = ( global_latest = (
session.scalar(select(func.max(StockDailyModel.trade_date))) if args.resume else None session.scalar(select(func.max(StockDailyModel.trade_date))) if args.resume else None
@@ -121,43 +161,119 @@ def cmd_daily(args) -> int:
latest = bar_repo.latest_date(symbol) latest = bar_repo.latest_date(symbol)
if latest is not None: if latest is not None:
if global_latest is not None and latest >= global_latest: if global_latest is not None and latest >= global_latest:
skipped += 1 # 已同步到本地最新交易日,无需续拉 n_skip += 1 # 已同步到本地最新交易日,无需续拉
continue continue
begin = max(begin, latest + timedelta(days=1)) begin = max(begin, latest + timedelta(days=1))
if begin > end: if begin > end:
skipped += 1 # 无待拉区间(如区间已含在本地) n_skip += 1 # 无待拉区间(如区间已含在本地)
continue continue
try: if getattr(args, "sleep", 0) > 0:
bars = provider.get_daily(symbol, begin, end) time.sleep(args.sleep)
factors = provider.get_adjust_factor(symbol, begin, end) res: DailySymbolResult = syncer.sync_symbol(symbol, begin, end)
bar_repo.upsert_many(bars) session.commit() # 逐只落库:中断/报错只丢当前一只,重跑增量续传
factor_repo.upsert_many(factors) if res.status == "ok":
total += len(bars) n_ok += 1
rows_tushare += res.bars_written
elif res.status == "sina":
n_sina += 1
rows_sina += res.bars_written
elif res.status == "failed":
n_failed += 1
_warn_notes(res.notes)
if i % 100 == 0: if i % 100 == 0:
session.commit() name = names.get(symbol, "")
print(f" ... {i}/{len(symbols)} {symbol} 累计 {total} 根") print(
except DataSourceError as exc: f" ... {i}/{len(symbols)} {symbol} {name}: "
print(f" [warn] {symbol} 拉取失败: {exc}", file=sys.stderr) f"累计 tushare {rows_tushare} 根 + 新浪补缺 {rows_sina} 根;"
session.commit() f"成功 {n_ok} / 新浪 {n_sina} / 失败待重试 {n_failed}"
detail = f"[daily] {len(symbols)} 只股票合计写入 {total} 根日线(含复权因子)" )
elapsed = time.monotonic() - started
detail = (
f"[daily] {len(symbols)} 只股票:成功 {n_ok} / 新浪校验补缺 {n_sina} / "
f"已最新跳过 {n_skip} / 失败待重试 {n_failed}"
)
if args.resume: if args.resume:
detail += f";增量跳过(已最新){skipped} 只(本地最新 {global_latest})" detail += f"(本地最新 {global_latest})"
detail += f";写入 {rows_tushare} 根(tushare 不复权)+ {rows_sina} 根(sina 前复权),耗时 {elapsed:.0f}s"
print(detail) print(detail)
return 0 return 0
def _fin_progress_line(i: int, n: int, symbol: str, name: str, res: FinancialSymbolResult) -> str:
"""financial 逐只进度行:结果 + 导入内容简单描述(报告期/公告区间、来源)。"""
head = f"[financial {i}/{n}] {symbol} {name or ''}".rstrip()
if res.status == "skip":
return f"{head}:已最新,跳过(增量)"
if res.status == "failed":
return f"{head}:失败待重试(tushare 失败;新浪源 {'未通过校验' if res.source == 'sina' else '不可用'})"
if res.status == "sina":
return (
f"{head}:tushare 失败 → 新浪校验通过,补入 {res.written} 行(source=sina)"
+ _fin_span(res)
)
# status == ok(tushare 成功)
if res.written:
updated = f",覆盖更新 {res.updated} 行" if res.updated else ""
return f"{head}:tushare 返回 {res.fetched} 行 → 新增 {res.written} 行{updated}" + _fin_span(res)
return f"{head}:tushare 返回 {res.fetched} 行,均已在库,无新增"
def _fin_span(res: FinancialSymbolResult) -> str:
if not res.written or res.report_first is None:
return ""
if res.announce_first is None or res.announce_last is None:
return ""
return (
f";报告期 {res.report_first.isoformat()}~{res.report_last.isoformat()}"
f"(公告 {res.announce_first.isoformat()}~{res.announce_last.isoformat()})"
)
def cmd_financial(args) -> int: def cmd_financial(args) -> int:
symbols = _symbols_of(args) symbols = _symbols_of(args)
total = 0 started = time.monotonic()
n_ok = n_sina = n_failed = n_skip = 0
rows_tushare = rows_sina = 0
with _session_ctx() as session: with _session_ctx() as session:
provider = _failover_provider(session) names = _stock_names(session, symbols)
fin_repo = SqlAlchemyFinancialRepository(session) audit = SqlAlchemySyncLogRepository(session).add
for symbol in symbols: syncer = VerifiedFinancialSyncer(
rows = provider.get_financial(symbol) primary=TushareProvider(token=get_settings().tushare_token),
fin_repo.upsert_many(rows) fallback=SinaProvider(),
total += len(rows) repo=SqlAlchemyFinancialRepository(session),
session.commit() audit=audit,
print(f"[financial] {len(symbols)} 只股票合计写入 {total} 条财务指标快照") )
for i, symbol in enumerate(symbols, start=1):
if getattr(args, "sleep", 0) > 0:
time.sleep(args.sleep)
res: FinancialSymbolResult = syncer.sync_symbol(symbol, force_full=args.full)
session.commit() # 逐只落库:中断只丢当前一只,重跑增量续传
print(_fin_progress_line(i, len(symbols), symbol, names.get(symbol, ""), res))
_warn_notes(res.notes)
if res.status == "ok":
n_ok += 1
rows_tushare += res.written
elif res.status == "sina":
n_sina += 1
rows_sina += res.written
elif res.status == "failed":
n_failed += 1
elif res.status == "skip":
n_skip += 1
elapsed = time.monotonic() - started
mode = "全量重拉(--full)" if args.full else "增量"
print(
f"[financial] 共 {len(symbols)} 只({mode}):成功 {n_ok} / 新浪校验兜底 {n_sina} / "
f"已最新跳过 {n_skip} / 失败待重试 {n_failed};"
f"合计写入 {rows_tushare + rows_sina} 行(tushare {rows_tushare} + sina {rows_sina}),"
f"耗时 {elapsed:.0f}s"
)
if n_failed:
print(
" [tip] 失败股票未写入未核验数据,重跑本命令即可续传补齐;"
"若因频率超限,可用 --sleep 加大间隔(如 --sleep 60)分多次跑。",
file=sys.stderr,
)
return 0 return 0
@@ -253,17 +369,34 @@ def build_parser() -> argparse.ArgumentParser:
p_cal.add_argument("--end", required=True, help="YYYYMMDD") p_cal.add_argument("--end", required=True, help="YYYYMMDD")
p_cal.set_defaults(func=cmd_calendar) p_cal.set_defaults(func=cmd_calendar)
p_daily = sub.add_parser("daily", help="同步日线与复权因子") p_daily = sub.add_parser("daily", help="同步日线与复权因子(Tushare 失败 → 新浪校验兜底补缺)")
p_daily.add_argument("--symbols", default="", help="600519.SH,000001.SZ") p_daily.add_argument("--symbols", default="", help="600519.SH,000001.SZ")
p_daily.add_argument("--all", action="store_true", help="遍历 stock 表全部股票") p_daily.add_argument("--all", action="store_true", help="遍历 stock 表全部股票")
p_daily.add_argument("--start", default="", help="YYYYMMDD(默认 20050101)") p_daily.add_argument("--start", default="", help="YYYYMMDD(默认 20050101)")
p_daily.add_argument("--end", default="", help="YYYYMMDD(默认今天)") p_daily.add_argument("--end", default="", help="YYYYMMDD(默认今天)")
p_daily.add_argument("--resume", action="store_true", help="从本地最新交易日续传") p_daily.add_argument("--resume", action="store_true", help="从本地最新交易日续传(增量)")
p_daily.add_argument(
"--sleep",
type=float,
default=0,
help="每只股票请求间隔秒数(限速时加大,如 1 或 60)",
)
p_daily.set_defaults(func=cmd_daily) p_daily.set_defaults(func=cmd_daily)
p_fin = sub.add_parser("financial", help="同步财务指标快照") p_fin = sub.add_parser("financial", help="同步财务指标快照(默认增量;Tushare 失败 → 新浪校验兜底)")
p_fin.add_argument("--symbols", default="") p_fin.add_argument("--symbols", default="")
p_fin.add_argument("--all", action="store_true") p_fin.add_argument("--all", action="store_true", help="遍历 stock 表全部股票")
p_fin.add_argument(
"--full",
action="store_true",
help="强制全量重拉并覆盖既有行(默认只补本地缺失/更新的报告期,已最新跳过)",
)
p_fin.add_argument(
"--sleep",
type=float,
default=0,
help="每只股票请求间隔秒数(限速时加大,如 1 或 60)",
)
p_fin.set_defaults(func=cmd_financial) p_fin.set_defaults(func=cmd_financial)
p_verify = sub.add_parser("verify", help="新浪交叉验证最新行情") p_verify = sub.add_parser("verify", help="新浪交叉验证最新行情")
+7
View File
@@ -83,11 +83,18 @@ class FinancialIndicator(BaseModel):
可见性红线:研究侧查询一律按 announce_date <= as_of_date 过滤, 可见性红线:研究侧查询一律按 announce_date <= as_of_date 过滤,
report_date 只表示报告所属期间,不代表公开时间。 report_date 只表示报告所属期间,不代表公开时间。
source 标记数据来源:tushare(首选,字段全)| sina(兜底,字段
可能不全——新浪关键指标只含 eps/roe/gross_margin 等少数项)。
新浪兜底行只在「该股票本地历史与新浪重叠部分两边一致」通过校验后
才导入(见 application/services/data_sync.py),且只补本地缺失键。
研究侧对同一报告期应优先消费 source=tushare 的行。
""" """
symbol: str symbol: str
report_date: date report_date: date
announce_date: date announce_date: date
source: str = Field(default="tushare", description="tushare | sina")
eps: Decimal | None = None eps: Decimal | None = None
roe: Decimal | None = None roe: Decimal | None = None
total_revenue: Decimal | None = None total_revenue: Decimal | None = None
+12 -1
View File
@@ -37,4 +37,15 @@ class MarketDataProvider(Protocol):
def get_adjust_factor(self, symbol: str, start: date, end: date) -> list[AdjustFactor]: ... def get_adjust_factor(self, symbol: str, start: date, end: date) -> list[AdjustFactor]: ...
def get_financial(self, symbol: str) -> list[FinancialIndicator]: ... def get_financial(
self,
symbol: str,
start: date | None = None,
end: date | None = None,
) -> list[FinancialIndicator]:
"""财务指标快照。
start/end 为**报告期**窗口(对应 Tushare fina_indicator 的
start_date/end_date 参数,按报告期过滤);不传表示全量历史。
新浪接口不支持按窗口拉取,提供方会忽略窗口后由调用方自行过滤。
"""
@@ -72,6 +72,12 @@ class AdjustFactorRepository(Protocol):
class FinancialRepository(Protocol): class FinancialRepository(Protocol):
def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int: ... def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int: ...
def list_symbol(self, symbol: str) -> list[FinancialIndicator]:
"""该股票本地全部财务行(增量判断 / 新浪校验重叠用,量级小)。"""
def has_report_period(self, symbol: str, report_date: date) -> bool:
"""本地是否已含该报告期(最新应披露报告期是否已入库)。"""
def list_announced( def list_announced(
self, self,
symbol: str, symbol: str,
@@ -69,11 +69,15 @@ class FailoverProvider:
fallback_call=lambda: self.fallback.get_adjust_factor(symbol, start, end), fallback_call=lambda: self.fallback.get_adjust_factor(symbol, start, end),
) )
def get_financial(self, symbol: str) -> list: def get_financial(
self, symbol: str, start: date | None = None, end: date | None = None
) -> list:
return self._with_failover( return self._with_failover(
"get_financial", "get_financial",
primary_call=lambda: self.primary.get_financial(symbol), start=start,
fallback_call=lambda: self.fallback.get_financial(symbol), end=end,
primary_call=lambda: self.primary.get_financial(symbol, start, end),
fallback_call=lambda: self.fallback.get_financial(symbol, start, end),
) )
# ---- 内部 ---- # ---- 内部 ----
@@ -128,8 +128,17 @@ class SinaProvider:
# ---- 财务(兜底 Tushare fina_indicator) ---- # ---- 财务(兜底 Tushare fina_indicator) ----
def get_financial(self, symbol: str) -> list[FinancialIndicator]: def get_financial(
"""新浪关键指标(source=gjzb),含披露日 publish_date → announce_date。""" self,
symbol: str,
start: date | None = None,
end: date | None = None,
) -> list[FinancialIndicator]:
"""新浪关键指标(source=gjzb),含披露日 publish_date → announce_date。
新浪不支持按报告期窗口拉取:忽略 start/end 时返回其全部返回的
报告期;传入窗口则按 report_date 客户端过滤(新浪行 source=sina)。
"""
params = { params = {
"paperCode": _to_sina_symbol(symbol), "paperCode": _to_sina_symbol(symbol),
"source": "gjzb", "source": "gjzb",
@@ -153,6 +162,11 @@ class SinaProvider:
announce = entry.get("publish_date") announce = entry.get("publish_date")
if not announce: if not announce:
continue # 无披露日不可用于研究(防未来函数) continue # 无披露日不可用于研究(防未来函数)
report_day = _to_date(str(rd))
if start is not None and report_day < start:
continue
if end is not None and report_day > end:
continue
fields: dict[str, Decimal | None] = { fields: dict[str, Decimal | None] = {
"eps": None, "eps": None,
"roe": None, "roe": None,
@@ -167,8 +181,9 @@ class SinaProvider:
rows.append( rows.append(
FinancialIndicator( FinancialIndicator(
symbol=symbol, symbol=symbol,
report_date=_to_date(str(rd)), report_date=report_day,
announce_date=_to_date(str(announce)), announce_date=_to_date(str(announce)),
source="sina",
eps=fields["eps"], eps=fields["eps"],
roe=fields["roe"], roe=fields["roe"],
total_revenue=fields["total_revenue"], total_revenue=fields["total_revenue"],
@@ -10,7 +10,7 @@ from __future__ import annotations
import importlib import importlib
import logging import logging
import time import time
from datetime import date, datetime from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
@@ -183,9 +183,42 @@ class TushareProvider:
) )
return self.normalize_adj_factor(records) return self.normalize_adj_factor(records)
def get_financial(self, symbol: str) -> list[FinancialIndicator]: def get_financial(
records = self._call("fina_indicator", ts_code=symbol) self,
return self.normalize_financial(records) symbol: str,
start: date | None = None,
end: date | None = None,
) -> list[FinancialIndicator]:
"""fina_indicator:报告期窗口 + 100 条/请求上限自动分页。
Tushare 单次请求最多返回 100 条(超出按最新 100 条截断),因此
全量历史必须按报告期窗口回卷分页,否则老报告期会被静默丢弃。
"""
lo = start or date(1990, 1, 1)
hi = end or date.today()
raw: list[dict[str, Any]] = []
while lo <= hi:
batch = self._call(
"fina_indicator",
ts_code=symbol,
start_date=lo.strftime(_TS_DATE),
end_date=hi.strftime(_TS_DATE),
)
raw += batch
if len(batch) < 100:
break
ends = [
datetime.strptime(str(r["end_date"])[:8], _TS_DATE).date()
for r in batch
if r.get("end_date")
]
if not ends:
break
next_hi = min(ends) - timedelta(days=1)
if next_hi < lo: # 无进展保护(边界簇被截断等极端情况)
break
hi = next_hi
return self.normalize_financial(raw)
# ---- 内部 ---- # ---- 内部 ----
@@ -0,0 +1,33 @@
"""financial_indicator 增加 source 来源标记
Revision ID: d3f6c9a21b04
Revises: 91c4e27a03fb
Create Date: 2026-09-08
新浪校验兜底导入的财务行带 source=sina(字段可能不全),与 Tushare
首选行区分;现有行回填默认 tushare(SQLite ADD COLUMN 带常量默认值,
不重写现有数据)。AGENT.md §7 数据来源可追溯。
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d3f6c9a21b04"
down_revision: str | None = "91c4e27a03fb"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"financial_indicator",
sa.Column("source", sa.String(length=16), nullable=False, server_default="tushare"),
)
def downgrade() -> None:
op.drop_column("financial_indicator", "source")
@@ -93,6 +93,9 @@ class FinancialIndicatorModel(Base):
symbol: Mapped[str] = mapped_column(String(SYMBOL_LEN), index=True) symbol: Mapped[str] = mapped_column(String(SYMBOL_LEN), index=True)
report_date: Mapped[date] = mapped_column(Date, index=True) report_date: Mapped[date] = mapped_column(Date, index=True)
announce_date: Mapped[date] = mapped_column(Date, index=True) announce_date: Mapped[date] = mapped_column(Date, index=True)
source: Mapped[str] = mapped_column(
String(16), default="tushare", server_default="tushare"
)
eps: Mapped[Decimal | None] = mapped_column(Numeric(12, 4), nullable=True) eps: Mapped[Decimal | None] = mapped_column(Numeric(12, 4), nullable=True)
roe: Mapped[Decimal | None] = mapped_column(Numeric(10, 4), nullable=True) roe: Mapped[Decimal | None] = mapped_column(Numeric(10, 4), nullable=True)
total_revenue: Mapped[Decimal | None] = mapped_column(Numeric(24, 2), nullable=True) total_revenue: Mapped[Decimal | None] = mapped_column(Numeric(24, 2), nullable=True)
@@ -241,6 +241,25 @@ class SqlAlchemyFinancialRepository:
def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int: def upsert_many(self, rows: Sequence[FinancialIndicator]) -> int:
return _upsert_by_business_key(self._session, FinancialIndicator, rows) return _upsert_by_business_key(self._session, FinancialIndicator, rows)
def list_symbol(self, symbol: str) -> list[FinancialIndicator]:
rows = self._session.scalars(
select(FinancialIndicatorModel)
.where(FinancialIndicatorModel.symbol == symbol)
.order_by(FinancialIndicatorModel.announce_date, FinancialIndicatorModel.report_date)
).all()
return [FinancialIndicator.model_validate(r, from_attributes=True) for r in rows]
def has_report_period(self, symbol: str, report_date: date) -> bool:
exists = self._session.scalar(
select(FinancialIndicatorModel.id)
.where(
FinancialIndicatorModel.symbol == symbol,
FinancialIndicatorModel.report_date == report_date,
)
.limit(1)
)
return exists is not None
def list_announced( def list_announced(
self, self,
symbol: str, symbol: str,
+450
View File
@@ -0,0 +1,450 @@
"""增量 + 新浪「两边一致」校验兜底同步服务测试。
覆盖:一致性校验纯函数、报告期节奏、财务/日线同步服务
(temp SQLite + Fake Provider,不触网)。
"""
from __future__ import annotations
from datetime import date
from decimal import Decimal
import pytest
from app.application.services.data_sync import (
VerifiedDailySyncer,
VerifiedFinancialSyncer,
daily_overlap_consistent,
financial_overlap_consistent,
latest_expected_report_period,
)
from app.domain.entities.market import DailyBar, FinancialIndicator, SyncLog
from app.infrastructure.data_sources.errors import DataSourceError
from app.infrastructure.persistence.sqlalchemy.base import Base
from app.infrastructure.persistence.sqlalchemy.models.market import (
AdjustFactorModel,
FinancialIndicatorModel,
StockDailyModel,
)
from app.infrastructure.persistence.sqlalchemy.repositories.market_impl import (
SqlAlchemyAdjustFactorRepository,
SqlAlchemyDailyBarRepository,
SqlAlchemyFinancialRepository,
)
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
TODAY = date(2026, 9, 8)
DUE = date(2026, 6, 30) # TODAY 属于半年报季 → 应披露最新报告期
@pytest.fixture()
def session(tmp_path) -> Session:
engine = create_engine(f"sqlite:///{tmp_path / 'ds.db'}", future=True)
Base.metadata.create_all(engine)
with Session(engine) as session:
yield session
def _fin(
symbol: str,
report: str,
announce: str,
*,
eps: str = "1.00",
roe: str | None = None,
gross: str | None = None,
source: str = "tushare",
) -> FinancialIndicator:
return FinancialIndicator(
symbol=symbol,
report_date=date.fromisoformat(report),
announce_date=date.fromisoformat(announce),
source=source,
eps=Decimal(eps),
roe=Decimal(roe) if roe is not None else None,
gross_margin=Decimal(gross) if gross is not None else None,
)
def _bar(symbol: str, day: str, close: str, source: str = "tushare") -> DailyBar:
return DailyBar(
symbol=symbol,
trade_date=date.fromisoformat(day),
source=source,
adjust="none" if source == "tushare" else "qfq",
open=Decimal(close),
high=Decimal(close),
low=Decimal(close),
close=Decimal(close),
volume=Decimal("1000"),
)
# ================================================================ 一致性校验
class TestFinancialConsistency:
def test_overlap_consistent_passes(self) -> None:
local = [
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", roe="10.5687", gross="89.7592"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="17.9543", gross="89.5552"),
]
sina = [
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", roe="10.57", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="16.75", gross="89.555212", source="sina"),
]
verdict = financial_overlap_consistent(local, sina)
# ROE 两边口径不同(新浪加权 vs tushare 摊薄),不参与比较也不判失败
assert verdict.ok
assert verdict.shared == 2
assert not verdict.mismatches
def test_roe_difference_not_compared(self) -> None:
"""口径差异字段绝不能导致误杀(17.9543 vs 16.75 是同源数据真实形态)。"""
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="17.9543", gross="89.5552")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", roe="16.75", gross="89.555212", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=1)
assert verdict.ok
def test_eps_mismatch_fails(self) -> None:
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.5552")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="99.99", gross="89.555212", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=1)
assert not verdict.ok
assert verdict.mismatches
def test_insufficient_overlap_fails(self) -> None:
local = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57")]
sina = [_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", source="sina")]
verdict = financial_overlap_consistent(local, sina, min_shared=2)
assert not verdict.ok
assert verdict.summary()
class TestDailyConsistency:
def test_recent_overlap_equal_passes(self) -> None:
local = [_bar("600519.SH", "2026-09-01", "10"), _bar("600519.SH", "2026-09-02", "10")]
sina = [
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
]
verdict = daily_overlap_consistent(local, sina, min_shared=2)
assert verdict.ok
def test_old_dividend_scale_gap_ignored(self) -> None:
"""新浪前复权:老交易日在除权后数值整体平移,只有「最近重叠段」可比。"""
local = [
_bar("600519.SH", "2025-06-02", "500"),
_bar("600519.SH", "2026-09-01", "10"),
_bar("600519.SH", "2026-09-02", "10"),
_bar("600519.SH", "2026-09-03", "10"),
]
sina = [
_bar("600519.SH", "2025-06-02", "480", source="sina"), # 除权后按因子平移
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
_bar("600519.SH", "2026-09-03", "10", source="sina"),
]
verdict = daily_overlap_consistent(local, sina, min_shared=2, max_recent=3)
assert verdict.ok
def test_recent_close_mismatch_fails(self) -> None:
local = [_bar("600519.SH", "2026-09-02", "10")]
sina = [_bar("600519.SH", "2026-09-02", "9", source="sina")]
verdict = daily_overlap_consistent(local, sina, min_shared=1)
assert not verdict.ok
class TestReportPeriodRhythm:
def test_windows(self) -> None:
assert latest_expected_report_period(date(2026, 1, 1)) == date(2025, 9, 30)
assert latest_expected_report_period(date(2026, 2, 14)) == date(2025, 9, 30)
assert latest_expected_report_period(date(2026, 2, 15)) == date(2026, 3, 31)
assert latest_expected_report_period(date(2026, 6, 30)) == date(2026, 3, 31)
assert latest_expected_report_period(date(2026, 7, 1)) == date(2026, 6, 30)
assert latest_expected_report_period(TODAY) == DUE
assert latest_expected_report_period(date(2026, 10, 15)) == date(2026, 6, 30)
assert latest_expected_report_period(date(2026, 10, 16)) == date(2026, 9, 30)
# ================================================================ 财务同步服务
class _FakeFinSource:
def __init__(self, *, name: str, payload=None, error: Exception | None = None) -> None:
self.name = name
self.payload = payload or []
self.error = error
self.calls: list[tuple] = []
def get_financial(self, symbol, start=None, end=None): # noqa: ANN001
self.calls.append((symbol, start, end))
if self.error is not None:
raise self.error
return list(self.payload)
def _fin_syncer(session, primary, fallback=None, *, today: date = TODAY):
logs: list[SyncLog] = []
syncer = VerifiedFinancialSyncer(
primary=primary,
fallback=fallback,
repo=SqlAlchemyFinancialRepository(session),
audit=logs.append,
today=today,
)
return syncer, logs
def _count(session, model) -> int:
return session.scalar(select(func.count()).select_from(model))
class TestVerifiedFinancialSyncer:
def _rows(self, symbol: str = "600519.SH") -> list[FinancialIndicator]:
return [
_fin(symbol, "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592"),
_fin(symbol, "2026-06-30", "2026-08-15", eps="35.57", gross="89.5552"),
]
def test_first_fetch_writes_delta_and_second_run_skips(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", payload=self._rows())
syncer, logs = _fin_syncer(session, primary)
res = syncer.sync_symbol("600519.SH")
session.commit()
assert res.status == "ok"
assert res.written == 2
assert _count(session, FinancialIndicatorModel) == 2
assert logs[0].source == "tushare" and logs[0].success
# 第二次:本地已含最新应披露报告期 2026-06-30 → 跳过,不再调 API
res2 = syncer.sync_symbol("600519.SH")
assert res2.status == "skip"
assert len(primary.calls) == 1
def test_force_full_overwrites(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", payload=self._rows())
syncer, _logs = _fin_syncer(session, primary)
syncer.sync_symbol("600519.SH")
session.commit()
changed = [
r.model_copy(update={"eps": Decimal("99")}) for r in self._rows()
] + [_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.1796")]
primary.payload = changed
res = syncer.sync_symbol("600519.SH", force_full=True)
session.commit()
assert res.status == "ok"
assert res.written == 3
assert res.updated == 2 # 既有 2 行被覆盖
got = session.scalars(select(FinancialIndicatorModel)).all()
assert len(got) == 3
assert max(float(r.eps) for r in got) == 99.0
def test_sina_fallback_verified_imports_only_new_keys(self, session: Session) -> None:
repo = SqlAlchemyFinancialRepository(session)
repo.upsert_many(
[
_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.1796"),
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592"),
]
)
session.commit()
sina_rows = [
_fin("600519.SH", "2025-12-31", "2026-04-17", eps="65.66", gross="91.179551", source="sina"),
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.555212", source="sina"),
]
primary = _FakeFinSource(name="tushare", error=DataSourceError("频率超限"))
fallback = _FakeFinSource(name="sina", payload=sina_rows)
syncer, logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
session.commit()
assert res.status == "sina"
assert res.written == 1 # 只补本地缺失键(2026-06-30)
rows = session.scalars(select(FinancialIndicatorModel)).all()
assert len(rows) == 3
new = next(r for r in rows if r.report_date == DUE)
assert new.source == "sina"
# 审计:tushare 失败 + sina 成功
assert [log.source for log in logs] == ["tushare", "sina"]
assert logs[0].success is False and logs[1].success is True
def test_sina_fallback_denied_on_mismatch(self, session: Session) -> None:
repo = SqlAlchemyFinancialRepository(session)
# 用非「最新应披露期」做种子,避免触发「已最新跳过」
repo.upsert_many(
[_fin("600519.SH", "2026-03-31", "2026-04-25", eps="21.76", gross="89.7592")]
)
session.commit()
primary = _FakeFinSource(name="tushare", error=DataSourceError("boom"))
# 同报告期 eps 不一致 → 真实性不成立 → 拒绝导入
fallback = _FakeFinSource(
name="sina",
payload=[
_fin("600519.SH", "2026-03-31", "2026-04-25", eps="1.00", gross="89.759217", source="sina"),
_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", gross="89.555212", source="sina"),
],
)
syncer, _logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
assert res.status == "failed"
assert res.source == "sina"
assert _count(session, FinancialIndicatorModel) == 1 # 未新增
def test_sina_fallback_no_local_history_denied(self, session: Session) -> None:
primary = _FakeFinSource(name="tushare", error=DataSourceError("频率超限"))
fallback = _FakeFinSource(
name="sina",
payload=[_fin("600519.SH", "2026-06-30", "2026-08-15", eps="35.57", source="sina")],
)
syncer, logs = _fin_syncer(session, primary, fallback)
res = syncer.sync_symbol("600519.SH")
assert res.status == "failed"
assert "本地无历史" in " ".join(res.notes)
assert _count(session, FinancialIndicatorModel) == 0
assert not logs[-1].success
# ================================================================ 日线同步服务
class _FakeDailyTushare:
name = "tushare"
def __init__(self, *, bars=None, factors=None, daily_error=None, factor_error=None) -> None: # noqa: ANN001
self.bars = bars or []
self.factors = factors or []
self.daily_error = daily_error
self.factor_error = factor_error
self.daily_calls = 0
def get_daily(self, symbol, start, end):
self.daily_calls += 1
if self.daily_error is not None:
raise self.daily_error
return list(self.bars)
def get_adjust_factor(self, symbol, start, end):
if self.factor_error is not None:
raise self.factor_error
return list(self.factors)
class _FakeDailySina:
name = "sina"
def __init__(self, bars) -> None:
self.bars = bars
def get_daily(self, symbol, start, end):
return [
b for b in self.bars if start <= b.trade_date <= end
]
def _daily_syncer(session, primary, fallback=None, *, today: date = TODAY):
logs: list[SyncLog] = []
syncer = VerifiedDailySyncer(
primary=primary,
fallback=fallback,
bars=SqlAlchemyDailyBarRepository(session),
factors=SqlAlchemyAdjustFactorRepository(session),
audit=logs.append,
today=today,
)
return syncer, logs
class TestVerifiedDailySyncer:
def test_primary_ok_writes_bars_and_factors(self, session: Session) -> None:
from app.domain.entities.market import AdjustFactor
bars = [_bar("600519.SH", "2026-09-01", "10"), _bar("600519.SH", "2026-09-02", "10")]
factors = [
AdjustFactor(symbol="600519.SH", trade_date=date(2026, 9, 1), factor=Decimal("1.0"))
]
syncer, logs = _daily_syncer(
session, _FakeDailyTushare(bars=bars, factors=factors)
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 1), date(2026, 9, 2))
assert res.status == "ok"
assert res.bars_written == 2 and res.factors_written == 1
assert _count(session, StockDailyModel) == 2
assert _count(session, AdjustFactorModel) == 1
assert [log.api for log in logs] == ["get_daily", "get_adjust_factor"]
def test_factor_failure_aborts_symbol_without_partial_write(self, session: Session) -> None:
bars = [_bar("600519.SH", "2026-09-01", "10")]
syncer, _logs = _daily_syncer(
session,
_FakeDailyTushare(bars=bars, factor_error=DataSourceError("限速")),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 1), date(2026, 9, 2))
assert res.status == "failed"
# 缺复权因子不写本段日线,避免 resume 漏补因子
assert _count(session, StockDailyModel) == 0
def _seed_local(self, session: Session) -> None:
repo = SqlAlchemyDailyBarRepository(session)
repo.upsert_many(
[
_bar("600519.SH", "2026-08-31", "10"),
_bar("600519.SH", "2026-09-01", "10"),
_bar("600519.SH", "2026-09-02", "10"),
]
)
session.commit()
def test_sina_fallback_fills_only_missing_dates(self, session: Session) -> None:
self._seed_local(session)
sina_bars = [
_bar("600519.SH", "2026-08-31", "10", source="sina"),
_bar("600519.SH", "2026-09-01", "10", source="sina"),
_bar("600519.SH", "2026-09-02", "10", source="sina"),
_bar("600519.SH", "2026-09-03", "10", source="sina"),
_bar("600519.SH", "2026-09-04", "10", source="sina"),
]
syncer, logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("频率超限")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
session.commit()
assert res.status == "sina"
assert res.bars_written == 2 # 只补 09-03/09-04
rows = session.scalars(select(StockDailyModel).order_by(StockDailyModel.trade_date)).all()
assert len(rows) == 5
gap = [r for r in rows if r.trade_date >= date(2026, 9, 3)]
assert all(r.source == "sina" and r.adjust == "qfq" for r in gap)
kept = [r for r in rows if r.trade_date < date(2026, 9, 3)]
assert all(r.source == "tushare" for r in kept) # 既有行不被覆盖
assert logs[-1].source == "sina" and logs[-1].success
def test_sina_fallback_denied_on_mismatch(self, session: Session) -> None:
self._seed_local(session)
# 最近重叠日收盘不一致(新浪 9 元 vs 本地 10 元)→ 通道不可信 → 拒绝
sina_bars = [_bar("600519.SH", "2026-09-02", "9", source="sina")]
syncer, _logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("boom")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
assert res.status == "failed"
assert _count(session, StockDailyModel) == 3 # 未新增
def test_sina_fallback_no_overlap_denied(self, session: Session) -> None:
# 本地没有任何近期历史可对照 → 拒绝新浪补缺
sina_bars = [_bar("600519.SH", "2026-09-03", "10", source="sina")]
syncer, logs = _daily_syncer(
session,
_FakeDailyTushare(daily_error=DataSourceError("boom")),
fallback=_FakeDailySina(sina_bars),
)
res = syncer.sync_symbol("600519.SH", date(2026, 9, 3), date(2026, 9, 4))
assert res.status == "failed"
assert _count(session, StockDailyModel) == 0
assert not logs[-1].success
+3
View File
@@ -53,6 +53,9 @@ def test_upgrade_head_creates_phase1_tables(tmp_path) -> None:
con.close() con.close()
assert {"report_date", "announce_date"} <= fin_cols assert {"report_date", "announce_date"} <= fin_cols
assert {"symbol", "trade_date", "close"} <= daily_cols assert {"symbol", "trade_date", "close"} <= daily_cols
# 来源标记列(新浪校验兜底行需与 Tushare 首选行区分)
assert "source" in fin_cols
assert {"source", "adjust"} <= daily_cols
def test_upgrade_head_idempotent(tmp_path) -> None: def test_upgrade_head_idempotent(tmp_path) -> None:
+30
View File
@@ -196,6 +196,36 @@ class TestFinancialRepository:
assert len(got) == 1 assert len(got) == 1
assert got[0].eps == Decimal("9.99") assert got[0].eps == Decimal("9.99")
def test_source_marker_and_incremental_queries(self, session: Session) -> None:
"""source 来源标记(默认 tushare)+ 增量判断查询(has_report_period/list_symbol)。"""
repo = SqlAlchemyFinancialRepository(session)
repo.upsert_many(
[
FinancialIndicator(
symbol="600519.SH",
report_date=date(2024, 6, 30),
announce_date=date(2024, 8, 31),
eps=Decimal("33.19"),
), # source 默认 tushare
FinancialIndicator(
symbol="600519.SH",
report_date=date(2024, 9, 30),
announce_date=date(2024, 10, 30),
source="sina",
eps=Decimal("48.42"),
),
]
)
session.commit()
rows = repo.list_symbol("600519.SH")
assert {r.source for r in rows} == {"tushare", "sina"}
assert rows[0].source == "tushare" # 按 announce_date 升序
assert repo.has_report_period("600519.SH", date(2024, 9, 30))
assert not repo.has_report_period("600519.SH", date(2024, 12, 31))
assert repo.list_symbol("000001.SZ") == []
class TestSyncLogRepository: class TestSyncLogRepository:
def test_add_and_recent(self, session: Session) -> None: def test_add_and_recent(self, session: Session) -> None:
+40
View File
@@ -125,9 +125,49 @@ class TestFinancial:
assert float(row.eps) == 1.23 assert float(row.eps) == 1.23
assert float(row.roe) == 15.5 assert float(row.roe) == 15.5
assert float(row.net_profit) == 1230000000 assert float(row.net_profit) == 1230000000
assert row.source == "sina" # 兜底行必须标记来源(与 Tushare 首选行区分)
# FinancialIndicator 校验能通过 → 格式与 Tushare 一致 # FinancialIndicator 校验能通过 → 格式与 Tushare 一致
assert row.model_dump()["eps"] is not None assert row.model_dump()["eps"] is not None
def test_window_filter(self) -> None:
payload = json.dumps(
{
"result": {
"data": {
"report_date": [
{"date_value": "2024-06-30"},
{"date_value": "2024-03-31"},
],
"report_list": {
"2024-06-30": {
"publish_date": "2024-08-31",
"data": [{"item_title": "基本每股收益", "item_value": "1.23"}],
},
"2024-03-31": {
"publish_date": "2024-04-27",
"data": [{"item_title": "基本每股收益", "item_value": "0.5"}],
},
},
}
}
},
ensure_ascii=False,
)
class _TwoResp:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return payload.encode("utf-8")
provider = SinaProvider(urlopen=lambda _req, **kw: _TwoResp())
rows = provider.get_financial("600519.SH", date(2024, 6, 30), date(2024, 6, 30))
assert [r.report_date for r in rows] == [date(2024, 6, 30)]
def test_bad_structure_raises(self) -> None: def test_bad_structure_raises(self) -> None:
class _BadResp(_FinResp): class _BadResp(_FinResp):
def read(self): def read(self):
+55
View File
@@ -169,3 +169,58 @@ class TestRateLimitBackoff:
ts_mod.time.sleep = orig_sleep ts_mod.time.sleep = orig_sleep
assert len(provider._pro.calls) == 3 # noqa: SLF001 —— 完整重试 3 次 assert len(provider._pro.calls) == 3 # noqa: SLF001 —— 完整重试 3 次
assert len(sleeps) >= 2 assert len(sleeps) >= 2
def _quarter_ends(count: int) -> list[str]:
"""最近 count 个季度末(YYYYMMDD,降序)。"""
ends: list[str] = []
y, m = 2026, 6
while len(ends) < count:
ends.append(f"{y}{m:02d}30" if m in (6, 9) else f"{y}{m:02d}31")
m -= 3
if m <= 0:
m += 12
y -= 1
return ends
class _FakeProQueue:
"""按调用顺序弹出 payload 的 Fake pro(模拟分页)。"""
def __init__(self, payloads: list[list[dict]]) -> None:
self.payloads = list(payloads)
self.kwargs: list[dict] = []
def fina_indicator(self, **kwargs):
self.kwargs.append(kwargs)
return self.payloads.pop(0)
class TestGetFinancialWindow:
def _record(self, end: str) -> dict:
return {"ts_code": "600519.SH", "end_date": end, "ann_date": end, "eps": "1.0"}
def test_window_args_passed(self) -> None:
ends = _quarter_ends(10)
fake = _FakeProQueue([[self._record(e) for e in ends]])
provider = TushareProvider(token="t", pro=fake)
rows = provider.get_financial("600519.SH", date(2024, 1, 1), date(2026, 6, 30))
assert len(rows) == 10
assert fake.kwargs[0]["start_date"] == "20240101"
assert fake.kwargs[0]["end_date"] == "20260630"
def test_paging_over_100_row_cap(self) -> None:
"""单请求最多 100 条 → 超过必须回卷报告期窗口继续取,老数据不丢。"""
newest = _quarter_ends(100)
older = _quarter_ends(140)[100:] # 100 条之外更早的 40 个季度
# 二次请求 end_date 必须早于首请求(分页回卷)
fake = _FakeProQueue(
[[self._record(e) for e in newest], [self._record(e) for e in older]]
)
provider = TushareProvider(token="t", pro=fake)
rows = provider.get_financial("600519.SH", date(2000, 1, 1), date(2026, 6, 30))
assert len(rows) == 100 + 40
assert len(fake.kwargs) == 2
assert fake.kwargs[1]["end_date"] < fake.kwargs[0]["end_date"]
assert {r.report_date for r in rows} == {date.fromisoformat(e[:4] + "-" + e[4:6] + "-" + e[6:]) for e in newest + older}
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -110,15 +110,26 @@ uv run python -m app.cli.sync basic # 股票基础信息(
uv run python -m app.cli.sync calendar --start 20240101 --end 20241231 uv run python -m app.cli.sync calendar --start 20240101 --end 20241231
uv run python -m app.cli.sync daily --symbols 600519.SH,000858.SZ --start 20230101 uv run python -m app.cli.sync daily --symbols 600519.SH,000858.SZ --start 20230101
uv run python -m app.cli.sync daily --all --start 20240101 --resume # 全市场 + 断点续传 uv run python -m app.cli.sync daily --all --start 20240101 --resume # 全市场 + 断点续传
uv run python -m app.cli.sync financial --all # 财务指标快照 uv run python -m app.cli.sync financial --all # 财务指标(默认增量)
uv run python -m app.cli.sync financial --all --full # 财务指标(强制全量重拉)
uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证 uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证
uv run python -m app.cli.sync export [--years 2023,2024] # 日线按年导出 Parquet uv run python -m app.cli.sync export [--years 2023,2024] # 日线按年导出 Parquet
``` ```
- 每次拉取写入 `sync_log` 审计(来源 / 成功与否 / 行数 / 区间),禁止静默切换数据源。 - 每次拉取写入 `sync_log` 审计(来源 / 成功与否 / 行数 / 区间),禁止静默切换数据源。
- `daily` 同时写入日线与复权因子;`--resume` 从本地最新交易日续传。 - `daily` 同时写入日线与复权因子;`--resume` 从本地最新交易日续传。`financial`
默认增量:本地已含「最新应披露报告期」(按 A 股披露节奏推算)的股票直接跳过;
逐只落库,中断/限速后重跑同一命令即可续传补齐(只丢当前一只)。
- 新浪兜底带「两边一致」真实性校验:Tushare 报错时,只有当该股票本地历史与新浪
返回数据**重叠部分一致**,才把新浪**新数据**(本地缺失键)导入,并标记
`source=sina`(日线另标记 `adjust=qfq` 前复权;新浪不提供复权因子)。
校验口径:财务为重叠报告期的 eps / 销售毛利率逐期一致(ROE 两端口径不同,
不作依据);日线为最近重叠交易日的前复权价一致(老交易日在除权后不可比)。
本地无历史可对照或校验不一致 → 拒绝导入并告警,留待 Tushare 恢复后重跑补齐。
- 限速时可加 `--sleep 秒数` 加大请求间隔;`financial --full` 重拉全部历史并覆盖既有行。
- 新浪(`verify`)仅交叉验证,返回**前复权**口径,不会并入不复权主库。 - 新浪(`verify`)仅交叉验证,返回**前复权**口径,不会并入不复权主库。
- 财务指标带 `announce_date`(公告日),研究侧只允许使用已公告数据(防未来函数)。 - 财务指标带 `announce_date`(公告日)与 `source`(tushare/sina);研究侧只允许
使用已公告数据(防未来函数),对同一报告期优先消费 `source=tushare` 的行。
- `export`:`data/parquet/stock_daily/<year>.parquet`(列:symbol/trade_date/ohlc/volume/amount), - `export`:`data/parquet/stock_daily/<year>.parquet`(列:symbol/trade_date/ohlc/volume/amount),
由 pyarrow 写出,可直接用 pandas 读取分析,或作为 Qlib 等引擎的后续数据源。 由 pyarrow 写出,可直接用 pandas 读取分析,或作为 Qlib 等引擎的后续数据源。
+149 -130
View File
@@ -5,6 +5,19 @@ import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, waitJob } from "@/lib/jobs";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LineChart } from "@/components/LineChart"; import { LineChart } from "@/components/LineChart";
export default function BacktestPage() { export default function BacktestPage() {
@@ -21,15 +34,26 @@ export default function BacktestPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors") apiGet<FactorMeta[]>("/factors")
.then(setFactors) .then((list) => {
.catch((e: Error) => setError(e.message)); if (!alive) return;
setFactors(list);
if (list.length > 0 && !list.some((f) => f.name === factor)) {
setFactor(list[0].name);
}
})
.catch((e: Error) => alive && setError(e.message));
const { start: s, end: e } = recentRange(); const { start: s, end: e } = recentRange();
setStart(s); setStart(s);
setEnd(e); setEnd(e);
return () => {
alive = false;
};
}, []); }, []);
async function run() { async function run() {
if (!factor) return;
setRunning(true); setRunning(true);
setError(""); setError("");
setJobId(""); setJobId("");
@@ -56,170 +80,165 @@ export default function BacktestPage() {
setError((e as Error).message); setError((e as Error).message);
} finally { } finally {
setRunning(false); setRunning(false);
setJobId("");
} }
} }
const s = result?.summary;
return ( return (
<> <>
<h1>选股回测</h1> <PageHeader
<div className="card"> title="选股回测"
<h2>研究参数(Research Specification)</h2> sub="以单个因子的横截面排序构建 Top N 组合并完整回测:净值 / 回撤 / 月度收益 / 持仓明细。"
actions={
result ? (
<Pill tone="pos" icon="check">
最近一次回测已完成
</Pill>
) : null
}
/>
<Card title="研究参数(Research Specification)" icon="gauge">
<div className="form-grid"> <div className="form-grid">
<label className="muted"> <Field label="因子">
因子 <select
<select value={factor} onChange={(e) => setFactor(e.target.value)}> className="input"
value={factor}
onChange={(e) => setFactor(e.target.value)}
>
{factors.map((f) => ( {factors.map((f) => (
<option key={f.name} value={f.name}> <option key={f.name} value={f.name}>
{f.name} {f.name}
</option> </option>
))} ))}
</select> </select>
</label> </Field>
<label className="muted"> <Field label="Top N">
Top N
<input <input
type="number" type="number"
className="input"
min={1} min={1}
max={50} max={50}
value={topN} value={topN}
onChange={(e) => setTopN(Number(e.target.value))} onChange={(e) => setTopN(Number(e.target.value))}
/> />
</label> </Field>
<label className="muted"> <Field label="调仓频率">
调仓
<select <select
className="input"
value={rebalance} value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")} onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
> >
<option value="monthly">月度</option> <option value="monthly">月度</option>
<option value="weekly">周度</option> <option value="weekly">周度</option>
</select> </select>
</label> </Field>
<label className="muted"> <Field label="开始日期">
开始 <input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} /> </Field>
</label> <Field label="结束日期">
<label className="muted"> <input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
结束 </Field>
<input type="date" value={end} onChange={(e) => setEnd(e.target.value)} /> <Field label="标的范围">
</label> <label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<label className="muted"> <input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
<input
type="checkbox"
checked={excludeSt}
onChange={(e) => setExcludeSt(e.target.checked)}
/>{" "}
剔除 ST 剔除 ST
</label> </label>
<button className="primary" onClick={run} disabled={running}> </Field>
<Btn variant="primary" icon="play" loading={running} disabled={running} onClick={run}>
{running ? "后台运行中…" : "运行回测"} {running ? "后台运行中…" : "运行回测"}
</button> </Btn>
</div>
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
{error && <div className="error">{error}</div>}
</div> </div>
{result && s && ( {running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={35}
label={
<> <>
<div className="card"> 任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,请稍候…
<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>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
<div className="card"> {!result && !running && !error ? (
<h2>月度收益(%)</h2> <Card>
<table> <Empty
<thead> icon="gauge"
<tr> title="尚未运行回测"
<th>月份</th> hint="设置上方参数后运行;完成展示净值曲线、回撤、月度收益与最新持仓。"
<th>收益</th> />
</tr> </Card>
</thead> ) : null}
<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"> {result ? <ResultView result={result} /> : null}
<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>
</>
)}
</> </>
); );
} }
function ResultView({ result }: { result: BacktestResult }) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<Pill tone="pos" icon="check">完成</Pill>
<Pill tone="violet" icon="target">Top N 等权</Pill>
<Pill>{s.start} ~ {s.end}</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<Card icon="target" title={`最新持仓 · ${last?.date ?? "—"}`}>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="chips">
{holdings.map((p) => (
<span className="chip" key={p.symbol}>
<b className="mono">{p.symbol}</b>
{(p.weight * 100).toFixed(0)}%
</span>
))}
</div>
)}
</Card>
<UnimplementedNote items={result.unimplemented} />
</>
);
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
+172 -63
View File
@@ -1,8 +1,19 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api"; import { apiGet, apiPost } from "@/lib/api";
import type { BacktestResult } from "@/lib/types"; import type { BacktestResult } from "@/lib/types";
import {
PageHeader,
Card,
Pill,
Btn,
Banner,
Empty,
Metric,
SkeletonLines,
} from "@/components/ui";
import { Icon } from "@/components/icons";
interface ExperimentMeta { interface ExperimentMeta {
id: string; id: string;
@@ -21,25 +32,76 @@ interface ExperimentDetail extends ExperimentMeta {
result: BacktestResult | null; result: BacktestResult | null;
} }
const KIND_MAP: Record<string, { label: string; tone: "accent" | "violet" | "default" }> = {
backtest: { label: "回测", tone: "accent" },
factor_test: { label: "因子测试", tone: "violet" },
};
function kindView(kind: string) {
const k = KIND_MAP[kind] ?? { label: kind, tone: "default" as const };
return <Pill tone={k.tone}>{k.label}</Pill>;
}
function statusView(status: string) {
switch (status) {
case "success":
return (
<Pill tone="pos" icon="check">
完成
</Pill>
);
case "running":
return (
<Pill tone="accent" icon="spinner">
运行中
</Pill>
);
case "queued":
return (
<Pill tone="warn" icon="clock">
排队
</Pill>
);
case "failed":
return (
<Pill tone="neg" icon="alert">
失败
</Pill>
);
case "cancelled":
return <Pill tone="warn">已取消</Pill>;
default:
return <Pill>{status}</Pill>;
}
}
export default function ExperimentsPage() { export default function ExperimentsPage() {
const [exps, setExps] = useState<ExperimentMeta[]>([]); const [exps, setExps] = useState<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<ExperimentDetail | null>(null); const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [jobId, setJobId] = useState(""); const [jobId, setJobId] = useState("");
const [jobStatus, setJobStatus] = useState(""); const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
function load() { const load = useCallback(() => {
apiGet<ExperimentMeta[]>("/experiments") apiGet<ExperimentMeta[]>("/experiments")
.then(setExps) .then(setExps)
.catch((e: Error) => setError(e.message)); .catch((e: Error) => setError(e.message))
} .finally(() => setLoading(false));
}, []);
useEffect(load, []); useEffect(load, [load]);
function open(id: string) { function open(id: string) {
setDetailLoading(true);
setDetail(null);
apiGet<ExperimentDetail>(`/experiments/${id}`) apiGet<ExperimentDetail>(`/experiments/${id}`)
.then(setDetail) .then((d) => {
.catch((e: Error) => setError(e.message)); setDetail(d);
})
.catch((e: Error) => setError(e.message))
.finally(() => setDetailLoading(false));
} }
function rerun(exp: ExperimentMeta) { function rerun(exp: ExperimentMeta) {
@@ -65,25 +127,51 @@ export default function ExperimentsPage() {
if (j.status === "success" || j.status === "failed" || tries > 60) { if (j.status === "success" || j.status === "failed" || tries > 60) {
window.clearInterval(timer); window.clearInterval(timer);
load(); load();
setDetail(null);
} }
}) })
.catch(() => window.clearInterval(timer)); .catch(() => window.clearInterval(timer));
}, 1000); }, 1200);
} }
const s = detail?.result?.summary; const s = detail?.result?.summary;
return ( return (
<> <>
<h1>实验归档(Experiment · 可复现)</h1> <PageHeader
{error && <div className="error">{error}</div>} title="实验归档"
{jobId && ( sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;支持一键复跑。"
<div className="card"> actions={exps.length > 0 ? <Pill tone="accent" icon="archive">共 {exps.length} 个实验</Pill> : undefined}
复跑 Job <b>{jobId}</b> → 状态:<b>{jobStatus}</b> />
{error ? <Banner tone="error">{error}</Banner> : null}
{jobId ? (
<Card
icon="refresh"
title={`复跑任务 ${jobId}`}
tools={statusView(jobStatus === "submitting" ? "queued" : jobStatus)}
>
<div className="hint">
任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
</div> </div>
)} </Card>
<div className="card"> ) : null}
<table>
<Card title="实验列表" icon="archive" flush>
{loading ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
</div>
) : exps.length === 0 ? (
<Empty
icon="archive"
title="暂无实验"
hint="在「回测」或「因子研究」中运行一次,即会自动归档于此。"
/>
) : (
<div className="table-wrap">
<table className="tbl">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -92,67 +180,88 @@ export default function ExperimentsPage() {
<th>区间</th> <th>区间</th>
<th>摘要</th> <th>摘要</th>
<th>代码版本</th> <th>代码版本</th>
<th></th> <th style={{ width: 150, textAlign: "right" }}>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{exps.map((e) => ( {exps.map((e) => (
<tr key={e.id}> <tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
<td>{e.id}</td> <td className="cell-mono cell-strong">{e.id}</td>
<td>{e.kind}</td> <td>{kindView(e.kind)}</td>
<td>{e.factors.join(", ")}</td>
<td> <td>
{e.period ? `${e.period[0]} ~ ${e.period[1]}` : "—"} <span className="row" style={{ gap: 4 }}>
{e.factors.map((f) => (
<span key={f} className="tag tag--mono">
{f}
</span>
))}
</span>
</td> </td>
<td>{e.summary_text ?? "—"}</td> <td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td>{e.code_version ?? "—"}</td> <td className="hint">{e.summary_text ?? "—"}</td>
<td> <td>{e.code_version ? <span className="tag tag--mono">{e.code_version}</span> : <span className="hint">—</span>}</td>
<button onClick={() => open(e.id)}>详情</button> <td style={{ textAlign: "right" }}>
<button className="primary" onClick={() => rerun(e)}> <span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
详情
</Btn>
<Btn size="sm" variant="primary" icon="refresh" onClick={() => rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}>
复跑 复跑
</button> </Btn>
</span>
</td> </td>
</tr> </tr>
))} ))}
{exps.length === 0 && (
<tr>
<td colSpan={7} className="muted">
暂无实验。先在「回测」或「因子研究」中运行一次,即会自动归档。
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
{detail && s && (
<div className="card">
<h2>
{detail.id} · {detail.factors.join(", ")} · {detail.period?.[0]}~{detail.period?.[1]}
</h2>
<div className="stat-grid">
<div className="stat">
<div className="label">总收益</div>
<div className="value">{s.total_return_pct.toFixed(2)}%</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>
<div className="muted" style={{ marginTop: 8 }}>
spec(可复现输入):{JSON.stringify(detail.spec).slice(0, 400)}…
</div>
</div>
)} )}
</Card>
{detail ? (
<Card
icon="book"
title={`${detail.id} · ${detail.factors.join("、")}`}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
tools={
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
}
>
{detailLoading ? (
<SkeletonLines n={3} />
) : (
<>
{s ? (
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="区间收益"
tone={s.total_return_pct >= 0 ? "pos" : "neg"}
value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`}
/>
<Metric
label="年化收益"
tone={s.annual_return_pct >= 0 ? "pos" : "neg"}
value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`}
/>
<Metric label="Sharpe" value={s.sharpe.toFixed(2)} />
<Metric label="最大回撤" tone="neg" value={`${s.max_drawdown_pct.toFixed(2)}%`} />
</div>
) : (
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
)}
{detail.spec ? (
<div className="hint" style={{ marginTop: 12 }}>
spec(可复现输入):<span className="mono">{JSON.stringify(detail.spec).slice(0, 500)}</span>
{JSON.stringify(detail.spec).length > 500 ? "…" : ""}
</div>
) : null}
</>
)}
</Card>
) : null}
</> </>
); );
} }
+189 -120
View File
@@ -2,10 +2,24 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import { LineChart } from "@/components/LineChart";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, waitJob } from "@/lib/jobs";
import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types"; import type { BacktestResult, FactorMeta, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Progress,
Signed,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LineChart } from "@/components/LineChart";
import { Icon } from "@/components/icons";
interface Pick { interface Pick {
name: string; name: string;
@@ -26,19 +40,23 @@ export default function ComposePage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors") apiGet<FactorMeta[]>("/factors")
.then((list) => { .then((list) => {
if (!alive) return;
setFactors(list); setFactors(list);
// 默认示例组合:中期动量(重)+ 低波动(防)
setPicks([ setPicks([
{ name: "momentum_60", weight: 1.5 }, { name: "momentum_60", weight: 1.5 },
{ name: "volatility_60", weight: 1.0 }, { name: "volatility_60", weight: 1.0 },
]); ]);
}) })
.catch((e: Error) => setError(e.message)); .catch((e: Error) => alive && setError(e.message));
const { start: s, end: e } = recentRange(); const { start: s, end: e } = recentRange();
setStart(s); setStart(s);
setEnd(e); setEnd(e);
return () => {
alive = false;
};
}, []); }, []);
function togglePick(name: string) { function togglePick(name: string) {
@@ -53,6 +71,10 @@ export default function ComposePage() {
setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p))); setPicks((prev) => prev.map((p) => (p.name === name ? { ...p, weight } : p)));
} }
function removePick(name: string) {
setPicks((prev) => prev.filter((p) => p.name !== name));
}
async function run() { async function run() {
const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0); const valid = picks.filter((p) => Number.isFinite(p.weight) && p.weight > 0);
if (valid.length === 0) { if (valid.length === 0) {
@@ -90,29 +112,43 @@ export default function ComposePage() {
return ( return (
<> <>
<h1>因子组合</h1> <PageHeader
<div className="card"> title="因子组合"
<h2>组合方式(如何合成一个分数)</h2> sub="多因子横截面 z-score 标准化后按权重叠加成综合分,每期取 Top N 等权持有。"
<ol className="muted" style={{ margin: 0, paddingLeft: 18, lineHeight: 1.8 }}> actions={
<Pill tone="accent" icon="layers">
{picks.length} 个因子
</Pill>
}
/>
<Card title="组合方式(如何合成一个分数)" icon="scale">
<ol className="steplist">
<li>选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。</li> <li>选择若干因子并设置权重(默认 1;低为好方向由引擎自动反向)。</li>
<li> <li>
每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b> 每个交易日:每个因子在全体股票横截面上做 <b>z-score 标准化</b>(当日缺失因子值的股票不参与该因子得分)。
(该日缺失因子值的股票不参与该因子得分)。 </li>
<li>
综合得分 = <b>Σ(权重 × 标准化后因子得分)</b>,得分越高越优先。
</li> </li>
<li>综合得分 = Σ(权重 × 标准化后因子得分),得分越高越优先。</li>
<li>每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。</li> <li>每期按综合得分取 Top N 等权持有,到下一调仓日再平衡(含成本 / 涨跌停 / 停牌近似)。</li>
</ol> </ol>
</div> </Card>
<div className="card"> <Card
<h2>选因子 + 设权重</h2> title="选择因子并设置权重"
<table> icon="target"
sub="权重可为小数(如 1.5 = 更侧重该因子)"
flush
>
<div className="table-wrap">
<table className="tbl">
<thead> <thead>
<tr> <tr>
<th></th> <th style={{ width: 46 }}></th>
<th>因子</th> <th>因子</th>
<th>方向</th> <th>方向</th>
<th>权重</th> <th style={{ width: 120 }}>权重</th>
<th>简介</th> <th>简介</th>
</tr> </tr>
</thead> </thead>
@@ -120,141 +156,174 @@ export default function ComposePage() {
{factors.map((f) => { {factors.map((f) => {
const pick = picks.find((p) => p.name === f.name); const pick = picks.find((p) => p.name === f.name);
return ( return (
<tr key={f.name}> <tr key={f.name} className={pick ? "row-active" : undefined}>
<td> <td>
<input <input
type="checkbox" type="checkbox"
checked={!!pick} checked={!!pick}
onChange={() => togglePick(f.name)} onChange={() => togglePick(f.name)}
aria-label={`选择因子 ${f.name}`}
/> />
</td> </td>
<td>{f.name}</td> <td className="cell-strong">
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td> <span className="mono">{f.name}</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
</td>
<td> <td>
<input <input
type="number" type="number"
className="input"
step="0.1" step="0.1"
min="0.1" min="0.1"
style={{ width: 80 }} style={{ width: 92 }}
value={pick?.weight ?? 1} value={pick?.weight ?? 1}
disabled={!pick} disabled={!pick}
onChange={(e) => setWeight(f.name, Number(e.target.value))} onChange={(e) => setWeight(f.name, Number(e.target.value))}
aria-label={`${f.name} 权重`}
/> />
</td> </td>
<td className="muted">{f.brief ?? f.description}</td> <td className="hint">{f.brief ?? f.description}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
</Card>
<div className="card"> <Card title="回测条件" icon="gauge">
<h2>回测条件</h2> {picks.length > 0 ? (
<div className="row"> <div className="chips" style={{ marginBottom: 14 }}>
<label className="muted"> {picks.map((p) => (
Top N{" "} <span className="chip" key={p.name}>
<input type="number" min={1} max={200} value={topN} onChange={(e) => setTopN(Number(e.target.value))} /> <b className="mono">{p.name}</b>
</label> 权重 {p.weight}
<label className="muted"> <button
调仓{" "} type="button"
<select value={rebalance} onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}> className="chip__x"
<option value="monthly">月度</option> aria-label={`移除 ${p.name}`}
<option value="weekly">周度</option> onClick={() => removePick(p.name)}
</select> >
</label> <Icon name="x" size={12} />
<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 ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</button> </button>
</div>
{running && jobId && <div className="muted">任务 {jobId} 后台执行中,请稍候…</div>}
{error && <div className="error">{error}</div>}
</div>
{result && <ResultView result={result} />}
</>
);
}
function ResultView({ result }: { result: BacktestResult }) {
const s = result.summary;
return (
<>
<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> </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: 10 }}>
<LineChart title="净值曲线" data={result.equity_curve} color="#34d399" />
</div>
<LineChart title="回撤(%)" data={result.drawdown} color="#f87171" />
</div>
{result.monthly_returns.length > 0 && (
<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> </div>
</table> ) : (
<div className="hint" style={{ marginBottom: 14 }}>
尚未选择因子 —— 在上方勾选并设置权重。
</div> </div>
)} )}
<div className="card"> <div className="form-grid">
<h2>未建模约束(如实标注)</h2> <Field label="Top N">
<ul className="muted">{result.unimplemented.map((u, i) => <li key={i}>{u}</li>)}</ul> <input
type="number"
className="input"
min={1}
max={200}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</Field>
<Field label="调仓频率">
<select
className="input"
value={rebalance}
onChange={(e) => setRebalance(e.target.value as "monthly" | "weekly")}
>
<option value="monthly">月度</option>
<option value="weekly">周度</option>
</select>
</Field>
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
<Field label="标的范围">
<label className="row" style={{ gap: 6, color: "var(--text-2)", fontSize: 13, cursor: "pointer" }}>
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || picks.length === 0}
onClick={run}
>
{running ? "后台运行中…" : `回测组合(${picks.length} 个因子)`}
</Btn>
</div> </div>
{running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={30}
label={
<>
任务 <span className="mono">{jobId || "排队中…"}</span> 后台执行中,完成后结果自动展示在本页。
</>
}
/>
</div>
) : null}
{error ? <div style={{ marginTop: 12 }}><Banner tone="error">{error}</Banner></div> : null}
</Card>
{result ? <ResultView result={result} params={{ picks, topN, rebalance, excludeSt, start, end }} /> : null}
</>
);
}
function ResultView({
result,
params,
}: {
result: BacktestResult;
params: { picks: Pick[]; topN: number; rebalance: string; excludeSt: boolean; start: string; end: string };
}) {
const s = result.summary;
return (
<>
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row">
<Pill tone="pos" icon="check">
回测完成
</Pill>
<Pill tone="violet" icon="target">Top {params.topN}</Pill>
<Pill>{params.rebalance === "monthly" ? "月度调仓" : "周度调仓"}</Pill>
{params.excludeSt ? <Pill>剔除 ST</Pill> : null}
<Pill>{params.start} ~ {params.end}</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
区间收益 <Signed value={s.total_return_pct} suffix="%" />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid">
<Card icon="chartLine" title="净值曲线" tools={<Pill tone="pos">期末 {s.final_equity.toLocaleString()}</Pill>}>
<LineChart data={result.equity_curve} color="#3ddc97" fill height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2)} />
</Card>
<Card icon="chartLine" title="回撤(%)" tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}>
<LineChart data={result.drawdown} color="#ff7a7a" height={300} xFmt={(d) => d.slice(5)} yFmt={(v) => v.toFixed(2) + "%"} />
</Card>
</div>
<Card icon="calendar" title="月度收益(%)">
<MonthlyReturnsTable rows={result.monthly_returns} />
</Card>
<UnimplementedNote items={result.unimplemented} />
</> </>
); );
} }
+205 -112
View File
@@ -5,32 +5,52 @@ import { apiGet } from "@/lib/api";
import { submitJob, waitJob } from "@/lib/jobs"; import { submitJob, waitJob } from "@/lib/jobs";
import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types"; import type { FactorMeta, FactorTestReport, ResearchSpec } from "@/lib/types";
import { recentRange } from "@/lib/dates"; import { recentRange } from "@/lib/dates";
import {
PageHeader,
Card,
Metric,
Pill,
Field,
Btn,
Banner,
Progress,
Empty,
SkeletonLines,
} from "@/components/ui";
export default function FactorsPage() { export default function FactorsPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]); const [factors, setFactors] = useState<FactorMeta[]>([]);
const [loading, setLoading] = useState(true);
const [checked, setChecked] = useState<Set<string>>(new Set()); const [checked, setChecked] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set()); const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [start, setStart] = useState(""); const [start, setStart] = useState("");
const [end, setEnd] = useState(""); const [end, setEnd] = useState("");
const [reports, setReports] = useState<Record<string, FactorTestReport>>({}); const [reports, setReports] = useState<Record<string, FactorTestReport>>({});
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [processed, setProcessed] = useState(0);
const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null); const [current, setCurrent] = useState<{ idx: number; total: number; name: string } | null>(null);
const [jobId, setJobId] = useState(""); const [jobId, setJobId] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors") apiGet<FactorMeta[]>("/factors")
.then((list) => { .then((list) => {
if (!alive) return;
setFactors(list); setFactors(list);
// 默认勾选两个代表性因子 const picks = ["momentum_60", "volatility_60"].filter((n) =>
const picks = ["momentum_60", "volatility_60"].filter((n) => list.some((f) => f.name === n)); list.some((f) => f.name === n),
);
setChecked(new Set(picks)); setChecked(new Set(picks));
}) })
.catch((e: Error) => setError(e.message)); .catch((e: Error) => setError(e.message))
// 区间默认:近 6 个月(客户端设置,避免 SSR 日期不一致) .finally(() => alive && setLoading(false));
const { start: s, end: e } = recentRange(); const { start: s, end: e } = recentRange();
setStart(s); setStart(s);
setEnd(e); setEnd(e);
return () => {
alive = false;
};
}, []); }, []);
function toggleCheck(name: string) { function toggleCheck(name: string) {
@@ -61,6 +81,7 @@ export default function FactorsPage() {
setError(""); setError("");
setJobId(""); setJobId("");
setReports({}); setReports({});
setProcessed(0);
try { try {
let index = 0; let index = 0;
for (const name of names) { for (const name of names) {
@@ -78,12 +99,12 @@ export default function FactorsPage() {
setJobId(job_id); setJobId(job_id);
const out = await waitJob<FactorTestReport>(job_id); const out = await waitJob<FactorTestReport>(job_id);
if (out.status === "success" && out.result) { if (out.status === "success" && out.result) {
const report = out.result; setReports((prev) => ({ ...prev, [name]: out.result! }));
setReports((prev) => ({ ...prev, [name]: report }));
} else { } else {
const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`; const msg = `因子 ${name}:${out.status}${out.error ? `:${out.error}` : ""}`;
setError((prev) => (prev ? `${prev}\n${msg}` : msg)); setError((prev) => (prev ? `${prev}\n${msg}` : msg));
} }
setProcessed(index);
} }
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -95,23 +116,47 @@ export default function FactorsPage() {
} }
const selectedCount = checked.size; const selectedCount = checked.size;
const doneCount = Object.keys(reports).length; const reportNames = Object.keys(reports);
const progressPct = selectedCount === 0 ? 0 : Math.round((processed / selectedCount) * 100);
return ( return (
<> <>
<h1>因子研究</h1> <PageHeader
<div className="card"> title="因子研究"
<h2>因子目录(行情因子 · 无未来函数)</h2> sub="勾选行情因子并运行单因子测试(IC / RankIC / 分层)。每个因子独立异步执行并归档为实验,可在「实验」页查看与复跑。"
<div className="muted" style={{ marginBottom: 8 }}> actions={<Pill tone="accent" icon="flask">{reportNames.length}/{selectedCount} 个已出报告</Pill>}
勾选因子后可多选;点击行展开详细说明。每个因子运行一次独立的 IC/RankIC 测试并归档。 />
<a href="/factors/compose" style={{ marginLeft: 8, color: "#38bdf8" }}>
用这些因子搭建选股组合 → 因子组合 {error && !running ? (
</a> <Banner tone="error">
<pre style={{ margin: 0, whiteSpace: "pre-line", font: "inherit" }}>{error}</pre>
</Banner>
) : null}
<Card
title="因子目录"
icon="flask"
sub="点击行展开定义 · 勾选后点下方「运行因子测试」"
tools={<Pill tone="violet">已选 {selectedCount} 个</Pill>}
flush
>
{loading ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={7} />
</div> </div>
<table> ) : factors.length === 0 ? (
<Empty
icon="flask"
title="暂无因子目录"
hint="请确认后端已启动,且 /api/factors 返回因子元数据。"
/>
) : (
<>
<div className="table-wrap">
<table className="tbl">
<thead> <thead>
<tr> <tr>
<th></th> <th style={{ width: 44 }}></th>
<th>因子</th> <th>因子</th>
<th>回看</th> <th>回看</th>
<th>方向</th> <th>方向</th>
@@ -131,51 +176,78 @@ export default function FactorsPage() {
))} ))}
</tbody> </tbody>
</table> </table>
<div className="muted" style={{ marginTop: 6 }}>
计分规则:每因子在每日横截面上 z-score 标准化(低为好自动取负),组合页可按权重叠加得分选股;
本页单个因子测试各自归档为 Experiment,可去「实验」页查看与复跑。
</div> </div>
<div className="hint" style={{ padding: "10px 16px", borderTop: "1px solid var(--line)" }}>
计分规则:每个因子在每日横截面做 z-score 标准化(低为好自动取负);组合页按权重叠加得分选股。
</div> </div>
</>
<div className="card">
<h2>运行单因子测试(IC / RankIC / 分层)</h2>
<div className="row">
<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 || selectedCount === 0}
>
{running ? `运行中 ${doneCount}/${selectedCount}…` : `运行因子测试(已选 ${selectedCount} 个)`}
</button>
</div>
{running && (
<div className="muted">
正在运行:{current?.name}({current?.idx}/{current?.total})—— 后台任务 {jobId},请稍候…
</div>
)} )}
{error && <div className="error" style={{ whiteSpace: "pre-line" }}>{error}</div>} </Card>
<Card title="运行单因子测试" icon="play" tools={running ? <Pill tone="accent" icon="spinner">执行中</Pill> : undefined}>
<div className="form-grid">
<Field label="开始日期">
<input type="date" className="input" value={start} onChange={(e) => setStart(e.target.value)} />
</Field>
<Field label="结束日期">
<input type="date" className="input" value={end} onChange={(e) => setEnd(e.target.value)} />
</Field>
<Btn
variant="primary"
icon="play"
loading={running}
disabled={running || selectedCount === 0}
onClick={run}
>
{running
? `运行中 ${processed}/${selectedCount}`
: selectedCount === 0
? "请先勾选因子"
: `运行因子测试(${selectedCount} 个)`}
</Btn>
</div> </div>
{Object.entries(reports).map(([name, report]) => { {running ? (
<div style={{ marginTop: 14 }}>
<Progress
value={progressPct}
label={
<>
正在运行:<b>{current?.name}</b>({current?.idx}/{current?.total})· 后台任务{" "}
<span className="mono">{jobId || "排队中…"}</span>
</>
}
/>
</div>
) : null}
</Card>
{reportNames.length === 0 && !running && !error ? (
<Card>
<Empty
icon="chartLine"
title="还没有测试报告"
hint="勾选一个或多个因子并设置研究区间后运行;完成后报告会按因子逐一出现在这里。"
/>
</Card>
) : null}
{reportNames.map((name) => {
const meta = factors.find((f) => f.name === name); const meta = factors.find((f) => f.name === name);
return ( return (
<div className="card" key={name}> <Card
<h2> key={name}
{name} · 测试报告 icon="chartLine"
{meta?.brief ? ( title={name}
<span className="muted" style={{ fontWeight: 400, fontSize: 13 }}> sub={meta?.brief}
{" "}— {meta.brief} tools={
</span> <Pill tone="pos" icon="check">
) : null} 已完成
</h2> </Pill>
<ReportView report={report} /> }
</div> >
<ReportView report={reports[name]} />
</Card>
); );
})} })}
</> </>
@@ -193,81 +265,102 @@ function FactorRow(props: {
return ( return (
<> <>
<tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}> <tr style={{ cursor: "pointer" }} onClick={props.onToggleExpand}>
<td onClick={(e) => { e.stopPropagation(); props.onToggleCheck(); }}> <td onClick={(e) => e.stopPropagation()}>
<input type="checkbox" checked={props.checked} onChange={props.onToggleCheck} /> <input
type="checkbox"
checked={props.checked}
onChange={props.onToggleCheck}
aria-label={`选择因子 ${f.name}`}
/>
</td> </td>
<td>{f.name}</td> <td className="cell-strong">
<td>{f.lookback}</td> <span className="mono">{f.name}</span>
<td>{f.direction === "higher_is_better" ? "高为好" : "低为好"}</td> </td>
<td>{f.brief ?? f.description}</td> <td>
<span className="tag tag--mono">{f.lookback} 日</span>
</td>
<td>
<Pill tone={f.direction === "higher_is_better" ? "accent" : "violet"}>
{f.direction === "higher_is_better" ? "高为好" : "低为好"}
</Pill>
</td>
<td className="hint">{f.brief ?? f.description}</td>
</tr> </tr>
{props.expanded && ( {props.expanded ? (
<tr> <tr className="expand-cell">
<td colSpan={5}> <td colSpan={5}>
<div style={{ background: "var(--panel-2)", padding: 10, borderRadius: 6 }}> <div>
<b>{f.name}</b> · {f.description} <b className="mono">{f.name}</b> · {f.description}
<div className="muted" style={{ marginTop: 4 }}> <div className="hint" style={{ marginTop: 6 }}>
公式:{f.formula};回看 {f.lookback} 个交易日;频率 {f.frequency}; 公式:<span className="mono">{f.formula}</span>;回看 {f.lookback} 个交易日;频率 {f.frequency};
方向:{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"} 方向:
{f.direction === "higher_is_better" ? "因子值越高得分越高" : "因子值越低得分越高(引擎自动反向)"}
</div> </div>
<div style={{ marginTop: 6 }}>{f.brief}</div> {f.brief ? <div className="hint" style={{ marginTop: 6 }}>{f.brief}</div> : null}
</div> </div>
</td> </td>
</tr> </tr>
)} ) : null}
</> </>
); );
} }
function ReportView({ report }: { report: FactorTestReport }) { function ReportView({ report }: { report: FactorTestReport }) {
const qs = report.quantile_returns ?? [];
const maxAbs = Math.max(0.001, ...qs.map((q) => Math.abs(q.return_pct)));
return ( return (
<> <div className="stack" style={{ gap: 16 }}>
<div className="stat-grid"> <div className="metric-grid" style={{ marginBottom: 0 }}>
<div className="stat"> <Metric
<div className="label">IC 均值</div> label="IC 均值"
<div className="value">{report.ic_mean.toFixed(4)}</div> icon="chartLine"
value={report.ic_mean.toFixed(4)}
tone={report.ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="RankIC 均值"
value={report.rank_ic_mean.toFixed(4)}
tone={report.rank_ic_mean >= 0 ? "pos" : "neg"}
/>
<Metric
label="ICIR"
value={report.icir.toFixed(3)}
tone={report.icir >= 1 ? "pos" : "plain"}
sub="越大越稳定"
/>
<Metric
label="正收益占比"
value={`${report.positive_ratio_pct.toFixed(1)}%`}
tone={report.positive_ratio_pct >= 50 ? "pos" : "warn"}
/>
<Metric label="样本日数" value={report.sample_days} icon="calendar" />
</div> </div>
<div className="stat">
<div className="label">RankIC 均值</div> {qs.length > 0 ? (
<div className="value">{report.rank_ic_mean.toFixed(4)}</div> <div>
<div className="field__label" style={{ marginBottom: 2 }}>
分层表现(Q1 最低因子值 → Q5 最高,未来 21 日平均收益)
</div> </div>
<div className="stat"> <div className="vbars" role="img" aria-label="分层收益柱状图">
<div className="label">ICIR</div> {qs.map((q) => {
<div className="value">{report.icir.toFixed(3)}</div> const v = q.return_pct;
const h = Math.max(4, Math.round((Math.abs(v) / maxAbs) * 72));
return (
<div className="vbars__item" key={q.quantile}>
<span className="vbars__val">{v >= 0 ? "+" : ""}{v.toFixed(2)}%</span>
<div className={`vbars__bar ${v >= 0 ? "is-pos" : "is-neg"}`} style={{ height: h }} />
<span className="vbars__cap">Q{q.quantile + 1}</span>
</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>
</div> </div>
{report.quantile_returns.length > 0 && ( ) : null}
<table style={{ marginTop: 12 }}>
<thead> <div className="hint">
<tr> 读数:IC / RankIC 为正表示与未来收益正相关,ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
<th>分层(1 最低 → 5 最高)</th> 单因子测试 ≠ 策略有效,需结合样本外与稳健性分析。
{report.quantile_returns.map((q) => ( </div>
<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 className="muted" style={{ marginTop: 6 }}>
读数:IC/RankIC 为正表示与未来收益正相关;ICIR 越大越稳定;分层收益若高分层显著高于低分层说明单调性好。
单因子测试≠策略有效,需结合样本外与稳健性分析。
</div> </div>
</>
); );
} }
+909
View File
@@ -0,0 +1,909 @@
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-001.woff2') format('woff2');
unicode-range: U+1f1e9-1f1f5, U+1f1f7-1f1ff, U+1f21a, U+1f232, U+1f234-1f237, U+1f250-1f251, U+1f300, U+1f302-1f308, U+1f30a-1f311, U+1f315, U+1f319-1f320, U+1f324, U+1f327, U+1f32a, U+1f32c-1f32d, U+1f330-1f357, U+1f359-1f37e;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-002.woff2') format('woff2');
unicode-range: U+fee3, U+fef3, U+ff03-ff04, U+ff07, U+ff0a, U+ff17-ff19, U+ff1c-ff1d, U+ff20-ff3a, U+ff3c, U+ff3e-ff5b, U+ff5d, U+ff61-ff65, U+ff67-ff6a, U+ff6c, U+ff6f-ff78, U+ff7a-ff7d, U+ff80-ff84, U+ff86, U+ff89-ff8e, U+ff92, U+ff97-ff9b, U+ff9d-ff9f, U+ffe0-ffe4, U+ffe6, U+ffe9, U+ffeb, U+ffed, U+fffc, U+1f004, U+1f170-1f171, U+1f192-1f195, U+1f198-1f19a, U+1f1e6-1f1e8;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-003.woff2') format('woff2');
unicode-range: U+f0a7, U+f0b2, U+f0b7, U+f0c9, U+f0d8, U+f0da, U+f0dc-f0dd, U+f0e0, U+f0e6, U+f0eb, U+f0fc, U+f101, U+f104-f105, U+f107, U+f10b, U+f11b, U+f14b, U+f18a, U+f193, U+f1d6-f1d7, U+f244, U+f27a, U+f296, U+f2ae, U+f471, U+f4b3, U+f610-f611, U+f880-f881, U+f8ec, U+f8f5, U+f8ff, U+f901, U+f90a, U+f92c-f92d, U+f934, U+f937, U+f941, U+f965, U+f967, U+f969, U+f96b, U+f96f, U+f974, U+f978-f979, U+f97e, U+f981, U+f98a, U+f98e, U+f997, U+f99c, U+f9b2, U+f9b5, U+f9ba, U+f9be, U+f9ca, U+f9d0-f9d1, U+f9dd, U+f9e0-f9e1, U+f9e4, U+f9f7, U+fa00-fa01, U+fa08, U+fa0a, U+fa11, U+fb01-fb02, U+fdfc, U+fe0e, U+fe30-fe31, U+fe33-fe44, U+fe49-fe52, U+fe54-fe57, U+fe59-fe66, U+fe68-fe6b, U+fe8e, U+fe92-fe93, U+feae, U+feb8, U+fecb-fecc, U+fee0;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-004.woff2') format('woff2');
unicode-range: U+9f83, U+9f85-9f8d, U+9f90-9f91, U+9f94-9f96, U+9f98, U+9f9b-9f9c, U+9f9e, U+9fa0, U+9fa2, U+9ff0-9fff, U+a001, U+a007, U+a025, U+a046-a047, U+a057, U+a072, U+a078-a079, U+a083, U+a085, U+a100, U+a118, U+a132, U+a134, U+a1f4, U+a242, U+a4a6, U+a4aa, U+a4b0-a4b1, U+a4b3, U+a9c1-a9c2, U+ac00-ac01, U+ac04, U+ac08, U+ac10-ac11, U+ac13-ac16, U+ac19, U+ac1c-ac1d, U+ac24, U+ac70-ac71, U+ac74, U+ac77-ac78, U+ac80-ac81, U+ac83, U+ac8c, U+ac90, U+ac9f-aca0, U+aca8-aca9, U+acac, U+acb0, U+acbd, U+acc1, U+acc4, U+ace0-ace1, U+ace4, U+ace8, U+acf3, U+acf5, U+acfc-acfd, U+ad00, U+ad0c, U+ad11, U+ad1c, U+ad34, U+ad50, U+ad64, U+ad6c, U+ad70, U+ad74, U+ad7f, U+ad81, U+ad8c, U+adc0, U+adc8, U+addc, U+ade0, U+adf8-adf9, U+adfc, U+ae00, U+ae08-ae09, U+ae0b, U+ae30, U+ae34, U+ae38, U+ae40, U+ae4a, U+ae4c, U+ae54, U+ae68, U+aebc, U+aed8, U+af2c-af2d;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-005.woff2') format('woff2');
unicode-range: U+9e30-9e33, U+9e35-9e3b, U+9e3e, U+9e40-9e44, U+9e46-9e4e, U+9e51, U+9e53, U+9e55-9e58, U+9e5a-9e5c, U+9e5e-9e63, U+9e66-9e6e, U+9e71, U+9e73, U+9e75, U+9e78-9e79, U+9e7c-9e7e, U+9e82, U+9e86-9e88, U+9e8b-9e8c, U+9e90-9e91, U+9e93, U+9e95, U+9e97, U+9e9d, U+9ea4-9ea5, U+9ea9-9eaa, U+9eb4-9eb5, U+9eb8-9eba, U+9ebc-9ebf, U+9ec3, U+9ec9, U+9ecd, U+9ed0, U+9ed2-9ed3, U+9ed5-9ed6, U+9ed9, U+9edc-9edd, U+9edf-9ee0, U+9ee2, U+9ee5, U+9ee7-9eea, U+9eef, U+9ef1, U+9ef3-9ef4, U+9ef6, U+9ef9, U+9efb-9efc, U+9efe, U+9f0b, U+9f0d, U+9f10, U+9f14, U+9f17, U+9f19, U+9f22, U+9f29, U+9f2c, U+9f2f, U+9f31, U+9f37, U+9f39, U+9f3d-9f3e, U+9f41, U+9f4a-9f4b, U+9f51-9f52, U+9f61-9f63, U+9f66-9f67, U+9f80-9f81;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-006.woff2') format('woff2');
unicode-range: U+9c82-9c83, U+9c85-9c8c, U+9c8e-9c92, U+9c94-9c9b, U+9c9e-9ca3, U+9ca5-9ca7, U+9ca9, U+9cab, U+9cad-9cae, U+9cb1-9cb7, U+9cb9-9cbd, U+9cbf-9cc0, U+9cc3, U+9cc5-9cc7, U+9cc9-9cd1, U+9cd3-9cda, U+9cdc-9cdd, U+9cdf, U+9ce1-9ce3, U+9ce5, U+9ce9, U+9cee-9cef, U+9cf3-9cf4, U+9cf6, U+9cfc-9cfd, U+9d02, U+9d08-9d09, U+9d12, U+9d1b, U+9d1e, U+9d26, U+9d28, U+9d37, U+9d3b, U+9d3f, U+9d51, U+9d59, U+9d5c-9d5d, U+9d5f-9d61, U+9d6c, U+9d70, U+9d72, U+9d7a, U+9d7e, U+9d84, U+9d89, U+9d8f, U+9d92, U+9daf, U+9db4, U+9db8, U+9dbc, U+9dc4, U+9dc7, U+9dc9, U+9dd7, U+9ddf, U+9df2, U+9df9-9dfa, U+9e0a, U+9e11, U+9e1a, U+9e1e, U+9e20, U+9e22, U+9e28-9e2c, U+9e2e-9e2f;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-007.woff2') format('woff2');
unicode-range: U+9a80, U+9a83, U+9a85, U+9a88-9a8a, U+9a8d-9a8e, U+9a90, U+9a92-9a93, U+9a95-9a96, U+9a98-9a99, U+9a9b-9aa2, U+9aa5, U+9aa7, U+9aaf-9ab1, U+9ab5-9ab6, U+9ab9-9aba, U+9ac0-9ac4, U+9ac8, U+9acb-9acc, U+9ace-9acf, U+9ad1-9ad2, U+9ad9, U+9adf, U+9ae1, U+9ae3, U+9aea-9aeb, U+9aed-9aef, U+9af4, U+9af9, U+9afb, U+9b03-9b04, U+9b06, U+9b08, U+9b0d, U+9b0f-9b10, U+9b13, U+9b18, U+9b1a, U+9b1f, U+9b22-9b23, U+9b25, U+9b27-9b28, U+9b2a, U+9b2f, U+9b31-9b32, U+9b3b, U+9b43, U+9b46-9b49, U+9b4d-9b4e, U+9b51, U+9b56, U+9b58, U+9b5a, U+9b5c, U+9b5f, U+9b61-9b62, U+9b6f, U+9b77, U+9b80, U+9b88, U+9b8b, U+9b8e, U+9b91, U+9b9f-9ba0, U+9ba8, U+9baa-9bab, U+9bad-9bae, U+9bb0-9bb1, U+9bb8, U+9bc9-9bca, U+9bd3, U+9bd6, U+9bdb, U+9be8, U+9bf0-9bf1, U+9c02, U+9c10, U+9c15, U+9c24, U+9c2d, U+9c32, U+9c39, U+9c3b, U+9c40, U+9c47-9c49, U+9c53, U+9c57, U+9c64, U+9c72, U+9c77-9c78, U+9c7b, U+9c7f-9c80;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-008.woff2') format('woff2');
unicode-range: U+98dd, U+98e1-98e2, U+98e7-98ea, U+98ec, U+98ee-98ef, U+98f2, U+98f4, U+98fc-98fe, U+9903, U+9905, U+9908, U+990a, U+990c-990d, U+9913-9914, U+9918, U+991a-991b, U+991e, U+9921, U+9928, U+992c, U+992e, U+9935, U+9938-9939, U+993d-993e, U+9945, U+994b-994c, U+9951-9952, U+9954-9955, U+9957, U+995e, U+9963, U+9966-9969, U+996b-996c, U+996f, U+9974-9975, U+9977-9979, U+997d-997e, U+9980-9981, U+9983-9984, U+9987, U+998a-998b, U+998d-9991, U+9993-9995, U+9997-9998, U+99a5, U+99ab, U+99ad-99ae, U+99b1, U+99b3-99b4, U+99bc, U+99bf, U+99c1, U+99c3-99c6, U+99cc, U+99d0, U+99d2, U+99d5, U+99db, U+99dd, U+99e1, U+99ed, U+99f1, U+99ff, U+9a01, U+9a03-9a04, U+9a0e-9a0f, U+9a11-9a13, U+9a19, U+9a1b, U+9a28, U+9a2b, U+9a30, U+9a32, U+9a37, U+9a40, U+9a45, U+9a4a, U+9a4d-9a4e, U+9a52, U+9a55, U+9a57, U+9a5a-9a5b, U+9a5f, U+9a62, U+9a65, U+9a69, U+9a6b, U+9a6e, U+9a75, U+9a77-9a7a, U+9a7d;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-009.woff2') format('woff2');
unicode-range: U+975b-975c, U+9763, U+9765-9766, U+976c-976d, U+9773, U+9776, U+977a, U+977c, U+9784-9785, U+978e-978f, U+9791-9792, U+9794-9795, U+9798, U+979a, U+979e, U+97a3, U+97a5-97a6, U+97a8, U+97ab-97ac, U+97ae-97af, U+97b2, U+97b4, U+97c6, U+97cb-97cc, U+97d3, U+97d8, U+97dc, U+97e1, U+97ea-97eb, U+97ee, U+97fb, U+97fe-97ff, U+9801-9803, U+9805-9806, U+9808, U+980c, U+9810-9814, U+9817-9818, U+981e, U+9820-9821, U+9824, U+9828, U+982b-982d, U+9830, U+9834, U+9838-9839, U+983c, U+9846, U+984d-984f, U+9851-9852, U+9854-9855, U+9857-9858, U+985a-985b, U+9862-9863, U+9865, U+9867, U+986b, U+986f-9871, U+9877-9878, U+987c, U+9880, U+9883, U+9885, U+9889, U+988b-988f, U+9893-9895, U+9899-989b, U+989e-989f, U+98a1-98a2, U+98a5-98a7, U+98a9, U+98af, U+98b1, U+98b6, U+98ba, U+98be, U+98c3-98c4, U+98c6-98c8, U+98cf-98d6, U+98da-98db;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-010.woff2') format('woff2');
unicode-range: U+95c4-95ca, U+95cc-95cd, U+95d4-95d6, U+95d8, U+95e1-95e2, U+95e9, U+95f0-95f1, U+95f3, U+95f6, U+95fc, U+95fe-95ff, U+9602-9604, U+9606-960d, U+960f, U+9611-9613, U+9615-9617, U+9619-961b, U+961d, U+9621, U+9628, U+962f, U+963c-963e, U+9641-9642, U+9649, U+9654, U+965b-965f, U+9661, U+9663, U+9665, U+9667-9668, U+966c, U+9670, U+9672-9674, U+9678, U+967a, U+967d, U+9682, U+9685, U+9688, U+968a, U+968d-968e, U+9695, U+9697-9698, U+969e, U+96a0, U+96a3-96a4, U+96a8, U+96aa, U+96b0-96b1, U+96b3-96b4, U+96b7-96b9, U+96bb-96bd, U+96c9, U+96cb, U+96ce, U+96d1-96d2, U+96d6, U+96d9, U+96db-96dc, U+96de, U+96e0, U+96e3, U+96e9, U+96eb, U+96f0-96f2, U+96f9, U+96ff, U+9701-9702, U+9705, U+9708, U+970a, U+970e-970f, U+9711, U+9719, U+9727, U+972a, U+972d, U+9730, U+973d, U+9742, U+9744, U+9748-9749, U+9750-9751, U+975a;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-011.woff2') format('woff2');
unicode-range: U+94f5, U+94f7, U+94f9, U+94fb-94fd, U+94ff, U+9503-9504, U+9506-9507, U+9509-950a, U+950d-950f, U+9511-9518, U+951a-9520, U+9522, U+9528-952d, U+9530-953a, U+953c-953f, U+9543-9546, U+9548-9550, U+9552-9555, U+9557-955b, U+955d-9568, U+956a-956d, U+9570-9574, U+9583, U+9586, U+9589, U+958e-958f, U+9591-9592, U+9594, U+9598-9599, U+959e-95a0, U+95a2-95a6, U+95a8-95b2, U+95b4, U+95b8-95c3;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-012.woff2') format('woff2');
unicode-range: U+941c-942b, U+942d-942e, U+9432-9433, U+9435, U+9438, U+943a, U+943e, U+9444, U+944a, U+9451-9452, U+945a, U+9462-9463, U+9465, U+9470-9487, U+948a-9492, U+9494-9498, U+949a, U+949c-949d, U+94a1, U+94a3-94a4, U+94a8, U+94aa-94ad, U+94af, U+94b2, U+94b4-94ba, U+94bc-94c0, U+94c4, U+94c6-94db, U+94de-94ec, U+94ee-94f1, U+94f3;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-013.woff2') format('woff2');
unicode-range: U+92ec-92ed, U+92f0, U+92f3, U+92f8, U+92fc, U+9304, U+9306, U+9310, U+9312, U+9315, U+9318, U+931a, U+931e, U+9320-9322, U+9324, U+9326-9329, U+932b-932c, U+932f, U+9331-9332, U+9335-9336, U+933e, U+9340-9341, U+934a-9360, U+9362-9363, U+9365-936b, U+936e, U+9375, U+937e, U+9382, U+938a, U+938c, U+938f, U+9393-9394, U+9396-9397, U+939a, U+93a2, U+93a7, U+93ac-93cd, U+93d0-93d1, U+93d6-93d8, U+93de-93df, U+93e1-93e2, U+93e4, U+93f8, U+93fb, U+93fd, U+940e-941a;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-014.woff2') format('woff2');
unicode-range: U+9163-9164, U+9169, U+9170, U+9172, U+9174, U+9179-917a, U+917d-917e, U+9182-9183, U+9185, U+918c-918d, U+9190-9191, U+919a, U+919c, U+91a1-91a4, U+91a8, U+91aa-91af, U+91b4-91b5, U+91b8, U+91ba, U+91be, U+91c0-91c1, U+91c6, U+91c8, U+91cb, U+91d0, U+91d2, U+91d7-91d8, U+91dd, U+91e3, U+91e6-91e7, U+91ed, U+91f0, U+91f5, U+91f9, U+9200, U+9205, U+9207-920a, U+920d-920e, U+9210, U+9214-9215, U+921c, U+921e, U+9221, U+9223-9227, U+9229-922a, U+922d, U+9234-9235, U+9237, U+9239-923a, U+923c-9240, U+9244-9246, U+9249, U+924e-924f, U+9251, U+9253, U+9257, U+925b, U+925e, U+9262, U+9264-9266, U+9268, U+926c, U+926f, U+9271, U+927b, U+927e, U+9280, U+9283, U+9285-928a, U+928e, U+9291, U+9293, U+9296, U+9298, U+929c-929d, U+92a8, U+92ab-92ae, U+92b3, U+92b6-92b7, U+92b9, U+92c1, U+92c5-92c6, U+92c8, U+92cc, U+92d0, U+92d2, U+92e4, U+92ea;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-015.woff2') format('woff2');
unicode-range: U+9004, U+900b, U+9011, U+9015-9016, U+901e, U+9021, U+9026, U+902d, U+902f, U+9031, U+9035-9036, U+9039-903a, U+9041, U+9044-9046, U+904a, U+904f-9052, U+9054-9055, U+9058-9059, U+905b-905e, U+9060-9062, U+9068-9069, U+906f, U+9072, U+9074, U+9076-907a, U+907c-907d, U+9081, U+9083, U+9085, U+9087-908b, U+908f, U+9095, U+9097, U+9099-909b, U+909d, U+90a0-90a1, U+90a8-90a9, U+90ac, U+90b0, U+90b2-90b4, U+90b6, U+90b8, U+90ba, U+90bd-90be, U+90c3-90c5, U+90c7-90c8, U+90cf-90d0, U+90d3, U+90d5, U+90d7, U+90da-90dc, U+90de, U+90e2, U+90e4, U+90e6-90e7, U+90ea-90eb, U+90ef, U+90f4-90f5, U+90f7, U+90fe-9100, U+9104, U+9109, U+910c, U+9112, U+9114-9115, U+9118, U+911c, U+911e, U+9120, U+9122-9123, U+9127, U+912d, U+912f-9132, U+9139-913a, U+9143, U+9146, U+9149-914a, U+914c, U+914e-9150, U+9154, U+9157, U+915a, U+915d-915e, U+9161-9162;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-016.woff2') format('woff2');
unicode-range: U+8e41-8e42, U+8e47, U+8e49-8e4b, U+8e50-8e53, U+8e59-8e5a, U+8e5f-8e60, U+8e64, U+8e69, U+8e6c, U+8e70, U+8e74, U+8e76, U+8e7a-8e7c, U+8e7f, U+8e84-8e85, U+8e87, U+8e89, U+8e8b, U+8e8d, U+8e8f-8e90, U+8e94, U+8e99, U+8e9c, U+8e9e, U+8eaa, U+8eac, U+8eb0, U+8eb6, U+8ec0, U+8ec6, U+8eca-8ece, U+8ed2, U+8eda, U+8edf, U+8ee2, U+8eeb, U+8ef8, U+8efb-8efe, U+8f03, U+8f09, U+8f0b, U+8f12-8f15, U+8f1b, U+8f1d, U+8f1f, U+8f29-8f2a, U+8f2f, U+8f36, U+8f38, U+8f3b, U+8f3e-8f3f, U+8f44-8f45, U+8f49, U+8f4d-8f4e, U+8f5f, U+8f6b, U+8f6d, U+8f71-8f73, U+8f75-8f76, U+8f78-8f7a, U+8f7c, U+8f7e, U+8f81-8f82, U+8f84, U+8f87, U+8f8a-8f8b, U+8f8d-8f8f, U+8f94-8f95, U+8f97-8f9a, U+8fa6, U+8fad-8faf, U+8fb2, U+8fb5-8fb7, U+8fba-8fbc, U+8fbf, U+8fc2, U+8fcb, U+8fcd, U+8fd3, U+8fd5, U+8fd7, U+8fda, U+8fe2-8fe5, U+8fe8-8fe9, U+8fee, U+8ff3-8ff4, U+8ff8, U+8ffa;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-017.woff2') format('woff2');
unicode-range: U+8cbd, U+8cbf-8cc4, U+8cc7-8cc8, U+8cca, U+8ccd, U+8cd1, U+8cd3, U+8cdb-8cdc, U+8cde, U+8ce0, U+8ce2-8ce4, U+8ce6-8ce8, U+8cea, U+8ced, U+8cf4, U+8cf8, U+8cfa, U+8cfc-8cfd, U+8d04-8d05, U+8d07-8d08, U+8d0a, U+8d0d, U+8d0f, U+8d13-8d14, U+8d16, U+8d1b, U+8d20, U+8d30, U+8d32-8d33, U+8d36, U+8d3b, U+8d3d, U+8d40, U+8d42-8d43, U+8d45-8d46, U+8d48-8d4a, U+8d4d, U+8d51, U+8d53, U+8d55, U+8d59, U+8d5c-8d5d, U+8d5f, U+8d61, U+8d66-8d67, U+8d6a, U+8d6d, U+8d71, U+8d73, U+8d84, U+8d90-8d91, U+8d94-8d95, U+8d99, U+8da8, U+8daf, U+8db1, U+8db5, U+8db8, U+8dba, U+8dbc, U+8dbf, U+8dc2, U+8dc4, U+8dc6, U+8dcb, U+8dce-8dcf, U+8dd6-8dd7, U+8dda-8ddb, U+8dde, U+8de1, U+8de3-8de4, U+8de9, U+8deb-8dec, U+8df0-8df1, U+8df6-8dfd, U+8e05, U+8e07, U+8e09-8e0a, U+8e0c, U+8e0e, U+8e10, U+8e14, U+8e1d-8e1f, U+8e23, U+8e26, U+8e2b-8e31, U+8e34-8e35, U+8e39-8e3a, U+8e3d, U+8e40;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-018.woff2') format('woff2');
unicode-range: U+8b80, U+8b83, U+8b8a, U+8b8c, U+8b90, U+8b93, U+8b99-8b9a, U+8ba0, U+8ba3, U+8ba5-8ba7, U+8baa-8bac, U+8bb4-8bb5, U+8bb7, U+8bb9, U+8bc2-8bc3, U+8bc5, U+8bcb-8bcc, U+8bce-8bd0, U+8bd2-8bd4, U+8bd6, U+8bd8-8bd9, U+8bdc, U+8bdf, U+8be3-8be4, U+8be7-8be9, U+8beb-8bec, U+8bee, U+8bf0, U+8bf2-8bf3, U+8bf6, U+8bf9, U+8bfc-8bfd, U+8bff-8c00, U+8c02, U+8c04, U+8c06-8c07, U+8c0c, U+8c0f, U+8c11-8c12, U+8c14-8c1b, U+8c1d-8c21, U+8c24-8c25, U+8c27, U+8c2a-8c2c, U+8c2e-8c30, U+8c32-8c36, U+8c3f, U+8c47-8c4c, U+8c4e-8c50, U+8c54-8c56, U+8c62, U+8c68, U+8c6c, U+8c73, U+8c78, U+8c7a, U+8c82, U+8c85, U+8c89-8c8a, U+8c8d-8c8e, U+8c90, U+8c93-8c94, U+8c98, U+8c9d-8c9e, U+8ca0-8ca2, U+8ca7-8cac, U+8caf-8cb0, U+8cb3-8cb4, U+8cb6-8cb9, U+8cbb-8cbc;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-019.woff2') format('woff2');
unicode-range: U+8a15-8a18, U+8a1a-8a1b, U+8a1d, U+8a1f, U+8a22-8a23, U+8a25, U+8a2b, U+8a2d, U+8a31, U+8a33-8a34, U+8a36-8a38, U+8a3a, U+8a3c, U+8a3e, U+8a40-8a41, U+8a46, U+8a48, U+8a50, U+8a52, U+8a54-8a55, U+8a58, U+8a5b, U+8a5d-8a63, U+8a66, U+8a69-8a6b, U+8a6d-8a6e, U+8a70, U+8a72-8a73, U+8a7a, U+8a85, U+8a87, U+8a8a, U+8a8c-8a8d, U+8a90-8a92, U+8a95, U+8a98, U+8aa0-8aa1, U+8aa3-8aa6, U+8aa8-8aa9, U+8aac-8aae, U+8ab0, U+8ab2, U+8ab8-8ab9, U+8abc, U+8abe-8abf, U+8ac7, U+8acf, U+8ad2, U+8ad6-8ad7, U+8adb-8adc, U+8adf, U+8ae1, U+8ae6-8ae8, U+8aeb, U+8aed-8aee, U+8af1, U+8af3-8af4, U+8af7-8af8, U+8afa, U+8afe, U+8b00-8b02, U+8b07, U+8b0a, U+8b0c, U+8b0e, U+8b10, U+8b17, U+8b19, U+8b1b, U+8b1d, U+8b20-8b21, U+8b26, U+8b28, U+8b2c, U+8b33, U+8b39, U+8b3e-8b3f, U+8b41, U+8b45, U+8b49, U+8b4c, U+8b4f, U+8b57-8b58, U+8b5a, U+8b5c, U+8b5e, U+8b60, U+8b6c, U+8b6f-8b70, U+8b72, U+8b74, U+8b77, U+8b7d;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-020.woff2') format('woff2');
unicode-range: U+8882, U+8884-8886, U+8888, U+888f, U+8892-8893, U+889b, U+88a2, U+88a4, U+88a6, U+88a8, U+88aa, U+88ae, U+88b1, U+88b4, U+88b7, U+88bc, U+88c0, U+88c6-88c9, U+88ce-88cf, U+88d1-88d3, U+88d8, U+88db-88dd, U+88df, U+88e1-88e3, U+88e5, U+88e8, U+88ec, U+88f0-88f1, U+88f3-88f4, U+88fc-88fe, U+8900, U+8902, U+8906-8907, U+8909-890c, U+8912-8915, U+8918-891b, U+8921, U+8925, U+892b, U+8930, U+8932, U+8934, U+8936, U+893b, U+893d, U+8941, U+894c, U+8955-8956, U+8959, U+895c, U+895e-8960, U+8966, U+896a, U+896c, U+896f-8970, U+8972, U+897b, U+897e, U+8980, U+8983, U+8985, U+8987-8988, U+898c, U+898f, U+8993, U+8997, U+899a, U+89a1, U+89a7, U+89a9-89aa, U+89b2-89b3, U+89b7, U+89c0, U+89c7, U+89ca-89cc, U+89ce-89d1, U+89d6, U+89da, U+89dc, U+89de, U+89e5, U+89e7, U+89eb, U+89ef, U+89f1, U+89f3-89f4, U+89f8, U+89ff, U+8a01-8a03, U+8a07-8a0a, U+8a0e-8a0f, U+8a13;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-021.woff2') format('woff2');
unicode-range: U+86f4, U+86f8-86f9, U+86fb, U+86fe, U+8703, U+8706-870a, U+870d, U+8711-8713, U+871a, U+871e, U+8722-8723, U+8725, U+8729, U+872e, U+8731, U+8734, U+8737, U+873a-873b, U+873e-8740, U+8742, U+8747-8748, U+8753, U+8755, U+8757-8758, U+875d, U+875f, U+8762-8766, U+8768, U+876e, U+8770, U+8772, U+8775, U+8778, U+877b-877e, U+8782, U+8785, U+8788, U+878b, U+8793, U+8797, U+879a, U+879e-87a0, U+87a2-87a3, U+87a8, U+87ab-87ad, U+87af, U+87b3, U+87b5, U+87bd, U+87c0, U+87c4, U+87c6, U+87ca-87cb, U+87d1-87d2, U+87db-87dc, U+87de, U+87e0, U+87e5, U+87ea, U+87ec, U+87ee, U+87f2-87f3, U+87fb, U+87fd-87fe, U+8802-8803, U+8805, U+880a-880b, U+880d, U+8813-8816, U+8819, U+881b, U+881f, U+8821, U+8823, U+8831-8832, U+8835-8836, U+8839, U+883b-883c, U+8844, U+8846, U+884a, U+884e, U+8852-8853, U+8855, U+8859, U+885b, U+885d-885e, U+8862, U+8864, U+8869-886a, U+886e-886f, U+8872, U+8879, U+887d-887f;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-022.woff2') format('woff2');
unicode-range: U+8548, U+854e, U+8553, U+8556-8557, U+8559, U+855e, U+8561, U+8564-8565, U+8568-856a, U+856d, U+856f-8570, U+8572, U+8576, U+8579-857b, U+8580, U+8585-8586, U+8588, U+858a, U+858f, U+8591, U+8594, U+8599, U+859c, U+85a2, U+85a4, U+85a6, U+85a8-85a9, U+85ab-85ac, U+85ae, U+85b7-85b9, U+85be, U+85c1, U+85c7, U+85cd, U+85d0, U+85d3, U+85d5, U+85dc-85dd, U+85df-85e0, U+85e5-85e6, U+85e8-85ea, U+85f4, U+85f9, U+85fe-85ff, U+8602, U+8605-8607, U+860a-860b, U+8616, U+8618, U+861a, U+8627, U+8629, U+862d, U+8638, U+863c, U+863f, U+864d, U+864f, U+8652-8655, U+865b-865c, U+865f, U+8662, U+8667, U+866c, U+866e, U+8671, U+8675, U+867a-867c, U+867f, U+868b, U+868d, U+8693, U+869c-869d, U+86a1, U+86a3-86a4, U+86a7-86a9, U+86ac, U+86af-86b1, U+86b4-86b6, U+86ba, U+86c0, U+86c4, U+86c6, U+86c9-86ca, U+86cd-86d1, U+86d4, U+86d8, U+86de-86df, U+86e4, U+86e6, U+86e9, U+86ed, U+86ef-86f3;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-023.woff2') format('woff2');
unicode-range: U+83c5, U+83c8-83c9, U+83cb, U+83d1, U+83d3-83d6, U+83d8, U+83db, U+83dd, U+83df, U+83e1, U+83e5, U+83ea-83eb, U+83f0, U+83f4, U+83f8-83f9, U+83fb, U+83fd, U+83ff, U+8401, U+8406, U+840a-840b, U+840f, U+8411, U+8418, U+841c, U+8420, U+8422-8424, U+8426, U+8429, U+842c, U+8438-8439, U+843b-843c, U+843f, U+8446-8447, U+8449, U+844e, U+8451-8452, U+8456, U+8459-845a, U+845c, U+8462, U+8466, U+846d, U+846f-8470, U+8473, U+8476-8478, U+847a, U+847d, U+8484-8485, U+8487, U+8489, U+848c, U+848e, U+8490, U+8493-8494, U+8497, U+849b, U+849e-849f, U+84a1, U+84a5, U+84a8, U+84af, U+84b4, U+84b9-84bf, U+84c1-84c2, U+84c5-84c7, U+84ca-84cb, U+84cd, U+84d0-84d1, U+84d3, U+84d6, U+84df-84e0, U+84e2-84e3, U+84e5-84e7, U+84ee, U+84f3, U+84f6, U+84fa, U+84fc, U+84ff-8500, U+850c, U+8511, U+8514-8515, U+8517-8518, U+851f, U+8523, U+8525-8526, U+8529, U+852b, U+852d, U+8532, U+8534-8535, U+8538-853a, U+853c, U+8543, U+8545;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-024.woff2') format('woff2');
unicode-range: U+82bc, U+82be, U+82c0-82c2, U+82c4-82c8, U+82ca-82cc, U+82ce, U+82d0, U+82d2-82d3, U+82d5-82d6, U+82d8-82d9, U+82dc-82de, U+82e0-82e4, U+82e7, U+82e9-82eb, U+82ed-82ee, U+82f3-82f4, U+82f7-82f8, U+82fa-8301, U+8306-8308, U+830c-830d, U+830f, U+8311, U+8313-8315, U+8318, U+831a-831b, U+831d, U+8324, U+8327, U+832a, U+832c-832d, U+832f, U+8331-8334, U+833a-833c, U+8340, U+8343-8345, U+8347-8348, U+834a, U+834c, U+834f, U+8351, U+8356, U+8358-835c, U+835e, U+8360, U+8364-8366, U+8368-836a, U+836c-836e, U+8373, U+8378, U+837b-837d, U+837f-8380, U+8382, U+8388, U+838a-838b, U+8392, U+8394, U+8396, U+8398-8399, U+839b-839c, U+83a0, U+83a2-83a3, U+83a8-83aa, U+83ae-83b0, U+83b3-83b4, U+83b6, U+83b8, U+83ba, U+83bc-83bd, U+83bf-83c0, U+83c2;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-025.woff2') format('woff2');
unicode-range: U+8166-8169, U+816b, U+816d, U+8171, U+8173-8174, U+8178, U+817c-817d, U+8182, U+8188, U+8191, U+8198-819b, U+81a0, U+81a3, U+81a5-81a6, U+81a9, U+81b6, U+81ba-81bb, U+81bd, U+81bf, U+81c1, U+81c3, U+81c6, U+81c9-81ca, U+81cc-81cd, U+81d1, U+81d3-81d4, U+81d8, U+81db-81dc, U+81de-81df, U+81e5, U+81e7-81e9, U+81eb-81ec, U+81ee-81ef, U+81f5, U+81f8, U+81fa, U+81fc, U+81fe, U+8200-8202, U+8204, U+8208-820a, U+820e-8210, U+8216-8218, U+821b-821c, U+8221-8224, U+8226-8228, U+822b, U+822d, U+822f, U+8232-8234, U+8237-8238, U+823a-823b, U+823e, U+8244, U+8249, U+824b, U+824f, U+8259-825a, U+825f, U+8266, U+8268, U+826e, U+8271, U+8276-8279, U+827d, U+827f, U+8283-8284, U+8288-828a, U+828d-8291, U+8293-8294, U+8296-8298, U+829f-82a1, U+82a3-82a4, U+82a7-82ab, U+82ae, U+82b0, U+82b2, U+82b4-82b6;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-026.woff2') format('woff2');
unicode-range: U+8016, U+8018-8019, U+801c, U+801e, U+8026-802a, U+8031, U+8034-8035, U+8037, U+8043, U+804b, U+804d, U+8052, U+8056, U+8059, U+805e, U+8061, U+8068-8069, U+806e-8074, U+8076-8078, U+807c-8080, U+8082, U+8084-8085, U+8088, U+808f, U+8093, U+809c, U+809f, U+80ab, U+80ad-80ae, U+80b1, U+80b6-80b8, U+80bc-80bd, U+80c2, U+80c4, U+80ca, U+80cd, U+80d1, U+80d4, U+80d7, U+80d9-80db, U+80dd, U+80e0, U+80e4-80e5, U+80e7-80ed, U+80ef-80f1, U+80f3-80f4, U+80fc, U+8101, U+8104-8105, U+8107-8108, U+810c-810e, U+8112-8115, U+8117-8119, U+811b-811f, U+8121-8130, U+8132-8134, U+8137, U+8139, U+813f-8140, U+8142, U+8146, U+8148, U+814d-814e, U+8151, U+8153, U+8158-815a, U+815e, U+8160;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-027.woff2') format('woff2');
unicode-range: U+7ef0-7ef2, U+7ef6, U+7efa-7efb, U+7efe, U+7f01-7f04, U+7f08, U+7f0a-7f12, U+7f17, U+7f19, U+7f1b-7f1c, U+7f1f, U+7f21-7f23, U+7f25-7f28, U+7f2a-7f33, U+7f35-7f37, U+7f3d, U+7f42, U+7f44-7f45, U+7f4c-7f4d, U+7f52, U+7f54, U+7f58-7f59, U+7f5d, U+7f5f-7f61, U+7f63, U+7f65, U+7f68, U+7f70-7f71, U+7f73-7f75, U+7f77, U+7f79, U+7f7d-7f7e, U+7f85-7f86, U+7f88-7f89, U+7f8b-7f8c, U+7f90-7f91, U+7f94-7f96, U+7f98-7f9b, U+7f9d, U+7f9f, U+7fa3, U+7fa7-7fa9, U+7fac-7fb2, U+7fb4, U+7fb6, U+7fb8, U+7fbc, U+7fbf-7fc0, U+7fc3, U+7fca, U+7fcc, U+7fce, U+7fd2, U+7fd5, U+7fd9-7fdb, U+7fdf, U+7fe3, U+7fe5-7fe7, U+7fe9, U+7feb-7fec, U+7fee-7fef, U+7ff1, U+7ff3-7ff4, U+7ff9-7ffa, U+7ffe, U+8004, U+8006, U+800b, U+800e, U+8011-8012, U+8014;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-028.woff2') format('woff2');
unicode-range: U+7dd2, U+7dd4, U+7dd6-7dd8, U+7dda-7de0, U+7de2-7de6, U+7de8-7ded, U+7def, U+7df1-7df5, U+7df7, U+7df9, U+7dfb-7dfc, U+7dfe-7e02, U+7e04, U+7e08-7e0b, U+7e12, U+7e1b, U+7e1e, U+7e20, U+7e22-7e23, U+7e26, U+7e29, U+7e2b, U+7e2e-7e2f, U+7e31, U+7e37, U+7e39-7e3e, U+7e40, U+7e43-7e44, U+7e46-7e47, U+7e4a-7e4b, U+7e4d-7e4e, U+7e51, U+7e54-7e56, U+7e58-7e5b, U+7e5d-7e5e, U+7e61, U+7e66-7e67, U+7e69-7e6b, U+7e6d, U+7e70, U+7e73, U+7e77, U+7e79, U+7e7b-7e7d, U+7e81-7e82, U+7e8c-7e8d, U+7e8f, U+7e92-7e94, U+7e96, U+7e98, U+7e9a-7e9c, U+7e9e-7e9f, U+7ea1, U+7ea3, U+7ea5, U+7ea8-7ea9, U+7eab, U+7ead-7eae, U+7eb0, U+7ebb, U+7ebe, U+7ec0-7ec2, U+7ec9, U+7ecb-7ecc, U+7ed0, U+7ed4, U+7ed7, U+7edb, U+7ee0-7ee2, U+7ee5-7ee6, U+7ee8, U+7eeb;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-029.woff2') format('woff2');
unicode-range: U+7ce8, U+7cec, U+7cf0, U+7cf5-7cf9, U+7cfc, U+7cfe, U+7d00, U+7d04-7d0b, U+7d0d, U+7d10-7d14, U+7d17-7d19, U+7d1b-7d1f, U+7d21, U+7d24-7d26, U+7d28-7d2a, U+7d2c-7d2e, U+7d30-7d31, U+7d33, U+7d35-7d36, U+7d38-7d3a, U+7d40, U+7d42-7d44, U+7d46, U+7d4b-7d4c, U+7d4f, U+7d51, U+7d54-7d56, U+7d58, U+7d5b-7d5c, U+7d5e, U+7d61-7d63, U+7d66, U+7d68, U+7d6a-7d6c, U+7d6f, U+7d71-7d73, U+7d75-7d77, U+7d79-7d7a, U+7d7e, U+7d81, U+7d84-7d8b, U+7d8d, U+7d8f, U+7d91, U+7d94, U+7d96, U+7d98-7d9a, U+7d9c-7da0, U+7da2, U+7da6, U+7daa-7db1, U+7db4-7db8, U+7dba-7dbf, U+7dc1, U+7dc4, U+7dc7-7dc8, U+7dca-7dcd, U+7dcf, U+7dd1;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-030.woff2') format('woff2');
unicode-range: U+7bd3-7bd4, U+7bd9-7bda, U+7bdd, U+7be0-7be1, U+7be4-7be6, U+7be9-7bea, U+7bef, U+7bf4, U+7bf6, U+7bfc, U+7bfe, U+7c01, U+7c03, U+7c07-7c08, U+7c0a-7c0d, U+7c0f, U+7c11, U+7c15-7c16, U+7c19, U+7c1e-7c21, U+7c23-7c24, U+7c26, U+7c28-7c33, U+7c35, U+7c37-7c3b, U+7c3d-7c3e, U+7c40-7c41, U+7c43, U+7c47-7c48, U+7c4c, U+7c50, U+7c53-7c54, U+7c59, U+7c5f-7c60, U+7c63-7c65, U+7c6c, U+7c6e, U+7c72, U+7c74, U+7c79-7c7a, U+7c7c, U+7c81-7c82, U+7c84-7c85, U+7c88, U+7c8a-7c91, U+7c93-7c96, U+7c99, U+7c9b-7c9e, U+7ca0-7ca2, U+7ca6-7ca9, U+7cac, U+7caf-7cb3, U+7cb5-7cb7, U+7cba-7cbd, U+7cbf-7cc2, U+7cc5, U+7cc7-7cc9, U+7ccc-7ccd, U+7cd7, U+7cdc, U+7cde, U+7ce0, U+7ce4-7ce5, U+7ce7;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-031.woff2') format('woff2');
unicode-range: U+7ae6, U+7af4-7af7, U+7afa-7afb, U+7afd-7b0a, U+7b0c, U+7b0e-7b0f, U+7b13, U+7b15-7b16, U+7b18-7b19, U+7b1e-7b20, U+7b22-7b25, U+7b29-7b2b, U+7b2d-7b2e, U+7b30-7b3b, U+7b3e-7b3f, U+7b41-7b42, U+7b44-7b47, U+7b4a, U+7b4c-7b50, U+7b58, U+7b5a, U+7b5c, U+7b60, U+7b66-7b67, U+7b69, U+7b6c-7b6f, U+7b72-7b76, U+7b7b-7b7d, U+7b7f, U+7b82, U+7b85, U+7b87, U+7b8b-7b96, U+7b98-7b99, U+7b9b-7b9f, U+7ba2-7ba4, U+7ba6-7bac, U+7bae-7bb0, U+7bb4, U+7bb7-7bb9, U+7bbb, U+7bc0-7bc1, U+7bc3-7bc4, U+7bc6, U+7bc8-7bcc, U+7bd1;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-032.woff2') format('woff2');
unicode-range: U+798b-798e, U+7992, U+7994-7995, U+7997-7998, U+799a-799c, U+799f, U+79a3-79a6, U+79a8-79ac, U+79ae-79b1, U+79b3-79b5, U+79b8, U+79ba, U+79bf, U+79c2, U+79c6, U+79c8, U+79cf, U+79d5-79d6, U+79dd-79de, U+79e3, U+79e7-79e8, U+79eb, U+79ed, U+79f4, U+79f7-79f8, U+79fa, U+79fe, U+7a02-7a03, U+7a05, U+7a0a, U+7a14, U+7a17, U+7a19, U+7a1c, U+7a1e-7a1f, U+7a23, U+7a25-7a26, U+7a2c, U+7a2e, U+7a30-7a32, U+7a36-7a37, U+7a39, U+7a3c, U+7a40, U+7a42, U+7a47, U+7a49, U+7a4c-7a4f, U+7a51, U+7a55, U+7a5b, U+7a5d-7a5e, U+7a62-7a63, U+7a66, U+7a68-7a69, U+7a6b, U+7a70, U+7a78, U+7a80, U+7a85-7a88, U+7a8a, U+7a90, U+7a93-7a96, U+7a98, U+7a9b-7a9c, U+7a9e, U+7aa0-7aa1, U+7aa3, U+7aa8-7aaa, U+7aac-7ab0, U+7ab3, U+7ab8, U+7aba, U+7abd-7abf, U+7ac4-7ac5, U+7ac7-7ac8, U+7aca, U+7ad1-7ad2, U+7ada-7add, U+7ae1, U+7ae4;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-033.woff2') format('woff2');
unicode-range: U+784c, U+784e-7854, U+7856-7857, U+7859-785a, U+7865, U+7869-786a, U+786d, U+786f, U+7876-7877, U+787c, U+787e-787f, U+7881, U+7887-7889, U+7893-7894, U+7898-789e, U+78a1, U+78a3, U+78a5, U+78a9, U+78ad, U+78b2, U+78b4, U+78b6, U+78b9-78ba, U+78bc, U+78bf, U+78c3, U+78c9, U+78cb, U+78d0-78d2, U+78d4, U+78d9-78da, U+78dc, U+78de, U+78e1, U+78e5-78e6, U+78ea, U+78ec, U+78ef, U+78f1-78f2, U+78f4, U+78fa-78fb, U+78fe, U+7901-7902, U+7905, U+7907, U+7909, U+790b-790c, U+790e, U+7910, U+7913, U+7919-791b, U+791e-791f, U+7921, U+7924, U+7926, U+792a-792b, U+7934, U+7936, U+7939, U+793b, U+793d, U+7940, U+7942-7943, U+7945-7947, U+7949-794a, U+794c, U+794e-7951, U+7953-7955, U+7957-795a, U+795c, U+795f-7960, U+7962, U+7964, U+7966-7967, U+7969, U+796b, U+796f, U+7972, U+7974, U+7979, U+797b-797c, U+797e-7980, U+7982, U+7986-7987, U+7989-798a;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-034.woff2') format('woff2');
unicode-range: U+7722, U+7726, U+7728, U+772b-7730, U+7732-7736, U+7739-773a, U+773d-773f, U+7743, U+7746-7747, U+774c-774f, U+7751-7752, U+7758-775a, U+775c-775e, U+7762, U+7765-7766, U+7768-776a, U+776c-776d, U+7771-7772, U+777a, U+777c-777e, U+7780, U+7785, U+7787, U+778b-778d, U+778f-7791, U+7793, U+779e-77a0, U+77a2, U+77a5, U+77ad, U+77af, U+77b4-77b7, U+77bd-77c0, U+77c2, U+77c5, U+77c7, U+77cd, U+77d6-77d7, U+77d9-77da, U+77dd-77de, U+77e7, U+77ea, U+77ec, U+77ef, U+77f8, U+77fb, U+77fd-77fe, U+7800, U+7803, U+7806, U+7809, U+780f-7812, U+7815, U+7817-7818, U+781a-781f, U+7821-7823, U+7825-7827, U+7829, U+782b-7830, U+7832-7833, U+7835, U+7837, U+7839-783c, U+783e, U+7841-7844, U+7847-7849, U+784b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-035.woff2') format('woff2');
unicode-range: U+7613-7619, U+761b-761d, U+761f-7622, U+7625, U+7627-762a, U+762e-7630, U+7632-7635, U+7638-763a, U+763c-763d, U+763f-7640, U+7642-7643, U+7647-7648, U+764d-764e, U+7652, U+7654, U+7658, U+765a, U+765c, U+765e-765f, U+7661-7663, U+7665, U+7669, U+766c, U+766e-766f, U+7671-7673, U+7675-7676, U+7678-767a, U+767f, U+7681, U+7683, U+7688, U+768a-768c, U+768e, U+7690-7692, U+7695, U+7698, U+769a-769b, U+769d-76a0, U+76a2, U+76a4-76a7, U+76ab-76ac, U+76af-76b0, U+76b2, U+76b4-76b5, U+76ba-76bb, U+76bf, U+76c2-76c3, U+76c5, U+76c9, U+76cc-76ce, U+76dc-76de, U+76e1-76ea, U+76f1, U+76f9-76fb, U+76fd, U+76ff-7700, U+7703-7704, U+7707-7708, U+770c-770f, U+7712, U+7714, U+7716, U+7719-771b, U+771e, U+7721;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-036.woff2') format('woff2');
unicode-range: U+750d, U+750f, U+7511, U+7513, U+7515, U+7517, U+7519, U+7521-7527, U+752a, U+752c-752d, U+752f, U+7534, U+7536, U+753a, U+753e, U+7540, U+7544, U+7547-754b, U+754d-754e, U+7550-7553, U+7556-7557, U+755a-755b, U+755d-755e, U+7560, U+7562, U+7564, U+7566-7568, U+756b-756c, U+756f-7573, U+7575, U+7579-757c, U+757e-757f, U+7581-7584, U+7587, U+7589-758e, U+7590, U+7592, U+7594, U+7596, U+7599-759a, U+759d, U+759f-75a0, U+75a3, U+75a5, U+75a8, U+75ac-75ad, U+75b0-75b1, U+75b3-75b5, U+75b8, U+75bd, U+75c1-75c4, U+75c8-75ca, U+75cc-75cd, U+75d4, U+75d6, U+75d9, U+75de, U+75e0, U+75e2-75e4, U+75e6-75ea, U+75f1-75f3, U+75f7, U+75f9-75fa, U+75fc, U+75fe-7601, U+7603, U+7605-7606, U+7608-760e, U+7610-7612;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-037.woff2') format('woff2');
unicode-range: U+73f0, U+73f2, U+73f4-73f5, U+73f7, U+73f9-73fa, U+73fc-73fd, U+73ff-7402, U+7404, U+7407-7408, U+740a-740f, U+7418, U+741a-741c, U+741e, U+7424-7425, U+7428-7429, U+742c-7430, U+7432, U+7435-7436, U+7438-743b, U+743e-7441, U+7443-7446, U+7448, U+744a-744b, U+7452, U+7457, U+745b, U+745d, U+7460, U+7462-7465, U+7467-746a, U+746d, U+746f, U+7471, U+7473-7474, U+7477, U+747a, U+747e, U+7481-7482, U+7484, U+7486, U+7488-748b, U+748e-748f, U+7493, U+7498, U+749a, U+749c-74a0, U+74a3, U+74a6, U+74a9-74aa, U+74ae, U+74b0-74b2, U+74b6, U+74b8-74ba, U+74bd, U+74bf, U+74c1, U+74c3, U+74c5, U+74c8, U+74ca, U+74cc, U+74cf, U+74d1-74d2, U+74d4-74d5, U+74d8-74db, U+74de-74e0, U+74e2, U+74e4-74e5, U+74e7-74e9, U+74ee-74ef, U+74f4, U+74ff, U+7501, U+7503, U+7505, U+7508;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-038.woff2') format('woff2');
unicode-range: U+72e6, U+72e8, U+72ef-72f0, U+72f2-72f4, U+72f6-72f7, U+72f9-72fb, U+72fd, U+7300-7304, U+7307, U+730a-730c, U+7313-7317, U+731d-7322, U+7327, U+7329, U+732c-732d, U+7330-7331, U+7333, U+7335-7337, U+7339, U+733d-733e, U+7340, U+7342, U+7344-7345, U+734a, U+734d-7350, U+7352, U+7355, U+7357, U+7359, U+735f-7360, U+7362-7363, U+7365, U+7368, U+736c-736d, U+736f-7370, U+7372, U+7374-7376, U+7378, U+737a-737b, U+737d-737e, U+7382-7383, U+7386, U+7388, U+738a, U+738c-7393, U+7395, U+7397-739a, U+739c, U+739e, U+73a0-73a3, U+73a5-73a8, U+73aa, U+73ad, U+73b1, U+73b3, U+73b6-73b7, U+73b9, U+73c2, U+73c5-73c9, U+73cc, U+73ce-73d0, U+73d2, U+73d6, U+73d9, U+73db-73de, U+73e3, U+73e5-73ea, U+73ee-73ef;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-039.woff2') format('woff2');
unicode-range: U+71a8, U+71af, U+71b1-71bc, U+71be, U+71c1-71c2, U+71c4, U+71c8-71cb, U+71ce-71d0, U+71d2, U+71d4, U+71d9-71da, U+71dc, U+71df-71e0, U+71e6-71e8, U+71ea, U+71ed-71ee, U+71f4, U+71f6, U+71f9, U+71fb-71fc, U+71ff-7200, U+7207, U+720c-720d, U+7210, U+7216, U+721a-721e, U+7223, U+7228, U+722b, U+722d-722e, U+7230, U+7232, U+723a-723c, U+723e-7242, U+7246, U+724b, U+724d-724e, U+7252, U+7256, U+7258, U+725a, U+725c-725d, U+7260, U+7264-7266, U+726a, U+726c, U+726e-726f, U+7271, U+7273-7274, U+7278, U+727b, U+727d-727e, U+7281-7282, U+7284, U+7287, U+728a, U+728d, U+728f, U+7292, U+729b, U+729f-72a0, U+72a7, U+72ad-72ae, U+72b0-72b5, U+72b7-72b8, U+72ba-72be, U+72c0-72c1, U+72c3, U+72c5-72c6, U+72c8, U+72cc-72ce, U+72d2, U+72d6, U+72db, U+72dd-72df, U+72e1, U+72e5;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-040.woff2') format('woff2');
unicode-range: U+700b, U+700d, U+7015, U+7018, U+701b, U+701d-701f, U+7023, U+7026-7028, U+702c, U+702e-7030, U+7035, U+7037, U+7039-703a, U+703c-703e, U+7044, U+7049-704b, U+704f, U+7051, U+7058, U+705a, U+705c-705e, U+7061, U+7064, U+7066, U+706c, U+707d, U+7080-7081, U+7085-7086, U+708a, U+708f, U+7091, U+7094-7095, U+7098-7099, U+709c-709d, U+709f, U+70a4, U+70a9-70aa, U+70af-70b2, U+70b4-70b7, U+70bb, U+70c0, U+70c3, U+70c7, U+70cb, U+70ce-70cf, U+70d4, U+70d9-70da, U+70dc-70dd, U+70e0, U+70e9, U+70ec, U+70f7, U+70fa, U+70fd, U+70ff, U+7104, U+7108-7109, U+710c, U+7110, U+7113-7114, U+7116-7118, U+711c, U+711e, U+7120, U+712e-712f, U+7131, U+713c, U+7142, U+7144-7147, U+7149-714b, U+7150, U+7152, U+7155-7156, U+7159-715a, U+715c, U+7161, U+7165-7166, U+7168-7169, U+716d, U+7173-7174, U+7176, U+7178, U+717a, U+717d, U+717f-7180, U+7184, U+7186-7188, U+7192, U+7198, U+719c, U+71a0, U+71a4-71a5;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-041.woff2') format('woff2');
unicode-range: U+6ed9, U+6edb, U+6edd, U+6edf-6ee0, U+6ee2, U+6ee6, U+6eea, U+6eec, U+6eee-6eef, U+6ef2-6ef3, U+6ef7-6efa, U+6efe, U+6f01, U+6f03, U+6f08-6f09, U+6f15-6f16, U+6f19, U+6f22-6f25, U+6f28-6f2a, U+6f2c-6f2d, U+6f2f, U+6f32, U+6f36-6f38, U+6f3f, U+6f43-6f46, U+6f48, U+6f4b, U+6f4e-6f4f, U+6f51, U+6f54-6f57, U+6f59-6f5b, U+6f5e-6f5f, U+6f61, U+6f64-6f67, U+6f69-6f6c, U+6f6f-6f72, U+6f74-6f76, U+6f78-6f7e, U+6f80-6f83, U+6f86, U+6f89, U+6f8b-6f8d, U+6f90, U+6f92, U+6f94, U+6f97-6f98, U+6f9b, U+6fa3-6fa5, U+6fa7, U+6faa, U+6faf, U+6fb1, U+6fb4, U+6fb6, U+6fb9, U+6fc1-6fcb, U+6fd1-6fd3, U+6fd5, U+6fdb, U+6fde-6fe1, U+6fe4, U+6fe9, U+6feb-6fec, U+6fee-6ff1, U+6ffa, U+6ffe, U+7005-7006, U+7009;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-042.woff2') format('woff2');
unicode-range: U+6dc3, U+6dc5-6dc6, U+6dc9, U+6dcc, U+6dcf, U+6dd2-6dd3, U+6dd6, U+6dd9-6dde, U+6de0, U+6de4, U+6de6, U+6de8-6dea, U+6dec, U+6def-6df0, U+6df5-6df6, U+6df8, U+6dfa, U+6dfc, U+6e03-6e04, U+6e07-6e09, U+6e0b-6e0c, U+6e0e, U+6e11, U+6e13, U+6e15-6e16, U+6e19-6e1b, U+6e1e-6e1f, U+6e22, U+6e25-6e27, U+6e2b-6e2c, U+6e36-6e37, U+6e39-6e3a, U+6e3c-6e41, U+6e44-6e45, U+6e47, U+6e49-6e4b, U+6e4d-6e4e, U+6e51, U+6e53-6e55, U+6e5c-6e5f, U+6e61-6e63, U+6e65-6e67, U+6e6a-6e6b, U+6e6d-6e70, U+6e72-6e74, U+6e76-6e78, U+6e7c, U+6e80-6e82, U+6e86-6e87, U+6e89, U+6e8d, U+6e8f, U+6e96, U+6e98, U+6e9d-6e9f, U+6ea1, U+6ea5-6ea7, U+6eab, U+6eb1-6eb2, U+6eb4, U+6eb7, U+6ebb-6ebd, U+6ebf-6ec6, U+6ec8-6ec9, U+6ecc, U+6ecf-6ed0, U+6ed3-6ed4, U+6ed7-6ed8;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-043.woff2') format('woff2');
unicode-range: U+6cb1-6cb2, U+6cb4-6cb5, U+6cb7, U+6cba, U+6cbc-6cbd, U+6cc1-6cc3, U+6cc5-6cc7, U+6cd0-6cd4, U+6cd6-6cd7, U+6cd9-6cda, U+6cde-6ce0, U+6ce4, U+6ce6, U+6ce9, U+6ceb-6cef, U+6cf1-6cf2, U+6cf6-6cf7, U+6cfa, U+6cfe, U+6d03-6d05, U+6d07-6d08, U+6d0a, U+6d0c, U+6d0e-6d11, U+6d13-6d14, U+6d16, U+6d18-6d1a, U+6d1c, U+6d1f, U+6d22-6d23, U+6d26-6d29, U+6d2b, U+6d2e-6d30, U+6d33, U+6d35-6d36, U+6d38-6d3a, U+6d3c, U+6d3f, U+6d42-6d44, U+6d48-6d49, U+6d4d, U+6d50, U+6d52, U+6d54, U+6d56-6d58, U+6d5a-6d5c, U+6d5e, U+6d60-6d61, U+6d63-6d65, U+6d67, U+6d6c-6d6d, U+6d6f, U+6d75, U+6d7b-6d7d, U+6d87, U+6d8a, U+6d8e, U+6d90-6d9a, U+6d9c-6da0, U+6da2-6da3, U+6da7, U+6daa-6dac, U+6dae, U+6db3-6db4, U+6db6, U+6db8, U+6dbc, U+6dbf, U+6dc2;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-044.woff2') format('woff2');
unicode-range: U+6b83-6b86, U+6b89, U+6b8d, U+6b91-6b93, U+6b95, U+6b97-6b98, U+6b9a-6b9b, U+6b9e, U+6ba1-6ba4, U+6ba9-6baa, U+6bad, U+6baf-6bb0, U+6bb2-6bb3, U+6bba-6bbd, U+6bc0, U+6bc2, U+6bc6, U+6bca-6bcc, U+6bce, U+6bd0-6bd1, U+6bd3, U+6bd6-6bd8, U+6bda, U+6be1, U+6be6, U+6bec, U+6bf1, U+6bf3-6bf5, U+6bf9, U+6bfd, U+6c05-6c08, U+6c0d, U+6c10, U+6c15-6c1a, U+6c21, U+6c23-6c26, U+6c29-6c2d, U+6c30-6c33, U+6c35-6c37, U+6c39-6c3a, U+6c3c-6c3f, U+6c46, U+6c4a-6c4c, U+6c4e-6c50, U+6c54, U+6c56, U+6c59-6c5c, U+6c5e, U+6c63, U+6c67-6c69, U+6c6b, U+6c6d, U+6c6f, U+6c72-6c74, U+6c78-6c7a, U+6c7c, U+6c84-6c87, U+6c8b-6c8c, U+6c8f, U+6c91, U+6c93-6c96, U+6c98, U+6c9a, U+6c9d, U+6ca2-6ca4, U+6ca8-6ca9, U+6cac-6cae;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-045.woff2') format('woff2');
unicode-range: U+69fe-6a01, U+6a06, U+6a09, U+6a0b, U+6a11, U+6a13, U+6a17-6a19, U+6a1b, U+6a1e, U+6a23, U+6a28-6a29, U+6a2b, U+6a2f-6a30, U+6a35, U+6a38-6a40, U+6a46-6a48, U+6a4a-6a4b, U+6a4e, U+6a50, U+6a52, U+6a5b, U+6a5e, U+6a62, U+6a65-6a67, U+6a6b, U+6a79, U+6a7c, U+6a7e-6a7f, U+6a84, U+6a86, U+6a8e, U+6a90-6a91, U+6a94, U+6a97, U+6a9c, U+6a9e, U+6aa0, U+6aa2, U+6aa4, U+6aa9, U+6aab, U+6aae-6ab0, U+6ab2-6ab3, U+6ab5, U+6ab7-6ab8, U+6aba-6abb, U+6abd, U+6abf, U+6ac2-6ac4, U+6ac6, U+6ac8, U+6acc, U+6ace, U+6ad2-6ad3, U+6ad8-6adc, U+6adf-6ae0, U+6ae4-6ae5, U+6ae7-6ae8, U+6afb, U+6b04-6b05, U+6b0d-6b13, U+6b16-6b17, U+6b19, U+6b24-6b25, U+6b2c, U+6b37-6b39, U+6b3b, U+6b3d, U+6b43, U+6b46, U+6b4e, U+6b50, U+6b53-6b54, U+6b58-6b59, U+6b5b, U+6b60, U+6b69, U+6b6d, U+6b6f-6b70, U+6b73-6b74, U+6b77-6b7a, U+6b80-6b82;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-046.woff2') format('woff2');
unicode-range: U+68d3, U+68d7, U+68dd, U+68df, U+68e1, U+68e3-68e4, U+68e6-68ed, U+68ef-68f0, U+68f2, U+68f4, U+68f6-68f7, U+68f9, U+68fb-68fd, U+68ff-6902, U+6906-6908, U+690b, U+6910, U+691a-691c, U+691f-6920, U+6924-6925, U+692a, U+692d, U+6934, U+6939, U+693c-6945, U+694a-694b, U+6952-6954, U+6957, U+6959, U+695b, U+695d, U+695f, U+6962-6964, U+6966, U+6968-696c, U+696e-696f, U+6971, U+6973-6974, U+6978-6979, U+697d, U+697f-6980, U+6985, U+6987-698a, U+698d-698e, U+6994-6999, U+699b, U+69a3-69a4, U+69a6-69a7, U+69ab, U+69ad-69ae, U+69b1, U+69b7, U+69bb-69bc, U+69c1, U+69c3-69c5, U+69c7, U+69ca-69ce, U+69d0-69d1, U+69d3-69d4, U+69d7-69da, U+69e0, U+69e4, U+69e6, U+69ec-69ed, U+69f1-69f3, U+69f8, U+69fa-69fc;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-047.woff2') format('woff2');
unicode-range: U+678b-678d, U+678f, U+6792-6793, U+6796, U+6798, U+679e-67a1, U+67a5, U+67a7-67a9, U+67ac-67ad, U+67b0-67b1, U+67b3, U+67b5, U+67b7, U+67b9, U+67bb-67bc, U+67c0-67c1, U+67c3, U+67c5-67ca, U+67d1-67d2, U+67d7-67d9, U+67dd-67df, U+67e2-67e4, U+67e6-67e9, U+67f0, U+67f5, U+67f7-67f8, U+67fa-67fb, U+67fd-67fe, U+6800-6801, U+6803-6804, U+6806, U+6809-680a, U+680c, U+680e, U+6812, U+681d-681f, U+6822, U+6824-6829, U+682b-682d, U+6831-6835, U+683b, U+683e, U+6840-6841, U+6844-6845, U+6849, U+684e, U+6853, U+6855-6856, U+685c-685d, U+685f-6862, U+6864, U+6866-6868, U+686b, U+686f, U+6872, U+6874, U+6877, U+687f, U+6883, U+6886, U+688f, U+689b, U+689f-68a0, U+68a2-68a3, U+68b1, U+68b6, U+68b9-68ba, U+68bc-68bf, U+68c1-68c4, U+68c6, U+68c8, U+68ca, U+68cc, U+68d0-68d1;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-048.woff2') format('woff2');
unicode-range: U+6631, U+6633-6634, U+6636, U+663a-663b, U+663d, U+6641, U+6644-6645, U+6649, U+664c, U+664f, U+6654, U+6659, U+665b, U+665d-665e, U+6660-6667, U+6669, U+666b-666c, U+6671, U+6673, U+6677-6679, U+667c, U+6680-6681, U+6684-6685, U+6688-6689, U+668b-668e, U+6690, U+6692, U+6695, U+6698, U+669a, U+669d, U+669f-66a0, U+66a2-66a3, U+66a6, U+66aa-66ab, U+66b1-66b2, U+66b5, U+66b8-66b9, U+66bb, U+66be, U+66c1, U+66c6-66c9, U+66cc, U+66d5-66d8, U+66da-66dc, U+66de-66e2, U+66e8-66ea, U+66ec, U+66f1, U+66f3, U+66f7, U+66fa, U+66fd, U+6702, U+6705, U+670a, U+670f-6710, U+6713, U+6715, U+6719, U+6722-6723, U+6725-6727, U+6729, U+672d-672e, U+6732-6733, U+6736, U+6739, U+673b, U+673f, U+6744, U+6748, U+674c-674d, U+6753, U+6755, U+6762, U+6767, U+6769-676c, U+676e, U+6772-6773, U+6775, U+6777, U+677a-677d, U+6782-6783, U+6787, U+678a;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-049.woff2') format('woff2');
unicode-range: U+64f1-64f2, U+64f4, U+64f7-64f8, U+64fa, U+64fc, U+64fe-64ff, U+6503, U+6509, U+650f, U+6514, U+6518, U+651c-651e, U+6522-6525, U+652a-652c, U+652e, U+6530-6532, U+6534-6535, U+6537-6538, U+653a, U+653c-653d, U+6542, U+6549-654b, U+654d-654e, U+6553-6555, U+6557-6558, U+655d, U+6564, U+6569, U+656b, U+656d-656f, U+6571, U+6573, U+6575-6576, U+6578-657e, U+6581-6583, U+6585-6586, U+6589, U+658e-658f, U+6592-6593, U+6595-6596, U+659b, U+659d, U+659f-65a1, U+65a3, U+65ab-65ac, U+65b2, U+65b6-65b7, U+65ba-65bb, U+65be-65c0, U+65c2-65c4, U+65c6-65c8, U+65cc, U+65ce, U+65d0, U+65d2-65d3, U+65d6, U+65db, U+65dd, U+65e1, U+65e3, U+65ee-65f0, U+65f3-65f5, U+65f8, U+65fb-65fc, U+65fe-6600, U+6603, U+6607, U+6609, U+660b, U+6610-6611, U+6619-661a, U+661c-661e, U+6621, U+6624, U+6626, U+662a-662c, U+662e, U+6630;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-050.woff2') format('woff2');
unicode-range: U+63b8-63bc, U+63be, U+63c0, U+63c3-63c4, U+63c6, U+63c8, U+63cd-63ce, U+63d1, U+63d6, U+63da-63db, U+63de, U+63e0, U+63e3, U+63e9-63ea, U+63ee, U+63f2, U+63f5-63fa, U+63fc, U+63fe-6400, U+6406, U+640b-640d, U+6410, U+6414, U+6416-6417, U+641b, U+6420-6423, U+6425-6428, U+642a, U+6431-6432, U+6434-6437, U+643d-6442, U+6445, U+6448, U+6450-6452, U+645b-645f, U+6462, U+6465, U+6468, U+646d, U+646f-6471, U+6473, U+6477, U+6479-647d, U+6482-6485, U+6487-6488, U+648c, U+6490, U+6493, U+6496-649a, U+649d, U+64a0, U+64a5, U+64ab-64ac, U+64b1-64b7, U+64b9-64bb, U+64be-64c1, U+64c4, U+64c7, U+64c9-64cb, U+64d0, U+64d4, U+64d7-64d8, U+64da, U+64de, U+64e0-64e2, U+64e4, U+64e9, U+64ec, U+64f0;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-051.woff2') format('woff2');
unicode-range: U+622c, U+622e-6230, U+6232, U+6238, U+623b, U+623d-623e, U+6243, U+6246, U+6248-6249, U+624c, U+6255, U+6259, U+625e, U+6260-6261, U+6265-6266, U+626a, U+6271, U+627a, U+627c-627d, U+6283, U+6286, U+6289, U+628e, U+6294, U+629c, U+629e-629f, U+62a1, U+62a8, U+62ba-62bb, U+62bf, U+62c2, U+62c4, U+62c8, U+62ca-62cb, U+62cf, U+62d1, U+62d7, U+62d9-62da, U+62dd, U+62e0-62e1, U+62e3-62e4, U+62e7, U+62eb, U+62ee, U+62f0, U+62f4-62f6, U+6308, U+630a-630e, U+6310, U+6312-6313, U+6317, U+6319, U+631b, U+631d-631f, U+6322, U+6326, U+6329, U+6331-6332, U+6334-6337, U+6339, U+633b-633c, U+633e-6340, U+6343, U+6347, U+634b-634e, U+6354, U+635c-635d, U+6368-6369, U+636d, U+636f-6372, U+6376, U+637a-637b, U+637d, U+6382-6383, U+6387, U+638a-638b, U+638d-638e, U+6391, U+6393-6397, U+6399, U+639b, U+639e-639f, U+63a1, U+63a3-63a4, U+63ac-63ae, U+63b1-63b5;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-052.woff2') format('woff2');
unicode-range: U+60ed-60ee, U+60f0-60f1, U+60f4, U+60f6, U+60fa, U+6100, U+6106, U+610d-610e, U+6112, U+6114-6115, U+6119, U+611c, U+6120, U+6122-6123, U+6126, U+6128-6130, U+6136-6137, U+613a, U+613d-613e, U+6144, U+6146-6147, U+614a-614b, U+6151, U+6153, U+6158, U+615a, U+615c-615d, U+615f, U+6161, U+6163-6165, U+616b-616c, U+616e, U+6171, U+6173-6177, U+617e, U+6182, U+6187, U+618a, U+618d-618e, U+6190-6191, U+6194, U+6199-619a, U+619c, U+619f, U+61a1, U+61a3-61a4, U+61a7-61a9, U+61ab-61ad, U+61b2-61b3, U+61b5-61b7, U+61ba-61bb, U+61bf, U+61c3-61c4, U+61c6-61c7, U+61c9-61cb, U+61d0-61d1, U+61d3-61d4, U+61d7, U+61da, U+61df-61e1, U+61e6, U+61ee, U+61f0, U+61f2, U+61f6-61f8, U+61fa, U+61fc-61fe, U+6200, U+6206-6207, U+6209, U+620b, U+620d-620e, U+6213-6215, U+6217, U+6219, U+621b-6223, U+6225-6226;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-053.woff2') format('woff2');
unicode-range: U+5fc4, U+5fc9, U+5fcb, U+5fce-5fd6, U+5fda-5fde, U+5fe1-5fe2, U+5fe4-5fe5, U+5fea, U+5fed-5fee, U+5ff1-5ff3, U+5ff6, U+5ff8, U+5ffb, U+5ffe-5fff, U+6002-6006, U+600a, U+600d, U+600f, U+6014, U+6019, U+601b, U+6020, U+6023, U+6026, U+6029, U+602b, U+602e-602f, U+6031, U+6033, U+6035, U+6039, U+603f, U+6041-6043, U+6046, U+604f, U+6053-6054, U+6058-605b, U+605d-605e, U+6060, U+6063, U+6065, U+6067, U+606a-606c, U+6075, U+6078-6079, U+607b, U+607d, U+607f, U+6083, U+6085-6087, U+608a, U+608c, U+608e-608f, U+6092-6093, U+6095-6097, U+609b-609d, U+60a2, U+60a7, U+60a9-60ab, U+60ad, U+60af-60b1, U+60b3-60b6, U+60b8, U+60bb, U+60bd-60be, U+60c0-60c3, U+60c6-60c9, U+60cb, U+60ce, U+60d3-60d4, U+60d7-60db, U+60dd, U+60e1-60e4, U+60e6, U+60ea, U+60ec;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-054.woff2') format('woff2');
unicode-range: U+5e98, U+5e9b, U+5e9d, U+5ea0-5ea5, U+5ea8, U+5eab, U+5eaf, U+5eb3, U+5eb5-5eb6, U+5eb9, U+5ebe, U+5ec1-5ec3, U+5ec6, U+5ec8, U+5ecb-5ecc, U+5ed1-5ed2, U+5ed4, U+5ed9-5edb, U+5edd, U+5edf-5ee0, U+5ee2-5ee3, U+5ee8, U+5eea, U+5eec, U+5eef-5ef0, U+5ef3-5ef4, U+5ef8, U+5efb-5efc, U+5efe-5eff, U+5f01, U+5f07, U+5f0b-5f0e, U+5f10-5f12, U+5f14, U+5f1a, U+5f22, U+5f28-5f29, U+5f2c-5f2d, U+5f35-5f36, U+5f38, U+5f3b-5f43, U+5f45-5f4a, U+5f4c-5f4e, U+5f50, U+5f54, U+5f56-5f59, U+5f5b-5f5f, U+5f61, U+5f63, U+5f65, U+5f67-5f68, U+5f6b, U+5f6e-5f6f, U+5f72-5f78, U+5f7a, U+5f7e-5f7f, U+5f82-5f83, U+5f87, U+5f89-5f8a, U+5f8d, U+5f91, U+5f93, U+5f95, U+5f98-5f99, U+5f9c, U+5f9e, U+5fa0, U+5fa6-5fa9, U+5fac-5fad, U+5faf, U+5fb3-5fb5, U+5fb9, U+5fbc;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-055.woff2') format('woff2');
unicode-range: U+5d26-5d27, U+5d2e-5d34, U+5d3c-5d3e, U+5d41-5d44, U+5d46-5d48, U+5d4a-5d4b, U+5d4e, U+5d50, U+5d52, U+5d55-5d58, U+5d5a-5d5d, U+5d68-5d69, U+5d6b-5d6c, U+5d6f, U+5d74, U+5d7f, U+5d82-5d89, U+5d8b-5d8c, U+5d8f, U+5d92-5d93, U+5d99, U+5d9d, U+5db2, U+5db6-5db7, U+5dba, U+5dbc-5dbd, U+5dc2-5dc3, U+5dc6-5dc7, U+5dc9, U+5dcc, U+5dd2, U+5dd4, U+5dd6-5dd8, U+5ddb-5ddc, U+5de3, U+5ded, U+5def, U+5df3, U+5df6, U+5dfa-5dfd, U+5dff-5e00, U+5e07, U+5e0f, U+5e11, U+5e13-5e14, U+5e19-5e1b, U+5e22, U+5e25, U+5e28, U+5e2a, U+5e2f-5e31, U+5e33-5e34, U+5e36, U+5e39-5e3c, U+5e3e, U+5e40, U+5e44, U+5e46-5e48, U+5e4c, U+5e4f, U+5e53-5e54, U+5e57, U+5e59, U+5e5b, U+5e5e-5e5f, U+5e61, U+5e63, U+5e6a-5e6b, U+5e75, U+5e77, U+5e79-5e7a, U+5e7e, U+5e80-5e81, U+5e83, U+5e85, U+5e87, U+5e8b, U+5e91-5e92, U+5e96;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-056.woff2') format('woff2');
unicode-range: U+5bec, U+5bee-5bf0, U+5bf2-5bf3, U+5bf5-5bf6, U+5bfe, U+5c02-5c03, U+5c05, U+5c07-5c09, U+5c0b-5c0c, U+5c0e, U+5c10, U+5c12-5c13, U+5c15, U+5c17, U+5c19, U+5c1b-5c1c, U+5c1e-5c1f, U+5c22, U+5c25, U+5c28, U+5c2a-5c2b, U+5c2f-5c30, U+5c37, U+5c3b, U+5c43-5c44, U+5c46-5c47, U+5c4d, U+5c50, U+5c59, U+5c5b-5c5c, U+5c62-5c64, U+5c66, U+5c6c, U+5c6e, U+5c74, U+5c78-5c7e, U+5c80, U+5c83-5c84, U+5c88, U+5c8b-5c8d, U+5c91, U+5c94-5c96, U+5c98-5c99, U+5c9c, U+5c9e, U+5ca1-5ca3, U+5cab-5cac, U+5cb1, U+5cb5, U+5cb7, U+5cba, U+5cbd-5cbf, U+5cc1, U+5cc3-5cc4, U+5cc7, U+5ccb, U+5cd2, U+5cd8-5cd9, U+5cdf-5ce0, U+5ce3-5ce6, U+5ce8-5cea, U+5ced, U+5cef, U+5cf3-5cf4, U+5cf6, U+5cf8, U+5cfd, U+5d00-5d04, U+5d06, U+5d08, U+5d0b-5d0d, U+5d0f-5d13, U+5d15, U+5d17-5d1a, U+5d1d-5d22, U+5d24-5d25;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-057.woff2') format('woff2');
unicode-range: U+5aa0, U+5aa3-5aa4, U+5aaa, U+5aae-5aaf, U+5ab1-5ab2, U+5ab4-5ab5, U+5ab7-5aba, U+5abd-5abf, U+5ac3-5ac4, U+5ac6-5ac8, U+5aca-5acb, U+5acd, U+5acf-5ad2, U+5ad4, U+5ad8-5ada, U+5adc, U+5adf-5ae2, U+5ae4, U+5ae6, U+5ae8, U+5aea-5aed, U+5af0-5af3, U+5af5, U+5af9-5afb, U+5afd, U+5b01, U+5b05, U+5b08, U+5b0b-5b0c, U+5b11, U+5b16-5b17, U+5b1b, U+5b21-5b22, U+5b24, U+5b27-5b2e, U+5b30, U+5b32, U+5b34, U+5b36-5b38, U+5b3e-5b40, U+5b43, U+5b45, U+5b4a-5b4b, U+5b51-5b53, U+5b56, U+5b5a-5b5b, U+5b62, U+5b65, U+5b67, U+5b6a-5b6e, U+5b70-5b71, U+5b73, U+5b7a-5b7b, U+5b7f-5b80, U+5b84, U+5b8d, U+5b91, U+5b93-5b95, U+5b9f, U+5ba5-5ba6, U+5bac, U+5bae, U+5bb8, U+5bc0, U+5bc3, U+5bcb, U+5bd0-5bd1, U+5bd4-5bd8, U+5bda-5bdc, U+5be2, U+5be4-5be5, U+5be7, U+5be9, U+5beb;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-058.woff2') format('woff2');
unicode-range: U+596a, U+596c-596e, U+5977, U+597b-597c, U+5981, U+598f, U+5997-5998, U+599a, U+599c-599d, U+59a0-59a1, U+59a3-59a4, U+59a7, U+59aa-59ad, U+59af, U+59b2-59b3, U+59b5-59b6, U+59b8, U+59ba, U+59bd-59be, U+59c0-59c1, U+59c3-59c4, U+59c7-59ca, U+59cc-59cd, U+59cf, U+59d2, U+59d5-59d6, U+59d8-59d9, U+59db, U+59dd-59e0, U+59e2-59e7, U+59e9-59eb, U+59ee, U+59f1, U+59f3, U+59f5, U+59f7-59f9, U+59fd, U+5a06, U+5a08-5a0a, U+5a0c-5a0d, U+5a11-5a13, U+5a15-5a16, U+5a1a-5a1b, U+5a21-5a23, U+5a2d-5a2f, U+5a32, U+5a38, U+5a3c, U+5a3e-5a45, U+5a47, U+5a4a, U+5a4c-5a4d, U+5a4f-5a51, U+5a53, U+5a55-5a57, U+5a5e, U+5a60, U+5a62, U+5a65-5a67, U+5a6a, U+5a6c-5a6d, U+5a72-5a73, U+5a75-5a76, U+5a79-5a7c, U+5a81-5a84, U+5a8c, U+5a8e, U+5a93, U+5a96-5a97, U+5a9c, U+5a9e;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-059.woff2') format('woff2');
unicode-range: U+5820, U+5822-5823, U+5825-5826, U+582c, U+582f, U+5831, U+583a, U+583d, U+583f-5842, U+5844-5846, U+5848, U+584a, U+584d, U+5852, U+5857, U+5859-585a, U+585c-585d, U+5862, U+5868-5869, U+586c-586d, U+586f-5873, U+5875, U+5879, U+587d-587e, U+5880-5881, U+5888-588a, U+588d, U+5892, U+5896-5898, U+589a, U+589c-589d, U+58a0-58a1, U+58a3, U+58a6, U+58a9, U+58ab-58ae, U+58b0, U+58b3, U+58bb-58bf, U+58c2-58c3, U+58c5-58c8, U+58ca, U+58cc, U+58ce, U+58d1-58d3, U+58d5, U+58d8-58d9, U+58de-58df, U+58e2, U+58e9, U+58ec, U+58ef, U+58f1-58f2, U+58f5, U+58f7-58f8, U+58fa, U+58fd, U+5900, U+5902, U+5906, U+5908-590c, U+590e, U+5910, U+5914, U+5919, U+591b, U+591d-591e, U+5920, U+5922-5925, U+5928, U+592c-592d, U+592f, U+5932, U+5936, U+593c, U+593e, U+5940-5942, U+5944, U+594c-594d, U+5950, U+5953, U+5958, U+595a, U+5961, U+5966-5968;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-060.woff2') format('woff2');
unicode-range: U+56f9, U+56fc, U+56ff-5700, U+5703-5704, U+5709-570a, U+570c-570d, U+570f, U+5712-5713, U+5718-5719, U+571c, U+571e, U+5725, U+5727, U+5729-572a, U+572c, U+572e-572f, U+5734-5735, U+5739, U+573b, U+5741, U+5743, U+5745, U+5749, U+574c-574d, U+575c, U+5763, U+5768-5769, U+576b, U+576d-576e, U+5770, U+5773, U+5775, U+5777, U+577b-577c, U+5785-5786, U+5788, U+578c, U+578e-578f, U+5793-5795, U+5799-57a1, U+57a3-57a4, U+57a6-57aa, U+57ac-57ad, U+57af-57b2, U+57b4-57b6, U+57b8-57b9, U+57bd-57bf, U+57c2, U+57c4-57c8, U+57cc-57cd, U+57cf, U+57d2, U+57d5-57de, U+57e1-57e2, U+57e4-57e5, U+57e7, U+57eb, U+57ed, U+57ef, U+57f4-57f8, U+57fc-57fd, U+5800-5801, U+5803, U+5805, U+5807, U+5809, U+580b-580e, U+5811, U+5814, U+5819, U+581b-581f;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-061.woff2') format('woff2');
unicode-range: U+55f5-55f7, U+55fb, U+55fe, U+5600-5601, U+5605-5606, U+5608, U+560c-560d, U+560f, U+5614, U+5616-5617, U+561a, U+561c, U+561e, U+5621-5625, U+5627, U+5629, U+562b-5630, U+5636, U+5638-563a, U+563c, U+5640-5642, U+5649, U+564c-5650, U+5653-5655, U+5657-565b, U+5660, U+5663-5664, U+5666, U+566b, U+566f-5671, U+5673-567c, U+567e, U+5684-5687, U+568c, U+568e-5693, U+5695, U+5697, U+569b-569c, U+569e-569f, U+56a1-56a2, U+56a4-56a9, U+56ac-56af, U+56b1, U+56b4, U+56b6-56b8, U+56bf, U+56c1-56c3, U+56c9, U+56cd, U+56d1, U+56d4, U+56d6-56d9, U+56dd, U+56df, U+56e1, U+56e3-56e6, U+56e8-56ec, U+56ee-56ef, U+56f1-56f3, U+56f5, U+56f7-56f8;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-062.woff2') format('woff2');
unicode-range: U+550f, U+5511-5514, U+5516-5517, U+5519, U+551b, U+551d-551e, U+5520, U+5522-5523, U+5526-5527, U+552a-552c, U+5530, U+5532-5535, U+5537-5538, U+553b-5541, U+5543-5544, U+5547-5549, U+554b, U+554d, U+5550, U+5553, U+5555-5558, U+555b-555f, U+5567-5569, U+556b-5572, U+5574-5577, U+557b-557c, U+557e-557f, U+5581, U+5583, U+5585-5586, U+5588, U+558b-558c, U+558e-5591, U+5593, U+5599-559a, U+559f, U+55a5-55a6, U+55a8-55ac, U+55ae, U+55b0-55b3, U+55b6, U+55b9-55ba, U+55bc-55be, U+55c4, U+55c6-55c7, U+55c9, U+55cc-55d2, U+55d4-55db, U+55dd-55df, U+55e1, U+55e3-55e6, U+55ea-55ee, U+55f0-55f3;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-063.woff2') format('woff2');
unicode-range: U+53e7-53e9, U+53f1, U+53f4-53f5, U+53fa-5400, U+5402, U+5405-5407, U+540b, U+540f, U+5412, U+5414, U+5416, U+5418-541a, U+541d, U+5420-5423, U+5425, U+5429-542a, U+542d-542e, U+5431-5433, U+5436, U+543d, U+543f, U+5442-5443, U+5449, U+544b-544c, U+544e, U+5451-5454, U+5456, U+5459, U+545b-545c, U+5461, U+5463-5464, U+546a-5472, U+5474, U+5476-5478, U+547a, U+547e-5484, U+5486, U+548a, U+548d-548e, U+5490-5491, U+5494, U+5497-5499, U+549b, U+549d, U+54a1-54a7, U+54a9, U+54ab, U+54ad, U+54b4-54b5, U+54b9, U+54bb, U+54be-54bf, U+54c2-54c3, U+54c9-54cc, U+54cf-54d0, U+54d3, U+54d5-54d6, U+54d9-54da, U+54dc-54de, U+54e2, U+54e7, U+54f3-54f4, U+54f8-54f9, U+54fd-54ff, U+5501, U+5504-5506, U+550c-550e;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-064.woff2') format('woff2');
unicode-range: U+5289, U+528b, U+528d, U+528f, U+5291-5293, U+529a, U+52a2, U+52a6-52a7, U+52ac-52ad, U+52af, U+52b4-52b5, U+52b9, U+52bb-52bc, U+52be, U+52c1, U+52c5, U+52ca, U+52cd, U+52d0, U+52d6-52d7, U+52d9, U+52db, U+52dd-52de, U+52e0, U+52e2-52e3, U+52e5, U+52e7-52f0, U+52f2-52f3, U+52f5-52f9, U+52fb-52fc, U+5302, U+5304, U+530b, U+530d, U+530f-5310, U+5315, U+531a, U+531c-531d, U+5321, U+5323, U+5326, U+532e-5331, U+5338, U+533c-533e, U+5344-5345, U+534b-534d, U+5350, U+5354, U+5358, U+535d-535f, U+5363, U+5368-5369, U+536c, U+536e-536f, U+5372, U+5379-537b, U+537d, U+538d-538e, U+5390, U+5393-5394, U+5396, U+539b-539d, U+53a0-53a1, U+53a3-53a5, U+53a9, U+53ad-53ae, U+53b0, U+53b2-53b3, U+53b5-53b8, U+53bc, U+53be, U+53c1, U+53c3-53c7, U+53ce-53cf, U+53d2-53d3, U+53d5, U+53da, U+53de-53df, U+53e1-53e2;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-065.woff2') format('woff2');
unicode-range: U+5104, U+5106-5107, U+5109-510b, U+510d, U+510f-5110, U+5113, U+5115, U+5117-5118, U+511a-511c, U+511e-511f, U+5121, U+5128, U+512b-512d, U+5131-5135, U+5137-5139, U+513c, U+5140, U+5142, U+5147, U+514c, U+514e-5150, U+5155-5158, U+5162, U+5169, U+5172, U+517f, U+5181-5184, U+5186-5187, U+518b, U+518f, U+5191, U+5195-5197, U+519a, U+51a2-51a3, U+51a6-51ab, U+51ad-51ae, U+51b1, U+51b4, U+51bc-51bd, U+51bf, U+51c3, U+51c7-51c8, U+51ca-51cb, U+51cd-51ce, U+51d4, U+51d6, U+51db-51dc, U+51e6, U+51e8-51eb, U+51f1, U+51f5, U+51fc, U+51ff, U+5202, U+5205, U+5208, U+520b, U+520d-520e, U+5215-5216, U+5228, U+522a, U+522c-522d, U+5233, U+523c-523d, U+523f-5240, U+5245, U+5247, U+5249, U+524b-524c, U+524e, U+5250, U+525b-525f, U+5261, U+5263-5264, U+5270, U+5273, U+5275, U+5277, U+527d, U+527f, U+5281-5285, U+5287;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-066.woff2') format('woff2');
unicode-range: U+4fd1, U+4fd3, U+4fda-4fdc, U+4fdf-4fe0, U+4fe2-4fe4, U+4fe6, U+4fe8, U+4feb-4fed, U+4ff3, U+4ff5-4ff6, U+4ff8, U+4ffe, U+5001, U+5005-5006, U+5009, U+500c, U+500f, U+5013-5018, U+501b-501e, U+5022-5025, U+5027-5028, U+502b-502e, U+5030, U+5033-5034, U+5036-5039, U+503b, U+5041-5043, U+5045-5046, U+5048-504a, U+504c-504e, U+5051, U+5053, U+5055-5057, U+505b, U+505e, U+5060, U+5062-5063, U+5067, U+506a, U+506c, U+5070-5072, U+5074-5075, U+5078, U+507b, U+507d-507e, U+5080, U+5088-5089, U+5091-5092, U+5095, U+5097-509e, U+50a2-50a3, U+50a5-50a7, U+50a9, U+50ad, U+50b3, U+50b5, U+50b7, U+50ba, U+50be, U+50c4-50c5, U+50c7, U+50ca, U+50cd, U+50d1, U+50d5-50d6, U+50da, U+50de, U+50e5-50e6, U+50ec-50ee, U+50f0-50f1, U+50f3, U+50f9-50fb, U+50fe-5102;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-067.woff2') format('woff2');
unicode-range: U+4ea3, U+4ea5, U+4eb0-4eb1, U+4eb3-4eb6, U+4eb8-4eb9, U+4ebb-4ebe, U+4ec2-4ec4, U+4ec8-4ec9, U+4ecc, U+4ecf-4ed0, U+4ed2, U+4eda-4edb, U+4edd-4ee1, U+4ee6-4ee9, U+4eeb, U+4eee-4eef, U+4ef3-4ef5, U+4ef8-4efa, U+4efc, U+4f00, U+4f03-4f05, U+4f08-4f09, U+4f0b, U+4f0e, U+4f12-4f13, U+4f15, U+4f1b, U+4f1d, U+4f21-4f22, U+4f25, U+4f27-4f29, U+4f2b-4f2e, U+4f31-4f33, U+4f36-4f37, U+4f39, U+4f3e, U+4f40-4f41, U+4f43, U+4f47-4f49, U+4f54, U+4f57-4f58, U+4f5d-4f5e, U+4f61-4f62, U+4f64-4f65, U+4f67, U+4f6a, U+4f6e-4f6f, U+4f72, U+4f74-4f7e, U+4f80-4f82, U+4f84, U+4f89-4f8a, U+4f8e-4f98, U+4f9e, U+4fa1, U+4fa5, U+4fa9-4faa, U+4fac, U+4fb3, U+4fb6-4fb8, U+4fbd, U+4fc2, U+4fc5-4fc6, U+4fcd-4fce, U+4fd0;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-068.woff2') format('woff2');
unicode-range: U+3129, U+3131, U+3134, U+3137, U+3139, U+3141-3142, U+3145, U+3147-3148, U+314b, U+314d-314e, U+315c, U+3160-3161, U+3163-3164, U+3186, U+318d, U+3192, U+3196-3198, U+319e-319f, U+3220-3229, U+3231, U+3268, U+3297, U+3299, U+32a3, U+338e-338f, U+3395, U+339c-339e, U+33c4, U+33d1-33d2, U+33d5, U+3434, U+34dc, U+34ee, U+353e, U+355d, U+3566, U+3575, U+3592, U+35a0-35a1, U+35ad, U+35ce, U+36a2, U+36ab, U+38a8, U+3dab, U+3de7, U+3deb, U+3e1a, U+3f1b, U+3f6d, U+4495, U+4723, U+48fa, U+4ca3, U+4db6-4dbf, U+4e02, U+4e04-4e06, U+4e0c, U+4e0f, U+4e15, U+4e17, U+4e1f-4e21, U+4e26, U+4e29, U+4e2c, U+4e2f, U+4e31, U+4e35, U+4e37, U+4e3c, U+4e3f-4e42, U+4e44, U+4e46-4e47, U+4e57, U+4e5a-4e5c, U+4e64-4e65, U+4e67, U+4e69, U+4e6d, U+4e78, U+4e7f-4e82, U+4e85, U+4e87, U+4e8a, U+4e8d, U+4e93, U+4e96, U+4e98-4e99, U+4e9c, U+4e9e-4ea0, U+4ea2;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-069.woff2') format('woff2');
unicode-range: U+279f-27a2, U+27a4-27a5, U+27a8, U+27b0, U+27b2-27b3, U+27b9, U+27e8-27e9, U+27f6, U+2800, U+28ec, U+2913, U+2921-2922, U+2934-2935, U+2a2f, U+2b05-2b07, U+2b50, U+2b55, U+2bc5-2bc6, U+2e1c-2e1d, U+2ebb, U+2f00, U+2f08, U+2f24, U+2f2d, U+2f2f-2f30, U+2f3c, U+2f45, U+2f63-2f64, U+2f74, U+2f83, U+2f8f, U+2fbc, U+3003, U+3005-3007, U+3012-3013, U+301c-301e, U+3021, U+3023-3024, U+3030, U+3034-3035, U+3041, U+3043, U+3045, U+3047, U+3049, U+3056, U+3058, U+305c, U+305e, U+3062, U+306c, U+3074, U+3077, U+307a, U+307c-307d, U+3080, U+308e, U+3090-3091, U+3099-309b, U+309d-309e, U+30a5, U+30bc, U+30be, U+30c2, U+30c5, U+30cc, U+30d8, U+30e2, U+30e8, U+30ee, U+30f0-30f2, U+30f4-30f6, U+30fd-30fe, U+3105-3126, U+3128;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-070.woff2') format('woff2');
unicode-range: U+2651-2655, U+2658, U+265a-265b, U+265d-265e, U+2660-266d, U+266f, U+267b, U+2688, U+2693-2696, U+2698-2699, U+269c, U+26a0-26a1, U+26a4, U+26aa-26ab, U+26bd-26be, U+26c4-26c5, U+26d4, U+26e9, U+26f0-26f1, U+26f3, U+26f5, U+26fd, U+2702, U+2704-2706, U+2708-270f, U+2712-2718, U+271a-271b, U+271d, U+271f, U+2721, U+2724-2730, U+2732-2734, U+273a, U+273d-2744, U+2747-2749, U+274c, U+274e-274f, U+2753-2757, U+275b, U+275d-275e, U+2763, U+2765-2767, U+276e-276f, U+2776-277e, U+2780-2782, U+278a-278c, U+278e, U+2794-2796, U+279c;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-071.woff2') format('woff2');
unicode-range: U+2550-2551, U+2554, U+2557, U+255a-255b, U+255d, U+255f-2560, U+2562-2563, U+2565-2567, U+2569-256a, U+256c-2572, U+2579, U+2580-2595, U+25a1, U+25a3, U+25a9-25ad, U+25b0, U+25b3-25bb, U+25bd-25c2, U+25c4, U+25c8-25cb, U+25cd, U+25d0-25d1, U+25d4-25d5, U+25d8, U+25dc-25e6, U+25ea-25eb, U+25ef, U+25fe, U+2600-2604, U+2609, U+260e-260f, U+2611, U+2614-2615, U+2618, U+261a-2620, U+2622-2623, U+262a, U+262d-2630, U+2639-2640, U+2642, U+2648-2650;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-072.woff2') format('woff2');
unicode-range: U+23f0, U+23f3, U+2445, U+2449, U+2465-2471, U+2474-249b, U+24b8, U+24c2, U+24c7, U+24c9, U+24d0, U+24d2, U+24d4, U+24d8, U+24dd-24de, U+24e3, U+24e6, U+24e8, U+2500-2509, U+250b-2526, U+2528-2534, U+2536-2537, U+253b-2548, U+254a-254b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-073.woff2') format('woff2');
unicode-range: U+207c-2083, U+208c-208e, U+2092, U+20a6, U+20a8-20ad, U+20af, U+20b1, U+20b4-20b5, U+20b8-20ba, U+20bd, U+20db, U+20dd, U+20e0, U+20e3, U+2105, U+2109, U+2113, U+2116-2117, U+2120-2121, U+2126, U+212b, U+2133, U+2139, U+2194, U+2196-2199, U+21a0, U+21a9-21aa, U+21af, U+21b3, U+21b5, U+21ba-21bb, U+21c4, U+21ca, U+21cc, U+21d0-21d4, U+21e1, U+21e6-21e9, U+2200, U+2202, U+2205-2208, U+220f, U+2211-2212, U+2215, U+2217-2219, U+221d-2220, U+2223, U+2225, U+2227-222b, U+222e, U+2234-2237, U+223c-223d, U+2248, U+224c, U+2252, U+2256, U+2260-2261, U+2266-2267, U+226a-226b, U+226e-226f, U+2282-2283, U+2295, U+2297, U+2299, U+22a5, U+22b0-22b1, U+22b9, U+22bf, U+22c5-22c6, U+22ef, U+2304, U+2307, U+230b, U+2312-2314, U+2318, U+231a-231b, U+2323, U+239b, U+239d-239e, U+23a0, U+23e9;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-074.woff2') format('woff2');
unicode-range: U+1d34-1d35, U+1d38-1d3a, U+1d3c, U+1d3f-1d40, U+1d49, U+1d4e-1d4f, U+1d52, U+1d55, U+1d5b, U+1d5e, U+1d9c, U+1da0, U+1dc4-1dc5, U+1e69, U+1e73, U+1ea0-1ea9, U+1eab-1ead, U+1eaf, U+1eb1, U+1eb3, U+1eb5, U+1eb7, U+1eb9, U+1ebb, U+1ebd-1ebe, U+1ec0-1ec3, U+1ec5-1ec6, U+1ec9-1ecd, U+1ecf-1ed3, U+1ed5, U+1ed7-1edf, U+1ee1, U+1ee3, U+1ee5-1eeb, U+1eed, U+1eef-1ef1, U+1ef3, U+1ef7, U+1ef9, U+1f62, U+1f7b, U+2001-2002, U+2004-2006, U+2009-200a, U+200c-2012, U+2015-2016, U+201a, U+201e-2021, U+2023, U+2025, U+2028, U+202a-202d, U+202f-2030, U+2032-2033, U+2035, U+2038, U+203c, U+203e-203f, U+2043-2044, U+2049, U+204d-204e, U+2060-2061, U+2070, U+2074-2078, U+207a-207b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-075.woff2') format('woff2');
unicode-range: U+2ae-2b3, U+2b5-2bf, U+2c2-2c3, U+2c6-2d1, U+2d8-2da, U+2dc, U+2e1-2e3, U+2e5, U+2eb, U+2ee-2f0, U+2f2-2f7, U+2f9-2ff, U+302-30d, U+311, U+31b, U+321-325, U+327-329, U+32b-32c, U+32e-32f, U+331-339, U+33c-33d, U+33f, U+348, U+352, U+35c, U+35e-35f, U+361, U+363, U+368, U+36c, U+36f, U+530-540, U+55d-55e, U+561, U+563, U+565, U+56b, U+56e-579;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-076.woff2') format('woff2');
unicode-range: U+176-17f, U+192, U+194, U+19a-19b, U+19d, U+1a0-1a1, U+1a3-1a4, U+1aa, U+1ac-1ad, U+1af-1bf, U+1d2, U+1d4, U+1d6, U+1d8, U+1da, U+1dc, U+1e3, U+1e7, U+1e9, U+1ee, U+1f0-1f1, U+1f3, U+1f5-1ff, U+219-21b, U+221, U+223-226, U+228, U+22b, U+22f, U+231, U+234-237, U+23a-23b, U+23d, U+250-252, U+254-255, U+259-25e, U+261-263, U+265, U+268, U+26a-26b, U+26f-277, U+279, U+27b-280, U+282-283, U+285, U+28a, U+28c, U+28f, U+292, U+294-296, U+298-29a, U+29c, U+29f, U+2a1-2a4, U+2a6-2a7, U+2a9, U+2ab;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-077.woff2') format('woff2');
unicode-range: U+a1-a4, U+a6-a8, U+aa, U+ac, U+af, U+b1, U+b3-b6, U+b8-ba, U+bc-d6, U+d8-de, U+e6, U+eb, U+ee-f0, U+f5, U+f7-f8, U+fb, U+fd-100, U+102, U+104-107, U+10d, U+10f-112, U+115, U+117, U+119, U+11b, U+11e-11f, U+121, U+123, U+125-127, U+129-12a, U+12d, U+12f-13f, U+141-142, U+144, U+146, U+14b-14c, U+14f-153, U+158-15b, U+15e-160, U+163-165, U+168-16a, U+16d-175;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-078.woff2') format('woff2');
unicode-range: U+221a, U+2264, U+2464, U+25a0, U+3008, U+4e10, U+512a, U+5152, U+5201, U+5241, U+5340, U+5352, U+549a, U+54b2, U+54c6, U+54d7, U+54e1, U+5509, U+55c5, U+5618, U+5716, U+576f, U+5784, U+57a2, U+589f, U+5a20, U+5a25, U+5a29, U+5a34, U+5a7f, U+5ad6, U+5b09, U+5b5c, U+5bc7, U+5be6, U+5c27, U+5d2d, U+5dcd, U+5f1b, U+5f37, U+604d, U+6055, U+6073, U+60eb, U+61ff, U+62ce, U+62ed, U+6345, U+6390, U+63b0, U+63b7, U+64ae, U+64c2, U+64d2, U+6556, U+663c, U+667e, U+66d9, U+66f8, U+6756, U+6789, U+689d, U+68f1, U+695e, U+6975, U+6a1f, U+6b0a, U+6b61, U+6b87, U+6c5d, U+6c7e, U+6c92, U+6d31, U+6df9, U+6e0d, U+6e2d, U+6f31, U+6f3e, U+70b3, U+70bd, U+70ca, U+70e8, U+725f, U+733f, U+7396, U+739f, U+7459, U+74a7, U+75a1, U+75f0, U+76cf, U+76d4, U+7729, U+77aa, U+77b0, U+77e3, U+780c, U+78d5, U+7941, U+7977, U+797a, U+79c3, U+7a20, U+7a92, U+7b71, U+7bf1, U+7c9f, U+7eb6, U+7eca, U+7ef7, U+7f07, U+7f09, U+7f15, U+7f81, U+7fb9, U+8038, U+8098, U+80b4, U+8110, U+814b-814c, U+816e, U+818a, U+8205, U+8235, U+828b, U+82a5, U+82b7, U+82d4, U+82db, U+82df, U+8317, U+8338, U+8385-8386, U+83c1, U+83cf, U+8537, U+853b, U+854a, U+8715, U+8783, U+892a, U+8a71, U+8bb3, U+8d2e, U+8d58, U+8dbe, U+8f67, U+8fab, U+8fc4, U+8fe6, U+9023, U+9084, U+9091, U+916a, U+91c9, U+91dc, U+94b3, U+9502, U+9523, U+9551, U+956f, U+960e, U+962a, U+962e, U+9647, U+96f3, U+9739, U+97a0, U+97ed, U+983b, U+985e, U+988a, U+99ac, U+9a6f, U+9a87, U+9a8b, U+9ab7, U+9abc, U+9ac5, U+9e25, U+e608, U+e621, U+ff06, U+ff14-ff16;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-079.woff2') format('woff2');
unicode-range: U+161, U+926, U+928, U+939, U+93f-940, U+94d, U+e17, U+e22, U+e44, U+25c7, U+25ce, U+2764, U+3009, U+3016-3017, U+4e4d, U+4e53, U+4f5a, U+4f70, U+4fae, U+4fd8, U+4ffa, U+5011, U+501a, U+51c4, U+5225, U+547b, U+5495, U+54e8, U+54ee, U+5594, U+55d3, U+55dc, U+55fd, U+560e, U+565c, U+5662, U+5669, U+566c, U+56bc, U+5742, U+5824, U+5834, U+598a, U+5992, U+59a9, U+5a04, U+5ac9, U+5b75, U+5b7d, U+5bc5, U+5c49, U+5c90, U+5e1c, U+5e27, U+5e2b, U+5e37, U+5e90, U+618b, U+61f5, U+620a, U+620c, U+6273, U+62c7, U+62f7, U+6320, U+6342, U+6401-6402, U+6413, U+6512, U+655b, U+65a7, U+65f1, U+65f7, U+665f, U+6687, U+66a7, U+673d, U+67b8, U+6854, U+68d8, U+68fa, U+696d, U+6a02, U+6a0a, U+6a80, U+6b7c, U+6bd9, U+6c2e, U+6c76, U+6cf8, U+6d4a, U+6d85, U+6e24, U+6e32, U+6ec7, U+6f88, U+700f, U+701a, U+7078, U+707c, U+70ac, U+70c1, U+72e9, U+7409, U+7422, U+745a, U+7480, U+74a8, U+752b, U+7574, U+7656, U+7699, U+7737, U+785d, U+78be, U+79b9, U+7a3d, U+7a91, U+7a9f, U+7ae3, U+7b77, U+7c3f, U+7d1a, U+7d50, U+7d93, U+8042, U+808b, U+8236, U+82b8-82b9, U+82ef, U+8309, U+836b, U+83ef, U+8431, U+85c9, U+865e, U+868c, U+8759, U+8760, U+8845, U+89ba, U+8a2a, U+8aaa, U+8c41, U+8d2c, U+8d4e, U+8e66, U+8e6d, U+8eaf, U+902e, U+914b, U+916e, U+919b, U+949b, U+94a0, U+94b0, U+9541-9542, U+9556, U+95eb, U+95f5, U+964b, U+968b, U+96cc-96cd, U+96cf, U+9713, U+9890, U+98a8, U+9985, U+9992, U+9a6d, U+9a81, U+9a86, U+9ab8, U+9ca4, U+e606-e607, U+e60a, U+e60c, U+e60e, U+fe0f, U+ff02, U+ff1e;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-080.woff2') format('woff2');
unicode-range: U+10c, U+627-629, U+639, U+644, U+64a, U+203b, U+2265, U+2463, U+2573, U+25b2, U+3448-3449, U+4e1e, U+4e5e, U+4f3a, U+4f5f, U+4fea, U+5026, U+508d, U+516e, U+5189, U+5254, U+5288, U+52d8, U+52fa, U+5306, U+5308, U+5364, U+5384, U+53ed, U+543c, U+5450, U+5455, U+5466, U+54c4, U+5578, U+55a7, U+561f, U+5631, U+572d, U+575f, U+57ae, U+57e0, U+5830, U+594e, U+5984, U+5993, U+5bdd, U+5c0d, U+5c7f, U+5c82, U+5e62, U+5ed3, U+5f08, U+607a, U+60bc, U+625b, U+6292, U+62e2, U+6363, U+6467, U+6714, U+675e, U+6771, U+67a2, U+67ff, U+6805, U+68a7, U+68e0, U+6930, U+6986, U+69a8, U+69df, U+6a44, U+6a5f, U+6c13, U+6c1f, U+6c22, U+6c2f, U+6c40, U+6c81, U+6c9b, U+6ca5, U+6da4, U+6df3, U+6e85, U+6eba, U+6ed5, U+6f13, U+6f33, U+6f62, U+715e, U+72c4, U+73d1, U+7405, U+7487, U+7578, U+75a4, U+75eb, U+7693, U+7738, U+7741, U+776b, U+7792, U+77a7, U+77a9, U+77b3, U+788c, U+7984, U+79a7, U+79e4, U+7a1a, U+7a57, U+7aa6, U+7b0b, U+7b5d, U+7c27, U+7c7d, U+7caa, U+7cd9, U+7cef, U+7eda, U+7ede, U+7f24, U+803f, U+8046, U+80fa, U+81fb, U+8207, U+8258, U+8335, U+8339, U+8354, U+840e, U+85b0, U+85fb, U+8695, U+86aa, U+8717, U+8749, U+874c, U+8996, U+89bd, U+89c5, U+8bdb, U+8bf5, U+8c5a, U+8cec, U+8d3f, U+8d9f, U+8e44, U+8fed, U+9005, U+9019, U+9082, U+90af, U+90dd, U+90e1, U+90f8, U+916f, U+9176, U+949e, U+94a7, U+94c2, U+9525, U+9580, U+95dc, U+96e2, U+96fb, U+9704, U+9a7c, U+9a7f, U+9b41, U+9ca8, U+9cc4, U+9cde, U+9e92, U+9ede, U+9f9a, U+e60b, U+e610, U+ff10, U+ff13, U+ff3b, U+ff3d, U+f012b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-081.woff2') format('woff2');
unicode-range: U+60, U+631, U+2606, U+3014-3015, U+309c, U+33a1, U+4e52, U+4ec6, U+4f86, U+4f8d, U+4fde, U+4fef, U+500b, U+502a, U+515c, U+518a, U+51a5, U+51f3, U+5243, U+52c9, U+52d5, U+53a2, U+53ee, U+54ce, U+54fa, U+54fc, U+5580, U+5587, U+563f, U+56da, U+5792, U+5815, U+5960, U+59d7, U+5b78, U+5b9b, U+5be1, U+5c4e, U+5c51, U+5c6f, U+5c9a, U+5cfb, U+5d16, U+5ed6, U+5f27, U+5f6a, U+609a, U+60df, U+6168, U+61c8, U+6236, U+62f1, U+62fd, U+631a, U+6328, U+632b, U+6346, U+638f, U+63a0, U+63c9, U+655e, U+6590, U+6615, U+6627, U+66ae, U+66e6, U+66f0, U+67da, U+67ec, U+6813, U+6816, U+6869, U+6893, U+68ad, U+68f5, U+6977, U+6984, U+69db, U+6b72, U+6bb7, U+6ce3, U+6cfb, U+6d47, U+6da1, U+6dc4, U+6e43, U+6eaf, U+6eff, U+6f8e, U+7011, U+7063, U+7076, U+7096, U+70ba, U+70db, U+70ef, U+7119-711a, U+7172, U+718f, U+7194, U+727a, U+72d9, U+72ed, U+7325, U+73ae, U+73ba, U+73c0, U+73fe, U+7410, U+7426, U+7455, U+7554, U+7576, U+75ae, U+75b9, U+762b, U+766b, U+7682, U+7750, U+7779, U+7784, U+77eb, U+77ee, U+78f7, U+79e9, U+7a79, U+7b1b, U+7b28, U+7bf7, U+7db2, U+7ec5, U+7eee, U+7f14, U+7f1a, U+7fe1, U+8087, U+809b, U+81b3, U+8231, U+830e, U+835f, U+83e9, U+849c, U+851a, U+868a, U+8718, U+874e, U+8822, U+8910, U+8944, U+8a3b, U+8bb6, U+8bbc, U+8e72, U+8f9c, U+900d, U+904b, U+904e, U+9063, U+90a2, U+90b9, U+9119, U+94f2, U+952f, U+9576-9577, U+9593, U+95f8, U+961c, U+969b, U+96a7, U+96c1, U+9716, U+9761, U+97ad, U+97e7, U+98a4, U+997a, U+9a73, U+9b44, U+9e3d, U+9ecf, U+9ed4, U+ff11-ff12, U+fffd;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-082.woff2') format('woff2');
unicode-range: U+2003, U+2193, U+2462, U+4e19, U+4e2b, U+4e36, U+4ea8, U+4ed1, U+4ed7, U+4f51, U+4f63, U+4f83, U+50e7, U+5112, U+5167, U+51a4, U+51b6, U+5239, U+5265, U+532a, U+5351, U+537f, U+5401, U+548f, U+5492, U+54af, U+54b3, U+54bd, U+54d1, U+54df, U+554f, U+5564, U+5598, U+5632, U+56a3, U+56e7, U+574e, U+575d-575e, U+57d4, U+584c, U+58e4, U+5937, U+5955, U+5a05, U+5a1f, U+5a49, U+5ac2, U+5c39, U+5c61, U+5d0e, U+5de9, U+5e9a, U+5eb8, U+5f0a, U+5f13, U+5f6c, U+5f8c, U+603c, U+608d, U+611b, U+6127, U+62a0, U+62d0, U+634f, U+635e, U+63fd, U+6577, U+658b, U+65bc, U+660a, U+6643, U+6656, U+6703, U+6760, U+67af, U+67c4, U+67e0, U+6817, U+68cd, U+690e, U+6960, U+69b4, U+6a71, U+6aac, U+6b67, U+6bb4, U+6c55, U+6c70, U+6c82, U+6ca6, U+6cb8, U+6cbe, U+6ede, U+6ee5, U+6f4d, U+6f84, U+6f9c, U+7115, U+7121, U+722a, U+7261, U+7272, U+7280, U+72f8, U+7504, U+754f, U+75d8, U+767c, U+76ef, U+778e, U+77bb, U+77f6, U+786b, U+78b1, U+7948, U+7985, U+79be, U+7a83, U+7a8d, U+7eac, U+7eef, U+7ef8, U+7efd, U+7f00, U+803d, U+8086, U+810a, U+8165, U+819d, U+81a8, U+8214, U+829c, U+831c, U+832b, U+8367, U+83e0, U+83f1, U+8403, U+846b, U+8475, U+84b2, U+8513, U+8574, U+85af, U+86d9, U+86db, U+8acb, U+8bbd, U+8be0-8be1, U+8c0e, U+8d29, U+8d50, U+8d63, U+8f7f, U+9032, U+9042, U+90b1, U+90b5, U+9165, U+9175, U+94a6, U+94c5, U+950c, U+9610, U+9631, U+9699, U+973e, U+978d, U+97ec, U+97f6, U+984c, U+987d, U+9882, U+9965, U+996a, U+9972, U+9a8f, U+9ad3, U+9ae6, U+9cb8, U+9edb, U+e600, U+e60f, U+e611, U+ff05, U+ff0b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-083.woff2') format('woff2');
unicode-range: U+5e, U+2190, U+250a, U+25bc, U+25cf, U+4e56, U+4ea9, U+4f3d, U+4f6c, U+4f88, U+4fa8, U+4fcf, U+5029, U+5188, U+51f9, U+5203, U+524a, U+5256, U+529d, U+5375, U+53db, U+541f, U+5435, U+5457, U+548b, U+54c7, U+54d4, U+54e9, U+556a, U+5589, U+55bb, U+55e8, U+55ef, U+563b, U+566a, U+576a, U+58f9, U+598d, U+599e, U+59a8, U+5a9b, U+5ae3, U+5bb0, U+5bde, U+5c4c, U+5c60, U+5d1b, U+5deb, U+5df7, U+5e18, U+5f26, U+5f64, U+601c, U+6084, U+60e9, U+614c, U+6208, U+621a, U+6233, U+6254, U+62d8, U+62e6, U+62ef, U+6323, U+632a, U+633d, U+6361, U+6405, U+640f, U+6614, U+6642, U+6657, U+67a3, U+6808, U+683d, U+6850, U+6897, U+68b3, U+68b5, U+68d5, U+6a58, U+6b47, U+6b6a, U+6c28, U+6c90, U+6ca7, U+6cf5, U+6d51, U+6da9, U+6dc7, U+6dd1, U+6e0a, U+6e5b, U+6e9c, U+6f47, U+6f6d, U+70ad, U+70f9, U+710a, U+7130, U+71ac, U+745f, U+7476, U+7490, U+7529, U+7538, U+75d2, U+7696, U+76b1, U+76fc, U+777f, U+77dc, U+789f, U+795b, U+79bd, U+79c9, U+7a3b, U+7a46, U+7aa5, U+7ad6, U+7ca5, U+7cb9, U+7cdf, U+7d6e, U+7f06, U+7f38, U+7fa1, U+7fc1, U+8015, U+803b, U+80a2, U+80aa, U+8116, U+813e, U+82bd, U+8305, U+8328, U+8346, U+846c, U+8549, U+859b, U+8611, U+8680, U+87f9, U+884d, U+8877, U+888d, U+88d4, U+898b, U+8a79, U+8a93, U+8c05, U+8c0d, U+8c26, U+8d1e, U+8d31, U+8d81, U+8e22, U+8e81, U+8f90, U+8f96, U+90ca, U+916c, U+917f, U+9187, U+918b, U+9499, U+94a9, U+9524, U+9540, U+958b, U+9600, U+9640, U+96b6, U+96c7, U+96ef, U+98d9, U+9976, U+997f, U+9a74, U+9a84, U+9c8d, U+9e26, U+9e9f, U+ad6d, U+c5b4, U+d55c, U+ff0f;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-084.woff2') format('woff2');
unicode-range: U+b0, U+2191, U+2460-2461, U+25c6, U+300e-300f, U+4e1b, U+4e7e, U+4ed5, U+4ef2, U+4f10, U+4f1e, U+4f50, U+4fa6, U+4faf, U+5021, U+50f5, U+5179, U+5180, U+51d1, U+522e, U+52a3, U+52c3, U+52cb, U+5300, U+5319, U+5320, U+5349, U+5395, U+53d9, U+541e, U+5428, U+543e, U+54b1, U+54c0, U+54d2, U+570b, U+5858, U+58f6, U+5974, U+59a5, U+59e8, U+59ec, U+5a36, U+5a9a, U+5ab3, U+5b99, U+5baa, U+5ce1, U+5d14, U+5d4c, U+5dc5, U+5de2, U+5e99, U+5e9e, U+5f18, U+5f66, U+5f70, U+6070, U+60d5, U+60e7, U+6101, U+611a, U+61be, U+6241, U+6252, U+626f, U+6296, U+62bc, U+62cc, U+6380, U+63a9, U+644a, U+6454, U+64a9, U+64b8, U+6500, U+6572, U+65a5, U+65a9, U+65ec, U+660f, U+6749, U+6795, U+67ab, U+68da, U+6912, U+6bbf, U+6bef, U+6cab, U+6cca, U+6ccc, U+6cfc, U+6d3d, U+6d78, U+6dee, U+6e17, U+6e34, U+6e83, U+6ea2, U+6eb6, U+6f20, U+6fa1, U+707f, U+70d8, U+70eb, U+714c, U+714e, U+7235, U+7239, U+73ca, U+743c, U+745c, U+7624, U+763e, U+76f2, U+77db, U+77e9, U+780d, U+7838, U+7845, U+78ca, U+796d, U+7a84, U+7aed, U+7b3c, U+7eb2, U+7f05, U+7f20, U+7f34, U+7f62, U+7fc5, U+7fd8, U+7ff0, U+800d, U+8036, U+80ba, U+80be, U+80c0-80c1, U+8155, U+817a, U+8180, U+81e3, U+8206, U+8247, U+8270, U+8299, U+82ad, U+8304, U+8393, U+83b9, U+840d, U+8427, U+8469, U+8471, U+84c4, U+84ec, U+853d, U+8681-8682, U+8721, U+8854, U+88d5, U+88f9, U+8bc0, U+8c0a, U+8c29, U+8c2d, U+8d41, U+8dea, U+8eb2, U+8f9f, U+903b, U+903e, U+9102, U+9493, U+94a5, U+94f8, U+95f7, U+9706, U+9709, U+9774, U+98a0, U+9e64, U+9f9f, U+e603;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-085.woff2') format('woff2');
unicode-range: U+200b, U+2103, U+4e18, U+4e27-4e28, U+4e38, U+4e59, U+4e8f, U+4ead, U+4ec7, U+4fe9, U+503a, U+5085, U+5146, U+51af, U+51f8, U+52ab, U+5339, U+535c, U+5378, U+538c, U+5398, U+53f9, U+5415, U+5475, U+54aa, U+54ac, U+54b8, U+5582, U+5760, U+5764, U+57cb, U+5835, U+5885, U+5951, U+5983, U+59da, U+5a77, U+5b5d, U+5b5f, U+5bb5, U+5bc2, U+5be8, U+5bfa, U+5c2c, U+5c34, U+5c41, U+5c48, U+5c65, U+5cad, U+5e06, U+5e42, U+5ef7, U+5f17, U+5f25, U+5f6d, U+5f79, U+6028, U+6064, U+6068, U+606d, U+607c, U+6094, U+6109, U+6124, U+6247, U+626d, U+6291, U+629a, U+62ac, U+62b9, U+62fe, U+6324, U+6349, U+6367, U+6398, U+6495, U+64a4, U+64b0, U+64bc, U+64ce, U+658c, U+65ed, U+6602, U+6674, U+6691, U+66a8, U+674f, U+679a, U+67ef, U+67f4, U+680b, U+6876, U+68a8, U+6a59, U+6a61, U+6b20, U+6bc5, U+6d12, U+6d46, U+6d8c, U+6dc0, U+6e14, U+6e23, U+6f06, U+7164, U+716e, U+7199, U+71e5, U+72ac, U+742a, U+755c, U+75ab, U+75b2, U+75f4, U+7897, U+78b3, U+78c5, U+7978, U+79fd, U+7a74, U+7b4b, U+7b5b, U+7ece, U+7ed2, U+7ee3, U+7ef3, U+7f50, U+7f55, U+7f9e, U+7fe0, U+809d, U+8106, U+814a, U+8154, U+817b, U+818f, U+81c2, U+81ed, U+821f, U+82a6, U+82d1, U+8302, U+83c7, U+83ca, U+845b, U+848b, U+84c9, U+85e4, U+86ee, U+8700, U+8774, U+8881, U+8c1c, U+8c79, U+8d2a, U+8d3c, U+8eba, U+8f70, U+8fa9, U+8fb1, U+900a, U+9017, U+901d, U+9022, U+906e, U+946b, U+94dd, U+94ed, U+953b, U+95ef, U+95fa, U+95fd, U+96c0, U+971e, U+9753, U+9756, U+97e6, U+9881, U+9887, U+9b4f, U+9e2d, U+9f0e, U+e601-e602, U+e604-e605, U+ff5c;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-086.woff2') format('woff2');
unicode-range: U+24, U+4e08, U+4e43, U+4e4f, U+4ef0, U+4f2a, U+507f, U+50ac, U+50bb, U+5151, U+51bb, U+51f6, U+51fd, U+5272, U+52fe, U+5362, U+53c9, U+53d4, U+53e0, U+543b, U+54f2, U+5507, U+5524, U+558a, U+55b5, U+561b, U+56ca, U+5782, U+57c3, U+5893, U+5915, U+5949, U+5962, U+59ae, U+59dc, U+59fb, U+5bd3, U+5c38, U+5cb3, U+5d07, U+5d29, U+5de1, U+5dfe, U+5e15, U+5eca, U+5f2f, U+5f7c, U+5fcc, U+6021, U+609f, U+60f9, U+6108, U+6148, U+6155, U+6170, U+61d2, U+6251, U+629b, U+62ab, U+62e8, U+62f3, U+6321, U+6350, U+6566, U+659c, U+65e8, U+6635, U+6655, U+6670, U+66f9, U+6734, U+679d, U+6851, U+6905, U+6b49, U+6b96, U+6c1b, U+6c41, U+6c6a, U+6c83, U+6cf3, U+6d9b, U+6dcb, U+6e1d, U+6e20-6e21, U+6eaa, U+6ee4, U+6ee9, U+6f58, U+70e4, U+722c, U+7262, U+7267, U+72b9, U+72e0, U+72ee, U+72f1, U+7334, U+73ab, U+7433, U+7470, U+758f, U+75d5, U+764c, U+7686, U+76c6, U+76fe, U+7720, U+77e2, U+7802, U+7816, U+788d, U+7891, U+7a00, U+7a9d, U+7b52, U+7bad, U+7c98, U+7cca, U+7eba, U+7eea, U+7ef5, U+7f1d, U+7f69, U+806a, U+809a, U+80bf, U+80c3, U+81c0, U+820c, U+82ac, U+82af, U+82cd, U+82d7, U+838e, U+839e, U+8404, U+84b8, U+852c, U+8587, U+8650, U+8679, U+86c7, U+8702, U+87ba, U+886b-886c, U+8870, U+8c10, U+8c23, U+8c6b, U+8d3e, U+8d4b-8d4c, U+8d64, U+8d6b, U+8d74, U+8e29, U+8f69, U+8f74, U+8fb0, U+8fdf, U+901b, U+9038, U+9093, U+9171, U+9489, U+94ae, U+94c3, U+9508, U+9510, U+9601, U+9614, U+964c, U+9675, U+971c, U+97f5, U+9888, U+98d8, U+9971, U+9aa4, U+9e3f, U+9e45, U+9e4f, U+9e70, U+9f7f, U+e715;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-087.woff2') format('woff2');
unicode-range: U+a5, U+2192, U+2605, U+4e11, U+4e22, U+4e32, U+4f0d, U+4f0f, U+4f69, U+4ff1, U+50b2, U+5154, U+51dd, U+51f0, U+5211, U+5269, U+533f, U+5366-5367, U+5389, U+5413, U+5440, U+5446, U+5561, U+574a, U+5751, U+57ab, U+5806, U+5821, U+582a, U+58f3, U+5938, U+5948, U+5978, U+59d1, U+5a03, U+5a07, U+5ac1, U+5acc, U+5ae9, U+5bb4, U+5bc4, U+5c3f, U+5e3d, U+5e7d, U+5f92, U+5faa, U+5fe0, U+5ffd, U+6016, U+60a0, U+60dc, U+60e8, U+614e, U+6212, U+6284, U+62c6, U+62d3-62d4, U+63f4, U+642c, U+6478, U+6491-6492, U+64e6, U+6591, U+65a4, U+664b, U+6735, U+6746, U+67f1, U+67f3, U+6842, U+68af, U+68c9, U+68cb, U+6a31, U+6b3a, U+6bc1, U+6c0f, U+6c27, U+6c57, U+6cc4, U+6ce5, U+6d2a, U+6d66, U+6d69, U+6daf, U+6e58, U+6ecb, U+6ef4, U+707e, U+7092, U+70ab, U+71d5, U+7275, U+7384, U+73b2, U+7434, U+74e6, U+74f7, U+75bc, U+76c8, U+76d0, U+7709, U+77ac, U+7855, U+78a7, U+78c1, U+7a77, U+7b79, U+7c92, U+7cae, U+7cd5, U+7ea4, U+7eb5, U+7ebd, U+7f5a, U+7fd4, U+7ffc, U+8083, U+8096, U+80a0, U+80d6, U+80de, U+8102, U+8109, U+810f, U+8179, U+8292, U+82b3, U+8352, U+8361, U+83cc, U+841d, U+8461, U+8482, U+8521, U+857e, U+85aa, U+866b, U+8776, U+8896, U+889c, U+88f8, U+8a9e, U+8bc8, U+8bf8, U+8c0b, U+8c28, U+8d2b, U+8d2f, U+8d37, U+8d3a, U+8d54, U+8dc3, U+8dcc, U+8df5, U+8e0f, U+8e48, U+8f86, U+8f88, U+8f9e, U+8fc1, U+8fc8, U+8feb, U+9065, U+90a6, U+90aa, U+90bb, U+90c1, U+94dc, U+9521, U+9676, U+96d5, U+970d, U+9897, U+997c, U+9a70, U+9a76, U+9a9a, U+9ad4, U+9e23, U+9e7f, U+9f3b, U+e675, U+e6b9, U+ffe5;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-088.woff2') format('woff2');
unicode-range: U+300c-300d, U+4e54, U+4e58, U+4e95, U+4ec1, U+4f2f, U+4f38, U+4fa3, U+4fca, U+503e, U+5141, U+5144, U+517c, U+51cc, U+51ed, U+5242, U+52b2, U+52d2, U+52e4, U+540a, U+5439, U+5448, U+5496, U+54ed, U+5565, U+5761, U+5766, U+58ee, U+593a, U+594b, U+594f, U+5954, U+5996, U+59c6, U+59ff, U+5b64, U+5bff, U+5c18, U+5c1d, U+5c97, U+5ca9, U+5cb8, U+5e9f, U+5ec9, U+5f04, U+5f7b, U+5fa1, U+5fcd, U+6012, U+60a6, U+60ac, U+60b2, U+60ef, U+626e, U+6270, U+6276, U+62d6, U+62dc, U+6316, U+632f, U+633a, U+6355, U+63aa, U+6447, U+649e, U+64c5, U+654c, U+65c1, U+65cb, U+65e6, U+6606, U+6731, U+675c, U+67cf, U+67dc, U+6846, U+6b8b, U+6beb, U+6c61, U+6c88, U+6cbf, U+6cdb, U+6cea, U+6d45, U+6d53, U+6d74, U+6d82, U+6da8, U+6db5, U+6deb, U+6eda, U+6ee8, U+6f0f, U+706d, U+708e, U+70ae, U+70bc, U+70c2, U+70e6, U+7237-7238, U+72fc, U+730e, U+731b, U+739b, U+73bb, U+7483, U+74dc, U+74f6, U+7586, U+7626, U+775b, U+77ff, U+788e, U+78b0, U+7956, U+7965, U+79e6, U+7af9, U+7bee, U+7c97, U+7eb1, U+7eb7, U+7ed1, U+7ed5, U+7f6a, U+7f72, U+7fbd, U+8017, U+808c, U+80a9, U+80c6, U+80ce, U+8150, U+8170, U+819c, U+820d, U+8230, U+8239, U+827e, U+8377, U+8389, U+83b2, U+8428, U+8463, U+867e, U+88c2, U+88d9, U+8986, U+8bca, U+8bde, U+8c13, U+8c8c, U+8d21, U+8d24, U+8d56, U+8d60, U+8d8b, U+8db4, U+8e2a, U+8f68, U+8f89, U+8f9b, U+8fa8, U+8fbd, U+9003, U+90ce, U+90ed, U+9189, U+94bb, U+9505, U+95f9, U+963b, U+9655, U+966a, U+9677, U+96fe, U+9896, U+99a8, U+9a71, U+9a82, U+9a91, U+9b45, U+9ece, U+9f20, U+feff, U+ff0d;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-089.woff2') format('woff2');
unicode-range: U+4e4c, U+4e88, U+4ea1, U+4ea6, U+4ed3-4ed4, U+4eff, U+4f30, U+4fa7, U+4fc4, U+4fd7, U+500d, U+504f, U+5076-5077, U+517d, U+5192, U+51c9, U+51ef, U+5238, U+5251, U+526a, U+52c7, U+52df, U+52ff, U+53a6, U+53a8, U+53ec, U+5410, U+559d, U+55b7, U+5634, U+573e, U+5783, U+585e, U+586b, U+58a8, U+5999, U+59d3, U+5a1c, U+5a46, U+5b54-5b55, U+5b85, U+5b8b, U+5b8f, U+5bbf, U+5bd2, U+5c16, U+5c24, U+5e05, U+5e45, U+5e7c, U+5e84, U+5f03, U+5f1f, U+5f31, U+5f84, U+5f90, U+5fbd, U+5fc6, U+5fd9, U+5fe7, U+6052, U+6062, U+6089, U+60a3, U+60d1, U+6167, U+622a, U+6234, U+624e, U+6269, U+626c, U+62b5, U+62d2, U+6325, U+63e1, U+643a, U+6446, U+6562, U+656c, U+65e2, U+65fa, U+660c, U+6628, U+6652, U+6668, U+6676, U+66fc, U+66ff, U+6717, U+676d, U+67aa, U+67d4, U+6843, U+6881, U+68d2, U+695a, U+69fd, U+6a2a, U+6b8a, U+6c60, U+6c64, U+6c9f, U+6caa, U+6cc9, U+6ce1, U+6cfd, U+6d1b, U+6d1e, U+6d6e, U+6de1, U+6e10, U+6e7f, U+6f5c, U+704c, U+7070, U+7089, U+70b8, U+718a, U+71c3, U+723d, U+732a, U+73cd, U+7518, U+756a, U+75af, U+75be, U+75c7, U+76d2, U+76d7, U+7763, U+78e8, U+795d, U+79df, U+7c4d, U+7d2f, U+7ee9, U+7f13, U+7f8a, U+8000, U+8010, U+80af, U+80f6, U+80f8, U+8212, U+8273, U+82f9, U+83ab, U+83b1, U+83f2, U+8584, U+871c, U+8861, U+888b, U+88c1, U+88e4, U+8bd1, U+8bf1, U+8c31, U+8d5a, U+8d75-8d76, U+8de8, U+8f85, U+8fa3, U+8fc5, U+9006, U+903c, U+904d, U+9075, U+9178, U+9274, U+950b, U+9526, U+95ea, U+9636, U+9686, U+978b, U+987f, U+9a7e, U+9b42, U+9e1f, U+9ea6, U+9f13, U+9f84, U+ff5e;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-090.woff2') format('woff2');
unicode-range: U+23, U+3d, U+4e01, U+4e39, U+4e73, U+4ecd, U+4ed9, U+4eea, U+4f0a, U+4f1f, U+4f5b, U+4fa0, U+4fc3, U+501f, U+50a8, U+515a, U+5175, U+51a0, U+51c0, U+51e1, U+51e4, U+5200, U+520a, U+5224, U+523a, U+52aa, U+52b1, U+52b3, U+5348, U+5353, U+5360, U+5371, U+5377, U+539a, U+541b, U+5434, U+547c, U+54e6, U+5510, U+5531, U+5609, U+56f0, U+56fa, U+5733, U+574f, U+5851, U+5854, U+5899, U+58c1, U+592e, U+5939, U+5976, U+5986, U+59bb, U+5a18, U+5a74, U+5b59, U+5b87, U+5b97, U+5ba0, U+5bab, U+5bbd-5bbe, U+5bf8, U+5c0a, U+5c3a, U+5c4a, U+5e16, U+5e1d, U+5e2d, U+5e8a, U+6015, U+602a, U+6050, U+6069, U+6162, U+61c2, U+6293, U+6297, U+62b1, U+62bd, U+62df, U+62fc, U+6302, U+635f, U+638c, U+63ed, U+6458, U+6469, U+6563, U+6620, U+6653, U+6696-6697, U+66dd, U+675f, U+676f-6770, U+67d0, U+67d3, U+684c, U+6865, U+6885, U+68b0, U+68ee, U+690d, U+6b23, U+6b32, U+6bd5, U+6c89, U+6d01, U+6d25, U+6d89, U+6da6, U+6db2, U+6df7, U+6ed1, U+6f02, U+70c8, U+70df, U+70e7, U+7126, U+7236, U+7259, U+731c, U+745e, U+74e3, U+751a, U+751c, U+7532, U+7545, U+75db, U+7761, U+7a0d, U+7b51, U+7ca4, U+7cd6, U+7d2b, U+7ea0, U+7eb9, U+7ed8, U+7f18, U+7f29, U+8033, U+804a, U+80a4-80a5, U+80e1, U+817f, U+829d, U+82e6, U+8336, U+840c, U+8499, U+864e, U+8651, U+865a, U+88ad, U+89e6, U+8bd7, U+8bfa, U+8c37, U+8d25, U+8d38, U+8ddd, U+8fea, U+9010, U+9012, U+906d, U+907f-9080, U+90d1, U+9177, U+91ca, U+94fa, U+9501, U+9634-9635, U+9694, U+9707, U+9738, U+9769, U+9a7b, U+9a97, U+9aa8, U+9b3c, U+9c81, U+9ed8;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-091.woff2') format('woff2');
unicode-range: U+26, U+3c, U+d7, U+4e4e, U+4e61, U+4e71, U+4ebf, U+4f26, U+5012, U+51ac, U+51b0, U+51b2, U+51b7, U+5218, U+521a, U+5220, U+5237, U+523b, U+526f, U+5385, U+53bf, U+53e5, U+53eb, U+53f3, U+53f6, U+5409, U+5438, U+54c8, U+54e5, U+552f, U+5584, U+5706, U+5723, U+5750, U+575a, U+5987-5988, U+59b9, U+59d0, U+59d4, U+5b88, U+5b9c, U+5bdf, U+5bfb, U+5c01, U+5c04, U+5c3e, U+5c4b, U+5c4f, U+5c9b, U+5cf0, U+5ddd, U+5de6, U+5de8, U+5e01, U+5e78, U+5e7b, U+5e9c, U+5ead, U+5ef6, U+5f39, U+5fd8, U+6000, U+6025, U+604b, U+6076, U+613f, U+6258, U+6263, U+6267, U+6298, U+62a2, U+62e5, U+62ec, U+6311, U+6377, U+6388-6389, U+63a2, U+63d2, U+641e, U+642d, U+654f, U+6551, U+6597, U+65cf, U+65d7, U+65e7, U+6682, U+66f2, U+671d, U+672b, U+6740, U+6751, U+6768, U+6811, U+6863, U+6982, U+6bd2, U+6cf0, U+6d0b, U+6d17, U+6d59, U+6dd8, U+6dfb, U+6e7e, U+6f6e, U+6fb3, U+706f, U+719f, U+72af, U+72d0, U+72d7, U+732b, U+732e, U+7389, U+73e0, U+7530, U+7687, U+76d6, U+76db, U+7840, U+786c, U+79cb, U+79d2, U+7a0e, U+7a33, U+7a3f, U+7a97, U+7ade-7adf, U+7b26, U+7e41, U+7ec3, U+7f3a, U+8089, U+80dc, U+811a, U+8131, U+8138, U+821e, U+8349, U+83dc, U+8457, U+867d, U+86cb, U+8a89, U+8ba8, U+8bad, U+8bef, U+8bfe, U+8c6a, U+8d1d, U+8d4f, U+8d62, U+8dd1, U+8df3, U+8f6e, U+8ff9, U+900f, U+9014, U+9057, U+9192, U+91ce, U+9488, U+94a2, U+9547, U+955c, U+95f2, U+9644, U+964d, U+96c4-96c5, U+96e8, U+96f6-96f7, U+9732, U+9759, U+9760, U+987a, U+989c, U+9910, U+996d-996e, U+9b54, U+9e21, U+9ebb, U+9f50;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-092.woff2') format('woff2');
unicode-range: U+7e, U+2026, U+4e03, U+4e25, U+4e30, U+4e34, U+4e45, U+4e5d, U+4e89, U+4eae, U+4ed8, U+4f11, U+4f19, U+4f24, U+4f34, U+4f59, U+4f73, U+4f9d, U+4fb5, U+5047, U+505c, U+5170, U+519c, U+51cf, U+5267, U+5356, U+5374, U+5382, U+538b, U+53e6, U+5426, U+542b, U+542f, U+5462, U+5473, U+554a, U+5566, U+5708, U+571f, U+5757, U+57df, U+57f9, U+5802, U+590f, U+591c, U+591f, U+592b, U+5965, U+5979, U+5a01, U+5a5a, U+5b63, U+5b69, U+5b81, U+5ba1, U+5ba3, U+5c3c, U+5c42, U+5c81, U+5de7, U+5dee, U+5e0c, U+5e10, U+5e55, U+5e86, U+5e8f, U+5ea7, U+5f02, U+5f52, U+5f81, U+5ff5, U+60ca, U+60e0, U+6279, U+62c5, U+62ff, U+63cf, U+6444, U+64cd, U+653b, U+65bd, U+65e9, U+665a, U+66b4, U+66fe, U+6728, U+6742, U+677e, U+67b6, U+680f, U+68a6, U+68c0, U+699c, U+6b4c, U+6b66, U+6b7b, U+6bcd, U+6bdb, U+6c38, U+6c47, U+6c49, U+6cb3, U+6cb9, U+6ce2, U+6d32, U+6d3e, U+6d4f, U+6e56, U+6fc0, U+7075, U+7206, U+725b, U+72c2, U+73ed, U+7565, U+7591, U+7597, U+75c5, U+76ae, U+76d1, U+76df, U+7834, U+7968, U+7981, U+79c0, U+7a7f, U+7a81, U+7ae5, U+7b14, U+7c89, U+7d27, U+7eaf, U+7eb3, U+7eb8, U+7ec7, U+7ee7, U+7eff, U+7f57, U+7ffb, U+805a, U+80a1, U+822c, U+82cf, U+82e5, U+8363, U+836f, U+84dd, U+878d, U+8840, U+8857, U+8863, U+8865, U+8b66, U+8bb2, U+8bda, U+8c01, U+8c08, U+8c46, U+8d1f, U+8d35, U+8d5b, U+8d5e, U+8da3, U+8ddf, U+8f93, U+8fdd, U+8ff0, U+8ff7, U+8ffd, U+9000, U+9047, U+9152, U+949f, U+94c1, U+94f6, U+9646, U+9648, U+9669, U+969c, U+96ea, U+97e9, U+987b, U+987e, U+989d, U+9970, U+9986, U+9c7c, U+9c9c;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-093.woff2') format('woff2');
unicode-range: U+25, U+4e14, U+4e1d, U+4e3d, U+4e49, U+4e60, U+4e9a, U+4eb2, U+4ec5, U+4efd, U+4f3c, U+4f4f, U+4f8b, U+4fbf, U+5019, U+5145, U+514b, U+516b, U+516d, U+5174, U+5178, U+517b, U+5199, U+519b, U+51b3, U+51b5, U+5207, U+5212, U+5219, U+521d, U+52bf, U+533b, U+5343, U+5347, U+534a, U+536b, U+5370, U+53e4, U+53f2, U+5403, U+542c, U+547d, U+54a8, U+54cd, U+54ea, U+552e, U+56f4, U+5747, U+575b, U+5883, U+589e, U+5931, U+5947, U+5956-5957, U+5a92, U+5b83, U+5ba4, U+5bb3, U+5bcc, U+5c14, U+5c1a, U+5c3d, U+5c40, U+5c45, U+5c5e, U+5df4, U+5e72, U+5e95, U+5f80, U+5f85, U+5fb7, U+5fd7, U+601d, U+626b, U+627f, U+62c9, U+62cd, U+6309, U+63a7, U+6545, U+65ad, U+65af, U+65c5, U+666e, U+667a, U+670b, U+671b, U+674e, U+677f, U+6781, U+6790, U+6797, U+6821, U+6838-6839, U+697c, U+6b27, U+6b62, U+6bb5, U+6c7d, U+6c99, U+6d4b, U+6d4e, U+6d6a, U+6e29, U+6e2f, U+6ee1, U+6f14, U+6f2b, U+72b6, U+72ec, U+7387, U+7533, U+753b, U+76ca, U+76d8, U+7701, U+773c, U+77ed, U+77f3, U+7814, U+793c, U+79bb, U+79c1, U+79d8, U+79ef, U+79fb, U+7a76, U+7b11, U+7b54, U+7b56, U+7b97, U+7bc7, U+7c73, U+7d20, U+7eaa, U+7ec8, U+7edd, U+7eed, U+7efc, U+7fa4, U+804c, U+8058, U+80cc, U+8111, U+817e, U+826f, U+8303, U+843d, U+89c9, U+89d2, U+8ba2, U+8bbf, U+8bc9, U+8bcd, U+8be6, U+8c22, U+8c61, U+8d22, U+8d26-8d27, U+8d8a, U+8f6f, U+8f7b, U+8f83, U+8f91, U+8fb9, U+8fd4, U+8fdc, U+9002, U+94b1, U+9519, U+95ed, U+961f, U+9632-9633, U+963f, U+968f-9690, U+96be, U+9876, U+9884, U+98de, U+9988, U+9999, U+9ec4, U+ff1b;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-094.woff2') format('woff2');
unicode-range: U+2b, U+40, U+3000, U+300a-300b, U+4e16, U+4e66, U+4e70, U+4e91-4e92, U+4e94, U+4e9b, U+4ec0, U+4eca, U+4f01, U+4f17-4f18, U+4f46, U+4f4e, U+4f9b, U+4fee, U+503c, U+5065, U+50cf, U+513f, U+5148, U+518d, U+51c6, U+51e0, U+5217, U+529e-529f, U+5341, U+534f, U+5361, U+5386, U+53c2, U+53c8, U+53cc, U+53d7-53d8, U+53ea, U+5404, U+5411, U+5417, U+5427, U+5468, U+559c, U+5668, U+56e0, U+56e2, U+56ed, U+5740, U+57fa, U+58eb, U+5904, U+592a, U+59cb, U+5a31, U+5b58, U+5b9d, U+5bc6, U+5c71, U+5dde, U+5df1, U+5e08, U+5e26, U+5e2e, U+5e93, U+5e97, U+5eb7, U+5f15, U+5f20, U+5f3a, U+5f62, U+5f69, U+5f88, U+5f8b, U+5fc5, U+600e, U+620f, U+6218, U+623f, U+627e, U+628a, U+62a4, U+62db, U+62e9, U+6307, U+6362, U+636e, U+64ad, U+6539, U+653f, U+6548, U+6574, U+6613, U+6625, U+663e, U+666f, U+672a, U+6750, U+6784, U+6a21, U+6b3e, U+6b65, U+6bcf, U+6c11, U+6c5f, U+6df1, U+706b, U+7167, U+724c, U+738b, U+73a9, U+73af, U+7403, U+7537, U+754c, U+7559, U+767d, U+7740, U+786e, U+795e, U+798f, U+79f0, U+7aef, U+7b7e, U+7bb1, U+7ea2, U+7ea6, U+7ec4, U+7ec6, U+7ecd, U+7edc, U+7ef4, U+8003, U+80b2, U+81f3-81f4, U+822a, U+827a, U+82f1, U+83b7, U+8425, U+89c2, U+89c8, U+8ba9, U+8bb8, U+8bc6, U+8bd5, U+8be2, U+8be5, U+8bed, U+8c03, U+8d23, U+8d2d, U+8d34, U+8d70, U+8db3, U+8fbe, U+8fce, U+8fd1, U+8fde, U+9001, U+901f-9020, U+90a3, U+914d, U+91c7, U+94fe, U+9500, U+952e, U+9605, U+9645, U+9662, U+9664, U+9700, U+9752, U+975e, U+97f3, U+9879, U+9886, U+98df, U+9a6c, U+9a8c, U+9ed1, U+9f99;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-095.woff2') format('woff2');
unicode-range: U+4e, U+201c-201d, U+3010-3011, U+4e07, U+4e1c, U+4e24, U+4e3e, U+4e48, U+4e50, U+4e5f, U+4e8b-4e8c, U+4ea4, U+4eab-4eac, U+4ecb, U+4ece, U+4ed6, U+4ee3, U+4ef6-4ef7, U+4efb, U+4f20, U+4f55, U+4f7f, U+4fdd, U+505a, U+5143, U+5149, U+514d, U+5171, U+5177, U+518c, U+51fb, U+521b, U+5229, U+522b, U+52a9, U+5305, U+5317, U+534e, U+5355, U+5357, U+535a, U+5373, U+539f, U+53bb, U+53ca, U+53cd, U+53d6, U+53e3, U+53f0, U+5458, U+5546, U+56db, U+573a, U+578b, U+57ce, U+58f0, U+590d, U+5934, U+5973, U+5b57, U+5b8c, U+5b98, U+5bb9, U+5bfc, U+5c06, U+5c11, U+5c31, U+5c55, U+5df2, U+5e03, U+5e38, U+5e76, U+5e94, U+5efa, U+5f71, U+5f97, U+5feb, U+6001, U+603b, U+60f3, U+611f, U+6216, U+624d, U+6253, U+6295, U+6301, U+6392, U+641c, U+652f, U+653e, U+6559, U+6599, U+661f, U+671f, U+672f, U+6761, U+67e5, U+6807, U+6837, U+683c, U+6848, U+6b22, U+6b64, U+6bd4, U+6c14, U+6c34, U+6c42, U+6ca1, U+6d41, U+6d77, U+6d88, U+6e05, U+6e38, U+6e90, U+7136, U+7231, U+7531, U+767e, U+76ee, U+76f4, U+771f, U+7801, U+793a, U+79cd, U+7a0b, U+7a7a, U+7acb, U+7ae0, U+7b2c, U+7b80, U+7ba1, U+7cbe, U+7d22, U+7ea7, U+7ed3, U+7ed9, U+7edf, U+7f16, U+7f6e, U+8001, U+800c, U+8272, U+8282, U+82b1, U+8350, U+88ab, U+88c5, U+897f, U+89c1, U+89c4, U+89e3, U+8a00, U+8ba1, U+8ba4, U+8bae-8bb0, U+8bbe, U+8bc1, U+8bc4, U+8bfb, U+8d28, U+8d39, U+8d77, U+8d85, U+8def, U+8eab, U+8f66, U+8f6c, U+8f7d, U+8fd0, U+9009, U+90ae, U+90fd, U+91cc-91cd, U+91cf, U+95fb, U+9650, U+96c6, U+9891, U+98ce, U+ff1f;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-096.woff2') format('woff2');
unicode-range: U+d, U+3e, U+5f, U+7c, U+a0, U+a9, U+4e09-4e0b, U+4e0d-4e0e, U+4e13, U+4e1a, U+4e2a, U+4e3a-4e3b, U+4e4b, U+4e86, U+4e8e, U+4ea7, U+4eba, U+4ee4-4ee5, U+4eec, U+4f1a, U+4f4d, U+4f53, U+4f5c, U+4f60, U+4fe1, U+5165, U+5168, U+516c, U+5173, U+5176, U+5185, U+51fa, U+5206, U+5230, U+5236, U+524d, U+529b, U+52a0-52a1, U+52a8, U+5316, U+533a, U+53cb, U+53d1, U+53ef, U+53f7-53f8, U+5408, U+540c-540e, U+544a, U+548c, U+54c1, U+56de, U+56fd-56fe, U+5728, U+5730, U+5907, U+5916, U+591a, U+5927, U+5929, U+597d, U+5982, U+5b50, U+5b66, U+5b89, U+5b9a, U+5b9e, U+5ba2, U+5bb6, U+5bf9, U+5c0f, U+5de5, U+5e02, U+5e73-5e74, U+5e7f, U+5ea6, U+5f00, U+5f0f, U+5f53, U+5f55, U+5fae, U+5fc3, U+6027, U+606f, U+60a8, U+60c5, U+610f, U+6210-6211, U+6237, U+6240, U+624b, U+6280, U+62a5, U+63a5, U+63a8, U+63d0, U+6536, U+6570, U+6587, U+65b9, U+65e0, U+65f6, U+660e, U+662d, U+662f, U+66f4, U+6700, U+670d, U+672c, U+673a, U+6743, U+6765, U+679c, U+682a, U+6b21, U+6b63, U+6cbb, U+6cd5, U+6ce8, U+6d3b, U+70ed, U+7247-7248, U+7269, U+7279, U+73b0, U+7406, U+751f, U+7528, U+7535, U+767b, U+76f8, U+770b, U+77e5, U+793e, U+79d1, U+7ad9, U+7b49, U+7c7b, U+7cfb, U+7ebf, U+7ecf, U+7f8e, U+8005, U+8054, U+80fd, U+81ea, U+85cf, U+884c, U+8868, U+8981, U+89c6, U+8bba, U+8bdd, U+8bf4, U+8bf7, U+8d44, U+8fc7, U+8fd8-8fd9, U+8fdb, U+901a, U+9053, U+90e8, U+91d1, U+957f, U+95e8, U+95ee, U+95f4, U+9762, U+9875, U+9898, U+9996, U+9ad8, U+ff01, U+ff08-ff09;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-097.woff2') format('woff2');
unicode-range: U+20-22, U+27-2a, U+2c-3b, U+3f, U+41-4d, U+4f-5d, U+61-7b, U+7d, U+ab, U+ae, U+b2, U+b7, U+bb, U+df-e5, U+e7-ea, U+ec-ed, U+f1-f4, U+f6, U+f9-fa, U+fc, U+101, U+103, U+113, U+12b, U+148, U+14d, U+16b, U+1ce, U+1d0, U+300-301, U+1ebf, U+1ec7, U+2013-2014, U+2022, U+2027, U+2039-203a, U+2122, U+3001-3002, U+3042, U+3044, U+3046, U+3048, U+304a-3055, U+3057, U+3059-305b, U+305d, U+305f-3061, U+3063-306b, U+306d-3073, U+3075-3076, U+3078-3079, U+307b, U+307e-307f, U+3081-308d, U+308f, U+3092-3093, U+30a1-30a4, U+30a6-30bb, U+30bd, U+30bf-30c1, U+30c3-30c4, U+30c6-30cb, U+30cd-30d7, U+30d9-30e1, U+30e3-30e7, U+30e9-30ed, U+30ef, U+30f3, U+30fb-30fc, U+3127, U+4e00, U+4e2d, U+65b0, U+65e5, U+6708-6709, U+70b9, U+7684, U+7f51, U+ff0c, U+ff0e, U+ff1a;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-098.woff2') format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-099.woff2') format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-100.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Noto Sans SC';
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url('/fonts/noto-sans-sc/noto-sc-101.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+1227 -103
View File
File diff suppressed because it is too large Load Diff
+8 -13
View File
@@ -1,26 +1,21 @@
import type { Metadata } from "next"; import type { Metadata, Viewport } from "next";
import Link from "next/link"; import { AppShell } from "@/components/app-shell";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "qlib-platform · A股量化研究", title: "qlib-platform · A股量化研究",
description: "个人 A 股量化研究平台:股票池 / 因子 / 回测", description: "个人 A 股量化研究平台:股票池 / 因子研究 / 因子组合 / 回测 / 实验归档",
};
export const viewport: Viewport = {
themeColor: "#060912",
}; };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="zh-CN"> <html lang="zh-CN">
<body> <body>
<nav> <AppShell>{children}</AppShell>
<span className="brand">qlib-platform</span>
<Link href="/">总览</Link>
<Link href="/stocks">股票池</Link>
<Link href="/factors">因子研究</Link>
<Link href="/factors/compose">因子组合</Link>
<Link href="/backtest">回测</Link>
<Link href="/experiments">实验</Link>
</nav>
<main>{children}</main>
</body> </body>
</html> </html>
); );
+126 -27
View File
@@ -1,49 +1,148 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import type { Stock, FactorMeta } from "@/lib/types"; import type { Stock, FactorMeta } from "@/lib/types";
import { PageHeader, Card, Metric, Banner, Loading } from "@/components/ui";
import { Icon as Ic } from "@/components/icons";
interface LoadState {
stocks: number | null;
factors: number | null;
loading: boolean;
error: string;
}
const LINKS = [
{
href: "/factors",
title: "因子研究",
desc: "查看行情因子目录,勾选运行 IC / RankIC 单因子测试",
icon: "flask" as const,
tone: "quick-card--violet",
},
{
href: "/factors/compose",
title: "因子组合",
desc: "多因子 z-score 叠加打分,回测 Top N 等权选股组合",
icon: "layers" as const,
tone: "quick-card--pos",
},
{
href: "/backtest",
title: "选股回测",
desc: "单因子完整回测:净值 / 回撤 / 月度收益 / 持仓",
icon: "gauge" as const,
tone: "quick-card--warn",
},
{
href: "/stocks",
title: "股票池",
desc: "浏览本地股票基础信息(Tushare 同步,全市场 5556 只)",
icon: "candles" as const,
tone: "",
},
];
export default function DashboardPage() { export default function DashboardPage() {
const [stockCount, setStockCount] = useState<number | null>(null); const [st, setSt] = useState<LoadState>({
const [factorCount, setFactorCount] = useState<number | null>(null); stocks: null,
const [error, setError] = useState(""); factors: null,
loading: true,
error: "",
});
useEffect(() => { useEffect(() => {
Promise.all([ let alive = true;
apiGet<Stock[]>("/stocks?limit=500"), Promise.all([apiGet<Stock[]>("/stocks?limit=500"), apiGet<FactorMeta[]>("/factors")])
apiGet<FactorMeta[]>("/factors"),
])
.then(([stocks, factors]) => { .then(([stocks, factors]) => {
setStockCount(stocks.length); if (!alive) return;
setFactorCount(factors.length); setSt({
stocks: stocks.length,
factors: factors.length,
loading: false,
error: "",
});
}) })
.catch((e: Error) => setError(e.message)); .catch((e: Error) => {
if (!alive) return;
setSt({ stocks: null, factors: null, loading: false, error: e.message });
});
return () => {
alive = false;
};
}, []); }, []);
return ( return (
<> <>
<h1>总览</h1> <PageHeader
{error && <div className="error">后端不可达:{error}</div>} title="研究总览"
<div className="stat-grid"> sub="个人 A 股量化研究平台:以 Tushare 行情为数据底座、Qlib 为研究引擎,覆盖选股、因子研究、组合回测与实验归档。"
<div className="stat"> />
<div className="label">股票池</div>
<div className="value">{stockCount ?? "—"}</div> {st.error ? (
</div> <Banner tone="error">
<div className="stat"> 后端不可达:{st.error}。请确认 FastAPI 服务已启动(scripts/dev.sh start)。
<div className="label">内置因子</div> </Banner>
<div className="value">{factorCount ?? "—"}</div> ) : null}
{st.loading ? (
<div className="metric-grid">
<div className="metric">
<Loading label="正在读取股票池…" />
</div> </div>
</div> </div>
<div className="card" style={{ marginTop: 16 }}> ) : (
<h2>说明</h2> <div className="metric-grid">
<div className="muted"> <Metric
个人 A 股量化研究平台(Phase 3 Web)。 label="股票池"
顶部导航进入「股票池 / 因子研究 / 回测」;研究基于本地 SQLite 行情 icon="candles"
(Tushare 同步,20 只权重股 2023–2024 日线),因子测试与回测均由后端 value={st.stocks?.toLocaleString() ?? "—"}
Research Specification 驱动并返回标准化结果。 sub="已建档基础信息"
/>
<Metric
label="内置因子"
icon="flask"
value={st.factors?.toLocaleString() ?? "—"}
sub="行情因子 · 无未来函数"
/>
</div> </div>
)}
<Card title="快速开始" icon="spark" sub="四个研究入口,从数据到结论" flush={false}>
<div className="quick-cards" style={{ marginBottom: 0 }}>
{LINKS.map((l) => (
<Link key={l.href} href={l.href} className={`quick-card ${l.tone}`}>
<span className="quick-ic">
<Ic name={l.icon} size={19} />
</span>
<span>
<h3>{l.title}</h3>
<p>{l.desc}</p>
<span className="go">
进入 <Ic name="arrowUpRight" size={13} />
</span>
</span>
</Link>
))}
</div> </div>
</Card>
<Card title="研究须知" icon="book">
<ul className="steplist">
<li>
因子/回测请求经 <b>异步 Job</b> 后台执行并自动归档为实验(Experiment),可在「实验」页查看与复跑。
</li>
<li>
数据底座为<b>本地 SQLite + Parquet</b>:行情经 <code>sync daily --resume</code> 增量同步;
新浪数据仅在「两边一致」校验通过后兜底入库(<code>source=sina</code>)。
</li>
<li>
研究默认<b>禁止未来函数</b>:财务按公告日可见、因子按已披露数据计算;回测含手续费 / 印花税 / 涨跌停等近似约束(如实标注)。
</li>
</ul>
</Card>
</> </>
); );
} }
+167 -19
View File
@@ -1,38 +1,125 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { apiGet } from "@/lib/api"; import { apiGet } from "@/lib/api";
import type { Stock } from "@/lib/types"; import type { Stock } from "@/lib/types";
import {
PageHeader,
Card,
Banner,
Empty,
Pill,
SkeletonLines,
SearchBox,
} from "@/components/ui";
import { Icon } from "@/components/icons";
const LIMIT = 300;
function marketOf(symbol: string): string {
return symbol.slice(-2);
}
function statusView(s: Stock) {
switch (s.status) {
case "L":
return (
<Pill tone="pos">
上市
</Pill>
);
case "D":
return <Pill tone="neg">退市</Pill>;
case "P":
return <Pill tone="warn">暂停</Pill>;
default:
return <Pill>{s.status}</Pill>;
}
}
export default function StocksPage() { export default function StocksPage() {
const [stocks, setStocks] = useState<Stock[]>([]); const [stocks, setStocks] = useState<Stock[]>([]);
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [reloadKey, setReloadKey] = useState(0);
const first = useRef(true);
useEffect(() => { const load = useCallback(async () => {
apiGet<Stock[]>(`/stocks?limit=300&q=${encodeURIComponent(q)}`) setError("");
.then(setStocks) try {
.catch((e: Error) => setError(e.message)); const list = await apiGet<Stock[]>(
`/stocks?limit=${LIMIT}&q=${encodeURIComponent(q)}`,
);
setStocks(list);
} catch (e) {
setError((e as Error).message);
setStocks([]);
} finally {
setLoading(false);
}
}, [q]); }, [q]);
// 输入防抖 250ms 后再请求
useEffect(() => {
if (first.current) {
first.current = false;
void load();
return;
}
setLoading(true);
const t = window.setTimeout(() => void load(), 250);
return () => window.clearTimeout(t);
}, [q, reloadKey, load]);
return ( return (
<> <>
<h1>股票池</h1> <PageHeader
<div className="row" style={{ marginBottom: 12 }}> title="股票池"
<input sub="本地股票基础信息(Tushare 同步)。支持按代码 / 名称模糊搜索,最多展示 300 条。"
placeholder="按代码 / 名称搜索(如 600519 或 茅台)" actions={
<>
<Pill tone="accent" icon="candles">
共 {stocks.length} 条
</Pill>
<button
type="button"
className="btn btn--sm"
aria-label="刷新列表"
onClick={() => {
setLoading(true);
setReloadKey((k) => k + 1);
}}
>
<Icon name="refresh" size={14} />
刷新
</button>
</>
}
/>
{error ? (
<Banner tone="error">加载失败:{error}</Banner>
) : null}
<div className="row" style={{ marginBottom: 14 }}>
<SearchBox
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={setQ}
onClear={() => setQ("")}
placeholder="按代码 / 名称搜索,如 600519 或 茅台"
label="搜索股票"
/> />
<span className="muted">共 {stocks.length} 条</span>
</div> </div>
{error && <div className="error">{error}</div>}
<div className="card"> <Card flush>
<table> <div className="table-wrap">
<table className="tbl">
<thead> <thead>
<tr> <tr>
<th>代码</th> <th>代码</th>
<th>名称</th> <th>名称</th>
<th>市场</th>
<th>行业</th> <th>行业</th>
<th>地区</th> <th>地区</th>
<th>上市日</th> <th>上市日</th>
@@ -42,17 +129,78 @@ export default function StocksPage() {
<tbody> <tbody>
{stocks.map((s) => ( {stocks.map((s) => (
<tr key={s.symbol}> <tr key={s.symbol}>
<td>{s.symbol}</td> <td className="cell-mono cell-strong">{s.symbol}</td>
<td>{s.name}</td> <td>{s.name}</td>
<td>{s.industry ?? "—"}</td> <td>
<td>{s.area ?? "—"}</td> <span className="tag tag--mono">{marketOf(s.symbol)}</span>
<td>{s.list_date}</td> </td>
<td>{s.status}</td> <td>{s.industry ? <span className="tag">{s.industry}</span> : <span className="hint">—</span>}</td>
<td className="hint">{s.area ?? "—"}</td>
<td className="cell-mono">{s.list_date}</td>
<td>{statusView(s)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{loading && !error && stocks.length === 0 ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
</div>
) : null}
{!loading && !error && stocks.length === 0 ? (
<Empty
icon="search"
title={q ? "没有匹配的股票" : "股票池为空"}
hint={
q
? `「${q}」没有命中。试试更短的代码或名称关键词。`
: "请先通过后端同步基础信息:uv run python -m app.cli.sync basic"
}
/>
) : null}
{stocks.length > 0 ? (
<div
className="hint"
style={{
padding: "8px 16px",
borderTop: "1px solid var(--line)",
display: "flex",
alignItems: "center",
gap: 14,
}}
>
<span>显示前 {stocks.length} 条(接口上限 {LIMIT})</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--pos)" />
上市
</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--warn)" />
暂停
</span>
<span className="row" style={{ gap: 4 }}>
<Dot color="var(--neg)" />
退市
</span>
</div>
) : null}
</Card>
</> </>
); );
} }
function Dot({ color }: { color: string }) {
return (
<span
aria-hidden="true"
style={{
display: "inline-block",
width: 8,
height: 8,
borderRadius: "50%",
background: color,
}}
/>
);
}
+70 -13
View File
@@ -1,49 +1,106 @@
"use client"; "use client";
/** 基于 ECharts 的折线图(净值 / 回撤),按需引入模块以压缩 bundle。 */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import * as echarts from "echarts"; import * as echarts from "echarts/core";
import { LineChart as EChartsLine } from "echarts/charts";
import { GridComponent, TitleComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([EChartsLine, GridComponent, TitleComponent, TooltipComponent, CanvasRenderer]);
export interface XY { export interface XY {
date: string; date: string;
value: number; value: number;
} }
const C = {
text: "#a8b3c9",
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
};
export function LineChart({ export function LineChart({
title, title,
data, data,
color = "#38bdf8", color = "#3fb6ff",
fill = false,
height = 320,
yFmt,
xFmt,
}: { }: {
title: string; title?: string;
data: XY[]; data: XY[];
color?: string; color?: string;
/** 线下渐变面积(净值类曲线推荐开启) */
fill?: boolean;
height?: number;
yFmt?: (v: number) => string;
xFmt?: (v: string) => string;
}) { }) {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!ref.current || data.length === 0) return; if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current); const chart = echarts.init(ref.current);
const areaColor = {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: color + "3d" },
{ offset: 1, color: color + "00" },
],
};
chart.setOption({ chart.setOption({
title: { text: title, textStyle: { color: "#e2e8f0", fontSize: 13 } }, backgroundColor: "transparent",
tooltip: { trigger: "axis" }, title: title
grid: { left: 60, right: 20, top: 40, bottom: 30 }, ? {
text: title,
textStyle: { color: "#e9eef7", fontSize: 13, fontWeight: 600 },
}
: undefined,
tooltip: {
trigger: "axis",
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
valueFormatter: (v: unknown) =>
typeof v === "number" ? (yFmt ? yFmt(v) : String(v)) : String(v),
},
grid: { left: 8, right: 14, top: title ? 42 : 18, bottom: 8, containLabel: true },
xAxis: { xAxis: {
type: "category", type: "category",
boundaryGap: false,
data: data.map((p) => p.date), data: data.map((p) => p.date),
axisLabel: { color: "#94a3b8", fontSize: 10 }, axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: {
color: C.faint,
fontSize: 10,
formatter: (v: string) => (xFmt ? xFmt(v) : v),
},
}, },
yAxis: { yAxis: {
type: "value", type: "value",
scale: true, scale: true,
axisLabel: { color: "#94a3b8" }, axisLabel: { color: C.faint, fontSize: 10, formatter: yFmt },
splitLine: { lineStyle: { color: "#334155" } }, splitLine: { lineStyle: { color: C.line } },
}, },
series: [ series: [
{ {
type: "line", type: "line",
showSymbol: false, showSymbol: false,
symbol: "circle",
data: data.map((p) => p.value), data: data.map((p) => p.value),
lineStyle: { color, width: 1.6 }, lineStyle: { color, width: 1.8 },
itemStyle: { color }, itemStyle: { color },
areaStyle: fill ? { color: areaColor } : undefined,
emphasis: { focus: "series" },
}, },
], ],
}); });
@@ -53,8 +110,8 @@ export function LineChart({
window.removeEventListener("resize", onResize); window.removeEventListener("resize", onResize);
chart.dispose(); chart.dispose();
}; };
}, [data, color, title]); }, [data, color, fill, title, height, yFmt, xFmt]);
if (data.length === 0) return <div className="muted">暂无数据</div>; if (data.length === 0) return <div className="hint">暂无数据</div>;
return <div ref={ref} style={{ height: 360, width: "100%" }} />; return <div ref={ref} style={{ height, width: "100%" }} />;
} }
+146
View File
@@ -0,0 +1,146 @@
"use client";
/** 应用外壳:桌面左侧导航栏 + 窄屏顶栏与抽屉(usePathname 高亮当前页)。 */
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Icon, type IconName } from "@/components/icons";
interface NavItem {
href: string;
label: string;
icon: IconName;
}
const GROUPS: { title: string; items: NavItem[] }[] = [
{
title: "研究",
items: [
{ href: "/", label: "总览", icon: "grid" },
{ href: "/stocks", label: "股票池", icon: "candles" },
{ href: "/factors", label: "因子研究", icon: "flask" },
{ href: "/factors/compose", label: "因子组合", icon: "layers" },
{ href: "/backtest", label: "选股回测", icon: "gauge" },
],
},
{
title: "归档",
items: [{ href: "/experiments", label: "实验", icon: "archive" }],
},
];
function isActive(href: string, pathname: string): boolean {
if (href === "/") return pathname === "/";
if (href === "/factors") return pathname === "/factors";
if (pathname === href || pathname.startsWith(href + "/")) return true;
return false;
}
function Brand() {
return (
<Link href="/" className="brand" style={{ textDecoration: "none" }}>
<span className="logo">
<Icon name="logo" size={18} />
</span>
<span>
qlib-platform
<small>个人 A 股量化研究台</small>
</span>
</Link>
);
}
function NavLinks({ pathname, onNavigate }: { pathname: string; onNavigate?: () => void }) {
return (
<>
{GROUPS.map((g) => (
<div key={g.title}>
<div className="nav-label">{g.title}</div>
{g.items.map((item) => {
const active = isActive(item.href, pathname);
return (
<Link
key={item.href}
href={item.href}
className={active ? "nav-link active" : "nav-link"}
aria-current={active ? "page" : undefined}
onClick={onNavigate}
>
<span className="nav-ic">
<Icon name={item.icon} size={17} />
</span>
{item.label}
</Link>
);
})}
</div>
))}
</>
);
}
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
// 路由变化时收起抽屉
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = "";
};
}, [open]);
return (
<div className="shell">
<aside className="rail nav" aria-label="主导航">
<Brand />
<NavLinks pathname={pathname} />
<div className="rail-foot">
数据源 Tushare 首选 · 新浪校验兜底
<br />
研究引擎 Qlib(本地 SQLite / Parquet)
</div>
</aside>
<div className="topbar">
<button
type="button"
className="icon-btn"
aria-label="打开导航菜单"
aria-expanded={open}
onClick={() => setOpen(true)}
style={{ width: 36, height: 36 }}
>
<Icon name="filter" size={18} />
</button>
<Brand />
</div>
{open ? (
<div className="scrim show" aria-hidden="true" onClick={() => setOpen(false)} />
) : null}
<aside
className={open ? "drawer nav open" : "drawer nav"}
aria-label="移动端导航"
aria-hidden={!open}
>
{open ? <NavLinks pathname={pathname} onNavigate={() => setOpen(false)} /> : null}
</aside>
<main className="content">
<div className="page">{children}</div>
</main>
</div>
);
}
+240
View File
@@ -0,0 +1,240 @@
/**
* 内联 SVG 图标集(Phosphor 风格线性图标,1.6px 描边,stroke=currentColor)。
* 不引入第三方图标库(离线可用),禁止用 emoji 作结构图标。
*
* 用法:<Icon name="search" size={16} /> 或 <Icon.Home .../>
*/
import type { ReactNode, SVGProps } from "react";
export type IconName =
| "logo"
| "grid"
| "candles"
| "flask"
| "layers"
| "gauge"
| "archive"
| "search"
| "x"
| "chevDown"
| "chevRight"
| "arrowUpRight"
| "arrowUp"
| "arrowDown"
| "play"
| "refresh"
| "spinner"
| "alert"
| "check"
| "info"
| "database"
| "calendar"
| "target"
| "chartLine"
| "scale"
| "book"
| "link"
| "spark"
| "clock"
| "filter";
const P: Record<
IconName,
(p: { filled?: boolean }) => ReactNode
> = {
logo: () => (
<>
<path d="M4 20V4h4v6h4V4h4v10h4V4h4v16H4z" fill="currentColor" stroke="none" />
<path d="M3 20h18" strokeWidth="2.4" strokeLinecap="round" />
</>
),
grid: () => (
<>
<rect x="3.5" y="3.5" width="7" height="7" rx="1.6" />
<rect x="13.5" y="3.5" width="7" height="7" rx="1.6" />
<rect x="3.5" y="13.5" width="7" height="7" rx="1.6" />
<rect x="13.5" y="13.5" width="7" height="7" rx="1.6" />
</>
),
candles: () => (
<>
<path d="M7 3v3.2M7 17.8V21M7 6.2a1.8 1.8 0 0 1 1.8 1.8v6a1.8 1.8 0 0 1-1.8 1.8v0a1.8 1.8 0 0 1-1.8-1.8V8a1.8 1.8 0 0 1 1.8-1.8z" />
<path d="M17 3v2M17 19v2M17 5a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2v0a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2z" />
</>
),
flask: () => (
<>
<path d="M9.5 3h5M10.5 3v6.2L5.2 18.6A2 2 0 0 0 6.9 21.5h10.2a2 2 0 0 0 1.7-2.9L13.5 9.2V3" />
<path d="M7.2 15.5h9.6" />
</>
),
layers: () => (
<>
<path d="M12 3.5 21 8.5l-9 5-9-5 9-5z" />
<path d="m4.5 12.5 7.5 4.2 7.5-4.2M4.5 16.5l7.5 4.2 7.5-4.2" />
</>
),
gauge: () => (
<>
<path d="M12 21a9 9 0 1 1 9-9" />
<path d="M12 12.5 16.6 6.8" />
<circle cx="12" cy="12.5" r="1.5" fill="currentColor" stroke="none" />
</>
),
archive: () => (
<>
<rect x="3.5" y="4" width="17" height="5" rx="1.6" />
<path d="M5.5 9v9.5a1.5 1.5 0 0 0 1.5 1.5h10a1.5 1.5 0 0 0 1.5-1.5V9" />
<path d="M10 13.5h4" />
</>
),
search: () => (
<>
<circle cx="11" cy="11" r="6.5" />
<path d="m20 20-4.4-4.4" strokeLinecap="round" />
</>
),
x: () => (
<>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</>
),
chevDown: () => <path d="m6 9.5 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />,
chevRight: () => <path d="m9.5 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />,
arrowUpRight: () => (
<>
<path d="M7 17 17 7M9 7h8v8" strokeLinecap="round" strokeLinejoin="round" />
</>
),
arrowUp: () => <path d="M12 19V5M5.5 11.5 12 5l6.5 6.5" strokeLinecap="round" strokeLinejoin="round" />,
arrowDown: () => <path d="M12 5v14M18.5 12.5 12 19l-6.5-6.5" strokeLinecap="round" strokeLinejoin="round" />,
play: ({ filled }) =>
filled ? (
<path d="M8 5.5v13a1 1 0 0 0 1.5.9l11-6.5a1 1 0 0 0 0-1.8l-11-6.5A1 1 0 0 0 8 5.5z" fill="currentColor" stroke="none" />
) : (
<path d="M8.5 5.8v12.4a.6.6 0 0 0 .9.5l10-6.2a.6.6 0 0 0 0-1L9.4 5.3a.6.6 0 0 0-.9.5z" fill="currentColor" stroke="none" />
),
refresh: () => (
<>
<path d="M20 12a8 8 0 1 1-2.3-5.6" strokeLinecap="round" />
<path d="M20 3.5V6.6a.4.4 0 0 1-.4.4H16.5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
spinner: () => (
<>
<path d="M12 3a9 9 0 1 0 9 9" strokeLinecap="round" />
</>
),
alert: ({ filled }) =>
filled ? (
<path
d="M12 2.6 23 20.8a1 1 0 0 1-.9 1.5H1.9a1 1 0 0 1-.9-1.5L12 2.6zM12 10v4.6m0 3.1v.1"
fill="currentColor"
stroke="none"
opacity="0.25"
/>
) : (
<>
<path d="M12 3.2 21.7 20a1 1 0 0 1-.85 1.5H3.15A1 1 0 0 1 2.3 20L12 3.2z" />
<path d="M12 9.5v4.4M12 17.4v.1" strokeLinecap="round" />
</>
),
check: () => <path d="m5 12.5 4.5 4.5L19 7.5" strokeLinecap="round" strokeLinejoin="round" />,
info: () => (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5.5M12 7.6v.1" strokeLinecap="round" />
</>
),
database: () => (
<>
<ellipse cx="12" cy="5.5" rx="7.5" ry="3" />
<path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
<path d="M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" />
</>
),
calendar: () => (
<>
<rect x="3.5" y="5" width="17" height="15.5" rx="2" />
<path d="M8 3v4M16 3v4M3.5 10h17" />
</>
),
target: () => (
<>
<circle cx="12" cy="12" r="8.5" />
<circle cx="12" cy="12" r="4.5" />
<circle cx="12" cy="12" r="0.8" fill="currentColor" stroke="none" />
</>
),
chartLine: () => (
<>
<path d="M3.5 3.5v17h17" />
<path d="m6.5 15 4-5 3.5 3 4.5-6.5" strokeLinecap="round" strokeLinejoin="round" />
</>
),
scale: () => (
<>
<path d="M12 4v16M8 20h8" />
<path d="m5 8 7-3 7 3-2.2 3H7.2L5 8z" />
<path d="M3.5 12.2 2 11l3-3M20.5 12.2 22 11l-3-3" strokeLinecap="round" strokeLinejoin="round" />
</>
),
book: () => (
<>
<path d="M4.5 5A2.5 2.5 0 0 1 7 2.5h12V18H7a2.5 2.5 0 0 0-2.5 2.5V5z" />
<path d="M4.5 20.5A2.5 2.5 0 0 1 7 18h12" />
<path d="M9.5 7h6" strokeLinecap="round" />
</>
),
link: () => (
<>
<path d="M10 14.5 14.5 10" strokeLinecap="round" />
<path d="M8.2 15.8 5.8 18.2a2.8 2.8 0 1 1-3.9-3.9l4-4a2.8 2.8 0 0 1 3.9 0" strokeLinecap="round" strokeLinejoin="round" />
<path d="M15.8 8.2l2.4-2.4a2.8 2.8 0 1 1 3.9 3.9l-4 4a2.8 2.8 0 0 1-3.9 0" strokeLinecap="round" strokeLinejoin="round" />
</>
),
spark: () => (
<>
<path d="M12 3.5 13.8 9l5.7 1.4-5.7 1.4L12 17.5l-1.8-5.7L4.5 10.4 10.2 9 12 3.5z" fill="currentColor" stroke="none" opacity="0.35" />
<path d="M19 15.5l.9 2.6 2.6.9-2.6.9-.9 2.6-.9-2.6-2.6-.9 2.6-.9.9-2.6z" fill="currentColor" stroke="none" />
</>
),
clock: () => (
<>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" strokeLinecap="round" strokeLinejoin="round" />
</>
),
filter: () => (
<>
<path d="M4 6h16M7 12h10M10 18h4" strokeLinecap="round" />
</>
),
};
interface IconProps extends Omit<SVGProps<SVGSVGElement>, "name"> {
name: IconName;
size?: number;
/** 填充态(部分图标支持实心强调) */
filled?: boolean;
}
export function Icon({ name, size = 18, filled = false, ...rest }: IconProps) {
return (
<svg
className="ic"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...rest}
>
{P[name]({ filled })}
</svg>
);
}
+449
View File
@@ -0,0 +1,449 @@
/**
* 设计系统 UI 基元(纯展示,无副作用):页头 / 卡片 / 指标卡 / 徽标 /
* 按钮 / 表单字段 / 提示条 / 空态 / 骨架 / 进度 / 回测结果区块。
*/
import { useId } from "react";
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode } from "react";
import type { BacktestSummary } from "@/lib/types";
import { Icon, type IconName } from "@/components/icons";
export function cx(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
/* ---------- 页头 ---------- */
export function PageHeader({
title,
sub,
actions,
}: {
title: string;
sub?: ReactNode;
actions?: ReactNode;
}) {
return (
<header className="page-head">
<div>
<h1 className="page-title">{title}</h1>
{sub ? <p className="page-sub">{sub}</p> : null}
</div>
{actions ? <div className="page-actions">{actions}</div> : null}
</header>
);
}
/* ---------- 卡片 ---------- */
export function Card({
title,
sub,
icon,
tools,
children,
flush = false,
className,
}: {
title?: ReactNode;
sub?: ReactNode;
icon?: IconName;
tools?: ReactNode;
children: ReactNode;
flush?: boolean;
className?: string;
}) {
const hasHead = title != null || tools != null;
return (
<section className={cx("card", className)}>
{hasHead ? (
<div className="card__head">
<div className="card__title">
{icon ? <Icon name={icon} size={17} /> : null}
<span>
{title}
{sub ? <span className="card__sub"> {sub}</span> : null}
</span>
</div>
{tools ? <div className="card__tools">{tools}</div> : null}
</div>
) : null}
<div className={flush ? "card__body--flush" : "card__body"}>{children}</div>
</section>
);
}
/* ---------- 指标卡 ---------- */
export function Metric({
label,
icon,
value,
sub,
tone = "plain",
small = false,
}: {
label: ReactNode;
icon?: IconName;
value: ReactNode;
sub?: ReactNode;
tone?: "plain" | "pos" | "neg" | "warn" | "accent";
small?: boolean;
}) {
return (
<div className="metric">
<div className="metric__label">
{icon ? <Icon name={icon} size={14} /> : null}
{label}
</div>
<div
className={cx(
"metric__value",
small && "metric__value--sm",
tone !== "plain" && `tone-${tone}`,
)}
>
{value}
</div>
{sub ? <div className="metric__sub">{sub}</div> : null}
</div>
);
}
/* ---------- 徽标 ---------- */
export function Pill({
tone = "default",
children,
icon,
}: {
tone?: "default" | "accent" | "pos" | "neg" | "warn" | "violet";
children: ReactNode;
icon?: IconName;
}) {
return (
<span className={cx("pill", tone !== "default" && `pill--${tone}`)}>
{icon ? <Icon name={icon} size={12} /> : null}
{children}
</span>
);
}
/* ---------- 按钮 ---------- */
interface BtnProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "ghost" | "danger";
size?: "sm" | "md";
icon?: IconName;
loading?: boolean;
children?: ReactNode;
}
export function Btn({
variant = "ghost",
size = "md",
icon,
loading = false,
children,
className,
...rest
}: BtnProps) {
return (
<button
className={cx(
"btn",
variant === "primary" && "btn--primary",
variant === "danger" && "btn--danger",
size === "sm" && "btn--sm",
className,
)}
disabled={rest.disabled || loading}
{...rest}
>
{loading ? <Icon name="spinner" size={14} className="spin" /> : icon ? <Icon name={icon} size={15} /> : null}
{children}
</button>
);
}
/* ---------- 表单 ---------- */
export function Field({
label,
hint,
htmlFor,
children,
className,
}: {
label: ReactNode;
hint?: ReactNode;
htmlFor?: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={cx("field", className)}>
<label className="field__label" htmlFor={htmlFor}>
{label}
</label>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</div>
);
}
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
mono?: boolean;
}
/** 统一样式的 input(含原生控件),mono 用于代码/数值输入。 */
export function TextInput({ className, mono, ...rest }: TextInputProps) {
return <input className={cx("input", mono && "mono", className)} {...rest} />;
}
export function SearchBox({
value,
onChange,
onClear,
placeholder,
label,
}: {
value: string;
onChange: (v: string) => void;
onClear?: () => void;
placeholder?: string;
label?: string;
}) {
const inputId = useId();
return (
<div className="searchbox" role="search">
<label className="sr-only" htmlFor={inputId}>
{label ?? "搜索"}
</label>
<Icon name="search" size={16} />
<TextInput
id={inputId}
type="search"
className="input"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
{value && onClear ? (
<button type="button" className="icon-btn clear" aria-label="清空搜索" onClick={onClear}>
<Icon name="x" size={13} />
</button>
) : null}
</div>
);
}
/* ---------- 提示条 / 空态 / 加载 ---------- */
export function Banner({
tone = "info",
children,
icon,
}: {
tone?: "error" | "warn" | "info" | "muted";
children: ReactNode;
icon?: IconName;
}) {
const ic: IconName =
icon ?? (tone === "error" || tone === "warn" ? "alert" : tone === "muted" ? "info" : "info");
return (
<div className={cx("banner", `banner--${tone}`)} role={tone === "error" ? "alert" : undefined}>
<Icon name={ic} size={16} />
<div>{children}</div>
</div>
);
}
export function Empty({
icon = "archive",
title,
hint,
children,
}: {
icon?: IconName;
title: ReactNode;
hint?: ReactNode;
children?: ReactNode;
}) {
return (
<div className="empty">
<Icon name={icon} size={30} />
<strong>{title}</strong>
{hint ? <p className="hint" style={{ maxWidth: 460, margin: "4px auto 0" }}>{hint}</p> : null}
{children ? <div style={{ marginTop: 14 }}>{children}</div> : null}
</div>
);
}
export function SkeletonLines({ n = 3, height = 14 }: { n?: number; height?: number }) {
return (
<div className="stack" aria-hidden="true">
{Array.from({ length: n }).map((_, i) => (
<div
key={i}
className="skeleton"
style={{ height, width: i === n - 1 ? "62%" : "100%" }}
/>
))}
</div>
);
}
export function Loading({ label = "加载中…" }: { label?: string }) {
return (
<div className="row" style={{ color: "var(--text-2)", padding: "10px 0" }} role="status">
<Icon name="spinner" size={16} className="spin" />
<span className="hint">{label}</span>
</div>
);
}
export function Progress({
value,
label,
}: {
/** 0-100 */
value: number;
label?: ReactNode;
}) {
return (
<div>
{label ? (
<div className="hint" style={{ marginBottom: 2 }}>
{label}
</div>
) : null}
<div
className="progress"
role="progressbar"
aria-valuenow={Math.round(value)}
aria-valuemin={0}
aria-valuemax={100}
>
<div className="progress__bar" style={{ width: `${Math.max(0, Math.min(100, value))}%` }} />
</div>
</div>
);
}
/* ---------- 数值 ---------- */
export function Signed({
value,
suffix = "",
digits = 2,
arrow = true,
}: {
value: number;
suffix?: string;
digits?: number;
arrow?: boolean;
}) {
const cls = value > 0 ? "tone-pos" : value < 0 ? "tone-neg" : "";
return (
<span className={cx("num delta", cls)}>
{arrow ? (value > 0 ? <Icon name="arrowUp" size={12} /> : value < 0 ? <Icon name="arrowDown" size={12} /> : null) : null}
{value > 0 ? "+" : ""}
{value.toFixed(digits)}
{suffix}
</span>
);
}
/* ---------- 回测 / 因子结果共用区块 ---------- */
export function BacktestMetrics({ s }: { s: BacktestSummary }) {
return (
<div className="metric-grid">
<Metric label="期末净值" value={s.final_equity.toLocaleString()} sub={`起始 ${s.initial_capital.toLocaleString()}`} />
<Metric label="区间收益" tone={s.total_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.total_return_pct} suffix="%" />} sub={`${s.start} ~ ${s.end}`} />
<Metric label="年化收益" tone={s.annual_return_pct >= 0 ? "pos" : "neg"} value={<Signed value={s.annual_return_pct} suffix="%" />} />
<Metric
label="Sharpe"
tone={s.sharpe >= 1 ? "pos" : s.sharpe < 0 ? "neg" : "plain"}
value={s.sharpe.toFixed(2)}
sub={`波动 ${s.volatility_pct.toFixed(2)}%`}
/>
<Metric label="最大回撤" tone="neg" value={<span className="num">{s.max_drawdown_pct.toFixed(2)}%</span>} />
<Metric label="胜率 / 换手" value={`${s.win_rate_pct.toFixed(1)}%`} sub={`交易 ${s.total_trades} 笔 · 平均换手 ${s.avg_turnover_pct.toFixed(0)}%`} />
</div>
);
}
/* ---------- 月度收益(热力着色) ---------- */
export function MonthlyReturnsTable({
rows,
}: {
rows: { year: number; month: number; return_pct: number }[];
}) {
const byYear = new Map<number, Map<number, number>>();
for (const r of rows) {
if (!byYear.has(r.year)) byYear.set(r.year, new Map());
byYear.get(r.year)!.set(r.month, r.return_pct);
}
const months = Array.from({ length: 12 }, (_, i) => i + 1);
const years = [...byYear.keys()].sort();
if (years.length === 0) return <Empty title="无月度收益数据" />;
return (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>年份</th>
{months.map((m) => (
<th key={m} style={{ textAlign: "right", minWidth: 62 }}>
{m}月
</th>
))}
</tr>
</thead>
<tbody>
{years.map((y) => {
const row = byYear.get(y)!;
return (
<tr key={y}>
<td className="cell-strong">{y}</td>
{months.map((m) => {
const v = row.get(m);
if (v == null)
return (
<td key={m} style={{ textAlign: "right" }}>
<span className="cell-heat flat">—</span>
</td>
);
const cls = v > 0 ? "pos" : v < 0 ? "neg" : "flat";
return (
<td key={m} style={{ textAlign: "right" }}>
<span className={`cell-heat ${cls}`}>
{v > 0 ? "+" : ""}
{v.toFixed(2)}%
</span>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export function UnimplementedNote({ items }: { items: string[] }) {
if (!items || items.length === 0) return null;
return (
<Card title="未建模约束(如实标注)" icon="info">
<ul className="hint" style={{ margin: 0, paddingLeft: 18 }}>
{items.map((u, i) => (
<li key={i}>{u}</li>
))}
</ul>
</Card>
);
}
+2
View File
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: true, reactStrictMode: true,
// 局域网/远程设备访问 dev server 时,/_next/* 资源按允许来源放行(Next 15.5+)
allowedDevOrigins: ["192.168.1.160", "127.0.0.1", "localhost"],
// 同源代理:页面请求 /api/* → 后端 FastAPI(默认 127.0.0.1:8000) // 同源代理:页面请求 /api/* → 后端 FastAPI(默认 127.0.0.1:8000)
// 使任何 IP/域名访问前端都无需 CORS 与硬编码后端地址。 // 使任何 IP/域名访问前端都无需 CORS 与硬编码后端地址。
// 后端地址可用环境变量 BACKEND_API_URL 覆盖(如 0.0.0.0 部署到别的机器)。 // 后端地址可用环境变量 BACKEND_API_URL 覆盖(如 0.0.0.0 部署到别的机器)。

Some files were not shown because too many files have changed in this diff Show More