Files
tick-stock-panel/backend/app/services/api_gateway.py
T
shy3130 918c7ade7e refactor(core): 策略与信号全面去内置化, 转为用户自定义定义
- 策略: 引擎只加载 data/strategies/{custom,ai,composite} 与研究模板目录,
  原 25 个内置策略一次性迁移为用户自定义文件(ID 不变, 引用零迁移);
  研究模板移至 app/strategy/research, 打包 spec 同步; 新装环境策略库为空
- 信号: 管线不再硬编码任何信号表达式; 涨跌停判定价成为运行时列
  limit_up_price/limit_down_price(生效价∓0.005 容差), 20 个信号转为
  data/user_data/custom_signals 定义(显式 column 沿用 signal_* 原列名);
  依赖解析/注入顺序/当日路径 prev-join 全部定义驱动; 语义统一为历史路径
  口径(顺带修正当日路径 BOLL 比较符与翘板/炸板空值两处既有偏差)
- 前端: 信号库单列表(全部可编辑), SignalPicker/监控选项/信号标签全走
  定义接口; 回测页新增免责声明(模拟性质/数据源准确性不作保证/不构成
  投资建议)
- 测试: 25 个策略夹具与 20 个信号种子入库 tests/fixtures, 受影响测试
  全部自包含化(不依赖运行目录用户数据); 77 万行 MA 死叉新旧表达式对拍
  逐行一致; 回测实跑 45 笔卖点信号出场验证矩阵链路
- 同批包含工作区既有进度: 策略结果 ext 列同日投影修复(含测试)、数据源插件、fuyao/auth/api_gateway/data_integrity 调整、README 信号库
  描述与 strategy.md 导入路径补充

验证: 后端 2592 passed(空数据目录 CI 模拟 2588 passed, 4 例为模拟环境
伪影复验排除); 前端 vitest 125 passed + tsc 0 错 + vite build 通过
2026-10-10 21:10:44 +08:00

216 lines
9.0 KiB
Python

"""API 开放网关 — Token 请求的网络门禁、scope 校验与限流 (docs/open-platform-plan.md §4-5)。
Token 通道有两道闸, 认证中间件按序调用:
1. token_network_verdict(client_ip) — 网络门禁: 默认仅本机/内网可调,
公网来源直接 403 (不消耗限流额度, 也不向公网探测者泄露 Token 有效性)。
2. evaluate(data_dir, method, path, plaintext) — 验 Token → scope → 限流,
得到放行 / 401 / 403 / 429 的裁决与响应头。UI 会话完全不经过本模块。
规则表按顺序匹配 (方法 + 路径前缀); 未命中 = 不对外开放 (403)。
刻意不放进规则表的: SSE 流端点 (EventSource 无法带 Authorization 头,
V2 用短期票据解决)、watchlist/监控规则管理、数据同步、设置 —— 保持最小开放面。
"""
from __future__ import annotations
import os
import threading
import time
from collections import deque
from pathlib import Path
from app.services import api_tokens
# (method, 路径前缀, scope) — method 用 "*" 表示任意; 顺序敏感, 具体规则在前
_RULES: list[tuple[str, str, str]] = [
# read:market — 行情读取
("GET", "/api/kline/instruments/search", "read:market"),
("GET", "/api/kline/daily", "read:market"),
("GET", "/api/kline/minute", "read:market"),
("GET", "/api/kline/minute-range", "read:market"),
("GET", "/api/intraday/status", "read:market"),
("GET", "/api/intraday/indices", "read:market"),
("GET", "/api/index/daily", "read:market"),
("GET", "/api/index/minute", "read:market"),
("GET", "/api/overview/market", "read:market"),
("GET", "/api/screener/market-snapshot", "read:market"),
# read:ext — 扩展数据读取 (Key 状态子路径除外, 属管理面)
("GET", "/api/ext-data/schema-all", "read:ext"),
("GET", "/api/ext-data/schema/", "read:ext"),
("GET", "/api/ext-data/", "read:ext"),
("GET", "/api/ext-data", "read:ext"),
# read:analysis — 策略/回测结果/环境/告警 读取
("GET", "/api/strategies", "read:analysis"),
("GET", "/api/screener/strategies", "read:analysis"),
("GET", "/api/screener/cached", "read:analysis"),
("GET", "/api/screener/limit-ladder", "read:analysis"),
("GET", "/api/backtest/status", "read:analysis"),
("GET", "/api/backtest/candidates", "read:analysis"),
("GET", "/api/backtest/factor/columns", "read:analysis"),
("GET", "/api/regime", "read:analysis"),
("GET", "/api/alerts", "read:analysis"),
# run:backtest — 触发计算任务
("POST", "/api/screener/run", "run:backtest"),
("POST", "/api/screener/run_preset", "run:backtest"),
("POST", "/api/screener/run_all", "run:backtest"),
("POST", "/api/backtest/run", "run:backtest"),
("POST", "/api/backtest/factor/run", "run:backtest"),
("POST", "/api/backtest/factor/batch", "run:backtest"),
("POST", "/api/backtest/strategy/run", "run:backtest"),
# paper:trade — 模拟盘全部 (读+写一体, 单一 scope 简化心智)
("*", "/api/paper", "paper:trade"),
# events — 换取 SSE 短期票据: 任意有效 Token 即可 (票据只继承已有 scope,
# 不放大权限, 故此处用 "*" 表示"仅需认证", 不做 scope 校验)
("POST", "/api/events/ticket", "*"),
# SSE 流本体: 网关只做标记 (契约可见); 真正凭证是 query 里的票据,
# 由端点内校验 (EventSource 带不了 Authorization 头)
("GET", "/api/events", "*"),
]
# 后缀规则表 — 路径中段含动态段 (如 config_id), 前缀表表达不了"以 X 结尾"语义。
# 限定在 /api/ext-data/ 下, 防止误伤其他路由的巧合后缀。
_SUFFIX_RULES: list[tuple[str, str, str]] = [
# write:ext — 程序化行数据写入 (结构配置/上传/拉取等管理端点仍不开放)
("POST", "/ingest", "write:ext"),
]
_RATE_WINDOW_S = 60.0
def rate_limit_per_min() -> int:
"""每 Token 每分钟上限 (env OPEN_API_RATE_LIMIT_PER_MIN, 默认 120, 范围 1~10000)。"""
try:
v = int(os.environ.get("OPEN_API_RATE_LIMIT_PER_MIN", "120"))
return v if 1 <= v <= 10000 else 120
except ValueError:
return 120
# ── 网络门禁: Token 通道默认仅本机/内网 (auth.setup 端点的同源判据) ──
def is_local_network(host: str | None) -> bool:
"""是否本机或内网地址: 127.0.0.1 / ::1 / localhost、10.x、172.16-31.x、192.168.x。
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
真实客户端由 _client_ip 经 X-Forwarded-For 还原(仅当直连 peer 可信时采信)。
"""
if not host:
return False
if host in ("127.0.0.1", "::1", "localhost"):
return True
if host.startswith("10.") or host.startswith("192.168."):
return True
if host.startswith("172."):
try:
second = int(host.split(".")[1])
if 16 <= second <= 31:
return True
except (IndexError, ValueError):
pass
return False
def token_channel_local_only() -> bool:
"""Token 通道是否仅限本机/内网调用 (env API_TOKEN_LOCAL_ONLY, 默认开)。
设 0/false/off/no 显式放开 — 供公网 VPS 部署的远程脚本/门户等场景自主选择,
开放后的数据再分发合规责任由部署者承担(见 docs/configuration.md)。
"""
return (os.environ.get("API_TOKEN_LOCAL_ONLY", "1").strip().lower()
not in ("0", "false", "off", "no"))
def token_network_verdict(client_ip: str | None) -> dict | None:
"""Token 通道网络门禁裁决: 公网来源且默认策略未放开 → 403 verdict; 放行 → None。
返回 None 时调用方继续走 evaluate(); 在 evaluate 之前拦截故不消耗限流额度,
且公网探测者拿不到 401/403(scope) 差异, 无法探测 Token 有效性。
"""
if not token_channel_local_only() or is_local_network(client_ip):
return None
return {
"status": 403,
"detail": (
"API Token 通道默认仅接受本机/内网请求; 远程调用请经 SSH 隧道"
"或使用浏览器密码会话。确需公网开放: 设置环境变量"
" API_TOKEN_LOCAL_ONLY=0 (数据再分发合规责任由部署者承担)"
),
"headers": {},
}
def required_scope(method: str, path: str) -> str | None:
"""路径+方法 → 所需 scope; None = 该端点不对外开放。
返回 "*" 表示仅需有效 Token (不做特定 scope 校验)。
"""
for m, prefix, scope in _RULES:
if (m == "*" or m == method) and (path == prefix or path.startswith(prefix.rstrip("/") + "/")):
# read:ext 域内的拉取 Key 状态子路径属管理面 (脱敏也不外露)
if scope == "read:ext" and path.endswith("/api-key"):
return None
return scope
if path.startswith("/api/ext-data/"):
for m, suffix, scope in _SUFFIX_RULES:
if (m == "*" or m == method) and path.endswith(suffix):
return scope
return None
# ── 限流: 进程内滑动窗口 (每 token_id 一个 deque; 重启清零无害) ──
_BUCKETS: dict[str, deque[float]] = {}
_BUCKET_LOCK = threading.Lock()
def _allow(key: str, limit: int) -> tuple[bool, int, int]:
"""返回 (是否放行, 剩余额度, 重试等待秒)。"""
now = time.monotonic()
with _BUCKET_LOCK:
q = _BUCKETS.setdefault(key, deque())
while q and now - q[0] >= _RATE_WINDOW_S:
q.popleft()
if len(q) >= limit:
retry = max(1, int(_RATE_WINDOW_S - (now - q[0])) + 1)
return False, 0, retry
q.append(now)
return True, limit - len(q), 0
def evaluate(data_dir: Path, method: str, path: str, plaintext: str) -> dict:
"""Token 请求裁决: {status: None=放行 | 401/403/429, detail, headers}。"""
record = api_tokens.verify_token(data_dir, plaintext)
if record is None:
return {"status": 401, "detail": "API Token 无效或已吊销", "headers": {}}
scope = required_scope(method, path)
if scope is None:
return {
"status": 403,
"detail": f"该端点 ({method} {path}) 未对外开放; 开放清单见 /api/openapi.json?tier=a",
"headers": {},
}
if scope == "*":
pass # 仅需有效 Token (票据签发): 已通过认证, 不校验具体 scope
elif scope not in record.get("scopes", []):
return {
"status": 403,
"detail": f"Token 缺少所需 scope: {scope} (持有: {record.get('scopes', [])})",
"headers": {},
}
limit = rate_limit_per_min()
ok, remaining, retry = _allow(record["id"], limit)
if not ok:
return {
"status": 429,
"detail": f"请求超出限额 ({limit}/分钟), {retry} 秒后重试",
"headers": {"Retry-After": str(retry)},
}
return {
"status": None,
"detail": "",
"headers": {
"X-RateLimit-Limit": str(limit),
"X-RateLimit-Remaining": str(remaining),
},
}