重置项目
This commit is contained in:
@@ -9,8 +9,6 @@ from typing import Literal
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services.ext_data import ExtConfigStore
|
||||
|
||||
router = APIRouter(prefix="/api/analysis-menus", tags=["analysis-menus"])
|
||||
|
||||
|
||||
@@ -113,44 +111,13 @@ def _save(request: Request, menu: AnalysisMenu) -> AnalysisMenu:
|
||||
|
||||
|
||||
def _default_menus(request: Request) -> list[AnalysisMenu]:
|
||||
ext_store = ExtConfigStore(_data_dir(request))
|
||||
menus: list[AnalysisMenu] = []
|
||||
for cfg in ext_store.load_all():
|
||||
fields = cfg.fields
|
||||
concept = next((f for f in fields if "概念" in f.name or "概念" in f.label or "concept" in f.name.lower()), None)
|
||||
if concept:
|
||||
detail_names = ["股票简称", "股票代码", concept.name, "人气排名", "资金流向", "PE", "PB"]
|
||||
detail_columns = []
|
||||
for name in detail_names:
|
||||
f = next((x for x in fields if x.name == name), None)
|
||||
if not f:
|
||||
continue
|
||||
is_num = f.dtype in ("int", "float")
|
||||
detail_columns.append(AnalysisColumn(
|
||||
field=f.name,
|
||||
label=f.label or f.name,
|
||||
type="number" if is_num else "string",
|
||||
sortable=is_num,
|
||||
precision=2 if f.dtype == "float" else None,
|
||||
))
|
||||
menus.append(AnalysisMenu(
|
||||
id="concept_analysis",
|
||||
label="概念分析",
|
||||
icon="tags",
|
||||
data_source=cfg.id,
|
||||
template="dimension_rank",
|
||||
dimension_field=concept.name,
|
||||
group_columns=[
|
||||
AnalysisColumn(field="__dimension", label="概念"),
|
||||
AnalysisColumn(field="__count", label="股票数", type="number", sortable=True),
|
||||
],
|
||||
detail_columns=detail_columns,
|
||||
default_sort=DefaultSort(field="人气排名", order="asc") if any(c.field == "人气排名" for c in detail_columns) else None,
|
||||
order=100,
|
||||
builtin=True,
|
||||
))
|
||||
break
|
||||
return menus
|
||||
"""自动生成的默认分析菜单。
|
||||
|
||||
历史上会扫描扩展数据配置,对含「概念」字段的表自动生成一个「概念分析」菜单。
|
||||
现已关闭自动生成 —— 内置的概念分析页(/concept-analysis)已覆盖该场景,
|
||||
自动菜单会造成导航重复。需要时用户可在「设置 → 扩展页面」手动创建。
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
+192
-59
@@ -1,80 +1,213 @@
|
||||
"""访问门控 API 与管理接口。"""
|
||||
"""访问认证 API。
|
||||
|
||||
端点:
|
||||
GET /api/auth/status — 是否已设密码、当前会话是否有效
|
||||
POST /api/auth/setup — 首次设置密码(仅限本机/内网, 防公网抢占)
|
||||
POST /api/auth/login — 登录(密码 → 会话 token, 含限流)
|
||||
POST /api/auth/logout — 注销当前会话
|
||||
POST /api/auth/change-password — 改密码(需已登录)
|
||||
|
||||
安全:
|
||||
- setup 端点只接受本机/内网请求(request.client.host), 公网请求 403。
|
||||
否则黑客可比用户更早扫到域名, 抢先设密码, 反客为主。
|
||||
- login 限流: 同一来源 IP 连续失败 5 次, 锁 5 分钟(内存计数)。
|
||||
- 会话 token 通过 HttpOnly cookie 下发, 前端无需手动管理。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
from app import auth
|
||||
from app import uuid_store
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.services import auth
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
admin_router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||
|
||||
COOKIE_NAME = "tf_session"
|
||||
_COOKIE_MAX_AGE = 30 * 24 * 3600 # 与 SESSION_TTL 一致
|
||||
|
||||
# 限流: { ip: (fail_count, lock_until_ts) }
|
||||
_fail_counter: dict[str, tuple[int, float]] = defaultdict(lambda: (0, 0.0))
|
||||
_fail_lock = Lock()
|
||||
_MAX_FAILS = 5
|
||||
_LOCK_SECONDS = 300
|
||||
|
||||
|
||||
@router.post("/verify")
|
||||
def verify_credential(req: auth.VerifyIn) -> auth.VerifyOut:
|
||||
"""校验管理员令牌或普通 UUID,成功后返回访问令牌及角色。"""
|
||||
role = auth.verify_credential(req.credential)
|
||||
if role:
|
||||
token = auth.create_access_token(role)
|
||||
return auth.VerifyOut(valid=True, role=role.value, token=token)
|
||||
return auth.VerifyOut(valid=False, role=None, token=None)
|
||||
def _is_local_network(host: str | None) -> bool:
|
||||
"""是否本机或内网请求。
|
||||
|
||||
反向代理(Nginx)场景下 request.client.host 是代理本身(127.0.0.1),
|
||||
需信任 X-Forwarded-For 的最左(原始客户端)。本项目部署若经反代,
|
||||
请在反代配置正确的 X-Forwarded-For(标准做法)。
|
||||
"""
|
||||
if not host:
|
||||
return False
|
||||
if host in ("127.0.0.1", "::1", "localhost"):
|
||||
return True
|
||||
# 内网网段: 10.x / 172.16-31.x / 192.168.x
|
||||
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 _client_ip(request: Request) -> str:
|
||||
"""取真实客户端 IP(信任反代 X-Forwarded-For)。"""
|
||||
xff = request.headers.get("x-forwarded-for")
|
||||
if xff:
|
||||
return xff.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _check_login_rate_limit(ip: str) -> None:
|
||||
"""登录失败限流检查, 触发则抛 429。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
now = time.time()
|
||||
if until > now:
|
||||
wait = int(until - now)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail=f"登录失败次数过多, 请 {wait} 秒后重试",
|
||||
)
|
||||
|
||||
|
||||
def _record_login_fail(ip: str) -> None:
|
||||
"""记录一次登录失败, 达阈值则锁定。"""
|
||||
with _fail_lock:
|
||||
count, until = _fail_counter.get(ip, (0, 0.0))
|
||||
count += 1
|
||||
if count >= _MAX_FAILS:
|
||||
until = time.time() + _LOCK_SECONDS
|
||||
logger.warning("auth login locked for %s after %d fails", ip, count)
|
||||
_fail_counter[ip] = (count, until)
|
||||
|
||||
|
||||
def _clear_login_fails(ip: str) -> None:
|
||||
"""登录成功后清除该 IP 的失败计数。"""
|
||||
with _fail_lock:
|
||||
_fail_counter.pop(ip, None)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 端点
|
||||
# ================================================================
|
||||
|
||||
class PasswordIn(BaseModel):
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class ChangePasswordIn(BaseModel):
|
||||
old_password: str = Field(min_length=1, max_length=128)
|
||||
new_password: str = Field(min_length=6, max_length=128)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def auth_status(request: Request) -> auth.AuthStatusOut:
|
||||
"""返回当前门控状态、当前请求是否通过校验及角色。"""
|
||||
enabled = auth.access_control_enabled()
|
||||
token = auth.get_access_token_from_request(request)
|
||||
role = auth.validate_access_token(token)
|
||||
return auth.AuthStatusOut(
|
||||
enabled=enabled,
|
||||
verified=role is not None,
|
||||
role=role.value if role else None,
|
||||
)
|
||||
def auth_status(request: Request) -> dict:
|
||||
"""认证状态: 是否已设密码 + 当前请求是否已登录。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
return {
|
||||
"configured": auth.is_configured(),
|
||||
"authenticated": bool(token and auth.is_valid_session(token)),
|
||||
}
|
||||
|
||||
|
||||
# ===== 管理员 UUID 管理 =====
|
||||
@router.post("/setup")
|
||||
def setup_password(req: PasswordIn, request: Request) -> dict:
|
||||
"""首次设置访问密码。仅限本机/内网请求(防公网抢占)。
|
||||
|
||||
@admin_router.get("/uuids")
|
||||
def list_uuids(request: Request) -> list[auth.UuidRecordOut]:
|
||||
"""列出所有动态 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
records = uuid_store.list_uuids()
|
||||
return [
|
||||
auth.UuidRecordOut(
|
||||
uuid=r["uuid"],
|
||||
label=r.get("label", ""),
|
||||
enabled=r.get("enabled", True),
|
||||
created_at=r.get("created_at", 0),
|
||||
若已设置过密码, 返回 409(改密码走 /change-password)。
|
||||
"""
|
||||
# 关键: 限制只有服务器主人(本机/内网)能设密码
|
||||
client_ip = _client_ip(request)
|
||||
if not _is_local_network(client_ip):
|
||||
logger.warning("setup rejected from non-local ip: %s", client_ip)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="首次设置密码仅允许本机或内网访问,请通过 SSH/本地浏览器操作",
|
||||
)
|
||||
for r in records
|
||||
]
|
||||
|
||||
if auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="密码已设置,如需修改请登录后使用改密码功能")
|
||||
|
||||
auth.set_password(req.password)
|
||||
logger.info("access password set up from %s", client_ip)
|
||||
return {"ok": True, "configured": True}
|
||||
|
||||
|
||||
@admin_router.post("/uuids")
|
||||
def create_uuid(req: auth.UuidCreateIn, request: Request) -> auth.UuidRecordOut:
|
||||
"""创建新的访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
record = uuid_store.create(req.label)
|
||||
return auth.UuidRecordOut(
|
||||
uuid=record["uuid"],
|
||||
label=record["label"],
|
||||
enabled=record["enabled"],
|
||||
created_at=record["created_at"],
|
||||
@router.post("/login")
|
||||
def login(req: LoginIn, request: Request, response: Response) -> dict:
|
||||
"""登录: 密码 → 会话 token(写 HttpOnly cookie)。含失败限流。"""
|
||||
ip = _client_ip(request)
|
||||
_check_login_rate_limit(ip)
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
token = auth.verify_and_create_session(req.password)
|
||||
if not token:
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
|
||||
_clear_login_fails(ip)
|
||||
# HttpOnly: 防 XSS 窃取; SameSite=Lax: 防 CSRF; Path=/: 全站生效
|
||||
response.set_cookie(
|
||||
key=COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
secure=False, # 自托管可能无 HTTPS, 不强制 secure(建议反代加 HTTPS)
|
||||
)
|
||||
return {"ok": True, "authenticated": True}
|
||||
|
||||
|
||||
@admin_router.delete("/uuids/{uuid}")
|
||||
def delete_uuid(uuid: str, request: Request) -> dict:
|
||||
"""删除访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.delete(uuid)
|
||||
return {"ok": ok}
|
||||
@router.post("/logout")
|
||||
def logout(request: Request, response: Response) -> dict:
|
||||
"""注销当前会话。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if token:
|
||||
auth.revoke_session(token)
|
||||
response.delete_cookie(key=COOKIE_NAME, path="/")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@admin_router.put("/uuids/{uuid}/toggle")
|
||||
def toggle_uuid(uuid: str, request: Request, enabled: bool) -> dict:
|
||||
"""启用/禁用访问 UUID(仅管理员)。"""
|
||||
auth.require_admin(request)
|
||||
ok = uuid_store.toggle(uuid, enabled)
|
||||
return {"ok": ok}
|
||||
@router.post("/change-password")
|
||||
def change_password(req: ChangePasswordIn, request: Request) -> dict:
|
||||
"""修改密码: 需验证旧密码, 成功后所有会话失效(含当前, 需重新登录)。"""
|
||||
token = request.cookies.get(COOKIE_NAME)
|
||||
if not (token and auth.is_valid_session(token)):
|
||||
raise HTTPException(status_code=401, detail="请先登录")
|
||||
|
||||
if not auth.is_configured():
|
||||
raise HTTPException(status_code=409, detail="尚未设置访问密码")
|
||||
|
||||
# 验证旧密码
|
||||
new_token = auth.verify_and_create_session(req.old_password)
|
||||
if not new_token:
|
||||
ip = _client_ip(request)
|
||||
_record_login_fail(ip)
|
||||
raise HTTPException(status_code=401, detail="旧密码错误")
|
||||
# 临时 token 用完即弃
|
||||
auth.revoke_session(new_token)
|
||||
|
||||
# 改密码(set_password 会清空所有会话)
|
||||
auth.set_password(req.new_password)
|
||||
return {"ok": True, "message": "密码已修改, 请重新登录"}
|
||||
|
||||
@@ -38,6 +38,9 @@ _table_cache: dict[str, dict | None] = {
|
||||
"index_daily": None,
|
||||
"index_enriched": None,
|
||||
"index_instruments": None,
|
||||
"etf_daily": None,
|
||||
"etf_enriched": None,
|
||||
"etf_instruments": None,
|
||||
"minute": None,
|
||||
"adj_factor": None,
|
||||
"instruments": None,
|
||||
@@ -262,6 +265,85 @@ def _safe_aggregate_index_instruments(repo) -> dict | None:
|
||||
}
|
||||
|
||||
|
||||
def _safe_aggregate_etf_instruments(repo) -> dict | None:
|
||||
"""ETF instruments 统计 — 优先独立 instruments_etf,兼容旧 instruments_index。"""
|
||||
queries = [
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments_etf""",
|
||||
"""SELECT count(*) AS rows,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count_if(name IS NOT NULL AND name != '') AS named
|
||||
FROM instruments_index
|
||||
WHERE asset_type = 'etf'""",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
row = repo.execute_one(sql)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate etf instruments fallback failed: %s", e)
|
||||
continue
|
||||
if row and row[0]:
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"symbols_covered": int(row[1] or 0),
|
||||
"latest_as_of": None,
|
||||
"named": int(row[2] or 0),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _safe_aggregate_etf_enriched(repo) -> dict | None:
|
||||
"""ETF enriched 统计 — 独立 kline_etf_enriched。"""
|
||||
fields = 0
|
||||
try:
|
||||
cols = repo.execute_all("DESCRIBE kline_etf_enriched")
|
||||
fields = len(cols)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
stats = _safe_aggregate(repo, "kline_etf_enriched")
|
||||
if not stats:
|
||||
return None
|
||||
return {**stats, "fields": fields}
|
||||
|
||||
|
||||
def _safe_aggregate_etf_daily(repo) -> dict | None:
|
||||
"""ETF 日K统计 — 优先独立 kline_etf_daily,兼容旧 index 存储。"""
|
||||
queries = [
|
||||
"""SELECT count(*) AS rows,
|
||||
min(date) AS earliest,
|
||||
max(date) AS latest,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT date) AS trading_days
|
||||
FROM kline_etf_daily""",
|
||||
"""SELECT count(*) AS rows,
|
||||
min(date) AS earliest,
|
||||
max(date) AS latest,
|
||||
count(DISTINCT symbol) AS symbols,
|
||||
count(DISTINCT date) AS trading_days
|
||||
FROM kline_index_daily
|
||||
WHERE symbol IN (
|
||||
SELECT DISTINCT symbol FROM instruments_index WHERE asset_type = 'etf'
|
||||
)""",
|
||||
]
|
||||
for sql in queries:
|
||||
try:
|
||||
row = repo.execute_one(sql)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("aggregate etf daily fallback failed: %s", e)
|
||||
continue
|
||||
if row and row[0]:
|
||||
return {
|
||||
"rows": int(row[0]),
|
||||
"earliest_date": str(row[1]) if row[1] else None,
|
||||
"latest_date": str(row[2]) if row[2] else None,
|
||||
"symbols_covered": int(row[3] or 0),
|
||||
"trading_days": int(row[4] or 0),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _safe_aggregate_adj_factor(repo) -> dict | None:
|
||||
"""adj_factor 视图统计,日期范围对齐日 K 覆盖区间。"""
|
||||
try:
|
||||
@@ -405,6 +487,10 @@ def _compute_storage(data_dir: Path) -> dict:
|
||||
"index_daily": data_dir / "kline_index_daily",
|
||||
"index_enriched": data_dir / "kline_index_enriched",
|
||||
"index_instruments": data_dir / "instruments_index",
|
||||
"etf_daily": data_dir / "kline_etf_daily",
|
||||
"etf_enriched": data_dir / "kline_etf_enriched",
|
||||
"etf_instruments": data_dir / "instruments_etf",
|
||||
"etf_adj_factor": data_dir / "adj_factor_etf",
|
||||
"minute": data_dir / "kline_minute",
|
||||
"adj_factor": data_dir / "adj_factor",
|
||||
"instruments": data_dir / "instruments",
|
||||
@@ -507,10 +593,13 @@ def status(request: Request) -> dict:
|
||||
return {
|
||||
"daily": _get_table_stats("daily", lambda: _safe_aggregate_daily(repo)),
|
||||
"enriched": _get_table_stats("enriched", lambda: _safe_aggregate_enriched(repo)),
|
||||
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
|
||||
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
|
||||
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
|
||||
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
|
||||
"index_daily": _get_table_stats("index_daily", lambda: _safe_aggregate_index_daily(repo)),
|
||||
"index_enriched": _get_table_stats("index_enriched", lambda: _safe_aggregate_index_enriched(repo)),
|
||||
"index_instruments": _get_table_stats("index_instruments", lambda: _safe_aggregate_index_instruments(repo)),
|
||||
"etf_daily": _get_table_stats("etf_daily", lambda: _safe_aggregate_etf_daily(repo)),
|
||||
"etf_enriched": _get_table_stats("etf_enriched", lambda: _safe_aggregate_etf_enriched(repo)),
|
||||
"etf_instruments": _get_table_stats("etf_instruments", lambda: _safe_aggregate_etf_instruments(repo)),
|
||||
"minute": _get_table_stats("minute", lambda: _safe_aggregate_minute(repo)),
|
||||
"adj_factor": _get_table_stats("adj_factor", lambda: _safe_aggregate_adj_factor(repo)),
|
||||
"instruments": _get_table_stats("instruments", lambda: _safe_aggregate_instruments(repo)),
|
||||
"financials": _get_table_stats("financials", lambda: _safe_aggregate_financials(repo)),
|
||||
@@ -537,8 +626,9 @@ def clear_data(request: Request):
|
||||
deleted = 0
|
||||
|
||||
for sub in (
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched", "kline_minute",
|
||||
"adj_factor", "instruments", "instruments_index", "pools", "financials",
|
||||
"kline_daily", "kline_daily_enriched", "kline_index_daily", "kline_index_enriched",
|
||||
"kline_etf_daily", "kline_etf_enriched", "kline_etf_minute", "kline_minute",
|
||||
"adj_factor", "adj_factor_etf", "instruments", "instruments_index", "instruments_etf", "pools", "financials",
|
||||
"backtest_results", "screener_results", "ai_cache",
|
||||
):
|
||||
d = data_dir / sub
|
||||
@@ -596,10 +686,15 @@ def clear_data(request: Request):
|
||||
"kline_enriched": f"{d}/kline_daily_enriched/**/*.parquet",
|
||||
"kline_index_daily": f"{d}/kline_index_daily/**/*.parquet",
|
||||
"kline_index_enriched": f"{d}/kline_index_enriched/**/*.parquet",
|
||||
"kline_etf_daily": f"{d}/kline_etf_daily/**/*.parquet",
|
||||
"kline_etf_enriched": f"{d}/kline_etf_enriched/**/*.parquet",
|
||||
"kline_etf_minute": f"{d}/kline_etf_minute/**/*.parquet",
|
||||
"kline_minute": f"{d}/kline_minute/**/*.parquet",
|
||||
"adj_factor": f"{d}/adj_factor/**/*.parquet",
|
||||
"adj_factor_etf": f"{d}/adj_factor_etf/**/*.parquet",
|
||||
"instruments": f"{d}/instruments/**/*.parquet",
|
||||
"instruments_index": f"{d}/instruments_index/**/*.parquet",
|
||||
"instruments_etf": f"{d}/instruments_etf/**/*.parquet",
|
||||
}.items():
|
||||
try:
|
||||
repo.db.execute(
|
||||
@@ -638,6 +733,17 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_index_enriched": ENRICHED_COLUMNS,
|
||||
"kline_etf_daily": {
|
||||
"symbol": "ETF代码",
|
||||
"date": "交易日期",
|
||||
"open": "开盘价",
|
||||
"high": "最高价",
|
||||
"low": "最低价",
|
||||
"close": "收盘价",
|
||||
"volume": "成交量",
|
||||
"amount": "成交额",
|
||||
},
|
||||
"kline_etf_enriched": ENRICHED_COLUMNS,
|
||||
"kline_minute": {
|
||||
"symbol": "股票代码",
|
||||
"datetime": "分钟时间戳",
|
||||
@@ -675,6 +781,13 @@ _TABLE_FIELD_DESC: dict[str, dict[str, str]] = {
|
||||
"code": "指数编码(纯数字)",
|
||||
"asset_type": "资产类型(index)",
|
||||
},
|
||||
"instruments_etf": {
|
||||
"symbol": "ETF代码",
|
||||
"name": "ETF名称",
|
||||
"code": "ETF编码(纯数字)",
|
||||
"asset_type": "资产类型(etf)",
|
||||
"source": "数据源",
|
||||
},
|
||||
}
|
||||
|
||||
# view 名 → DuckDB 视图名
|
||||
@@ -684,6 +797,9 @@ _SCHEMA_VIEWS: dict[str, str] = {
|
||||
"index_daily": "kline_index_daily",
|
||||
"index_enriched": "kline_index_enriched",
|
||||
"index_instruments": "instruments_index",
|
||||
"etf_daily": "kline_etf_daily",
|
||||
"etf_enriched": "kline_etf_enriched",
|
||||
"etf_instruments": "instruments_etf",
|
||||
"minute": "kline_minute",
|
||||
"adj_factor": "adj_factor",
|
||||
"instruments": "instruments",
|
||||
@@ -730,7 +846,7 @@ def get_version(request: Request) -> dict:
|
||||
"""
|
||||
from app import __version__
|
||||
|
||||
# 1. 优先用 app.__version__ (开发期 bump_version.py 写入)
|
||||
# 1. 优先用 app.__version__ (唯一权威版本, 打包期由 PyInstaller 注入)
|
||||
if __version__:
|
||||
v = __version__.strip()
|
||||
return {"version": v if v.startswith("v") else f"v{v}"}
|
||||
|
||||
@@ -325,6 +325,27 @@ def list_configs(request: Request):
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.post("/presets/{config_id}/fetch")
|
||||
async def fetch_preset_data(request: Request, config_id: str):
|
||||
"""手动触发内置预设 (概念/行业) 的数据拉取。
|
||||
|
||||
注意: 必须在 /{config_id}/... 动态路由之前声明, 否则 'presets' 会被当成 config_id。
|
||||
与通用 pull/run 不同: 走 ext_presets 的结构转换 (接口的 concepts/industries
|
||||
数组 → 拼接成字符串), 保证 schema 与现有数据一致。
|
||||
"""
|
||||
from app.services.ext_presets import fetch_preset
|
||||
|
||||
try:
|
||||
n = await fetch_preset(config_id, _data_dir(request))
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
_refresh_views(request)
|
||||
return {"status": "ok", "rows": n}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def create_config(request: Request, body: CreateExtReq):
|
||||
"""创建扩展数据配置。"""
|
||||
@@ -554,6 +575,13 @@ def configure_pull(request: Request, config_id: str, body: PullConfigReq):
|
||||
# 刷新调度器
|
||||
pull_scheduler.refresh(_data_dir(request))
|
||||
|
||||
# 关闭定时拉取时清理残留的 next_run, 避免前端展示一个永不执行的"下次"
|
||||
if not config.pull.enabled:
|
||||
cleared = store.get(config_id)
|
||||
if cleared and cleared.pull and cleared.pull.next_run:
|
||||
cleared.pull.next_run = None
|
||||
store.upsert(cleared)
|
||||
|
||||
return {"status": "ok", "pull": config.pull.to_dict()}
|
||||
|
||||
|
||||
@@ -609,8 +637,25 @@ async def run_pull(request: Request, config_id: str):
|
||||
try:
|
||||
n, d = await fetch_and_ingest(config, _data_dir(request))
|
||||
_refresh_views(request)
|
||||
# 写回执行状态, 让前端"上次执行"面板立即反映
|
||||
updated = store.get(config_id)
|
||||
if updated and updated.pull:
|
||||
from datetime import datetime, timezone
|
||||
updated.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
updated.pull.last_status = "success"
|
||||
updated.pull.last_message = f"{n} rows @ {d}"
|
||||
updated.pull.last_rows = n
|
||||
store.upsert(updated)
|
||||
return {"status": "ok", "rows": n, "date": d}
|
||||
except Exception as e:
|
||||
# 失败也写回状态, 记录错误信息
|
||||
failed = store.get(config_id)
|
||||
if failed and failed.pull:
|
||||
from datetime import datetime, timezone
|
||||
failed.pull.last_run = datetime.now(timezone.utc).isoformat()
|
||||
failed.pull.last_status = "error"
|
||||
failed.pull.last_message = str(e)[:200]
|
||||
store.upsert(failed)
|
||||
raise HTTPException(400, f"拉取失败: {e}") from e
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,12 @@ import logging
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.financial_sync import get_financial_df
|
||||
from app.services.financial_analyzer import analyze_financials_stream
|
||||
from app.services import ai_reports
|
||||
from app.tickflow.capabilities import Cap
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -109,7 +113,12 @@ def get_cash_flow(request: Request, symbol: str | None = None):
|
||||
|
||||
@router.post("/sync/{table}")
|
||||
def sync_table(request: Request, table: str):
|
||||
"""手动触发同步。table: metrics / income / balance_sheet / cash_flow / all"""
|
||||
"""手动触发同步(立即返回,后台异步执行)。
|
||||
|
||||
table: metrics / income / balance_sheet / cash_flow / all
|
||||
同步在后台线程执行,全量同步需数分钟。本接口立即返回 started 状态,
|
||||
前端通过轮询 GET /status 的 syncing 字段观察进度。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
@@ -122,6 +131,87 @@ def sync_table(request: Request, table: str):
|
||||
return {"status": "error", "message": "FinancialScheduler not available"}
|
||||
|
||||
target = None if table == "all" else table
|
||||
result = fs.run_now(target)
|
||||
result = fs.trigger(target)
|
||||
|
||||
return {"status": "ok", "synced": result}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 财务分析请求。"""
|
||||
symbol: str
|
||||
focus: str = "" # 可选:用户追加的分析关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_financials(request: Request, req: AnalyzeRequest):
|
||||
"""AI 财务分析 — SSE 流式返回。
|
||||
|
||||
后端读取该标的 4 张财务表 → 注入 CFA 分析师级提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 SSE 形式推给前端(JSON per line, 非 text/event-stream,
|
||||
以便前端用 ReadableStream 逐行解析,更简单可靠)。
|
||||
"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
|
||||
if not req.symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_financials_stream(data_dir, req.symbol, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# AI 报告 CRUD(历史报告持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 财务分析报告。"""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
focus: str = ""
|
||||
content: str
|
||||
periods: int | None = None
|
||||
summary: str = ""
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。无需 FINANCIAL 能力读取列表元信息。"""
|
||||
capset = request.app.state.capabilities
|
||||
if not capset.has(Cap.FINANCIAL):
|
||||
return {"reports": []}
|
||||
return {"reports": ai_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
report = ai_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"periods": req.periods,
|
||||
"summary": req.summary,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
capset = request.app.state.capabilities
|
||||
capset.require(Cap.FINANCIAL)
|
||||
ok = ai_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
|
||||
@@ -94,7 +94,7 @@ def get_index_daily(
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 150)
|
||||
except Exception as e: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": info.get("name"), "index_info": info, "rows": [], "source": "none"}
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ def index_quotes(
|
||||
request: Request,
|
||||
symbols: str | None = Query(None, description="逗号分隔的指数 symbol 列表"),
|
||||
):
|
||||
"""返回实时指数行情缓存,不触发数据源请求。"""
|
||||
"""返回实时指数行情缓存,不触发 TickFlow 请求。"""
|
||||
symbol_list = [s.strip() for s in symbols.split(",") if s.strip()] if symbols else None
|
||||
qs = _get_quote_service(request)
|
||||
if not qs:
|
||||
@@ -136,6 +136,9 @@ async def quote_stream(request: Request):
|
||||
"depth": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_depth_update, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
"review": asyncio.ensure_future(
|
||||
asyncio.to_thread(qs.wait_for_review, timeout=5.0) if qs else asyncio.sleep(5)
|
||||
),
|
||||
}
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
@@ -160,6 +163,14 @@ async def quote_stream(request: Request):
|
||||
}, ensure_ascii=False),
|
||||
}
|
||||
|
||||
# 推送复盘进度 (定时复盘流式生成时) — 前端 reviewStore 直接消费
|
||||
# 事件已是 recap_market_stream 产出的 JSON 字符串, 逐条转发
|
||||
for evt_json in qs.pop_review_events():
|
||||
yield {
|
||||
"event": "review_progress",
|
||||
"data": evt_json,
|
||||
}
|
||||
|
||||
# 推送行情更新 (行情信号触发)
|
||||
if tasks["quote"] in done:
|
||||
try:
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
|
||||
from app.indicators.pipeline import compute_enriched_single
|
||||
from app.indicators.pipeline import compute_enriched, compute_enriched_single
|
||||
from app.services import kline_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -123,10 +123,19 @@ def get_daily(
|
||||
try:
|
||||
raw = kline_sync.sync_daily_batch([symbol], count=days + 30)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"数据源 fetch failed: {e}") from e
|
||||
raise HTTPException(status_code=502, detail=f"TickFlow fetch failed: {e}") from e
|
||||
if raw.is_empty():
|
||||
return {"symbol": symbol, "name": stock_name, "stock_info": stock_info, "rows": []}
|
||||
enriched = compute_enriched_single(raw)
|
||||
# 拉除权因子做前复权 (Starter+ 有权限), 否则空 df → compute_enriched 退回未复权
|
||||
factors = pl.DataFrame()
|
||||
capset = getattr(request.app.state, "capabilities", None)
|
||||
try:
|
||||
from app.tickflow.capabilities import Cap
|
||||
if capset and capset.has(Cap.ADJ_FACTOR):
|
||||
factors = kline_sync.fetch_adj_factor_single(symbol)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("单股除权因子拉取失败 %s: %s", symbol, e)
|
||||
enriched = compute_enriched(raw, factors=factors)
|
||||
rows = enriched.tail(days).to_dicts()
|
||||
# 即使 live 模式也尝试追加实时蜡烛
|
||||
rows = _maybe_inject_live_candle(request, symbol, rows)
|
||||
@@ -328,7 +337,7 @@ def get_minute(
|
||||
"""读取某只股票某天的分钟 K 线。
|
||||
|
||||
- 本地有完整数据(240条) → 直接返回
|
||||
- 本地无数据或不完整 → 从数据源实时拉取返回(不写入)
|
||||
- 本地无数据或不完整 → 从 TickFlow 实时拉取返回(不写入)
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
stock_info = _get_stock_info(repo, symbol)
|
||||
@@ -337,7 +346,7 @@ def get_minute(
|
||||
if trade_date is None:
|
||||
trade_date = repo.latest_minute_date(symbol)
|
||||
if trade_date is None:
|
||||
# 本地无任何分钟K,尝试从数据源拉取当天
|
||||
# 本地无任何分钟K,尝试从 TickFlow 拉取当天
|
||||
trade_date = date.today()
|
||||
df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
@@ -373,7 +382,7 @@ def get_minute(
|
||||
"date": str(trade_date), "rows": df.to_dicts(), "source": "local",
|
||||
}
|
||||
|
||||
# 本地不完整或无数据 → 从数据源实时拉取
|
||||
# 本地不完整或无数据 → 从 TickFlow 实时拉取
|
||||
live_df = kline_sync.fetch_minute_single(symbol, trade_date)
|
||||
return {
|
||||
"symbol": symbol, "name": stock_name, "stock_info": stock_info,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""AI 大盘复盘 API — 流式复盘 + 报告持久化。
|
||||
|
||||
路由前缀: /api/market-recap
|
||||
|
||||
端点:
|
||||
POST /analyze AI 流式大盘复盘(NDJSON)
|
||||
GET /reports 历史复盘列表
|
||||
POST /reports 保存一条复盘报告
|
||||
DELETE /reports/{report_id} 删除一条复盘报告
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import market_recap_reports
|
||||
from app.services.market_recap import recap_market_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/market-recap", tags=["market-recap"])
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 大盘复盘请求。"""
|
||||
as_of: str | None = None # 可选:复盘日期(YYYY-MM-DD),缺省取最新有数据日
|
||||
focus: str = "" # 可选:用户追加的复盘关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_market(request: Request, req: AnalyzeRequest):
|
||||
"""AI 大盘复盘 — NDJSON 流式返回。
|
||||
|
||||
装配市场总览(指数/涨跌/连板/封板/板块/情绪雷达)→ 复盘提示词 →
|
||||
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
|
||||
协议:
|
||||
{"type":"meta","as_of","emotion_score","emotion_label","summary"}
|
||||
{"type":"delta","content":"..."}
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
from datetime import date as date_cls
|
||||
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
depth_service = getattr(request.app.state, "depth_service", None)
|
||||
|
||||
as_of = None
|
||||
if req.as_of:
|
||||
try:
|
||||
as_of = date_cls.fromisoformat(req.as_of)
|
||||
except ValueError:
|
||||
raise HTTPException(400, f"as_of 格式应为 YYYY-MM-DD,收到: {req.as_of}")
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in recap_market_stream(repo, quote_service, depth_service, as_of, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 报告 CRUD(历史复盘持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 大盘复盘报告。"""
|
||||
as_of: str
|
||||
focus: str = ""
|
||||
content: str
|
||||
summary: str = ""
|
||||
emotion_score: int | None = None
|
||||
emotion_label: str = ""
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史复盘(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": market_recap_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条复盘报告。"""
|
||||
report = market_recap_reports.save_report({
|
||||
"as_of": req.as_of,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"summary": req.summary,
|
||||
"emotion_score": req.emotion_score,
|
||||
"emotion_label": req.emotion_label,
|
||||
})
|
||||
# 推送到飞书(可选): 与定时复盘共用同一开关 review_push_enabled 与 _maybe_push_review。
|
||||
# 内部 try/except 静默降级, 不影响归档返回值。
|
||||
from app.jobs.daily_pipeline import _maybe_push_review
|
||||
_maybe_push_review(req.content, {
|
||||
"as_of": req.as_of,
|
||||
"emotion_label": req.emotion_label,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条复盘报告。"""
|
||||
ok = market_recap_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
@@ -47,9 +47,12 @@ class RuleModel(BaseModel):
|
||||
logic: str = "and" # and | or
|
||||
cooldown_seconds: int = 3600
|
||||
severity: str = "info" # info | warn | critical
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 开发中)
|
||||
webhook_url: str = "" # Webhook 推送地址 (推送到 QMT 等外部软件, 待定)
|
||||
webhook_enabled: bool = False
|
||||
message: str = ""
|
||||
# ladder 专属 (连板梯队封单监控)
|
||||
metric: str = "sealed_vol" # sealed_vol=封单量(手) | sealed_amount=封单额(元)
|
||||
threshold: float = 0 # 封单 <= 此值时报警 (原始单位: 量=手, 额=元)
|
||||
|
||||
|
||||
# ── 字段选项 ─────────────────────────────────────────────
|
||||
@@ -128,6 +131,16 @@ def list_rules(request: Request):
|
||||
@router.post("")
|
||||
def save_rule(req: RuleModel, request: Request):
|
||||
rule = monitor_rules.normalize(req.model_dump())
|
||||
# 连板梯队封单监控 (type=ladder) 依赖五档盘口数据, 需 Pro+ (DEPTH5_BATCH 能力)。
|
||||
# 无能力时拒绝创建, 避免规则存了却永远无法触发。
|
||||
if rule.get("type") == "ladder":
|
||||
from app.tickflow.capabilities import Cap
|
||||
capset = getattr(request.app.state, "capabilities", None)
|
||||
if capset is None or not capset.has(Cap.DEPTH5_BATCH):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="封单监控需要 Pro+ 套餐 (批量五档能力),请升级后在「设置」页配置",
|
||||
)
|
||||
# 编辑现有规则时, 保留原 created_at (避免按时间排序时位置跳动)
|
||||
existing = monitor_rules.load_one(_data_dir(request), rule["id"])
|
||||
if existing and existing.get("created_at"):
|
||||
@@ -233,3 +246,254 @@ def seed_demo_rules(request: Request):
|
||||
i += 1
|
||||
_sync_engine(request)
|
||||
return {"ok": True, "generated": len(created), "ids": created}
|
||||
|
||||
|
||||
# ── 封单监控模拟触发 (Dev 调试用) ─────────────────────
|
||||
@router.post("/test-ladder")
|
||||
def test_ladder(request: Request):
|
||||
"""模拟触发所有 ladder 规则, 返回命中结果 (不落盘、不推送飞书)。
|
||||
|
||||
用当前 depth_service 的封单数据 + enriched 最新日 close 构造 mock DataFrame,
|
||||
跑 _evaluate_ladder 判断哪些规则会触发。供 Dev 页面调试验证。
|
||||
"""
|
||||
import polars as pl
|
||||
|
||||
repo = request.app.state.repo
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
engine = getattr(request.app.state, "monitor_engine", None)
|
||||
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
if not engine or not engine.has_rule_type("ladder"):
|
||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
||||
|
||||
# 最新交易日
|
||||
latest = repo.enriched_latest_date()
|
||||
if not latest:
|
||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
||||
|
||||
# 取涨停+跌停封单 {symbol: vol}
|
||||
sealed: dict[str, int] = {}
|
||||
for is_down in (False, True):
|
||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
||||
for sym, info in m.items():
|
||||
vol = (info or {}).get("vol")
|
||||
if vol and vol > 0:
|
||||
sealed[sym] = vol
|
||||
|
||||
if not sealed:
|
||||
raise HTTPException(status_code=400, detail="无封单数据 (depth 未拉取或无涨停/跌停股)")
|
||||
|
||||
# 取这些 symbol 的 close (算封单额用)
|
||||
enriched_today, _ = repo.get_enriched_latest()
|
||||
cols = ["symbol", "close", "change_pct"]
|
||||
avail = [c for c in cols if c in enriched_today.columns]
|
||||
mock = enriched_today.select(avail).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
||||
|
||||
# 注入 _sealed_vol
|
||||
sealed_df = pl.DataFrame({
|
||||
"symbol": list(sealed.keys()),
|
||||
"_sealed_vol": list(sealed.values()),
|
||||
})
|
||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
||||
|
||||
# 取所有 ladder 规则, 逐条纯条件判断 (绕过引擎 cooldown, 不污染 _last_fire)
|
||||
ladder_rules = [r for r in engine.rules.values() if r.get("type") == "ladder" and r.get("enabled", True)]
|
||||
all_events = []
|
||||
not_triggered = []
|
||||
|
||||
for rule in ladder_rules:
|
||||
syms = rule.get("symbols", [])
|
||||
sym = syms[0] if syms else None
|
||||
metric = rule.get("metric", "sealed_vol")
|
||||
thr = rule.get("threshold", 0)
|
||||
direction = rule.get("direction", "up")
|
||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
||||
|
||||
# 取该 symbol 的封单数据
|
||||
cur_vol = sealed.get(sym) if sym else None
|
||||
row = mock.filter(pl.col("symbol") == sym) if sym else mock.clear()
|
||||
cur_close = row["close"][0] if len(row) and "close" in row.columns else None
|
||||
cur_amt = (cur_vol * 100 * cur_close) if (cur_vol and cur_close) else None
|
||||
cur_val = cur_amt if metric == "sealed_amount" else cur_vol
|
||||
|
||||
# 条件判断: 封单 > 0 且 比较值 <= 阈值
|
||||
if cur_val is not None and cur_val > 0 and cur_val <= thr:
|
||||
if metric == "sealed_amount":
|
||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
||||
th_text = f"{thr / 1e4:.0f}万元"
|
||||
else:
|
||||
sv_text = f"{cur_val:,.0f} 手"
|
||||
th_text = f"{thr:,.0f} 手"
|
||||
all_events.append({
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"symbol": sym,
|
||||
"name": sym,
|
||||
"type": warn_label,
|
||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
||||
"severity": rule.get("severity", "warn"),
|
||||
"sealed_value": cur_val,
|
||||
"sealed_metric": metric,
|
||||
"current_sealed_vol": cur_vol,
|
||||
"current_sealed_amount": cur_amt,
|
||||
})
|
||||
else:
|
||||
reason = "封单数据缺失" if cur_val is None else (
|
||||
f"封单 {cur_val:,.0f} > 阈值 {thr:,.0f}" if cur_val > thr else "封单为 0"
|
||||
)
|
||||
not_triggered.append({
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"symbol": sym,
|
||||
"metric": metric,
|
||||
"threshold": thr,
|
||||
"current_value": cur_val,
|
||||
"current_sealed_vol": cur_vol,
|
||||
"current_sealed_amount": cur_amt,
|
||||
"reason": reason,
|
||||
})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"as_of": str(latest),
|
||||
"sealed_count": len(sealed),
|
||||
"triggered": all_events,
|
||||
"not_triggered": not_triggered,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/trigger-ladder")
|
||||
def trigger_ladder(request: Request):
|
||||
"""真实触发一次 ladder 预警 (落盘 + 飞书推送 + SSE), 供 Dev 调试验证完整效果。
|
||||
|
||||
与 test-ladder 区别: 本端点会真的把预警写入 alerts.jsonl、推送飞书、触发 SSE,
|
||||
让用户看到真实的预警通知。绕过 cooldown 强制触发。
|
||||
"""
|
||||
import time
|
||||
from app.services import alert_store
|
||||
|
||||
repo = request.app.state.repo
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
engine = getattr(request.app.state, "monitor_engine", None)
|
||||
quote_svc = getattr(request.app.state, "quote_service", None)
|
||||
|
||||
if not depth_svc:
|
||||
raise HTTPException(status_code=503, detail="depth 服务未初始化")
|
||||
if not engine or not engine.has_rule_type("ladder"):
|
||||
raise HTTPException(status_code=400, detail="无 ladder 类型监控规则")
|
||||
|
||||
latest = repo.enriched_latest_date()
|
||||
if not latest:
|
||||
raise HTTPException(status_code=400, detail="无 enriched 数据")
|
||||
|
||||
# 取封单
|
||||
sealed: dict[str, int] = {}
|
||||
for is_down in (False, True):
|
||||
m = depth_svc.get_sealed_map(latest, is_down=is_down)
|
||||
for sym, info in m.items():
|
||||
vol = (info or {}).get("vol")
|
||||
if vol and vol > 0:
|
||||
sealed[sym] = vol
|
||||
if not sealed:
|
||||
raise HTTPException(status_code=400, detail="无封单数据")
|
||||
|
||||
# 构造真实 rule_events (与 _evaluate_ladder 产出格式一致)
|
||||
import polars as pl
|
||||
enriched_today, _ = repo.get_enriched_latest()
|
||||
cols = [c for c in ["symbol", "close", "change_pct"] if c in enriched_today.columns]
|
||||
mock = enriched_today.select(cols).filter(pl.col("symbol").is_in(list(sealed.keys())))
|
||||
sealed_df = pl.DataFrame({"symbol": list(sealed.keys()), "_sealed_vol": list(sealed.values())})
|
||||
mock = mock.join(sealed_df, on="symbol", how="inner")
|
||||
|
||||
now = time.time()
|
||||
rule_events: list[dict] = []
|
||||
name_map = {}
|
||||
try:
|
||||
inst = repo.get_instruments()
|
||||
if not inst.is_empty() and "name" in inst.columns:
|
||||
name_map = {r["symbol"]: r["name"] for r in inst.select(["symbol", "name"]).iter_rows(named=True) if r.get("name")}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
for rule in engine.rules.values():
|
||||
if rule.get("type") != "ladder" or not rule.get("enabled", True):
|
||||
continue
|
||||
sym = rule.get("symbols", [""])[0] if rule.get("symbols") else ""
|
||||
metric = rule.get("metric", "sealed_vol")
|
||||
thr = rule.get("threshold", 0)
|
||||
direction = rule.get("direction", "up")
|
||||
warn_label = "炸板预警" if direction == "up" else "翘板预警"
|
||||
|
||||
row = mock.filter(pl.col("symbol") == sym)
|
||||
if row.is_empty():
|
||||
continue
|
||||
cur_vol = row["_sealed_vol"][0]
|
||||
close_v = row["close"][0] if "close" in row.columns else None
|
||||
cur_val = cur_vol * 100 * close_v if metric == "sealed_amount" else cur_vol
|
||||
if not cur_val or cur_val <= 0 or cur_val > thr:
|
||||
continue # 不满足条件, 跳过
|
||||
|
||||
if metric == "sealed_amount":
|
||||
sv_text = f"{cur_val / 1e4:.0f}万元"
|
||||
th_text = f"{thr / 1e4:.0f}万元"
|
||||
else:
|
||||
sv_text = f"{cur_val:,.0f} 手"
|
||||
th_text = f"{thr:,.0f} 手"
|
||||
|
||||
rule_events.append({
|
||||
"ts": int(now * 1000),
|
||||
"rule_id": rule["id"],
|
||||
"rule_name": rule.get("name", ""),
|
||||
"source": "ladder",
|
||||
"type": warn_label,
|
||||
"symbol": sym,
|
||||
"name": name_map.get(sym, sym),
|
||||
"message": f"{warn_label} · 封单 {sv_text} ≤ {th_text}",
|
||||
"price": close_v,
|
||||
"change_pct": row["change_pct"][0] if "change_pct" in row.columns else None,
|
||||
"signals": [],
|
||||
"severity": rule.get("severity", "warn"),
|
||||
"conditions": [],
|
||||
"logic": "and",
|
||||
"sealed_value": cur_val,
|
||||
"sealed_metric": metric,
|
||||
})
|
||||
|
||||
if not rule_events:
|
||||
raise HTTPException(status_code=400, detail="当前无 ladder 规则满足触发条件 (封单均 > 阈值)")
|
||||
|
||||
# 1. 落盘到 alerts.jsonl
|
||||
try:
|
||||
alert_store.append_many(repo.store.data_dir, rule_events)
|
||||
except Exception as e: # noqa: BLE001
|
||||
pass # 落盘失败不阻断推送
|
||||
|
||||
# 2. SSE 推送 (入 pending_alerts 队列)
|
||||
if quote_svc:
|
||||
sse_alerts = [{
|
||||
"source": ev["source"], "type": ev["type"], "rule_id": ev["rule_id"],
|
||||
"strategy_id": None, "symbol": ev["symbol"], "name": ev["name"],
|
||||
"message": ev["message"], "price": ev["price"], "change_pct": ev["change_pct"],
|
||||
"signals": ev["signals"], "severity": ev["severity"],
|
||||
"conditions": ev["conditions"], "logic": ev["logic"],
|
||||
} for ev in rule_events]
|
||||
try:
|
||||
with quote_svc._lock:
|
||||
quote_svc._pending_alerts.extend(sse_alerts)
|
||||
quote_svc._alert_event.set()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
# 3. 飞书推送
|
||||
if quote_svc:
|
||||
try:
|
||||
quote_svc._maybe_send_webhook(rule_events, engine)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"triggered": len(rule_events),
|
||||
"events": [{"symbol": ev["symbol"], "name": ev["name"], "message": ev["message"]} for ev in rule_events],
|
||||
}
|
||||
|
||||
@@ -343,216 +343,18 @@ def _pct_band_rows(values: list[float]) -> list[dict]:
|
||||
|
||||
|
||||
def _build_overview(request: Request, as_of: date | None = None) -> dict:
|
||||
repo = request.app.state.repo
|
||||
svc = ScreenerService(repo)
|
||||
as_of = as_of or svc.latest_date()
|
||||
status = _quote_status(request)
|
||||
indices = _index_quotes(request, as_of)
|
||||
"""装配市场总览(委托给 services.market_overview_builder,保持行为一致)。
|
||||
|
||||
if not as_of:
|
||||
return {
|
||||
"as_of": None,
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {"total": 0, "up": 0, "down": 0, "flat": 0, "up_pct": 0, "down_pct": 0},
|
||||
"amount": {"total": 0, "avg": 0},
|
||||
"boards": [],
|
||||
"limit": {"limit_up": 0, "broken": 0, "failed": 0, "limit_down": 0, "max_boards": 0, "tiers": []},
|
||||
"distribution": [],
|
||||
"trend": {"above_ma5": 0, "above_ma20": 0, "above_ma60": 0, "above_ma5_pct": 0, "above_ma20_pct": 0, "above_ma60_pct": 0, "new_high": 0, "new_low": 0},
|
||||
"activity": {"avg_turnover": 0, "high_turnover": 0, "high_vol_ratio": 0, "vol_ratio": 1},
|
||||
"radar": [],
|
||||
"emotion": {"score": 50, "label": "暂无"},
|
||||
"top_gainers": [],
|
||||
"top_losers": [],
|
||||
"turnover_leaders": [],
|
||||
"active_leaders": [],
|
||||
"concept_rank": {"leading": [], "lagging": []},
|
||||
"industry_rank": {"leading": [], "lagging": []},
|
||||
}
|
||||
|
||||
df = svc._load_enriched_for_date(as_of)
|
||||
if df.is_empty():
|
||||
rows: list[dict] = []
|
||||
else:
|
||||
cols = [
|
||||
"symbol", "name", "close", "change_pct", "amount", "turnover_rate", "volume",
|
||||
"vol_ratio_5d", "consecutive_limit_ups", "signal_limit_up", "signal_broken_limit_up", "signal_limit_down",
|
||||
"ma5", "ma20", "ma60", "high_60d", "low_60d", "signal_n_day_high", "signal_n_day_low",
|
||||
]
|
||||
df = df.select([c for c in cols if c in df.columns])
|
||||
rows = df.to_dicts()
|
||||
|
||||
# 过滤真停牌(volume=0 且 change_pct=0),保留有涨跌幅的浮点误差股以对齐同花顺口径
|
||||
if rows and "volume" in rows[0]:
|
||||
rows = [r for r in rows
|
||||
if (_finite(r.get("volume")) or 0) > 0
|
||||
or (_finite(r.get("change_pct")) or 0) != 0]
|
||||
|
||||
total = len(rows)
|
||||
up = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) > 0)
|
||||
down = sum(1 for r in rows if (_finite(r.get("change_pct")) or 0) < 0)
|
||||
flat = max(0, total - up - down)
|
||||
up_pct = up / total * 100 if total else 0
|
||||
down_pct = down / total * 100 if total else 0
|
||||
|
||||
amounts = [_finite(r.get("amount")) or 0 for r in rows]
|
||||
total_amount = sum(amounts)
|
||||
avg_amount = total_amount / total if total else 0
|
||||
|
||||
pct_values = [_finite(r.get("change_pct")) for r in rows]
|
||||
pct_values = [v for v in pct_values if v is not None]
|
||||
avg_pct = sum(pct_values) / len(pct_values) if pct_values else 0
|
||||
median_pct = sorted(pct_values)[len(pct_values) // 2] if pct_values else 0
|
||||
strong_up = sum(1 for v in pct_values if v >= 0.03)
|
||||
strong_down = sum(1 for v in pct_values if v <= -0.03)
|
||||
|
||||
limit_up = sum(1 for r in rows if bool(r.get("signal_limit_up")) or (_finite(r.get("consecutive_limit_ups")) or 0) > 0)
|
||||
broken = sum(1 for r in rows if bool(r.get("signal_broken_limit_up")))
|
||||
limit_down = sum(1 for r in rows if bool(r.get("signal_limit_down")))
|
||||
max_boards = max([int(_finite(r.get("consecutive_limit_ups")) or 0) for r in rows], default=0)
|
||||
|
||||
# 五档 sealed 修正: 假涨停/假跌停不计入(需 Pro+ depth5.batch 能力)
|
||||
depth_svc = getattr(request.app.state, "depth_service", None)
|
||||
sealed_ready = False
|
||||
fake_up = 0
|
||||
fake_down = 0
|
||||
if depth_svc:
|
||||
up_map = depth_svc.get_sealed_map(as_of, is_down=False)
|
||||
down_map = depth_svc.get_sealed_map(as_of, is_down=True)
|
||||
sealed_ready = bool(up_map or down_map) and depth_svc.is_sealed_ready(as_of)
|
||||
if up_map:
|
||||
fake_up = sum(1 for v in up_map.values() if v.get("sealed") is False)
|
||||
if down_map:
|
||||
fake_down = sum(1 for v in down_map.values() if v.get("sealed") is False)
|
||||
if sealed_ready:
|
||||
limit_up = max(0, limit_up - fake_up)
|
||||
limit_down = max(0, limit_down - fake_down)
|
||||
|
||||
seal_rate = limit_up / (limit_up + broken) * 100 if (limit_up + broken) > 0 else 0
|
||||
|
||||
def above_ma_count(ma_key: str) -> int:
|
||||
return sum(1 for r in rows if (_finite(r.get("close")) is not None and _finite(r.get(ma_key)) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get(ma_key)) or 0)))
|
||||
|
||||
above_ma5 = above_ma_count("ma5")
|
||||
above_ma20 = above_ma_count("ma20")
|
||||
above_ma60 = above_ma_count("ma60")
|
||||
new_high = sum(1 for r in rows if bool(r.get("signal_n_day_high")) or (_finite(r.get("close")) is not None and _finite(r.get("high_60d")) is not None and (_finite(r.get("close")) or 0) >= (_finite(r.get("high_60d")) or 0)))
|
||||
new_low = sum(1 for r in rows if bool(r.get("signal_n_day_low")) or (_finite(r.get("close")) is not None and _finite(r.get("low_60d")) is not None and (_finite(r.get("close")) or 0) <= (_finite(r.get("low_60d")) or 0)))
|
||||
|
||||
turnovers = [_finite(r.get("turnover_rate")) for r in rows]
|
||||
turnovers = [v for v in turnovers if v is not None]
|
||||
avg_turnover = sum(turnovers) / len(turnovers) if turnovers else 0
|
||||
high_turnover = sum(1 for v in turnovers if v >= 5)
|
||||
|
||||
boards_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
b = _board(str(r.get("symbol") or ""))
|
||||
item = boards_map.setdefault(b, {"board": b, "count": 0, "up": 0, "down": 0, "amount": 0.0})
|
||||
item["count"] += 1
|
||||
change = _finite(r.get("change_pct")) or 0
|
||||
if change > 0:
|
||||
item["up"] += 1
|
||||
elif change < 0:
|
||||
item["down"] += 1
|
||||
item["amount"] += _finite(r.get("amount")) or 0
|
||||
boards = sorted(boards_map.values(), key=lambda x: x["amount"], reverse=True)
|
||||
for b in boards:
|
||||
count = b["count"] or 1
|
||||
b["up_pct"] = b["up"] / count * 100
|
||||
|
||||
tiers_map: dict[int, int] = {}
|
||||
for r in rows:
|
||||
n = int(_finite(r.get("consecutive_limit_ups")) or 0)
|
||||
if n > 0:
|
||||
tiers_map[n] = tiers_map.get(n, 0) + 1
|
||||
tiers = [{"boards": k, "count": v} for k, v in sorted(tiers_map.items(), key=lambda item: -item[0])]
|
||||
|
||||
index_changes = [_finite(r.get("change_pct")) for r in indices]
|
||||
index_changes = [v for v in index_changes if v is not None]
|
||||
avg_index_pct = sum(index_changes) / len(index_changes) if index_changes else 0
|
||||
vol_ratios = [_finite(r.get("vol_ratio_5d")) for r in rows]
|
||||
vol_ratios = [v for v in vol_ratios if v is not None]
|
||||
avg_vol_ratio = sum(vol_ratios) / len(vol_ratios) if vol_ratios else 1
|
||||
high_vol_ratio = sum(1 for v in vol_ratios if v >= 1.5)
|
||||
|
||||
concept_rank = _dimension_rank(rows, request, "concept")
|
||||
industry_rank = _dimension_rank(rows, request, "industry", level=2)
|
||||
|
||||
strong_diff_pct = (strong_up - strong_down) / total * 100 if total else 0
|
||||
high_vol_pct = high_vol_ratio / total * 100 if total else 0
|
||||
strong_down_pct = strong_down / total * 100 if total else 0
|
||||
tier2_count = sum(t["count"] for t in tiers if t["boards"] >= 2)
|
||||
mainline_items = [*concept_rank["leading"][:3], *industry_rank["leading"][:3]]
|
||||
mainline_avg = max([_finite(item.get("avg_pct")) or 0 for item in mainline_items], default=0)
|
||||
mainline_cover_pct = max([(_finite(item.get("count")) or 0) / total * 100 for item in mainline_items], default=0) if total else 0
|
||||
mainline_score = round(_score(mainline_avg, -0.005, 0.03) * 0.65 + _score(mainline_cover_pct, 1, 12) * 0.35) if mainline_items else 50
|
||||
|
||||
radar = [
|
||||
{"key": "index", "label": "指数", "value": _score(avg_index_pct, -2.5, 2.5)},
|
||||
{"key": "profit", "label": "赚钱", "value": round(_score(up_pct, 20, 80) * 0.45 + _score(avg_pct, -0.02, 0.02) * 0.25 + _score(median_pct, -0.02, 0.02) * 0.20 + _score(strong_diff_pct, -8, 8) * 0.10)},
|
||||
{"key": "money", "label": "量能", "value": round(_score(avg_vol_ratio, 0.6, 1.8) * 0.70 + _score(high_vol_pct, 2, 12) * 0.30)},
|
||||
{"key": "speculation", "label": "投机", "value": round(_score(limit_up, 5, 90) * 0.25 + _score(seal_rate, 30, 85) * 0.35 + _score(max_boards, 1, 8) * 0.25 + _score(tier2_count, 0, 30) * 0.15)},
|
||||
{"key": "resilience", "label": "抗跌", "value": 100 - round(_score(down_pct, 20, 80) * 0.55 + _score(strong_down_pct, 1, 12) * 0.45)},
|
||||
{"key": "mainline", "label": "主线", "value": mainline_score},
|
||||
]
|
||||
emotion_score = round(sum(r["value"] for r in radar) / len(radar)) if radar else 50
|
||||
if emotion_score >= 70:
|
||||
emotion_label = "强势"
|
||||
elif emotion_score >= 55:
|
||||
emotion_label = "偏暖"
|
||||
elif emotion_score >= 45:
|
||||
emotion_label = "震荡"
|
||||
elif emotion_score >= 30:
|
||||
emotion_label = "偏冷"
|
||||
else:
|
||||
emotion_label = "冰点"
|
||||
|
||||
return _json_safe({
|
||||
"as_of": str(as_of),
|
||||
"quote_status": status,
|
||||
"indices": indices,
|
||||
"breadth": {
|
||||
"total": total,
|
||||
"up": up,
|
||||
"down": down,
|
||||
"flat": flat,
|
||||
"up_pct": up_pct,
|
||||
"down_pct": down_pct,
|
||||
"avg_pct": avg_pct,
|
||||
"median_pct": median_pct,
|
||||
"strong_up": strong_up,
|
||||
"strong_down": strong_down,
|
||||
},
|
||||
"amount": {"total": total_amount, "avg": avg_amount},
|
||||
"boards": boards,
|
||||
"limit": {"limit_up": limit_up, "broken": broken, "failed": 0, "limit_down": limit_down, "max_boards": max_boards, "seal_rate": seal_rate, "tiers": tiers, "sealed_ready": sealed_ready, "fake_up": fake_up, "fake_down": fake_down},
|
||||
"distribution": _pct_band_rows(pct_values),
|
||||
"trend": {
|
||||
"above_ma5": above_ma5,
|
||||
"above_ma20": above_ma20,
|
||||
"above_ma60": above_ma60,
|
||||
"above_ma5_pct": above_ma5 / total * 100 if total else 0,
|
||||
"above_ma20_pct": above_ma20 / total * 100 if total else 0,
|
||||
"above_ma60_pct": above_ma60 / total * 100 if total else 0,
|
||||
"new_high": new_high,
|
||||
"new_low": new_low,
|
||||
},
|
||||
"activity": {
|
||||
"avg_turnover": avg_turnover,
|
||||
"high_turnover": high_turnover,
|
||||
"high_vol_ratio": high_vol_ratio,
|
||||
"vol_ratio": avg_vol_ratio,
|
||||
},
|
||||
"radar": radar,
|
||||
"emotion": {"score": emotion_score, "label": emotion_label},
|
||||
"top_gainers": _top_rows(rows, "change_pct", True),
|
||||
"top_losers": _top_rows(rows, "change_pct", False),
|
||||
"turnover_leaders": _top_rows(rows, "amount", True),
|
||||
"active_leaders": _top_rows(rows, "turnover_rate", True),
|
||||
"concept_rank": concept_rank,
|
||||
"industry_rank": industry_rank,
|
||||
})
|
||||
逻辑已抽离至 build_market_overview,以解耦对 Request 的依赖,
|
||||
使大盘复盘等无 Request 的调用方可复用同一装配逻辑。
|
||||
"""
|
||||
from app.services.market_overview_builder import build_market_overview
|
||||
return build_market_overview(
|
||||
repo=request.app.state.repo,
|
||||
quote_service=getattr(request.app.state, "quote_service", None),
|
||||
depth_service=getattr(request.app.state, "depth_service", None),
|
||||
as_of=as_of,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/market")
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""涨幅轮动矩阵 API。
|
||||
|
||||
供「概念分析 → 涨幅RPS轮动」对话框调用。返回最近 N 个交易日的概念涨幅
|
||||
排名矩阵:每列(日期)各自把所有概念按当天涨幅从高到低排序。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import rps_rotation
|
||||
from app.services.concept_rotation_analyzer import analyze_rotation_stream
|
||||
|
||||
router = APIRouter(prefix="/api/rps", tags=["rps"])
|
||||
|
||||
|
||||
@router.get("/rotation")
|
||||
def get_rotation(
|
||||
request: Request,
|
||||
days: int = Query(12, ge=7, le=30, description="最近 N 个交易日(7-30)"),
|
||||
) -> dict:
|
||||
"""概念涨幅轮动矩阵。
|
||||
|
||||
Returns:
|
||||
dates: 日期字符串列表(最新在最前)
|
||||
columns: {日期: [[概念名, 涨幅小数], ...]} 每列各自降序
|
||||
concept_count: 去重概念总数
|
||||
"""
|
||||
return rps_rotation.build_rps_rotation(request.app.state.repo, days)
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 概念轮动分析请求。"""
|
||||
days: int = 12 # 分析最近 N 个交易日
|
||||
focus: str = "" # 用户追加的关注点
|
||||
|
||||
|
||||
@router.post("/rotation-analyze")
|
||||
async def analyze_rotation(request: Request, req: AnalyzeRequest):
|
||||
"""AI 概念轮动分析 — NDJSON 流式返回。
|
||||
|
||||
装配轮动矩阵信号 + 大盘背景 → 分析提示词 → 流式调用 LLM →
|
||||
逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
|
||||
协议:
|
||||
{"type":"meta","days","summary"}
|
||||
{"type":"delta","content":"..."}
|
||||
{"type":"error","message":"..."}
|
||||
{"type":"done"}
|
||||
"""
|
||||
repo = request.app.state.repo
|
||||
quote_service = getattr(request.app.state, "quote_service", None)
|
||||
depth_service = getattr(request.app.state, "depth_service", None)
|
||||
days = max(7, min(30, req.days))
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_rotation_stream(
|
||||
repo, days, req.focus, quote_service, depth_service,
|
||||
):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
@@ -278,11 +278,35 @@ def get_cached(
|
||||
request: Request,
|
||||
ext_columns: Optional[str] = Query(None, description="逗号分隔: config_id.field_name"),
|
||||
):
|
||||
"""读取策略结果缓存。返回 None 表示无缓存。"""
|
||||
"""读取策略结果缓存, 并叠加监控引擎本轮实时算出的结果。
|
||||
|
||||
- 盘后缓存 (strategy_cache.json): 非监控策略 / 页面秒加载用, run_all 写入。
|
||||
- 监控引擎内存结果 (latest_strategy_results): 实时行情每轮对「加入监控的策略」算出,
|
||||
不落盘 (避免与 read_cache 的 mtime 校验冲突), 在此直接叠加覆盖盘后结果。
|
||||
被监控的策略拿到新鲜数据, 非监控策略仍用盘后缓存。
|
||||
"""
|
||||
data_dir = request.app.state.repo.store.data_dir
|
||||
cached = strategy_cache.read_cache(data_dir)
|
||||
if cached is None:
|
||||
cached = {"as_of": None, "results": {}, "updated_at": None}
|
||||
|
||||
# 叠加监控引擎内存里的实时结果 (若有), 用新鲜数据覆盖同策略的盘后结果
|
||||
monitor_engine = getattr(request.app.state, "monitor_engine", None)
|
||||
if monitor_engine is not None:
|
||||
realtime_results = monitor_engine.latest_strategy_results()
|
||||
if realtime_results:
|
||||
results = dict(cached.get("results") or {})
|
||||
results.update(realtime_results)
|
||||
cached = dict(cached)
|
||||
cached["results"] = results
|
||||
# 有实时数据时, 以最新时间戳为准
|
||||
import time as _time
|
||||
cached["updated_at"] = int(_time.time() * 1000)
|
||||
|
||||
# 无任何数据 (盘后缓存空 + 无实时结果) → 返回空标记, 前端据此提示
|
||||
if not cached.get("results") and cached.get("as_of") is None:
|
||||
return {"as_of": None, "results": {}, "updated_at": None}
|
||||
|
||||
ext_values = _load_ext_value_maps(request.app.state.repo, ext_columns)
|
||||
return _cache_payload_with_ext(cached, ext_values)
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ from __future__ import annotations
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app import secrets_store
|
||||
from app.services.financial_sync import financial_scheduler
|
||||
from app.tickflow import client as tf_client
|
||||
from app.tickflow.policy import (
|
||||
detect_capabilities,
|
||||
@@ -30,25 +29,34 @@ router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
DEFAULT_PAID_ENDPOINT = "https://api.tickflow.org"
|
||||
|
||||
|
||||
def _sync_financial_scheduler_caps(app_state, capset) -> None:
|
||||
"""把重新探测出的能力同步给财务调度器。
|
||||
|
||||
app.state.capabilities 在此已更新, 但 FinancialScheduler 在启动时捕获的是旧引用,
|
||||
需显式刷新, 否则用户升级到 Expert 后点「全部同步」仍会因调度器读旧 capset 而被拒。
|
||||
"""
|
||||
fs = getattr(app_state, "financial_scheduler", None)
|
||||
if fs is None:
|
||||
return
|
||||
try:
|
||||
fs.update_capabilities(capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logging.getLogger(__name__).warning("update financial_scheduler capabilities failed: %s", e)
|
||||
|
||||
|
||||
class TickflowKeyIn(BaseModel):
|
||||
api_key: str
|
||||
|
||||
|
||||
def _sync_financial_scheduler(request: Request, capset) -> None:
|
||||
"""Key 变更后同步财务调度器状态,无需重启服务。"""
|
||||
try:
|
||||
financial_scheduler.update(request.app.state.repo.store.data_dir, capset)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("financial_scheduler update failed: %s", e)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def get_settings() -> dict:
|
||||
"""返回当前配置概况(Key 脱敏)。"""
|
||||
from app.config import settings
|
||||
from app.services import preferences
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_codex_command
|
||||
|
||||
key = secrets_store.get_tickflow_key()
|
||||
ai_provider = secrets_store.get_ai_config("ai_provider", settings.ai_provider)
|
||||
return {
|
||||
"mode": tf_client.current_mode(),
|
||||
"tickflow_api_key_masked": secrets_store.mask(key),
|
||||
@@ -61,12 +69,14 @@ def get_settings() -> dict:
|
||||
# 首次使用引导
|
||||
"onboarding_completed": preferences.get_onboarding_completed(),
|
||||
# AI 配置
|
||||
"ai_provider": secrets_store.get_ai_config("ai_provider", settings.ai_provider),
|
||||
"ai_provider": ai_provider,
|
||||
"ai_base_url": secrets_store.get_ai_config("ai_base_url", settings.ai_base_url),
|
||||
"ai_api_key_masked": secrets_store.mask(secrets_store.get_ai_key()),
|
||||
"has_ai_key": bool(secrets_store.get_ai_key()),
|
||||
"ai_model": secrets_store.get_ai_config("ai_model", settings.ai_model),
|
||||
"ai_daily_token_budget": int(secrets_store.get_ai_config("ai_daily_token_budget", str(settings.ai_daily_token_budget)) or settings.ai_daily_token_budget),
|
||||
"ai_configured": ai_configured(ai_provider),
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_user_agent": secrets_store.get_ai_config("ai_user_agent", settings.ai_user_agent),
|
||||
}
|
||||
|
||||
|
||||
@@ -76,9 +86,9 @@ class SwitchEndpointIn(BaseModel):
|
||||
|
||||
@router.post("/switch_endpoint")
|
||||
def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
"""切换数据源端点并立即生效。
|
||||
"""切换 TickFlow 端点并立即生效。
|
||||
|
||||
端点切换仅对付费档(starter+,走付费 API 节点)有意义;
|
||||
端点切换仅对付费档(starter+,走 api.tickflow.org)有意义;
|
||||
none/free 档运行在 free-api 服务器,无付费端点权限,禁止切换。
|
||||
"""
|
||||
# none/free 档没有付费端点权限,禁止切换
|
||||
@@ -102,7 +112,7 @@ def switch_endpoint(req: SwitchEndpointIn, request: Request) -> dict:
|
||||
|
||||
@router.post("/tickflow-key")
|
||||
def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
"""保存数据源 API Key 并立即重新探测能力。
|
||||
"""保存 TickFlow API Key 并立即重新探测能力。
|
||||
|
||||
先探后存(关键改动,修复乱填 key 也会被持久化的问题):
|
||||
1. 临时用新 key 探测(付费端点),判定档位
|
||||
@@ -112,7 +122,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
4. 判定为 starter+ → 存 key,切到付费端点(现有逻辑)
|
||||
|
||||
端点联动:从无 key 升级到付费 key 时,残留的 free-api 端点不可用,
|
||||
故自动切到默认付费端点;free 档则清除自定义端点。
|
||||
故自动切到默认付费端点(api.tickflow.org);free 档则清除自定义端点。
|
||||
"""
|
||||
from app.tickflow.policy import (
|
||||
base_tier_name, is_invalid_key,
|
||||
@@ -129,7 +139,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
# 立即重新探测(此时 client 已按档位判定,但首次探测必然走付费端点验证)
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
# ===== 2) 判定为无效 key(连单只日K都拿不到)→ 不存,清除 =====
|
||||
if is_invalid_key() or base_tier_name() == "none":
|
||||
@@ -138,7 +148,7 @@ def save_tickflow_key(req: TickflowKeyIn, request: Request) -> dict:
|
||||
tf_client.reset_clients()
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "invalid",
|
||||
@@ -195,7 +205,7 @@ def clear_tickflow_key(request: Request) -> dict:
|
||||
|
||||
capset = detect_capabilities(force=True)
|
||||
request.app.state.capabilities = capset
|
||||
_sync_financial_scheduler(request, capset)
|
||||
_sync_financial_scheduler_caps(request.app.state, capset)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
@@ -223,13 +233,15 @@ class AiSettingsIn(BaseModel):
|
||||
base_url: str = ""
|
||||
api_key: str | None = None
|
||||
model: str = ""
|
||||
daily_token_budget: int = 500_000
|
||||
codex_command: str = ""
|
||||
user_agent: str = ""
|
||||
|
||||
|
||||
@router.post("/ai")
|
||||
def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
"""保存 AI 配置(全部持久化到 secrets.json)"""
|
||||
from app.config import settings
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider, current_codex_command, normalize_codex_command
|
||||
|
||||
updates: dict = {}
|
||||
if req.provider:
|
||||
@@ -245,15 +257,52 @@ def save_ai_settings(req: AiSettingsIn) -> dict:
|
||||
else:
|
||||
secrets_store.clear("ai_api_key")
|
||||
settings.ai_api_key = ""
|
||||
if req.model:
|
||||
if req.provider == "codex_cli" and not req.model:
|
||||
secrets_store.clear("ai_model")
|
||||
settings.ai_model = ""
|
||||
elif req.model:
|
||||
updates["ai_model"] = req.model
|
||||
settings.ai_model = req.model
|
||||
updates["ai_daily_token_budget"] = req.daily_token_budget
|
||||
settings.ai_daily_token_budget = req.daily_token_budget
|
||||
if req.provider == "codex_cli":
|
||||
try:
|
||||
codex_command = normalize_codex_command(req.codex_command)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
updates["ai_codex_command"] = codex_command
|
||||
settings.ai_codex_command = codex_command
|
||||
# user_agent 允许清空(回到默认浏览器 UA),故无条件持久化
|
||||
updates["ai_user_agent"] = req.user_agent
|
||||
settings.ai_user_agent = req.user_agent
|
||||
|
||||
if updates:
|
||||
secrets_store.save(updates)
|
||||
|
||||
provider = current_ai_provider()
|
||||
return {
|
||||
"ok": True,
|
||||
"ai_provider": provider,
|
||||
"ai_model": current_ai_model(),
|
||||
"ai_codex_command": current_codex_command(),
|
||||
"ai_configured": ai_configured(provider),
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/ai")
|
||||
def clear_ai_settings() -> dict:
|
||||
"""一键清空 AI 配置(provider / base_url / api_key / model)。
|
||||
|
||||
保留 ai_user_agent —— 自定义请求头与凭证解耦,清空凭证不影响绕过 CDN 拦截的设置。
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
secrets_store.clear("ai_provider", "ai_base_url", "ai_api_key", "ai_model", "ai_codex_command")
|
||||
# 同步重置运行时内存(provider 回默认值,其余置空)
|
||||
settings.ai_provider = "openai_compat"
|
||||
settings.ai_base_url = ""
|
||||
settings.ai_api_key = ""
|
||||
settings.ai_model = ""
|
||||
settings.ai_codex_command = "codex"
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@@ -280,6 +329,16 @@ def get_preferences() -> dict:
|
||||
"indices_nav_pinned": preferences.get_indices_nav_pinned(),
|
||||
"minute_sync_enabled": preferences.get_minute_sync_enabled(),
|
||||
"minute_sync_days": preferences.get_minute_sync_days(),
|
||||
"daily_data_provider": preferences.get_daily_data_provider(),
|
||||
"adj_factor_provider": preferences.get_adj_factor_provider(),
|
||||
"minute_data_provider": preferences.get_minute_data_provider(),
|
||||
"realtime_data_provider": preferences.get_realtime_data_provider(),
|
||||
"realtime_watchlist_symbols": preferences.get_realtime_watchlist_symbols(),
|
||||
**preferences.get_realtime_quote_scope(),
|
||||
"pipeline_pull_a_share": preferences.get_pipeline_pull_a_share(),
|
||||
"pipeline_pull_etf": preferences.get_pipeline_pull_etf(),
|
||||
"pipeline_pull_index": preferences.get_pipeline_pull_index(),
|
||||
"pipeline_index_symbols": preferences.get_pipeline_index_symbols(),
|
||||
"pipeline_schedule": preferences.get_pipeline_schedule(),
|
||||
"instruments_schedule": preferences.get_instruments_schedule(),
|
||||
"enriched_batch_size": preferences.get_enriched_batch_size(),
|
||||
@@ -290,6 +349,9 @@ def get_preferences() -> dict:
|
||||
"strategy_monitor_enabled": preferences.get_strategy_monitor_enabled(),
|
||||
"strategy_monitor_ids": preferences.get_strategy_monitor_ids(),
|
||||
"system_notify_enabled": preferences.get_system_notify_enabled(),
|
||||
"feishu_webhook_url": preferences.get_feishu_webhook_url(),
|
||||
"feishu_webhook_secret": preferences.get_feishu_webhook_secret(),
|
||||
"webhook_enabled_default": preferences.get_webhook_enabled_default(),
|
||||
"sidebar_index_symbols": preferences.get_sidebar_index_symbols(),
|
||||
"nav_order": preferences.get_nav_order(),
|
||||
"nav_hidden": preferences.get_nav_hidden(),
|
||||
@@ -297,6 +359,8 @@ def get_preferences() -> dict:
|
||||
"limit_ladder_monitor_enabled": preferences.get_limit_ladder_monitor_enabled(),
|
||||
"depth_polling_interval": preferences.get_depth_polling_interval(),
|
||||
"depth_finalize_time": preferences.get_depth_finalize_time(),
|
||||
"review_schedule": preferences.get_review_schedule(),
|
||||
"review_push_channels": preferences.get_review_push_channels(),
|
||||
}
|
||||
|
||||
|
||||
@@ -377,11 +441,19 @@ class RealtimeQuotesPrefs(BaseModel):
|
||||
realtime_quotes_enabled: bool
|
||||
|
||||
|
||||
class RealtimeQuoteScopePrefs(BaseModel):
|
||||
realtime_pull_stock: bool | None = None
|
||||
realtime_pull_etf: bool | None = None
|
||||
realtime_pull_index: bool | None = None
|
||||
realtime_index_mode: str | None = None
|
||||
realtime_index_symbols: list[str] | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quotes")
|
||||
def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
"""保存全局实时行情开关。
|
||||
|
||||
none/free 档无实时行情权限:拒绝开启,persist 为关闭并返回 allowed=False,
|
||||
none 档无实时行情权限;free 档开启自选股实时;starter+ 开启全市场实时。
|
||||
前端据此把开关置灰 / 回弹。
|
||||
"""
|
||||
from app.services import preferences
|
||||
@@ -394,6 +466,9 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
if qs:
|
||||
qs.disable()
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": False}
|
||||
if req.realtime_quotes_enabled and qs and qs.realtime_mode() == "watchlist" and not preferences.get_realtime_watchlist_symbols():
|
||||
preferences.save({"realtime_quotes_enabled": False})
|
||||
return {"realtime_quotes_enabled": False, "realtime_allowed": True, "mode": "watchlist", "error": "watchlist_empty"}
|
||||
|
||||
preferences.save({"realtime_quotes_enabled": req.realtime_quotes_enabled})
|
||||
if qs:
|
||||
@@ -405,6 +480,26 @@ def update_realtime_quotes(req: RealtimeQuotesPrefs, request: Request) -> dict:
|
||||
return {"realtime_quotes_enabled": req.realtime_quotes_enabled, "realtime_allowed": allowed}
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-quote-scope")
|
||||
def update_realtime_quote_scope(req: RealtimeQuoteScopePrefs) -> dict:
|
||||
"""保存盘中实时行情范围;独立于盘后管道范围。"""
|
||||
from app.services import preferences
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
return preferences.set_realtime_quote_scope(cfg)
|
||||
|
||||
|
||||
class RealtimeWatchlistPrefs(BaseModel):
|
||||
symbols: list[str] = []
|
||||
|
||||
|
||||
@router.put("/preferences/realtime-watchlist")
|
||||
def update_realtime_watchlist(req: RealtimeWatchlistPrefs) -> dict:
|
||||
"""兼容旧入口;Free 实时标的由自选页前 5 个决定。"""
|
||||
from app.services import preferences
|
||||
symbols = preferences.set_realtime_watchlist_symbols(req.symbols)
|
||||
return {"realtime_watchlist_symbols": symbols}
|
||||
|
||||
|
||||
class IndicesNavPinnedPrefs(BaseModel):
|
||||
indices_nav_pinned: bool
|
||||
|
||||
@@ -457,6 +552,34 @@ def update_realtime_monitor_config(req: RealtimeMonitorConfigIn, request: Reques
|
||||
return result
|
||||
|
||||
|
||||
class PipelinePullTypesIn(BaseModel):
|
||||
"""盘后管道拉取内容开关(A股 / ETF / 指数 独立控制)。"""
|
||||
pipeline_pull_a_share: bool | None = None
|
||||
pipeline_pull_etf: bool | None = None
|
||||
pipeline_pull_index: bool | None = None
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-pull-types")
|
||||
def update_pipeline_pull_types(req: PipelinePullTypesIn) -> dict:
|
||||
"""更新盘后管道拉取内容开关。"""
|
||||
from app.services import preferences
|
||||
cfg = req.model_dump(exclude_none=True)
|
||||
return preferences.set_pipeline_pull_types(cfg)
|
||||
|
||||
|
||||
class PipelineIndexSymbolsIn(BaseModel):
|
||||
"""指数自定义拉取代码(逗号/换行/空格分隔,空串表示全量)。"""
|
||||
symbols: str = ""
|
||||
|
||||
|
||||
@router.put("/preferences/pipeline-index-symbols")
|
||||
def update_pipeline_index_symbols(req: PipelineIndexSymbolsIn) -> dict:
|
||||
"""保存指数自定义拉取代码。"""
|
||||
from app.services import preferences
|
||||
symbols = preferences.set_pipeline_index_symbols(req.symbols)
|
||||
return {"pipeline_index_symbols": symbols}
|
||||
|
||||
|
||||
class QuoteIntervalIn(BaseModel):
|
||||
interval: float
|
||||
|
||||
@@ -477,6 +600,50 @@ def update_system_notify(req: SystemNotifyPrefsIn) -> dict:
|
||||
return {"system_notify_enabled": saved}
|
||||
|
||||
|
||||
class FeishuWebhookPrefsIn(BaseModel):
|
||||
url: str
|
||||
secret: str = ""
|
||||
|
||||
|
||||
@router.put("/preferences/feishu-webhook")
|
||||
def update_feishu_webhook(req: FeishuWebhookPrefsIn) -> dict:
|
||||
"""飞书 Webhook 地址 + 签名密钥 — 全局一处配置, 所有启用推送的监控规则共用。
|
||||
|
||||
- url: 传入空串表示清空配置; 非空则需为合法的飞书自定义机器人地址。
|
||||
- secret: 机器人启用了「签名校验」时填密钥, 留空表示不验签。
|
||||
"""
|
||||
from app.services import preferences
|
||||
from app.services import webhook_adapter
|
||||
|
||||
url = (req.url or "").strip()
|
||||
if url and not webhook_adapter.is_valid_feishu_url(url):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Webhook 地址非法, 需为飞书自定义机器人地址 "
|
||||
"(https://open.feishu.cn/open-apis/bot/v2/hook/...)",
|
||||
)
|
||||
saved_url = preferences.set_feishu_webhook_url(url)
|
||||
saved_secret = preferences.set_feishu_webhook_secret((req.secret or "").strip())
|
||||
return {"feishu_webhook_url": saved_url, "feishu_webhook_secret": saved_secret}
|
||||
|
||||
|
||||
class WebhookEnabledDefaultIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
@router.put("/preferences/webhook-enabled-default")
|
||||
def update_webhook_enabled_default(req: WebhookEnabledDefaultIn) -> dict:
|
||||
"""新建监控规则时是否默认勾选「飞书推送」。
|
||||
|
||||
数据模型当前只有飞书一个可用渠道 (QMT/ptrade 待定),故此处仅一个布尔。
|
||||
单条规则仍可在规则编辑页独立修改此项。
|
||||
"""
|
||||
from app.services import preferences
|
||||
|
||||
saved = preferences.set_webhook_enabled_default(req.enabled)
|
||||
return {"webhook_enabled_default": saved}
|
||||
|
||||
|
||||
@router.put("/preferences/quote-interval")
|
||||
def update_quote_interval(req: QuoteIntervalIn, request: Request) -> dict:
|
||||
"""更新行情轮询间隔。按档位自动 clamp。"""
|
||||
@@ -510,7 +677,7 @@ class TestEndpointIn(BaseModel):
|
||||
rounds: int | None = None
|
||||
|
||||
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取数据源官网 /endpoints.json
|
||||
# 官方端点发现清单 —— 前端浏览器无法直接跨域拉取 tickflow.org/endpoints.json
|
||||
# (无 CORS 头),因此由后端代理。缓存 5 分钟,失败时回退到内置列表。
|
||||
ENDPOINTS_URL = "https://tickflow.org/endpoints.json"
|
||||
ENDPOINTS_TTL = 300.0 # 秒
|
||||
@@ -574,7 +741,7 @@ _endpoints_cache: dict = {"ts": 0.0, "data": None}
|
||||
|
||||
@router.get("/endpoints")
|
||||
def list_endpoints() -> dict:
|
||||
"""代理拉取数据源官网 /endpoints.json 并返回规范化端点列表。
|
||||
"""代理拉取 tickflow.org/endpoints.json 并返回规范化端点列表。
|
||||
|
||||
前端无法跨域直连该 URL(无 CORS 头),故由本接口代理。带 8s 超时、
|
||||
5 分钟内存缓存,远程失败时回退到内置列表,保证 UI 始终有内容。
|
||||
@@ -601,7 +768,7 @@ def list_endpoints() -> dict:
|
||||
data = {
|
||||
"version": parsed.get("version", 1),
|
||||
"description": parsed.get(
|
||||
"description", "API 端点配置"
|
||||
"description", "TickFlow API 端点配置"
|
||||
),
|
||||
"healthPath": parsed.get("healthPath", "/health"),
|
||||
"testRounds": parsed.get("testRounds", 5),
|
||||
@@ -614,7 +781,7 @@ def list_endpoints() -> dict:
|
||||
source = "fallback"
|
||||
data = {
|
||||
"version": 1,
|
||||
"description": "API 端点配置",
|
||||
"description": "TickFlow API 端点配置",
|
||||
"healthPath": "/health",
|
||||
"testRounds": 5,
|
||||
"endpoints": _FALLBACK_ENDPOINTS,
|
||||
@@ -652,7 +819,7 @@ async def _http_ping(url: str, timeout: float = 10.0) -> float | None:
|
||||
async def test_endpoint(req: TestEndpointIn) -> dict:
|
||||
"""测试端点网络延迟:对 /health 多轮探测取中位数。
|
||||
|
||||
参考官方 latency_test.py:
|
||||
参考 TickFlow 官方 latency_test.py:
|
||||
- 路径用 /health(公开、轻量),反映真实网络延迟而非业务接口耗时
|
||||
- 多轮探测(默认 5 轮,取自 endpoints.json 的 testRounds),间隔 0.3s
|
||||
- 返回 median/min/max/success,前端显示中位数
|
||||
@@ -852,3 +1019,65 @@ def update_depth_finalize_time(req: DepthFinalizeTimeIn, request: Request) -> di
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
class ReviewScheduleIn(BaseModel):
|
||||
enabled: bool
|
||||
hour: int
|
||||
minute: int
|
||||
|
||||
|
||||
@router.put("/preferences/review-schedule")
|
||||
def update_review_schedule(req: ReviewScheduleIn, request: Request) -> dict:
|
||||
"""保存定时复盘调度并立即更新 APScheduler job。
|
||||
|
||||
- enabled=True: 注册/更新 job(工作日定时生成复盘报告)
|
||||
- enabled=False: 移除 job(停止定时复盘)
|
||||
- 校验: 开启时若 AI Key 未配置则拒绝(复盘依赖 AI), 提示用户先配置。
|
||||
- 时间下限 15:00(A股收盘), 由 preferences 层强制。
|
||||
"""
|
||||
from app.services import preferences
|
||||
|
||||
if req.enabled:
|
||||
# 复盘必须有 AI Key, 否则每日报错刷日志
|
||||
from app import secrets_store
|
||||
if not secrets_store.get_ai_key():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="复盘依赖 AI,请先在「设置 → AI」配置 API Key 后再开启定时复盘",
|
||||
)
|
||||
|
||||
sched = preferences.set_review_schedule(req.enabled, req.hour, req.minute)
|
||||
|
||||
# 动态操作 APScheduler job
|
||||
from app.jobs.daily_pipeline import _register_review_job, REVIEW_JOB_ID
|
||||
scheduler = getattr(request.app.state, "scheduler", None)
|
||||
if scheduler:
|
||||
if sched["enabled"]:
|
||||
_register_review_job(scheduler, request.app.state.repo, sched["hour"], sched["minute"])
|
||||
logger.info("scheduled_review enabled @%02d:%02d mon-fri", sched["hour"], sched["minute"])
|
||||
else:
|
||||
try:
|
||||
scheduler.remove_job(REVIEW_JOB_ID)
|
||||
logger.info("scheduled_review disabled (job removed)")
|
||||
except Exception:
|
||||
pass # job 本就不存在(从未开过), 无需处理
|
||||
|
||||
return sched
|
||||
|
||||
|
||||
class ReviewPushIn(BaseModel):
|
||||
channels: list[str] # 多选: ['feishu'] 等; 空数组=不推送。微信等开发中
|
||||
|
||||
|
||||
@router.put("/preferences/review-push")
|
||||
def update_review_push(req: ReviewPushIn) -> dict:
|
||||
"""复盘推送渠道(多选) — 选定把复盘报告(手动生成 / 定时生成归档后)推送到哪些外部工具。
|
||||
|
||||
纯偏好, 与定时复盘 / 实时行情完全独立, 常驻可单独设置。空数组=不推送。
|
||||
实际推送由归档端点(POST /api/market-recap/reports)与定时任务(_run_scheduled_review)
|
||||
在归档后读取本列表逐个推送。白名单外的渠道会被过滤掉。
|
||||
"""
|
||||
from app.services import preferences
|
||||
saved = preferences.set_review_push_channels(req.channels)
|
||||
return {"review_push_channels": saved}
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""个股分析 API — 关键价位 + AI 四维分析 + 报告持久化。
|
||||
|
||||
路由前缀: /api/stock-analysis
|
||||
|
||||
端点:
|
||||
GET /levels?symbol= 11 类关键价位(图表 markLine 数据源)
|
||||
POST /analyze AI 流式四维分析(NDJSON)
|
||||
GET /reports 历史报告列表
|
||||
POST /reports 保存一条报告
|
||||
DELETE /reports/{report_id} 删除一条报告
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
|
||||
import polars as pl
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.indicators.levels import compute_levels, summarize_levels
|
||||
from app.services import stock_reports
|
||||
from app.services.stock_analyzer import analyze_stock_stream
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/stock-analysis", tags=["stock-analysis"])
|
||||
|
||||
|
||||
def _to_float_list(series: pl.Series) -> list:
|
||||
"""polars Series → JSON 安全的 float 列表(null/NaN → None)。"""
|
||||
out: list = []
|
||||
for v in series.to_list():
|
||||
if v is None:
|
||||
out.append(None)
|
||||
continue
|
||||
try:
|
||||
f = float(v)
|
||||
out.append(round(f, 2) if math.isfinite(f) else None)
|
||||
except (TypeError, ValueError):
|
||||
out.append(None)
|
||||
return out
|
||||
|
||||
|
||||
def _build_series(df: pl.DataFrame) -> dict:
|
||||
"""提取带状指标(布林带 / Keltner通道 / ATR止损)的每日时间序列。
|
||||
|
||||
这些指标的本质是"每日一条线",随 MA/ATR/σ 漂移,画成曲线才能体现通道形态。
|
||||
其余固定价位(枢轴/前高前低等)不在此,仍用水平 markLine。
|
||||
|
||||
返回结构(每个 value 都是按日期对齐的数组):
|
||||
{
|
||||
"boll": {"upper": [...], "lower": [...]},
|
||||
"keltner_s": {"upper": [...], "lower": [...]}, # 短期 MA20±2ATR
|
||||
"keltner_m": {"upper": [...], "lower": [...]}, # 中期 MA60±2.5ATR
|
||||
"keltner_l": {"upper": [...], "lower": [...]}, # 长期 MA120±3ATR
|
||||
"atr": {"stop_loss": [...], "take_profit": [...]}, # close∓2ATR
|
||||
}
|
||||
"""
|
||||
if df.is_empty() or "close" not in df.columns:
|
||||
return {}
|
||||
|
||||
out: dict[str, dict] = {}
|
||||
close = df["close"]
|
||||
has_atr = "atr_14" in df.columns
|
||||
|
||||
# 布林带(上/下/中轨;中轨 = MA20,数据层已预计算)
|
||||
if "boll_upper" in df.columns and "boll_lower" in df.columns:
|
||||
out["boll"] = {
|
||||
"upper": _to_float_list(df["boll_upper"]),
|
||||
"lower": _to_float_list(df["boll_lower"]),
|
||||
"mid": _to_float_list(df["ma20"]) if "ma20" in df.columns else None,
|
||||
}
|
||||
|
||||
# Keltner 通道三档(需要 ATR)
|
||||
if has_atr:
|
||||
atr = df["atr_14"]
|
||||
# MA120 现场算(不在预计算列中)
|
||||
ma120 = df.select(pl.col("close").rolling_mean(120))["close"] if df.height >= 120 else None
|
||||
|
||||
def _channel(ma: pl.Series, n: float) -> dict:
|
||||
return {
|
||||
"upper": _to_float_list(ma + n * atr),
|
||||
"lower": _to_float_list(ma - n * atr),
|
||||
}
|
||||
|
||||
if "ma20" in df.columns:
|
||||
out["keltner_s"] = _channel(df["ma20"], 2.0)
|
||||
if "ma60" in df.columns:
|
||||
out["keltner_m"] = _channel(df["ma60"], 2.5)
|
||||
if ma120 is not None:
|
||||
out["keltner_l"] = _channel(ma120, 3.0)
|
||||
|
||||
# ATR 止损/止盈: close ± 2×ATR(跟随行情漂移的动态止损线)
|
||||
out["atr"] = {
|
||||
"stop_loss": _to_float_list(close - 2 * atr),
|
||||
"take_profit": _to_float_list(close + 2 * atr),
|
||||
}
|
||||
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/levels")
|
||||
def get_levels(
|
||||
request: Request,
|
||||
symbol: str = Query(..., description="标的代码,如 000001.SZ"),
|
||||
days: int = Query(120, ge=30, le=500, description="计算样本天数"),
|
||||
):
|
||||
"""计算 11 类关键价位(成交密集区压力支撑 / 枢轴点 / 前高前低 /
|
||||
布林带 / Keltner短中长 / ATR止损 / 缺口 / 斐波那契 / 整数关口)。
|
||||
|
||||
返回 {levels: {sr, pivot, extreme, boll, keltner_s, keltner_m, keltner_l,
|
||||
atr_stop, gap, fib, round}, close, summary, dates, series}。
|
||||
前端按 levels 的 key 渲染开关按钮,逐组显隐 markLine / 曲线。
|
||||
"""
|
||||
if not symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
repo = request.app.state.repo
|
||||
end = date.today()
|
||||
start = end - timedelta(days=days * 2)
|
||||
df = repo.get_daily(symbol, start, end)
|
||||
if df.is_empty():
|
||||
return {"levels": {"sr": [], "pivot": [], "extreme": [],
|
||||
"boll": [], "keltner_s": [], "keltner_m": [], "keltner_l": [],
|
||||
"atr_stop": [], "gap": [], "fib": [], "round": []},
|
||||
"close": None, "summary": "无数据", "symbol": symbol,
|
||||
"dates": [], "series": {}}
|
||||
|
||||
levels = compute_levels(df)
|
||||
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
|
||||
# 日期 + 带状曲线序列(供前端画 Keltner/ATR/布林带曲线)
|
||||
dates = df["date"].to_list()
|
||||
series = _build_series(df)
|
||||
return {
|
||||
"levels": levels,
|
||||
"close": close,
|
||||
"summary": summarize_levels(levels, close),
|
||||
"symbol": symbol,
|
||||
"dates": [str(d) for d in dates],
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
"""AI 个股分析请求。"""
|
||||
symbol: str
|
||||
focus: str = "" # 可选:用户追加的分析关注点
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_stock(request: Request, req: AnalyzeRequest):
|
||||
"""AI 个股四维分析 — NDJSON 流式返回。
|
||||
|
||||
组合 K 线(技术指标)+ 财务表 + 关键价位 → 实战派提示词 →
|
||||
流式调用 LLM → 逐 chunk 以 NDJSON 推给前端(每行一个 JSON)。
|
||||
"""
|
||||
if not req.symbol:
|
||||
raise HTTPException(400, "symbol 不能为空")
|
||||
|
||||
repo = request.app.state.repo
|
||||
data_dir = repo.store.data_dir
|
||||
|
||||
async def stream_gen():
|
||||
async for chunk in analyze_stock_stream(repo, data_dir, req.symbol, req.focus):
|
||||
yield chunk + "\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_gen(),
|
||||
media_type="application/x-ndjson",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# 报告 CRUD(历史报告持久化)
|
||||
# ================================================================
|
||||
|
||||
class SaveReportRequest(BaseModel):
|
||||
"""保存一条 AI 个股分析报告。"""
|
||||
symbol: str
|
||||
name: str = ""
|
||||
focus: str = ""
|
||||
content: str
|
||||
summary: str = ""
|
||||
close: float | None = None
|
||||
levels: dict | None = None
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports(request: Request):
|
||||
"""获取全部历史报告(按时间降序,后端已裁剪到上限)。"""
|
||||
return {"reports": stock_reports.list_reports()}
|
||||
|
||||
|
||||
@router.post("/reports")
|
||||
def save_report(request: Request, req: SaveReportRequest):
|
||||
"""保存一条报告。"""
|
||||
report = stock_reports.save_report({
|
||||
"symbol": req.symbol,
|
||||
"name": req.name,
|
||||
"focus": req.focus,
|
||||
"content": req.content,
|
||||
"summary": req.summary,
|
||||
"close": req.close,
|
||||
"levels": req.levels,
|
||||
})
|
||||
return {"ok": True, "report": report}
|
||||
|
||||
|
||||
@router.delete("/reports/{report_id}")
|
||||
def delete_report(request: Request, report_id: str):
|
||||
"""删除一条报告。"""
|
||||
ok = stock_reports.delete_report(report_id)
|
||||
return {"ok": ok}
|
||||
@@ -84,6 +84,7 @@ def _strategy_detail(s: StrategyDef, overrides: dict | None = None) -> dict:
|
||||
"entry_signals": s.entry_signals,
|
||||
"exit_signals": s.exit_signals,
|
||||
"stop_loss": overrides.get("stop_loss", s.stop_loss) if overrides else s.stop_loss,
|
||||
"take_profit": getattr(s, "take_profit", None),
|
||||
"trailing_stop": getattr(s, "trailing_stop", None),
|
||||
"trailing_take_profit_activate": getattr(s, "trailing_take_profit_activate", None),
|
||||
"trailing_take_profit_drawdown": getattr(s, "trailing_take_profit_drawdown", None),
|
||||
@@ -285,12 +286,19 @@ class BuildRequest(BaseModel):
|
||||
|
||||
@router.get("/ai/status")
|
||||
def ai_status(request: Request):
|
||||
"""检查 AI 配置状态"""
|
||||
from app.config import settings
|
||||
"""Check whether the selected AI provider is configured."""
|
||||
from app import secrets_store
|
||||
from app.services.ai_provider import ai_configured, current_ai_model, current_ai_provider
|
||||
|
||||
has_key = bool(secrets_store.get_ai_key())
|
||||
has_model = bool(settings.ai_model)
|
||||
return {"configured": has_key and has_model, "has_key": has_key, "has_model": has_model}
|
||||
model = current_ai_model()
|
||||
provider = current_ai_provider()
|
||||
return {
|
||||
"configured": ai_configured(provider) and bool(model or provider == "codex_cli"),
|
||||
"has_key": has_key,
|
||||
"has_model": bool(model),
|
||||
"provider": provider,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{strategy_id}/source")
|
||||
@@ -314,24 +322,17 @@ def get_strategy_source(strategy_id: str, request: Request):
|
||||
|
||||
@router.post("/ai/test")
|
||||
async def ai_test(request: Request):
|
||||
"""测试 AI 连通性 — 发送简单请求验证 Key 和模型"""
|
||||
from app.config import settings
|
||||
from app import secrets_store
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
ai_key = secrets_store.get_ai_key()
|
||||
if not ai_key:
|
||||
return {"ok": False, "error": "未配置 API Key"}
|
||||
"""Send a small prompt through the selected AI provider."""
|
||||
from app.services.ai_provider import current_ai_model, current_ai_provider, generate_ai_text
|
||||
|
||||
try:
|
||||
client = AsyncOpenAI(api_key=ai_key, base_url=settings.ai_base_url)
|
||||
resp = await client.chat.completions.create(
|
||||
model=settings.ai_model,
|
||||
messages=[{"role": "user", "content": "回复 OK"}],
|
||||
max_tokens=5,
|
||||
text = await generate_ai_text(
|
||||
[{"role": "user", "content": "Reply exactly: OK"}],
|
||||
temperature=0,
|
||||
max_tokens=8,
|
||||
timeout=15,
|
||||
)
|
||||
return {"ok": True, "model": resp.model, "usage": {"prompt": resp.usage.prompt_tokens, "completion": resp.usage.completion_tokens} if resp.usage else None}
|
||||
return {"ok": True, "model": current_ai_model() or current_ai_provider(), "response": text[:80]}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
@@ -27,28 +27,48 @@ class BatchAddRequest(BaseModel):
|
||||
note: str = ""
|
||||
|
||||
|
||||
def _with_names(rows: list[dict], request: Request) -> list[dict]:
|
||||
if not rows:
|
||||
return rows
|
||||
try:
|
||||
df_i = request.app.state.repo.get_instruments()
|
||||
if df_i.is_empty() or "symbol" not in df_i.columns or "name" not in df_i.columns:
|
||||
return rows
|
||||
name_by_symbol = dict(df_i.select(["symbol", "name"]).iter_rows())
|
||||
return [{**row, "name": name_by_symbol.get(row.get("symbol"))} for row in rows]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("attach watchlist names failed: %s", e)
|
||||
return rows
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_all():
|
||||
return {"symbols": watchlist.list_symbols()}
|
||||
def list_all(request: Request):
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request)}
|
||||
|
||||
|
||||
@router.post("")
|
||||
def add_one(req: AddRequest):
|
||||
def add_one(req: AddRequest, request: Request):
|
||||
rows = watchlist.add(req.symbol, req.note)
|
||||
return {"symbols": rows}
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
def add_batch(req: BatchAddRequest):
|
||||
def add_batch(req: BatchAddRequest, request: Request):
|
||||
for sym in req.symbols:
|
||||
watchlist.add(sym, req.note)
|
||||
return {"symbols": watchlist.list_symbols(), "added": len(req.symbols)}
|
||||
return {"symbols": _with_names(watchlist.list_symbols(), request), "added": len(req.symbols)}
|
||||
|
||||
|
||||
@router.post("/{symbol}/top")
|
||||
def move_one_to_top(symbol: str, request: Request):
|
||||
rows = watchlist.move_to_top(symbol)
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("/{symbol}")
|
||||
def remove_one(symbol: str):
|
||||
def remove_one(symbol: str, request: Request):
|
||||
rows = watchlist.remove(symbol)
|
||||
return {"symbols": rows}
|
||||
return {"symbols": _with_names(rows, request)}
|
||||
|
||||
|
||||
@router.delete("")
|
||||
|
||||
Reference in New Issue
Block a user