重置项目

This commit is contained in:
2026-07-04 15:59:20 +08:00
parent 374e587f2d
commit 648a8b7f1c
224 changed files with 19700 additions and 9547 deletions
+429
View File
@@ -0,0 +1,429 @@
"""AI provider adapter for OpenAI-compatible APIs and local Codex CLI."""
from __future__ import annotations
import asyncio
import os
import re
import shutil
import sys
import tempfile
import tomllib
from collections.abc import AsyncIterator, Sequence
from pathlib import Path
from app import secrets_store
from app.config import settings
OPENAI_COMPAT_PROVIDER = "openai_compat"
CODEX_CLI_PROVIDER = "codex_cli"
CODEX_DEFAULT_COMMAND = "codex"
CODEX_SERVICE_TIER_FALLBACK = "fast"
CODEX_SUPPORTED_SERVICE_TIERS = {"fast", "flex"}
Message = dict[str, str]
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
def current_ai_provider() -> str:
return secrets_store.get_ai_config("ai_provider", settings.ai_provider) or OPENAI_COMPAT_PROVIDER
def current_ai_model() -> str:
if current_ai_provider() == CODEX_CLI_PROVIDER:
return normalize_codex_model(str(secrets_store.load().get("ai_model") or ""))
return secrets_store.get_ai_config("ai_model", settings.ai_model)
def current_codex_command() -> str:
return normalize_codex_command(
secrets_store.get_ai_config("ai_codex_command", settings.ai_codex_command),
strict=False,
)
def is_codex_cli_provider(provider: str | None = None) -> bool:
return (provider or current_ai_provider()) == CODEX_CLI_PROVIDER
def normalize_codex_model(model: str) -> str:
value = model.strip()
aliases = {
"gpt5": "gpt-5",
"gpt5.5": "gpt-5.5",
}
return aliases.get(value.lower(), value)
def normalize_codex_command(command: str | None, *, strict: bool = True) -> str:
value = (command or "").strip()
if not value or value.lower() == CODEX_DEFAULT_COMMAND:
return CODEX_DEFAULT_COMMAND
if strict:
raise ValueError("Codex CLI 仅支持使用默认 codex 命令自动解析, 不支持自定义可执行路径")
return CODEX_DEFAULT_COMMAND
def normalize_openai_base_url(url: str) -> str:
"""Return the OpenAI-compatible base URL expected by the OpenAI SDK."""
base = (url or "").strip().rstrip("/")
if base.endswith("/chat/completions"):
base = base[: -len("/chat/completions")].rstrip("/")
if not base.endswith("/v1"):
base = f"{base}/v1"
return base
def codex_cli_available() -> bool:
try:
_codex_base_command()
return True
except RuntimeError:
return False
def ai_configured(provider: str | None = None) -> bool:
provider = provider or current_ai_provider()
if is_codex_cli_provider(provider):
return codex_cli_available()
return bool(secrets_store.get_ai_key())
async def generate_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.3,
max_tokens: int = 3000,
timeout: float = 180.0,
) -> str:
"""Return a complete AI response from the currently configured provider."""
if is_codex_cli_provider():
return await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return await _run_openai_once(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
)
async def stream_ai_text(
messages: Sequence[Message],
*,
temperature: float = 0.5,
max_tokens: int = 4000,
timeout: float = 180.0,
) -> AsyncIterator[str]:
"""Yield text deltas from the configured provider.
Codex CLI only exposes the final assistant message for this use case, so it
yields one complete chunk after the command exits.
"""
if is_codex_cli_provider():
yield await _run_codex_cli(messages, max_tokens=max_tokens, timeout=max(timeout, 600.0))
return
async for chunk in _stream_openai(
messages,
temperature=temperature,
max_tokens=max_tokens,
timeout=timeout,
):
yield chunk
async def _run_openai_once(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> str:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
resp = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
)
if not resp.choices:
return ""
return (resp.choices[0].message.content or "").strip()
async def _stream_openai(
messages: Sequence[Message],
*,
temperature: float,
max_tokens: int,
timeout: float,
) -> AsyncIterator[str]:
ai_key = secrets_store.get_ai_key()
if not ai_key:
raise RuntimeError("AI API Key 未配置, 请在设置页配置")
client = _openai_client(ai_key, timeout)
stream = await client.chat.completions.create(
model=current_ai_model(),
messages=list(messages),
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in stream:
delta = chunk.choices[0].delta if chunk.choices else None
if delta and delta.content:
yield delta.content
def _openai_client(api_key: str, timeout: float):
from openai import AsyncOpenAI
user_agent = secrets_store.get_ai_config("ai_user_agent", "") or settings.ai_user_agent
return AsyncOpenAI(
api_key=api_key,
base_url=normalize_openai_base_url(secrets_store.get_ai_config("ai_base_url", settings.ai_base_url)),
timeout=timeout,
max_retries=2,
default_headers={"User-Agent": user_agent},
)
async def _run_codex_cli(
messages: Sequence[Message],
*,
max_tokens: int,
timeout: float,
) -> str:
prompt = _codex_prompt(messages, max_tokens=max_tokens)
with tempfile.TemporaryDirectory(prefix="tickflow-codex-run-") as run_dir:
run_path = Path(run_dir)
codex_home_path = run_path / "codex-home"
workspace_path = run_path / "workspace"
codex_home_path.mkdir()
workspace_path.mkdir()
output_path = codex_home_path / "last-message.txt"
_prepare_codex_home(codex_home_path)
args = [
*_codex_base_command(),
"exec",
"--ephemeral",
"--sandbox",
"read-only",
"--skip-git-repo-check",
"--color",
"never",
"--output-last-message",
str(output_path),
]
model = current_ai_model().strip()
if model:
args.extend(["--model", model])
args.extend(["--cd", str(workspace_path), "-"])
env = os.environ.copy()
env.setdefault("NO_COLOR", "1")
env["CODEX_HOME"] = str(codex_home_path)
proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
try:
stdout, stderr = await asyncio.wait_for(
proc.communicate(prompt.encode("utf-8")),
timeout=timeout,
)
except TimeoutError as exc:
proc.kill()
await proc.wait()
raise RuntimeError("Codex CLI 调用超时, 请稍后重试或检查本机 Codex 登录状态") from exc
out = _clean_process_text(stdout)
err = _clean_process_text(stderr)
final_message = _read_output_file(output_path)
if proc.returncode != 0:
detail = err or out or f"exit code {proc.returncode}"
raise RuntimeError(f"Codex CLI 调用失败: {detail[-1200:]}")
result = final_message or out
if not result:
raise RuntimeError("Codex CLI 未返回内容")
return result
def _codex_prompt(messages: Sequence[Message], *, max_tokens: int) -> str:
parts = [
"You are TickFlow Stock Panel's local AI provider.",
"This is a text-generation task. The working directory is intentionally empty.",
"Use only the user-provided prompt content below; do not inspect or modify local files.",
"Return only the final requested content; do not include execution logs.",
]
if max_tokens > 0:
parts.append(f"Keep the final answer within about {max_tokens} output tokens.")
for message in messages:
role = message.get("role", "user")
content = message.get("content", "")
parts.append(f"\n<{role}>\n{content}\n</{role}>")
return "\n".join(parts)
def _codex_base_command() -> list[str]:
command = current_codex_command()
resolved = _resolve_command(command)
if not resolved:
raise RuntimeError(f"未找到 Codex CLI 命令: {command}")
if sys.platform == "win32" and resolved.lower().endswith(".ps1"):
return ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", resolved]
return [resolved]
def _resolve_command(command: str) -> str | None:
if command.lower() != CODEX_DEFAULT_COMMAND:
return None
if sys.platform == "win32":
desktop_codex = _resolve_windows_desktop_codex()
if desktop_codex:
return desktop_codex
resolved = shutil.which(command)
if sys.platform == "win32" and resolved:
resolved_path = Path(resolved)
if not resolved_path.suffix:
cmd_path = resolved_path.with_suffix(".cmd")
if cmd_path.exists():
return str(cmd_path)
if not resolved and sys.platform == "win32" and not command.lower().endswith(".cmd"):
resolved = shutil.which(f"{command}.cmd")
if not resolved and sys.platform == "win32":
resolved = _resolve_windows_codex_command(command)
return resolved
def _resolve_windows_codex_command(command: str) -> str | None:
"""Find npm-installed Codex when the backend process has a minimal PATH."""
raw = Path(command)
if raw.parent != Path("."):
return None
names = [command]
if not raw.suffix:
names = [f"{command}.cmd", f"{command}.exe", f"{command}.bat", f"{command}.ps1", command]
dirs: list[Path] = []
appdata = os.environ.get("APPDATA")
if appdata:
dirs.append(Path(appdata) / "npm")
dirs.append(Path.home() / "AppData" / "Roaming" / "npm")
for env_name in ("ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"):
value = os.environ.get(env_name)
if value:
dirs.append(Path(value) / "nodejs")
for directory in dirs:
for name in names:
candidate = directory / name
if candidate.exists():
return str(candidate)
return None
def _resolve_windows_desktop_codex() -> str | None:
"""Prefer the Codex Desktop bundled CLI over an older npm shim."""
local_appdata = os.environ.get("LOCALAPPDATA")
if not local_appdata:
return None
root = Path(local_appdata) / "OpenAI" / "Codex" / "bin"
if not root.exists():
return None
candidates = list(root.glob("*/codex.exe"))
direct = root / "codex.exe"
if direct.exists():
candidates.append(direct)
if not candidates:
return None
newest = max(candidates, key=lambda p: p.stat().st_mtime)
return str(newest)
def _prepare_codex_home(target: Path) -> None:
"""Create an isolated CODEX_HOME that reuses auth but not fragile config."""
source = _codex_home()
auth_file = source / "auth.json"
if auth_file.exists():
shutil.copy2(auth_file, target / "auth.json")
_write_compatible_codex_config(target / "config.toml")
def _codex_home() -> Path:
return Path(os.environ.get("CODEX_HOME") or Path.home() / ".codex")
def _write_compatible_codex_config(path: Path) -> None:
config = _read_codex_config()
lines: list[str] = []
tier = str(config.get("service_tier") or "").strip()
if tier not in CODEX_SUPPORTED_SERVICE_TIERS:
tier = CODEX_SERVICE_TIER_FALLBACK
lines.append(_toml_string("service_tier", tier))
lines.append(_toml_string("approval_policy", "never"))
lines.append(_toml_string("sandbox_mode", "read-only"))
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def _read_codex_config() -> dict:
path = _codex_home() / "config.toml"
if not path.exists():
return {}
try:
with path.open("rb") as f:
return tomllib.load(f)
except tomllib.TOMLDecodeError:
return _read_codex_config_lenient(path)
except OSError:
return {}
def _read_codex_config_lenient(path: Path) -> dict:
config: dict[str, str] = {}
pattern = re.compile(r'^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$')
try:
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
match = pattern.match(line)
if match:
config[match.group(1)] = match.group(2)
except OSError:
pass
return config
def _toml_string(key: str, value: str) -> str:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
return f'{key} = "{escaped}"'
def _clean_process_text(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
return _ANSI_RE.sub("", text).strip()
def _read_output_file(path: Path) -> str:
if path.exists():
return _ANSI_RE.sub("", path.read_text(encoding="utf-8", errors="replace")).strip()
return ""
+101
View File
@@ -0,0 +1,101 @@
"""AI 财务分析报告持久化存储。
存储位置: data/user_data/ai_reports.json (数组,按 created_at 降序)
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "rpt_xxx", # 唯一 id
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"periods": 4, # 基于几期数据生成
"summary": "metrics: 1期...", # 数据摘要
"created_at": "2026-06-25T10:00:00"
}
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_reports.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
except Exception as e: # noqa: BLE001
logger.warning("ai_reports.json malformed: %s", e)
return []
def _save_all(reports: list[dict]) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
# 保持降序
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。
自动补全 id 与 created_at(若缺),并裁剪到上限。
"""
reports = list_reports()
if not report.get("id"):
report["id"] = f"rpt_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("AI report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
return True
return False
def clear_reports() -> int:
"""清空全部报告。返回删除数量。"""
reports = list_reports()
n = len(reports)
if n > 0:
_save_all([])
return n
def _now_iso() -> str:
"""当前本地时间 ISO 字符串(带秒精度,前端 toLocaleString 友好)。"""
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+201
View File
@@ -0,0 +1,201 @@
"""访问密码认证 — 单用户, 自托管场景。
设计:
- 密码用 PBKDF2-HMAC-SHA256 哈希(标准库 hashlib, 无新依赖), 加随机 salt。
即使 auth.json 泄露, 也无法逆向出明文密码。
- 会话用随机 token(token_urlsafe), 内存 + 文件双存(支持多进程/重启不丢失)。
- 存储: data/user_data/auth.json (chmod 0600), 仿 secrets_store 模式。
安全要点:
- 设密码接口必须限制本机/内网(见 auth router), 防黑客抢占域名抢先设密码。
- 登录限流: 错5次锁5分钟(见 auth router 内存计数)。
- 单密码, 不做多用户(避免重构全项目数据层)。
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import secrets as _secrets
import threading
import time
from pathlib import Path
logger = logging.getLogger(__name__)
# PBKDF2 参数(NIST 推荐, 单次校验 ~100ms, 兼顾安全与响应)
_PBKDF2_ITER = 200_000
_SALT_LEN = 16
_TOKEN_BYTES = 32
# 会话有效期: 30 天(自托管单用户, 长一点减少重登频率)
SESSION_TTL = 30 * 24 * 3600
_lock = threading.Lock()
# 内存中的有效会话: { token: expire_ts }。进程重启后从磁盘恢复。
_sessions: dict[str, float] = {}
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "auth.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def _load() -> dict:
p = _path()
if p.exists():
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
logger.warning("auth.json malformed: %s", e)
return {}
def _save(data: dict) -> None:
p = _path()
p.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
try:
os.chmod(p, 0o600)
except OSError:
pass
def _hash_password(password: str, salt: bytes | None = None) -> tuple[str, str]:
"""返回 (salt_hex, hash_hex)。salt 为 None 时生成新 salt。"""
if salt is None:
salt = os.urandom(_SALT_LEN)
dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
return salt.hex(), dk.hex()
def _verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
"""恒定时间比较, 防时序攻击。"""
try:
salt = bytes.fromhex(salt_hex)
expected = bytes.fromhex(hash_hex)
except ValueError:
return False
actual = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _PBKDF2_ITER)
return _secrets.compare_digest(actual, expected)
# ================================================================
# 密码管理
# ================================================================
def is_configured() -> bool:
"""是否已设置访问密码。"""
d = _load()
return bool(d.get("password_hash"))
def set_password(password: str) -> None:
"""设置/修改访问密码。清空所有现有会话(强制重新登录)。"""
if len(password) < 6:
raise ValueError("密码至少 6 位")
salt_hex, hash_hex = _hash_password(password)
with _lock:
_sessions.clear() # 改密码 = 旧会话全部失效
_save({
"password_hash": hash_hex,
"password_salt": salt_hex,
"updated_at": int(time.time()),
"sessions": {}, # 清空持久化会话
})
logger.info("access password set")
def bootstrap_from_env() -> bool:
"""首次初始化: 若环境变量 AUTH_PASSWORD 已配置且尚未设过密码, 则用它设密码。
公网服务器部署场景: 避免每次都要 SSH 端口转发才能设首个密码。
明文密码只在内存/配置中, 经 set_password() 哈希后写入 auth.json (chmod 0600)。
一旦设置成功, 后续重启不再覆盖 (用户改密码走 UI, 不受环境变量影响)。
Returns:
True 表示本次用环境变量初始化了密码; False 表示无需初始化。
"""
from app.config import settings
pwd = (settings.auth_password or "").strip()
if not pwd:
return False
if is_configured():
# 已设过密码, 不覆盖 (避免环境变量反复重置用户在 UI 改的密码)
return False
try:
set_password(pwd)
logger.info("access password bootstrapped from AUTH_PASSWORD env (one-time)")
return True
except ValueError as e:
# 密码不合规 (< 6 位), 记日志但不阻断启动
logger.warning("AUTH_PASSWORD bootstrap skipped: %s", e)
return False
def verify_and_create_session(password: str) -> str | None:
"""验证密码, 成功则创建会话并返回 token, 失败返回 None。"""
d = _load()
if not d.get("password_hash"):
return None
if not _verify_password(password, d.get("password_salt", ""), d["password_hash"]):
return None
token = _secrets.token_urlsafe(_TOKEN_BYTES)
expire = time.time() + SESSION_TTL
with _lock:
_sessions[token] = expire
_persist_sessions_locked()
return token
def revoke_session(token: str) -> None:
"""注销会话(登出)。"""
with _lock:
_sessions.pop(token, None)
_persist_sessions_locked()
def is_valid_session(token: str) -> bool:
"""检查会话是否有效(存在且未过期)。过期则清理。"""
if not token:
return False
with _lock:
expire = _sessions.get(token)
if expire is None:
return False
if time.time() > expire:
_sessions.pop(token, None)
_persist_sessions_locked()
return False
return True
def _persist_sessions_locked() -> None:
"""把当前内存会话写回 auth.json(需持锁调用)。"""
d = _load()
d["sessions"] = {t: exp for t, exp in _sessions.items()}
_save(d)
def _restore_sessions() -> None:
"""启动时从 auth.json 恢复未过期会话(支持进程重启不丢登录态)。"""
with _lock:
d = _load()
now = time.time()
saved = d.get("sessions") or {}
for token, expire in saved.items():
if isinstance(expire, (int, float)) and expire > now:
_sessions[token] = expire
if len(_sessions) != len(saved):
# 有过期会话被清理, 落盘一次
_persist_sessions_locked()
# 模块加载时恢复会话
try:
_restore_sessions()
except Exception as e: # noqa: BLE001
logger.warning("restore sessions failed: %s", e)
@@ -0,0 +1,357 @@
"""AI 概念轮动分析 — 从概念涨幅排名矩阵提炼主线/新晋/退潮信号。
数据来源:
- rps_rotation.build_rps_rotation: 概念涨幅排名矩阵 (N 日 × ~387 概念)
- market_overview_builder.build_market_overview: 大盘背景 (指数/情绪/涨停)
架构 (复刻 market_recap):
预计算轮动信号 → 拼装 prompt → stream_ai_text 流式调用 → NDJSON 协议输出
协议事件: meta(摘要) / delta(文本片段) / error / done
"""
from __future__ import annotations
import json
import logging
import math
from collections.abc import AsyncIterator
logger = logging.getLogger(__name__)
# ================================================================
# System Prompt — 轮动策略师人格 + 固定章节模板
# ================================================================
_SYSTEM_PROMPT = """你是一位专注 A 股题材轮动的资深策略师,拥有 12 年一线实战经验,擅长从概念板块的**涨幅排名矩阵**中识别主力资金脉络,区分机构主导的持续性主线与游资驱动的脉冲式轮动,产出可直接指导题材跟踪与节奏把握的轮动分析。
## 输出规范
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
### 1. 🎯 主线研判(2-3 句)
点名当前最核心的 1-2 条主线题材(连续多日霸榜的强势概念),用一句话概括其逻辑(政策/产业/业绩/事件驱动),并判断是**主升期/加速期/扩散期/见顶期**。结尾用【主线强度:强 / 中 / 弱】定性。
### 2. 🆕 新晋强势
列出排名快速跃升的概念(从榜单中后段冲进前列的),逐个给出:
- 概念名 + 近 N 日排名变化(如 `45→20→8`)
- 涨幅加速度(连日递增 = 趋势加强)
- 可能的驱动逻辑(从板块属性推断,不要编造具体消息)
- 判断是**主力切入**还是**消息脉冲**
### 3. 📉 退潮预警
列出从高位明显滑落的概念(连续排名下滑或涨幅骤降),逐个给出:
- 概念名 + 排名下滑轨迹
- 退潮性质(高位分歧/资金撤离/补跌)
- 是否扩散风险
### 4. 🏛️ 机构主线 vs 🎰 游资轮动
基于排名稳定性区分两类资金行为:
- **机构主线**:排名标准差小、长期稳居前列的概念 → 持续性判断、是否可作底仓方向
- **游资轮动**:排名剧烈波动、脉冲式冲高的概念 → 短线节奏提示、追高风险
给出当前市场**整体轮动节奏**(快轮动/慢轮动/主线聚焦)的判断。
### 5. 🌐 结合大盘
结合提供的大盘数据(指数涨跌/情绪/涨停数),判断:
- 当前大盘环境对题材轮动是助力还是阻力
- 情绪温度与轮动节奏的匹配度(如情绪冰点但题材活跃 = 抱团;情绪火热但轮动快 = 末段)
### 6. 🎯 操作建议
- **跟踪方向**:主线延续 + 新晋确认的概念
- **规避方向**:明确退潮 + 高位脉冲的概念
- **节奏提示**:当前适合追高 / 低吸 / 观望,及切换信号(如"主线概念连续 2 日跌出前 10 则确认退潮")
### 7. ⚠️ 风险提示
列出需要盯的风险(如主线断层、情绪与轮动背离、成交萎缩)。末尾附一行:
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
## 分析准则(务必遵守)
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤。不要写"我先看...""基于上述数据我认为"——直接给结论。
1. **数据说话**:每个判断引用具体排名/涨幅数值,严禁空泛套话("强势"必须改成"连续 4 日稳居前 5,均涨 +4.2%")。
2. **诚实中立**:数据不支持的结论就直言"信号不足,暂无法判断",不要硬凑。
3. **区分资金性质**:这是本分析的核心价值——机构 vs 游资的判断必须基于排名稳定性(标准差),不要凭感觉。
4. **不重复数字**:正文负责解读信号含义,不要照抄罗列已提供的全部原始数据。
5. **简明实战**:总字数 1000-1800 字,重在可执行。
6. **客观推断**:若无明确消息,从量价异动推断可能逻辑并给结论,不要标注"[推断]"或编造具体新闻。
现在请基于下方概念轮动数据进行分析。"""
# ================================================================
# 预计算: 把排名矩阵转成结构化轮动信号
# ================================================================
# 每类信号最多取多少个概念喂给 AI (控制 token)
_TOP_N = 8
def _compute_rotation_signals(dates: list[str], columns: dict) -> dict:
"""从概念涨幅排名矩阵计算轮动信号。
Args:
dates: 日期列表 (最新在最前, 与 columns key 一致)
columns: {日期: [[概念, 涨幅], ...]} 每列各自降序
Returns:
{
"persistent_leaders": [...], # 连续多日稳居前列 (主线)
"rising": [...], # 排名快速跃升 (新晋)
"fading": [...], # 从高位滑落 (退潮)
"institutional": [...], # 排名稳定 (机构特征)
"hot_money": [...], # 排名波动大 (游资特征)
}
每项含: concept, ranks (按 dates 顺序), pcts, avg_rank, rank_std
ranks 时间方向: ranks[0] = 最早日, ranks[-1] = 最新日 (已反转, 左老右新)
"""
if not dates or not columns:
return {}
# 按时间正序 (左老右新) 处理
dates_asc = list(reversed(dates))
# 收集每个概念在各日期的 (排名, 涨幅)。排名 = 该日在列中的索引 + 1。
concept_data: dict[str, list[tuple[int, float]]] = {}
for d in dates_asc:
col = columns.get(d) or []
for idx, (name, pct) in enumerate(col):
concept_data.setdefault(name, []).append((idx + 1, pct))
n_dates = len(dates_asc)
def _stats(ranks_pcts: list[tuple[int, float]]) -> dict:
ranks = [r for r, _ in ranks_pcts]
pcts = [p for _, p in ranks_pcts]
avg = sum(ranks) / len(ranks) if ranks else 0
var = sum((r - avg) ** 2 for r in ranks) / len(ranks) if ranks else 0
return {
"ranks": ranks,
"pcts": [round(p, 4) for p in pcts],
"avg_rank": round(avg, 1),
"rank_std": round(math.sqrt(var), 1),
}
persistent: list[dict] = []
rising: list[dict] = []
fading: list[dict] = []
institutional: list[dict] = []
hot_money: list[dict] = []
for concept, rp in concept_data.items():
# 缺失日补 (大排名, 0 涨幅) 保持时间轴对齐
if len(rp) < n_dates:
rp = rp + [(999, 0.0)] * (n_dates - len(rp))
s = _stats(rp)
s["concept"] = concept
ranks = s["ranks"]
latest_rank = ranks[-1]
earliest_rank = ranks[0]
# 最近 3 日 (不足则全部) 均排名, 判断近期强度
recent = ranks[-min(3, len(ranks)):]
recent_avg = sum(recent) / len(recent)
# 主线: 近期稳居前 10
if recent_avg <= 10 and latest_rank <= 10:
persistent.append(s)
# 新晋: 早期排名靠后(>30), 最新冲进前 20, 跃升幅度大
jump = earliest_rank - latest_rank
if earliest_rank > 30 and latest_rank <= 20 and jump >= 20:
rising.append(s)
# 退潮: 早期排名靠前(<=10), 最新滑落到 30 外
drop = latest_rank - earliest_rank
if earliest_rank <= 10 and latest_rank > 30 and drop >= 20:
fading.append(s)
# 机构: 排名标准差小且平均排名靠前 (稳定强势)
if s["rank_std"] <= 5 and s["avg_rank"] <= 20:
institutional.append(s)
# 游资: 排名标准差大 (波动剧烈)
if s["rank_std"] >= 20:
hot_money.append(s)
# 排序: 主线按近期排名升序; 新晋按跃升幅度降序; 退潮按跌幅降序
persistent.sort(key=lambda x: x["avg_rank"])
rising.sort(key=lambda x: x["ranks"][0] - x["ranks"][-1], reverse=True)
fading.sort(key=lambda x: x["ranks"][-1] - x["ranks"][0], reverse=True)
institutional.sort(key=lambda x: (x["rank_std"], x["avg_rank"]))
hot_money.sort(key=lambda x: x["rank_std"], reverse=True)
return {
"persistent_leaders": persistent[:_TOP_N],
"rising": rising[:_TOP_N],
"fading": fading[:_TOP_N],
"institutional": institutional[:_TOP_N],
"hot_money": hot_money[:_TOP_N],
}
# ================================================================
# Prompt 构建
# ================================================================
def _fmt_pct(v) -> str:
if v is None:
return ""
return f"{v*100:+.2f}%"
def _build_market_block(overview: dict) -> str:
"""大盘背景精简块 (复用 market_overview 已算好的字段)。"""
indices = overview.get("indices") or []
emo = overview.get("emotion") or {}
lim = overview.get("limit") or {}
amt = overview.get("amount") or {}
idx_lines = []
for idx in indices[:4]:
name = idx.get("name") or idx.get("symbol") or "?"
chg = idx.get("change_pct")
idx_lines.append(f"{name} {_fmt_pct(chg)}")
idx_str = " / ".join(idx_lines) or "指数缺失"
total_amount = (amt.get("total") or 0) / 1e8 # 元 → 亿
return (
f"- 指数: {idx_str}\n"
f"- 情绪: {emo.get('score', 50)} ({emo.get('label', '')})\n"
f"- 涨停/炸板/跌停: {lim.get('limit_up', 0)} / {lim.get('broken', 0)} / {lim.get('limit_down', 0)}"
f" (最高连板 {lim.get('max_boards', 0)})\n"
f"- 两市成交额: {total_amount:.0f} 亿元"
)
def _build_signal_block(title: str, items: list[dict]) -> str:
"""轮动信号块: 把预计算的概念信号转成紧凑文本。"""
if not items:
return f"### {title}\n(本类无明显信号)"
lines = [f"### {title}"]
for it in items:
ranks_str = "".join(str(r) if r < 999 else "" for r in it["ranks"])
avg_pct = sum(it["pcts"]) / len(it["pcts"]) if it["pcts"] else 0
lines.append(
f"- {it['concept']}: 排名 {ranks_str} | 均排名 {it['avg_rank']} "
f"| 排名波动σ {it['rank_std']} | 区间均涨 {_fmt_pct(avg_pct)}"
)
return "\n".join(lines)
def _build_user_prompt(signals: dict, overview: dict, days: int, dates: list[str], focus: str) -> str:
"""组装 user 消息: 大盘背景 + 轮动信号 + focus。"""
dates_asc = list(reversed(dates))
date_range = f"{dates_asc[0]} ~ {dates_asc[-1]}" if dates_asc else ""
parts = [
f"# 概念涨幅轮动数据 (最近 {days} 个交易日: {date_range})",
"",
"## 大盘背景",
_build_market_block(overview),
"",
"## 轮动信号 (排名时间方向: 左→右 = 旧→新, 排名越小越强)",
"",
_build_signal_block("🎯 主线 (连续霸榜)", signals.get("persistent_leaders", [])),
"",
_build_signal_block("🆕 新晋强势 (排名跃升)", signals.get("rising", [])),
"",
_build_signal_block("📉 退潮预警 (高位滑落)", signals.get("fading", [])),
"",
_build_signal_block("🏛️ 机构特征 (排名稳定)", signals.get("institutional", [])),
"",
_build_signal_block("🎰 游资特征 (排名波动大)", signals.get("hot_money", [])),
]
if focus.strip():
parts.extend(["", f"## 用户关注点\n{focus.strip()}"])
return "\n".join(parts)
def _build_summary(signals: dict) -> str:
"""meta 事件的摘要 (前端可立即展示)。"""
leaders = signals.get("persistent_leaders", [])
rising = signals.get("rising", [])
fading = signals.get("fading", [])
leader_names = "".join(it["concept"] for it in leaders[:3]) or "暂无明确主线"
return f"主线: {leader_names} | 新晋 {len(rising)} | 退潮 {len(fading)}"
# ================================================================
# 流式主入口
# ================================================================
async def analyze_rotation_stream(
repo,
days: int = 12,
focus: str = "",
quote_service=None,
depth_service=None,
) -> AsyncIterator[str]:
"""流式概念轮动分析: yield 出每个 NDJSON 事件。
Args:
repo: KlineRepository (必填)。
days: 分析最近 N 个交易日 (7-30)。
focus: 用户追加的关注点。
quote_service / depth_service: 可选, 大盘背景装配依赖。
"""
from app.services.rps_rotation import build_rps_rotation
from app.services.market_overview_builder import build_market_overview
# 1. 取轮动矩阵
rotation = build_rps_rotation(repo, days)
dates = rotation.get("dates") or []
columns = rotation.get("columns") or {}
if not dates or not columns:
yield json.dumps({
"type": "error",
"message": "暂无概念轮动数据,请先在「概念分析」页获取概念数据源",
}, ensure_ascii=False)
return
# 2. 预计算轮动信号
signals = _compute_rotation_signals(dates, columns)
# 3. 大盘背景 (失败不阻断, 降级为空)
try:
overview = build_market_overview(repo, quote_service, depth_service)
except Exception as e: # noqa: BLE001
logger.warning("rotation analyze: 大盘背景获取失败, 降级为空: %s", e)
overview = {}
# 4. meta 事件
yield json.dumps({
"type": "meta",
"days": days,
"summary": _build_summary(signals),
}, ensure_ascii=False)
# 5. 构建 prompt + 流式调用 LLM
try:
from app.services.ai_provider import stream_ai_text, ai_configured
if not ai_configured():
yield json.dumps({
"type": "error",
"message": "AI 未配置,请在「设置」页填写 API Key 与接口地址",
}, ensure_ascii=False)
return
user_prompt = _build_user_prompt(signals, overview, days, dates, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4000,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI concept rotation analyze failed: %s", e)
yield json.dumps({"type": "error", "message": f"AI 轮动分析失败: {e}"}, ensure_ascii=False)
yield json.dumps({"type": "done"}, ensure_ascii=False)
+12 -1
View File
@@ -316,7 +316,18 @@ class DepthService:
"status": e.get("status"),
"fetched_at": e.get("fetched_ts"),
})
df = pl.DataFrame(rows)
# 显式 schema: sealed_up/sealed_down 是 bool 与 None 混合, 不指定 schema
# polars 会按首行推断类型, 后续遇到不一致 (bool vs null) 报
# "could not append value: false of type: bool to the builder"。
df = pl.DataFrame(rows, schema={
"symbol": pl.Utf8,
"sealed_up": pl.Boolean,
"sealed_down": pl.Boolean,
"ask1_vol": pl.Int64,
"bid1_vol": pl.Int64,
"status": pl.Utf8,
"fetched_at": pl.Float64,
})
ds = today.isoformat()
out = self._repo.store.data_dir / "depth5" / f"date={ds}" / "part.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
+11 -4
View File
@@ -38,6 +38,7 @@ class PullConfig:
"url", "method", "headers", "body", "response_path",
"field_map", "schedule_minutes", "enabled",
"last_run", "last_status", "last_message", "last_rows",
"next_run",
)
def __init__(
@@ -54,6 +55,7 @@ class PullConfig:
last_status: str | None = None,
last_message: str | None = None,
last_rows: int | None = None,
next_run: str | None = None,
) -> None:
self.url = url
self.method = method # GET | POST
@@ -67,6 +69,7 @@ class PullConfig:
self.last_status = last_status # "success" | "error"
self.last_message = last_message
self.last_rows = last_rows
self.next_run = next_run # 下次预计运行 (ISO, 调度器写入)
def to_dict(self) -> dict:
return {
@@ -82,6 +85,7 @@ class PullConfig:
"last_status": self.last_status,
"last_message": self.last_message,
"last_rows": self.last_rows,
"next_run": self.next_run,
}
@classmethod
@@ -101,6 +105,7 @@ class PullConfig:
last_status=d.get("last_status"),
last_message=d.get("last_message"),
last_rows=d.get("last_rows"),
next_run=d.get("next_run"),
)
@@ -407,8 +412,10 @@ def write_ext_parquet(
existing = pl.read_parquet(out_path)
key = "symbol" if "symbol" in df.columns else df.columns[0]
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
except Exception:
pass
except Exception as e:
# schema 不一致 (列不同) 时 concat 失败 → 直接用新 df 覆盖。
# 记日志而非静默吞掉, 便于排查"数据结构错乱"类问题。
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
else:
# 时序: timeseries/ 下按日期分区
out_dir = cfg_dir / "timeseries" / f"date={snap}"
@@ -421,8 +428,8 @@ def write_ext_parquet(
existing = pl.read_parquet(out_path)
key = "symbol" if "symbol" in df.columns else df.columns[0]
df = pl.concat([existing, df]).unique(subset=[key], keep="last")
except Exception:
pass
except Exception as e:
logger.warning("扩展表 %s 合并去重失败, 将覆盖写入: %s", config.id, e)
df = cast_df_to_schema(df, config.fields)
df.write_parquet(out_path)
+236
View File
@@ -0,0 +1,236 @@
"""内置扩展数据预设 — 概念/行业首次启动自动拉取。
设计原则:
- 扩展数据通用逻辑零改动 (ExtConfig / fetch_and_ingest / API / 前端均不动)
- 仅在本模块做「接口结构 → 本地 schema」的转换
- 「已存在则跳过」: 绝不覆盖用户已有数据, 老用户零影响
- 拉取失败只记 warning, 不阻断启动 (保持「没数据也能跑」)
种子数据来源: https://files.688798.xyz/ths/{concepts,industries}.json
作者更新数据只需改接口上的 JSON, 用户下次拉取自动同步, 无需发版。
接入点: app.main.lifespan → ensure_builtin_presets(store.data_dir)
"""
from __future__ import annotations
import logging
from pathlib import Path
from app.services.ext_data import (
ExtConfig,
ExtConfigStore,
ExtField,
PullConfig,
rows_to_parquet,
)
logger = logging.getLogger(__name__)
# 种子数据源 (作者维护, 改这里即对所有用户生效)
_THS_BASE = "https://files.688798.xyz/ths"
# ---------------------------------------------------------------------------
# 预设定义: 字段结构 + 拉取配方
# ---------------------------------------------------------------------------
def _concept_preset() -> ExtConfig:
"""扩展概念 (ext_gn_ths)。
接口结构: [{symbol, name, concepts: [概念1, 概念2, ...]}]
本地 schema: 股票代码 / 股票简称 / 所属概念(分号拼接) / symbol / code
"""
return ExtConfig(
id="ext_gn_ths",
label="扩展概念",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("code", "string", "代码"),
ExtField("股票代码", "string", "股票代码"),
ExtField("股票简称", "string", "股票简称"),
ExtField("所属概念", "string", "所属概念"),
],
description="同花顺概念分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
url=f"{_THS_BASE}/concepts.json",
method="GET",
schedule_minutes=1440,
enabled=False,
),
)
def _industry_preset() -> ExtConfig:
"""扩展行业 (ext_hy_ths)。
接口结构: [{symbol, name, industries: [一级行业, 二级行业, 三级行业]}]
本地 schema: 股票代码 / 股票简称 / 所属同花顺行业(横杠拼接) / symbol / code
"""
return ExtConfig(
id="ext_hy_ths",
label="扩展行业",
mode="snapshot",
fields=[
ExtField("symbol", "string", "标的代码"),
ExtField("code", "string", "代码"),
ExtField("股票代码", "string", "股票代码"),
ExtField("股票简称", "string", "股票简称"),
ExtField("所属同花顺行业", "string", "所属同花顺行业"),
],
description="同花顺行业分类 (首次启动自动拉取, 可在扩展数据页手动更新)",
symbol_map={"type": "mapped", "col": "股票代码"},
code_map={"type": "computed", "from": "symbol", "method": "strip_exchange"},
pull=PullConfig(
url=f"{_THS_BASE}/industries.json",
method="GET",
schedule_minutes=1440,
enabled=False,
),
)
def _presets() -> list[ExtConfig]:
return [_concept_preset(), _industry_preset()]
# ---------------------------------------------------------------------------
# 接口结构 → 本地 schema 转换 (仅预设使用)
# ---------------------------------------------------------------------------
def _symbol_to_code(symbol: str) -> str:
"""symbol (000001.SZ) → code (000001)。"""
return symbol.split(".", 1)[0] if "." in symbol else symbol
def _flatten_concept_rows(raw_rows: list[dict]) -> list[dict]:
"""概念: concepts 数组 → 分号拼接成「所属概念」字符串。
[{symbol, name, concepts:[...]}] → [{股票代码, 股票简称, 所属概念, symbol, code}]
注: code 由 symbol 派生 (000001.SZ → 000001), 因 rows_to_parquet 不执行 code_map。
"""
out: list[dict] = []
for r in raw_rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
concepts = r.get("concepts") or []
out.append({
"股票代码": sym,
"股票简称": r.get("name") or "",
"所属概念": ";".join(str(c) for c in concepts if c),
"symbol": sym,
"code": _symbol_to_code(sym),
})
return out
def _flatten_industry_rows(raw_rows: list[dict]) -> list[dict]:
"""行业: industries 数组 → 横杠拼接成「所属同花顺行业」字符串。
[{symbol, name, industries:[...]}] → [{股票代码, 股票简称, 所属同花顺行业, symbol, code}]
"""
out: list[dict] = []
for r in raw_rows:
sym = (r.get("symbol") or "").strip()
if not sym:
continue
inds = r.get("industries") or []
out.append({
"股票代码": sym,
"股票简称": r.get("name") or "",
"所属同花顺行业": "-".join(str(i) for i in inds if i),
"symbol": sym,
"code": _symbol_to_code(sym),
})
return out
# ---------------------------------------------------------------------------
# 拉取执行 (复用 httpx, 不依赖 fetch_and_ingest 的 PullConfig 路径)
# ---------------------------------------------------------------------------
async def _fetch_json(url: str) -> list[dict]:
"""请求 JSON 接口, 返回行数组。超时 30s, 失败抛异常由调用方兜底。"""
import httpx
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, list):
raise ValueError(f"接口返回不是数组: {type(data)}")
return data
async def _seed_one(config: ExtConfig, flatten, data_dir: Path) -> int:
"""拉取 + 转换 + 写入单个预设。返回写入行数。"""
from datetime import date
raw = await _fetch_json(config.pull.url)
rows = flatten(raw)
if not rows:
raise ValueError(f"接口返回 0 行: {config.pull.url}")
n = rows_to_parquet(rows, config, data_dir, snapshot_date=date.today())
return n
# ---------------------------------------------------------------------------
# 对外入口
# ---------------------------------------------------------------------------
def get_preset(config_id: str) -> ExtConfig | None:
"""按 id 取预设定义 (供 API 层校验 id 合法性)。"""
for c in _presets():
if c.id == config_id:
return c
return None
async def ensure_builtin_presets(data_dir: Path) -> None:
"""启动时: 为缺失的预设创建 config.json (含 pull 配置), 但【不拉取数据】。
设计: 数据获取改为用户在概念/行业页手动点「获取数据」触发, 避免启动时
网络请求阻塞, 也避免「自动拉取」与「用户自主控制」的预期冲突。
安全保证:
- 已存在则完全跳过 (绝不覆盖用户数据)
- 只写 config.json, 失败只记 warning 不阻断启动
"""
store = ExtConfigStore(data_dir)
for config in _presets():
existing = store.get(config.id)
if existing is not None:
# 用户已有此表 (老用户 / 自己重建过) → 一律不动
continue
try:
store.upsert(config)
logger.info("内置扩展表 %s 配置已就绪 (待用户手动获取数据)", config.id)
except Exception as e: # noqa: BLE001
logger.warning("内置扩展表 %s 配置写入失败 (不影响启动): %s", config.id, e)
async def fetch_preset(config_id: str, data_dir: Path) -> int:
"""手动触发某个预设的数据拉取 (供 API 调用)。
Raises:
ValueError: config_id 不是内置预设
Exception: 网络请求/解析/写入失败 (由 API 层转 HTTP 错误)
"""
config = get_preset(config_id)
if config is None:
raise ValueError(f"未知的内置预设: {config_id}")
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
# 确保 config.json 存在 (用户可能从未启动过 ensure_builtin_presets)
store = ExtConfigStore(data_dir)
if store.get(config_id) is None:
store.upsert(config)
n = await _seed_one(config, flatten, data_dir)
logger.info("内置扩展表 %s 手动拉取成功: %d", config_id, n)
return n
+157 -34
View File
@@ -75,6 +75,19 @@ def _apply_field_map(rows: list[dict], field_map: dict[str, str]) -> list[dict]:
# 拉取执行
# ---------------------------------------------------------------------------
def _apply_preset_flatten(config_id: str, rows: list[dict]) -> list[dict]:
"""对内置预设 (概念/行业) 应用结构转换, 与 fetch_preset 保持一致。
延迟导入避免与 ext_presets 形成循环依赖。
非预设 id 原样返回。
"""
if config_id not in ("ext_gn_ths", "ext_hy_ths"):
return rows
from app.services.ext_presets import _flatten_concept_rows, _flatten_industry_rows
flatten = _flatten_concept_rows if config_id == "ext_gn_ths" else _flatten_industry_rows
return flatten(rows)
async def fetch_and_ingest(
config: ExtConfig,
data_dir,
@@ -111,6 +124,12 @@ async def fetch_and_ingest(
if not rows:
raise ValueError("提取到的行数为 0")
# 内置预设 (概念/行业): 应用结构转换, 让产出 schema 与分析页一致。
# 否则 raw 接口列 (concepts/industries 数组、name) 会直接覆盖正确的 part.parquet,
# 导致分析页因找不到维度字段 (所属概念/所属同花顺行业) 而"数据消失"。
# 见 ext_presets._flatten_* —— 手动拉取 / 定时拉取都必须走同一套转换。
rows = _apply_preset_flatten(config.id, rows)
# 字段映射
rows = _apply_field_map(rows, pull.field_map)
@@ -129,21 +148,46 @@ async def fetch_and_ingest(
# ---------------------------------------------------------------------------
class PullScheduler:
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。"""
"""后台调度器:为每个启用了 pull 的 ExtConfig 维护定时任务。
线程安全说明:
refresh()/stop() 可能从主事件循环 (lifespan startup) 或同步路由的
worker 线程 (configure_pull 是 def 而非 async def, FastAPI 丢进线程池)
调用。worker 线程里没有 running loop, 直接 asyncio.create_task 会抛
"no running event loop"。因此对 task 的增删一律通过
call_soon_threadsafe 提交到主循环执行 —— 同一套代码两种调用场景都安全。
"""
def __init__(self) -> None:
self._tasks: dict[str, asyncio.Task] = {}
self._running = False
self._lock = threading.Lock()
self._loop: asyncio.AbstractEventLoop | None = None
def start(self, data_dir) -> None:
"""启动调度(在 lifespan startup 调用)。"""
"""启动调度(在 lifespan startup 调用,主事件循环内)。"""
self._running = True
self._data_dir = data_dir
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = None
logger.info("PullScheduler started")
def _submit(self, fn, *args) -> None:
"""把一个 callable 提交到主事件循环执行 (线程安全)。
startup 在主循环内调用时 fn 立即排队; worker 线程调用时跨线程排队。
两者都通过 call_soon_threadsafe, 保证 _tasks 字典的读写只在主循环里发生。
"""
loop = self._loop
if loop is None or loop.is_closed():
raise RuntimeError(
"PullScheduler: 事件循环不可用 (start() 未在事件循环中调用?)"
)
loop.call_soon_threadsafe(fn, *args)
def stop(self) -> None:
"""停止所有任务。"""
"""停止所有任务 (从 shutdown 调用)"""
self._running = False
for task in self._tasks.values():
task.cancel()
@@ -151,47 +195,61 @@ class PullScheduler:
logger.info("PullScheduler stopped")
def refresh(self, data_dir) -> None:
"""重新加载配置,更新调度任务(增/删/改)。"""
"""重新加载配置,更新调度任务(增/删/改)。线程安全。"""
self._data_dir = data_dir
store = ExtConfigStore(data_dir)
configs = store.load_all()
active_ids: set[str] = set()
new_configs: list[ExtConfig] = []
for config in configs:
if not config.pull or not config.pull.enabled or not config.pull.url:
continue
active_ids.add(config.id)
if config.id not in self._tasks:
# 新增调度
task = asyncio.create_task(self._run_loop(config))
self._tasks[config.id] = task
logger.info("PullScheduler: scheduled %s (every %d min)", config.id, config.pull.schedule_minutes)
new_configs.append(config)
# 移除不再活跃的
for cid in list(self._tasks):
if cid not in active_ids:
self._tasks[cid].cancel()
del self._tasks[cid]
logger.info("PullScheduler: removed %s", cid)
# 需要移除的 id (快照当前 task 字典的键, 避免遍历时改字典)
remove_ids = [cid for cid in list(self._tasks) if cid not in active_ids]
# 所有对 _tasks 的修改都提交到主循环里执行, 保证线程安全
def _apply() -> None:
for config in new_configs:
if config.id not in self._tasks: # 二次校验, 防重复
self._tasks[config.id] = self._loop.create_task(
self._run_loop(config)
)
logger.info(
"PullScheduler: scheduled %s (every %d min)",
config.id, config.pull.schedule_minutes,
)
for cid in remove_ids:
task = self._tasks.pop(cid, None)
if task is not None:
task.cancel()
logger.info("PullScheduler: removed %s", cid)
self._submit(_apply)
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。"""
"""单个配置的定时拉取循环。
策略: 启用后立即执行一次, 之后按 interval 循环。
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
这样用户中途修改间隔也能立即生效 (无需重启)。
"""
try:
while self._running:
pull = config.pull
if not pull:
break
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
await asyncio.sleep(interval)
if not self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
try:
# 重新加载最新配置(用户可能中途修改)
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
n, d = await fetch_and_ingest(fresh, self._data_dir)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
@@ -200,14 +258,79 @@ class PullScheduler:
store.upsert(fresh)
logger.info("PullScheduler: %s success, %d rows", config.id, n)
except Exception as e:
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if fresh and fresh.pull:
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "error"
fresh.pull.last_message = str(e)[:200]
store.upsert(fresh)
fresh2 = store.get(config.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
store.upsert(fresh2)
logger.warning("PullScheduler: %s error: %s", config.id, e)
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(timezone.utc).timestamp() + interval
latest = store.get(config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=timezone.utc
).isoformat()
store.upsert(latest)
await asyncio.sleep(interval)
if not self._running:
break
except asyncio.CancelledError:
pass
async def _run_loop(self, config: ExtConfig) -> None:
"""单个配置的定时拉取循环。
策略: 启用后立即执行一次, 之后按 interval 循环。
每次循环重读最新配置 (fresh), interval 取自 fresh.pull.schedule_minutes,
这样用户中途修改间隔也能立即生效 (无需重启)。
"""
try:
while self._running:
# 每轮重读最新配置 — 用户可能修改了 url / interval / enabled
store = ExtConfigStore(self._data_dir)
fresh = store.get(config.id)
if not fresh or not fresh.pull or not fresh.pull.enabled:
break
pull = fresh.pull
# 先执行一次 (启用即拉取, 让用户立刻看到生效)
try:
n, d = await fetch_and_ingest(fresh, self._data_dir)
fresh.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh.pull.last_status = "success"
fresh.pull.last_message = f"{n} rows @ {d}"
fresh.pull.last_rows = n
store.upsert(fresh)
logger.info("PullScheduler: %s success, %d rows", config.id, n)
except Exception as e:
fresh2 = store.get(config.id)
if fresh2 and fresh2.pull:
fresh2.pull.last_run = datetime.now(timezone.utc).isoformat()
fresh2.pull.last_status = "error"
fresh2.pull.last_message = str(e)[:200]
store.upsert(fresh2)
logger.warning("PullScheduler: %s error: %s", config.id, e)
# 间隔取自最新配置 (每次重新读取, 修复改间隔不生效)
interval = max(pull.schedule_minutes * 60, 60) # 至少 60s
# 预告下次运行时间, 供前端展示
next_dt = datetime.now(timezone.utc).timestamp() + interval
latest = store.get(config.id)
if latest and latest.pull:
latest.pull.next_run = datetime.fromtimestamp(
next_dt, tz=timezone.utc
).isoformat()
store.upsert(latest)
await asyncio.sleep(interval)
if not self._running:
break
except asyncio.CancelledError:
pass
@@ -0,0 +1,187 @@
"""AI 财务分析服务 — 读取个股财务数据 → 构建专业提示词 → 流式调用 LLM。
职责: 拉取单只标的的 4 张财务表 → 转成紧凑 JSON → 拼装 CFA 分析师级系统提示词
→ 流式调用 OpenAI 兼容 API → 逐 chunk 吐给前端。
不知道: HTTP、前端、配置持久化。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import AsyncIterator
import polars as pl
from app.services.financial_sync import get_financial_df
logger = logging.getLogger(__name__)
# 最多注入的报告期数(最新 N 期),避免上下文爆炸 / token 浪费
_MAX_PERIODS = 4
def _load_stock_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
"""读取该标的的 4 张财务表,返回 {table: [records...]}(按 period_end 降序,截取最新 N 期)。
数值统一做 NaN/Inf → null 清洗,保证 JSON 序列化不报错。
"""
result: dict[str, list[dict]] = {}
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
df = get_financial_df(data_dir, table)
if df.is_empty():
result[table] = []
continue
df = df.filter(pl.col("symbol") == symbol)
if df.is_empty():
result[table] = []
continue
# 按 period_end 降序,截取最新 N 期
if "period_end" in df.columns:
df = df.sort("period_end", descending=True).head(_MAX_PERIODS)
# 清洗 NaN/Inf,转成 JSON 安全的 dict 列表
rows = []
for rec in df.to_dicts():
clean = {}
for k, v in rec.items():
if k == "symbol":
continue # 不需要重复回传 symbol
if isinstance(v, float):
import math
clean[k] = None if not math.isfinite(v) else v
else:
clean[k] = v
rows.append(clean)
result[table] = rows
return result
def _summarize(fins: dict[str, list[dict]]) -> str:
"""生成一行业务摘要,便于 LLM 快速把握数据全貌(行数/期数)。"""
parts = []
for table in ("metrics", "income", "balance_sheet", "cash_flow"):
rows = fins.get(table, [])
if rows:
periods = [r.get("period_end") for r in rows if r.get("period_end")]
parts.append(f"{table}: {len(rows)}期 ({', '.join(str(p) for p in periods[:3])})")
else:
parts.append(f"{table}: 无数据")
return " · ".join(parts)
# ================================================================
# 系统提示词 —— CFA 分析师级,九维分析框架
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股投研经验的资深财务分析师(CFA + CPA),服务于专业机构投资者。你的任务是:基于提供的上市公司财务数据,产出一份**严谨、专业、可直接用于投资决策**的财务分析报告。
## 输出规范
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
### 1. 📌 核心摘要(1-2 句)
用一句话概括该公司的财务画像:盈利质量、成长动能、财务健康度的最关键判断。结尾用【综合评级:★★★☆☆】给出 1-5 星评级。
### 2. ✅ 亮点(2-3 条)
列出最值得关注的**积极信号**,每条用加粗短语领起,配数据支撑。例如盈利高增、ROE 持续提升、现金流充沛等。
### 3. ⚠️ 风险提示(2-3 条)
客观指出**潜在风险或值得警惕的信号**,例如应收激增、存货堆积、经营现金流与净利润背离、债务攀升等。宁可保守,不要回避。
### 4. 📊 分项诊断
用**表格**呈现各维度的诊断结论,列为「维度 / 关键指标 / 判断」。维度包括:
- **盈利能力**:ROE / ROA / 毛利率 / 净利率
- **成长性**:营收同比 / 净利润同比
- **偿债能力**:资产负债率 / 流动比率(用资产/负债估算)
- **现金流**:经营现金流净额 / 与净利润的匹配度
- **营运效率**:存货周转率等(有数据时)
每个判断给「优秀 / 良好 / 一般 / 偏弱 / 警惕」之一,并一句话说明依据。
### 5. 🎯 综合评估与展望
2-3 段总结:该公司当前的财务状态(优秀/稳健/承压/恶化)、核心驱动力、未来需重点跟踪的指标。**结尾给出"投资参考"**:从纯财务质量角度,该股属于(高质量蓝筹 / 稳健成长 / 周期波动 / 财务承压 / 高风险)中的哪一类。
## 分析准则(务必遵守)
1. **数据说话**:每个判断必须引用具体数值(如"营收同比 +28.5%"),严禁空泛套话
2. **纵向对比**:利用多期数据看趋势(改善/恶化),而非只看单期
3. **交叉验证**:经营现金流 vs 净利润(是否造血)、毛利率 vs 费用率(盈利结构)、负债 vs 资产(杠杆)
4. **行业常识**:对照 A 股常识判断水平(如 ROE>15% 优秀,资产负债率>70% 偏高,毛利率<20% 偏低)
5. **诚实中立**:数据不支持时直言"数据不足,无法判断",绝不编造或过度演绎
6. **简明有力**:避免冗长,用专业投资者能扫读的密度输出,总字数 800-1500 字
## 重要免责
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开财务数据生成,仅供参考,不构成任何投资建议。"
现在请基于下方数据进行分析。"""
def _build_user_prompt(fins: dict[str, list[dict]], symbol: str, focus: str) -> str:
"""构建用户消息:标的代码 + 数据 JSON + 可选关注点。"""
data_json = json.dumps(fins, ensure_ascii=False, indent=2)
lines = [
f"标的标准代码: {symbol}",
f"数据概览: {_summarize(fins)}",
"",
"以下是该标的最新财务数据(JSON 格式,金额单位为元,比率类指标为百分点):",
"```json",
data_json,
"```",
]
if focus.strip():
lines.extend([
"",
f"本次分析请特别关注: {focus.strip()}",
])
return "\n".join(lines)
async def analyze_financials_stream(
data_dir: Path,
symbol: str,
focus: str = "",
) -> AsyncIterator[str]:
"""流式分析:yield 出每个文本 chunk。
- 启动时先 yield 一条 {"type":"meta",...} 让前端显示数据摘要
- 之后逐 chunk yield {"type":"delta","content":"..."}
- 出错时 yield {"type":"error","message":"..."}
- 结束 yield {"type":"done"}
"""
# 1. 加载数据
fins = _load_stock_financials(data_dir, symbol)
total_rows = sum(len(v) for v in fins.values())
if total_rows == 0:
yield json.dumps({"type": "error", "message": f"标的 {symbol} 暂无任何财务数据,请先同步财务表"}, ensure_ascii=False)
return
# 2. meta
yield json.dumps({
"type": "meta",
"symbol": symbol,
"summary": _summarize(fins),
"periods": total_rows,
}, ensure_ascii=False)
# 3. 调用 LLM 流式
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(fins, symbol, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.4,
max_tokens=4000,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI financial analysis failed for %s: %s", symbol, e)
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
+141 -45
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import asyncio
import logging
import threading
from datetime import date, datetime, timezone
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -200,15 +200,82 @@ class FinancialScheduler:
# 手动同步(run_now)是否正在进行。前端据此显示"同步中"并防重复点击。
self._is_syncing = False
def start(self, data_dir: Path, capset: CapabilitySet) -> None:
def start(self, data_dir: Path, capset: CapabilitySet, *, auto_schedule: bool = False) -> None:
"""初始化调度器,并按需启动周期同步后台任务。
auto_schedule=False (默认): 仅初始化 (设置数据目录/能力 + 恢复 last_sync),
供 /api/financials/sync/* 手动同步使用, 不启动自动调度。
auto_schedule=True: 额外启动每周一次的 metrics 自动同步 (启动后 60s 首跑)。
"""
# 先记录 data_dir/capset, 即使当前无 FINANCIAL 也保留引用:
# 用户稍后在「设置」页升级到 Expert Key 时, update_capabilities() 会把新 capset
# 推进来,trigger()/run_now() 才能用上 FINANCIAL。否则 _capset 永远是 None,
# 即便 app.state.capabilities 已更新, 调度器仍报 "no FINANCIAL capability"。
self._data_dir = data_dir
self._capset = capset
if not capset.has(Cap.FINANCIAL):
logger.info("FinancialScheduler skipped: no FINANCIAL capability")
return
self._data_dir = data_dir
self._capset = capset
# 从持久化恢复上次同步时间: 重启后前端仍能显示真实最后同步时间,而非"尚未同步"
try:
from app.services import preferences
restored = dict(preferences.get_financial_sync_times())
# 老用户迁移兜底: 若某表在 preferences 无记录但 parquet 已存在(升级前同步过),
# 用 parquet 文件的修改时间作为同步时间并补写持久化。
for table in FINANCIAL_TABLES:
if table in restored:
continue
parquet = data_dir / "financials" / table / "part.parquet"
if parquet.exists():
mtime = datetime.fromtimestamp(parquet.stat().st_mtime, tz=timezone.utc).isoformat()
restored[table] = mtime
preferences.set_financial_sync_time(table, mtime)
logger.info("FinancialScheduler backfilled last_sync for %s from parquet mtime", table)
self._last_sync = restored
if self._last_sync:
logger.info("FinancialScheduler restored last_sync: %s", list(self._last_sync.keys()))
except Exception as e: # noqa: BLE001
logger.warning("restore financial_sync_times failed: %s", e)
if not auto_schedule:
# 仅初始化 (手动同步用), 不启动周期任务。
logger.info("FinancialScheduler initialized (auto-schedule disabled; manual sync only)")
return
self._running = True
self._task = asyncio.create_task(self._run_loop())
logger.info("FinancialScheduler started")
logger.info("FinancialScheduler started (auto-schedule enabled)")
def _record_sync(self, table: str) -> None:
"""记录一张表的同步完成时间: 更新内存 + 持久化到 preferences.json。
持久化确保即使重启,前端 /status 仍返回真实的最后同步时间,
不会错误地显示"尚未同步"
"""
ts = datetime.now(timezone.utc).isoformat()
self._last_sync[table] = ts
try:
from app.services import preferences
preferences.set_financial_sync_time(table, ts)
except Exception as e: # noqa: BLE001
logger.warning("persist financial_sync_time(%s) failed: %s", e)
def update_capabilities(self, capset: CapabilitySet) -> None:
"""刷新调度器持有的能力集。
用户在「设置」页新增/清除 API Key 后, settings API 会重新探测能力并更新
app.state.capabilities; 必须同步推给本调度器, 否则 trigger()/run_now() 仍读
启动时的旧 capset, 即便 app.state 已含 FINANCIAL, 调度器仍报
"no FINANCIAL capability" 而拒绝同步 (表现为前端「全部同步」按钮闪一下无动作)。
"""
prev = self._capset
self._capset = capset
had = bool(prev) and prev.has(Cap.FINANCIAL)
now = capset.has(Cap.FINANCIAL)
if had != now:
logger.info(
"FinancialScheduler capabilities updated: FINANCIAL %s -> %s", had, now
)
def stop(self) -> None:
self._running = False
@@ -217,22 +284,6 @@ class FinancialScheduler:
self._task = None
logger.info("FinancialScheduler stopped")
def update(self, data_dir: Path, capset: CapabilitySet) -> None:
"""运行时更新数据目录和能力集。
用户在设置页更换/清除 Key 后,能力集可能变化,无需重启服务即可让
财务调度器生效或失效。
"""
had_financial = self._capset is not None and self._capset.has(Cap.FINANCIAL)
has_financial = capset.has(Cap.FINANCIAL)
self._data_dir = data_dir
self._capset = capset
if has_financial and not self._running:
self.start(data_dir, capset)
elif had_financial and not has_financial and self._running:
self.stop()
async def _run_loop(self) -> None:
"""每周执行一次 metrics 同步。"""
try:
@@ -245,7 +296,7 @@ class FinancialScheduler:
# 每周: 只同步 metrics
try:
rows = sync_metrics(self._data_dir, self._capset)
self._last_sync["metrics"] = datetime.now(timezone.utc).isoformat()
self._record_sync("metrics")
logger.info("FinancialScheduler: metrics synced, %d rows", rows)
except Exception as e:
logger.warning("FinancialScheduler: metrics sync failed: %s", e)
@@ -259,8 +310,39 @@ class FinancialScheduler:
except asyncio.CancelledError:
pass
def _run_body(self, table: str | None) -> dict[str, int]:
"""同步逻辑本体(不加锁,假设调用方已持有 _is_syncing)。
table=None 同步全部 4 张表;否则只同步指定表。
每张表完成立即更新 last_sync,让前端轮询 /status 能看到进度递增。
"""
if table:
fn = {
"metrics": sync_metrics,
"income": sync_income,
"balance_sheet": sync_balance_sheet,
"cash_flow": sync_cash_flow,
}.get(table)
if not fn:
return {}
rows = fn(self._data_dir, self._capset)
self._record_sync(table)
return {table: rows}
# 全部同步
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
self._record_sync(t)
_refresh_financials_views(self._data_dir)
return result
def run_now(self, table: str | None = None) -> dict[str, int]:
"""手动触发同步。table=None 同步全部
"""同步执行一次同步(阻塞调用线程)
⚠ 全量同步需数分钟,务必在后台线程调用,不要直接在 HTTP 请求线程里阻塞,
否则请求会长时间 pending 直至被浏览器/代理超时掐断(表现为"点击无反应")。
HTTP 接口应调用 trigger() 立即返回,再让前端轮询 /status.syncing 看进度。
用 _is_syncing 标志防并发:若已有同步在进行,本次直接跳过,
避免重复请求拖慢服务端 / 触发上游限流。
@@ -273,32 +355,46 @@ class FinancialScheduler:
return {"_skipped": 1}
self._is_syncing = True
try:
if table:
fn = {
"metrics": sync_metrics,
"income": sync_income,
"balance_sheet": sync_balance_sheet,
"cash_flow": sync_cash_flow,
}.get(table)
if not fn:
return {}
rows = fn(self._data_dir, self._capset)
self._last_sync[table] = datetime.now(timezone.utc).isoformat()
return {table: rows}
else:
# 全部同步: 逐表执行, 每张完成立即更新 last_sync,
# 让前端轮询 /status 能看到进度递增 (而非等全部完成才一次性更新)。
symbols = _get_symbols(self._data_dir)
result: dict[str, int] = {}
for t in FINANCIAL_TABLES:
result[t] = _sync_table(t, symbols, self._data_dir, self._capset, latest_only=True)
self._last_sync[t] = datetime.now(timezone.utc).isoformat()
_refresh_financials_views(self._data_dir)
return result
return self._run_body(table)
finally:
with self._lock:
self._is_syncing = False
def trigger(self, table: str | None = None) -> dict[str, int]:
"""触发一次同步(非阻塞,立即返回)。
在后台线程执行同步体,HTTP 请求无需等待。
返回 {"started": True/False}:
- False = 能力不足或已有同步在进行(被防并发跳过)
- True = 已在后台开始,前端应轮询 /status.syncing 观察进度
⚠ _is_syncing 在此处置 True(持锁),确保 trigger 返回时前端轮询
/status 已能看到 syncing=True,无竞态窗口;同时防止快速重复点击
启动多个后台线程。后台线程复用 _run_body 执行真正的同步逻辑。
"""
if not self._capset or not self._capset.has(Cap.FINANCIAL):
return {"started": False, "reason": "no FINANCIAL capability"}
with self._lock:
if self._is_syncing:
logger.info("financial sync trigger skipped: already running")
return {"started": False, "reason": "already running"}
# 持锁置位:保证 trigger 返回前 syncing 已为 True
self._is_syncing = True
def _bg() -> None:
try:
self._run_body(table)
except Exception as e: # noqa: BLE001
logger.exception("background financial sync failed: %s", e)
finally:
with self._lock:
self._is_syncing = False
t = threading.Thread(target=_bg, name="financial-sync", daemon=True)
t.start()
logger.info("financial sync triggered in background: table=%s", table or "all")
return {"started": True}
@property
def is_syncing(self) -> bool:
"""手动同步是否正在进行(供 /status 返回,前端据此显示"同步中")。"""
+245 -31
View File
@@ -1,8 +1,14 @@
"""指数数据同步服务。"""
"""指数 / ETF 数据同步服务。
标的列表优先用免费的 exchanges.get_instruments(type=index/etf) 拉取
(None/Free 档均可用,无需 quote.pool 权限);付费档可额外用
quotes.get_by_universes 作为补充来源。日K统一走 klines.batch。
"""
from __future__ import annotations
import logging
import gc
from collections.abc import Callable
from datetime import datetime, timedelta
import polars as pl
@@ -15,9 +21,15 @@ from app.tickflow.repository import KlineRepository
logger = logging.getLogger(__name__)
# exchanges.get_instruments 查询的交易所(沪深京)
_EXCHANGES = ["SH", "SZ", "BJ"]
def _quotes_to_index_instruments(resp) -> pl.DataFrame:
"""数据源 quotes 响应规范为指数 instruments。"""
""" TickFlow quotes 响应(get_by_universes)规范为指数 instruments。
付费档(Starter+)的补充来源,免费档用不到。
"""
if resp is None:
return pl.DataFrame()
@@ -61,33 +73,119 @@ def _quotes_to_index_instruments(resp) -> pl.DataFrame:
return result.unique(subset=["symbol"], keep="last").sort("symbol")
def sync_index_instruments(repo: KlineRepository) -> int:
"""同步 CN_Index 指数标的维表,返回指数数量。"""
def _fetch_instruments_by_type(instrument_type: str, asset_type_label: str) -> pl.DataFrame:
"""用免费的 exchanges.get_instruments 拉取指定类型的标的列表。
None/Free 档均可使用(标的信息查询免费开放)。
instrument_type: 'index' / 'etf'
asset_type_label: 写入 instruments 表的 asset_type 标记('index' / 'etf')
"""
tf = get_client()
resp = None
errors: list[str] = []
for kwargs in (
{"universes": ["CN_Index"]},
{"universes": ["CN_Index"], "as_dataframe": False},
):
rows: list[dict] = []
for ex in _EXCHANGES:
try:
resp = tf.quotes.get_by_universes(**kwargs)
if resp is not None and len(resp) > 0:
break
items = tf.exchanges.get_instruments(ex, instrument_type=instrument_type)
for it in items or []:
item = it if isinstance(it, dict) else {}
symbol = item.get("symbol")
if not symbol:
continue
rows.append({
"symbol": str(symbol),
"name": item.get("name") or str(symbol),
})
except Exception as e: # noqa: BLE001
errors.append(str(e))
resp = None
logger.warning("get_instruments(%s, type=%s) failed: %s", ex, instrument_type, e)
if resp is None or len(resp) == 0:
logger.warning("CN_Index universe returned empty: %s", "; ".join(errors))
return 0
if not rows:
return pl.DataFrame()
instruments = _quotes_to_index_instruments(resp)
if instruments.is_empty():
return (
pl.DataFrame(rows)
.with_columns([
pl.col("symbol").str.split(".").list.first().alias("code"),
pl.lit(asset_type_label).alias("asset_type"),
])
.unique(subset=["symbol"], keep="last")
.sort("symbol")
)
def sync_index_instruments(
repo: KlineRepository,
pull_index: bool = True,
pull_etf: bool = True,
) -> int:
"""同步指数 / ETF 标的维表,返回标的总数。
新版物理分开保存: 指数写 instruments_index, ETF 写 instruments_etf。
读取层仍兼容旧版 instruments_index 中 asset_type='etf' 的历史数据。
"""
index_parts: list[pl.DataFrame] = []
etf_parts: list[pl.DataFrame] = []
# 1) 免费通道:按开关分别拉 index / etf
if pull_index:
index_df = _fetch_instruments_by_type("index", "index")
if not index_df.is_empty():
index_parts.append(index_df)
if pull_etf:
etf_df = _fetch_instruments_by_type("etf", "etf")
if not etf_df.is_empty():
etf_parts.append(etf_df)
# 2) 付费补充:Starter+ 用 get_by_universes 补指数(仅当开启指数拉取)
if pull_index:
capset = None
try:
from app.tickflow import policy
capset = policy.detect_capabilities(force=False)
except Exception: # noqa: BLE001
pass
if capset is not None and capset.has(Cap.QUOTE_POOL):
tf = get_client()
for kwargs in (
{"universes": ["CN_Index"]},
{"universes": ["CN_Index"], "as_dataframe": False},
):
try:
resp = tf.quotes.get_by_universes(**kwargs)
if resp is not None and len(resp) > 0:
sup = _quotes_to_index_instruments(resp)
if not sup.is_empty():
index_parts.append(sup)
break
except Exception as e: # noqa: BLE001
logger.debug("CN_Index universe supplement failed: %s", e)
total = 0
if index_parts:
index_inst = pl.concat(index_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
if not index_inst.is_empty():
repo.save_index_instruments(index_inst)
total += index_inst.height
if etf_parts:
etf_inst = pl.concat(etf_parts, how="diagonal_relaxed").unique(subset=["symbol"], keep="last").sort("symbol")
if not etf_inst.is_empty():
repo.save_etf_instruments(etf_inst)
total += etf_inst.height
if total == 0:
logger.warning("指数/ETF 标的列表为空(pull_index=%s, pull_etf=%s)", pull_index, pull_etf)
return 0
repo.save_index_instruments(instruments)
repo.refresh_index_views()
return instruments.height
logger.info("指数/ETF 标的同步完成: %d", total)
return total
def sync_etf_instruments(repo: KlineRepository) -> int:
"""单独同步 ETF 标的维表(返回 ETF 数量)。"""
etf_df = _fetch_instruments_by_type("etf", "etf")
if etf_df.is_empty():
return 0
repo.save_etf_instruments(etf_df)
repo.refresh_index_views()
return etf_df.height
def sync_and_persist_index_daily(
@@ -96,19 +194,32 @@ def sync_and_persist_index_daily(
count: int | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步指数日K到独立 parquet并计算指数 enriched。"""
"""同步指数/ETF 日K到独立 parquet,并计算 enriched。
symbols_override 非空时,只拉这些代码(跳过 instruments 表),用于自定义范围。
否则取 index_instruments 表全量(指数+ETF 合并存储)。
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
instruments = repo.get_index_instruments()
if instruments.is_empty():
sync_index_instruments(repo)
if symbols_override:
symbols = sorted(set(s for s in symbols_override if s))
if not symbols:
return 0
else:
instruments = repo.get_index_instruments()
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
if instruments.is_empty():
sync_index_instruments(repo, pull_index=True, pull_etf=False)
instruments = repo.get_index_instruments()
if not instruments.is_empty() and "asset_type" in instruments.columns:
instruments = instruments.filter(pl.col("asset_type") != "etf")
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
batch_size = preferences.get_index_daily_batch_size()
if lim and lim.batch:
@@ -139,7 +250,110 @@ def sync_and_persist_index_daily(
enriched = compute_enriched(raw, factors=None, instruments=None)
repo.append_index_enriched(enriched)
total_rows += raw.height
logger.info("index daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
logger.info("index/etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
return total_rows
def _load_etf_factors(repo: KlineRepository) -> pl.DataFrame:
factor_path = repo.store.data_dir / "adj_factor_etf" / "all.parquet"
if not factor_path.exists():
return pl.DataFrame()
try:
return pl.read_parquet(factor_path)
except Exception as e: # noqa: BLE001
logger.warning("ETF 复权因子读取失败: %s", e)
return pl.DataFrame()
def sync_etf_adj_factor(
symbols: list[str],
repo: KlineRepository,
capset: CapabilitySet,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done=None,
) -> tuple[int, list[str]]:
"""同步 ETF 复权因子;失败由调用方降级为 warning。"""
return kline_sync.sync_adj_factor(
symbols,
repo,
capset,
start_time=start_time,
end_time=end_time,
on_chunk_done=on_chunk_done,
asset_type="etf",
)
def sync_and_persist_etf_daily(
repo: KlineRepository,
capset: CapabilitySet,
count: int | None = None,
start_date: datetime | None = None,
end_date: datetime | None = None,
symbols_override: list[str] | None = None,
on_chunk_done: Callable[[int, int], None] | None = None,
) -> int:
"""同步 ETF 日K到独立 kline_etf_* parquet,并计算 ETF enriched。
on_chunk_done(current, total) 每个批次完成后回调。
"""
if not capset.has(Cap.KLINE_DAILY_BATCH):
return 0
if symbols_override:
symbols = sorted(set(s for s in symbols_override if s))
else:
instruments = repo.get_etf_instruments()
if instruments.is_empty():
sync_etf_instruments(repo)
instruments = repo.get_etf_instruments()
if instruments.is_empty() or "symbol" not in instruments.columns:
return 0
symbols = sorted(set(instruments["symbol"].to_list()))
if not symbols:
return 0
lim = capset.limits(Cap.KLINE_DAILY_BATCH)
batch_size = preferences.get_index_daily_batch_size()
if lim and lim.batch:
batch_size = min(batch_size, lim.batch)
rpm = lim.rpm if lim else None
end_time = end_date or datetime.now()
start_time = start_date or (end_time - timedelta(days=365))
total_rows = 0
interval = (60.0 / rpm) if rpm else 0
chunks = [symbols[i:i + batch_size] for i in range(0, len(symbols), batch_size)]
factors = _load_etf_factors(repo)
for i, chunk in enumerate(chunks):
if i > 0 and interval > 0 and len(chunks) > rpm:
import time
time.sleep(interval)
raw = kline_sync.sync_daily_batch(
chunk,
count=count,
batch_size=None,
start_time=start_time,
end_time=end_time,
)
if raw.is_empty():
continue
repo.append_etf_daily(raw)
batch_factors = factors.filter(pl.col("symbol").is_in(chunk)) if not factors.is_empty() else factors
# ETF 使用复权和通用技术指标;不传 instruments,避免套用 A股涨跌停/连板逻辑。
enriched = compute_enriched(raw, factors=batch_factors, instruments=None)
repo.append_etf_enriched(enriched)
total_rows += raw.height
logger.info("etf daily synced: %d/%d chunks, +%d rows", i + 1, len(chunks), raw.height)
if on_chunk_done:
on_chunk_done(i + 1, len(chunks))
del raw, enriched
gc.collect()
repo.refresh_index_views()
+64 -7
View File
@@ -223,11 +223,53 @@ def sync_daily_by_quotes(repo: KlineRepository) -> int:
return daily_df.height
def _normalize_adj_factor(raw) -> pl.DataFrame:
"""Normalize SDK ex_factors response to symbol/trade_date/ex_factor."""
if raw is None or len(raw) == 0:
return pl.DataFrame()
if isinstance(raw, dict):
rows: list[dict] = []
for sym, values in raw.items():
for item in values or []:
row = dict(item or {})
row.setdefault("symbol", sym)
rows.append(row)
df = pl.DataFrame(rows) if rows else pl.DataFrame()
elif isinstance(raw, pl.DataFrame):
df = raw
else:
df = pl.from_pandas(raw.reset_index() if hasattr(raw, "reset_index") else raw)
if df.is_empty():
return df
# rename: timestamp/date → trade_date, adj_factor → ex_factor
# 注意: 新版 SDK 可能同时返回 timestamp 和 trade_date (或 adj_factor 和 ex_factor),
# 直接 rename 会产生重复列报错。仅当目标列不存在时才 rename。
rename_map: dict[str, str] = {}
for src, dst in (("timestamp", "trade_date"), ("date", "trade_date"), ("adj_factor", "ex_factor")):
if src in df.columns and dst not in df.columns:
rename_map[src] = dst
df = df.rename(rename_map)
if "trade_date" in df.columns:
if df.schema["trade_date"] in {pl.Int64, pl.Int32, pl.UInt64, pl.UInt32, pl.Float64, pl.Float32}:
df = df.with_columns(
pl.from_epoch(pl.col("trade_date").cast(pl.Int64), time_unit="ms").dt.date().alias("trade_date")
)
else:
df = df.with_columns(pl.col("trade_date").cast(pl.Date, strict=False))
if "ex_factor" in df.columns:
df = df.with_columns(pl.col("ex_factor").cast(pl.Float64, strict=False))
cols = [c for c in ["symbol", "trade_date", "ex_factor"] if c in df.columns]
if len(cols) < 3:
return pl.DataFrame()
return df.select(cols).drop_nulls()
def sync_adj_factor(symbols: list[str], repo: KlineRepository,
capset: CapabilitySet,
start_time: datetime | None = None,
end_time: datetime | None = None,
on_chunk_done: Callable[[int, int], None] | None = None) -> tuple[int, list[str]]:
on_chunk_done: Callable[[int, int], None] | None = None,
asset_type: str = "stock") -> tuple[int, list[str]]:
"""同步除权因子(Starter+)。SDK 接口:`tf.klines.ex_factors(symbols=...)`。
支持增量: 传 start_time/end_time 只拉取该时间范围内的新除权事件。
@@ -257,10 +299,9 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
time.sleep(interval)
try:
raw = tf.klines.ex_factors(chunk, **sdk_kwargs)
if raw is not None and len(raw) > 0:
all_dfs.append(pl.from_pandas(
raw.reset_index() if hasattr(raw, "reset_index") else raw
))
normalized = _normalize_adj_factor(raw)
if not normalized.is_empty():
all_dfs.append(normalized)
logger.debug("adj_factor chunk %d/%d: %d symbols", i + 1, len(chunks), len(chunk))
except Exception as e: # noqa: BLE001
logger.warning("adj_factor chunk %d failed: %s", i + 1, e)
@@ -276,7 +317,8 @@ def sync_adj_factor(symbols: list[str], repo: KlineRepository,
# 提取受影响的 symbol 列表(合并前)
affected = new_data["symbol"].unique().to_list()
out = repo.store.data_dir / "adj_factor" / "all.parquet"
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
out = repo.store.data_dir / factor_dir / "all.parquet"
out.parent.mkdir(parents=True, exist_ok=True)
if out.exists():
@@ -420,7 +462,7 @@ def sync_minute_batch(
def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
"""数据源实时拉取单股单日分钟 K(不写入本地)。"""
""" TickFlow 实时拉取单股单日分钟 K(不写入本地)。"""
from datetime import datetime
start_time = datetime(trade_date.year, trade_date.month, trade_date.day, 9, 25, 0)
end_time = datetime(trade_date.year, trade_date.month, trade_date.day, 15, 5, 0)
@@ -445,6 +487,21 @@ def fetch_minute_single(symbol: str, trade_date: date) -> pl.DataFrame:
return pl.DataFrame()
def fetch_adj_factor_single(symbol: str) -> pl.DataFrame:
"""从 TickFlow 实时拉取单股除权因子(不写入本地), 用于单股 K 线即时前复权。
返回结构: symbol, trade_date, ex_factor (空 DataFrame 表示无除权事件或拉取失败)。
与 _apply_adj_factor / compute_enriched 的 factors 参数格式一致。
"""
tf = get_client()
try:
raw = tf.klines.ex_factors([symbol], as_dataframe=True, show_progress=False)
except Exception as e: # noqa: BLE001
logger.warning("fetch_adj_factor_single(%s) failed: %s", symbol, e)
return pl.DataFrame()
return _normalize_adj_factor(raw)
def _latest_minute_datetime(repo: KlineRepository) -> datetime | None:
"""本地分钟 K 数据的最新时间。"""
try:
@@ -0,0 +1,576 @@
"""市场总览数据装配(与 HTTP Request 解耦)。
本模块由 `app.api.overview._build_overview` 抽离而来,目的是让「大盘复盘」
等无 Request 的调用方(定时任务、复盘服务)也能复用同一套聚合逻辑。
行为与原 `_build_overview` 完全一致,仅把对 `request.app.state.{repo,
quote_service,depth_service}` 的依赖改为显式参数。
公共入口:
build_market_overview(repo, quote_service, depth_service, as_of)
"""
from __future__ import annotations
import math
import re
from datetime import date
from typing import Any
import polars as pl
from app.services.ext_data import ExtConfig, ExtConfigStore
from app.services.screener import ScreenerService
# ================================================================
# 常量(与 overview.py 保持同步;复盘复盘仅 A 股核心指数)
# ================================================================
CORE_INDEX_NAMES = {
"000001.SH": "上证指数",
"399001.SZ": "深证成指",
"399006.SZ": "创业板指",
"000680.SH": "科创综指",
}
CORE_INDEX_SYMBOLS = tuple(CORE_INDEX_NAMES.keys())
_DIMENSION_SEP = re.compile(r"[、,;|/\s]+")
# ================================================================
# 通用工具
# ================================================================
def _finite(v: Any) -> float | None:
if v is None:
return None
try:
f = float(v)
except (TypeError, ValueError):
return None
return f if math.isfinite(f) else None
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {k: _json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [_json_safe(v) for v in value]
if isinstance(value, float) and not math.isfinite(value):
return None
return value
def _board(symbol: str) -> str:
if symbol.endswith(".BJ"):
return "北交所"
if symbol.startswith(("300", "301")):
return "创业板"
if symbol.startswith(("688", "689")):
return "科创板"
if symbol.endswith(".SH"):
return "沪主板"
if symbol.endswith(".SZ"):
return "深主板"
return "其他"
def _score(value: float, low: float, high: float) -> int:
if high <= low:
return 50
return max(0, min(100, round((value - low) / (high - low) * 100)))
# ================================================================
# 指数行情(实时 quote_service 优先,回退 kline_index_daily SQL)
# ================================================================
def _quote_status(quote_service) -> dict:
qs = quote_service
if not qs:
return {"enabled": False, "running": False, "quote_age_ms": None, "is_trading_hours": False}
return qs.status()
def _index_quotes(repo, quote_service, as_of: date | None = None) -> list[dict]:
rows: list[dict] = []
if quote_service and as_of is None:
df = quote_service.get_index_quotes(list(CORE_INDEX_SYMBOLS))
if not df.is_empty():
rows = df.to_dicts()
if not rows and repo:
placeholders = ", ".join("?" for _ in CORE_INDEX_SYMBOLS)
try:
db_rows = repo.execute_all(
f"""
WITH ranked AS (
SELECT symbol, date, close,
row_number() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
FROM kline_index_daily
WHERE symbol IN ({placeholders})
AND (? IS NULL OR date <= ?)
), latest AS (
SELECT symbol,
max(CASE WHEN rn = 1 THEN date END) AS date,
max(CASE WHEN rn = 1 THEN close END) AS last_price,
max(CASE WHEN rn = 2 THEN close END) AS prev_close
FROM ranked
WHERE rn <= 2
GROUP BY symbol
)
SELECT symbol, date, last_price, prev_close
FROM latest
""",
[*CORE_INDEX_SYMBOLS, as_of, as_of],
)
except Exception: # noqa: BLE001
db_rows = []
for symbol, dt, last_price, prev_close in db_rows:
change_amount = None
change_pct = None
lp = _finite(last_price)
pc = _finite(prev_close)
if lp is not None and pc not in (None, 0):
change_amount = lp - pc
change_pct = change_amount / pc * 100
rows.append({
"symbol": symbol,
"name": CORE_INDEX_NAMES.get(symbol),
"date": str(dt) if dt else None,
"last_price": lp,
"close": lp,
"prev_close": pc,
"change_amount": change_amount,
"change_pct": change_pct,
})
by_symbol = {r.get("symbol"): r for r in rows}
out = []
for symbol in CORE_INDEX_SYMBOLS:
r = by_symbol.get(symbol, {"symbol": symbol})
out.append({
"symbol": symbol,
"name": r.get("name") or CORE_INDEX_NAMES[symbol],
"last_price": _finite(r.get("last_price") if r.get("last_price") is not None else r.get("close")),
"change_pct": _finite(r.get("change_pct")),
"change_amount": _finite(r.get("change_amount")),
})
return out
# ================================================================
# 扩展数据(行业 / 概念)维度聚合
# ================================================================
def _dimension_field(config: ExtConfig, kind: str) -> str | None:
candidates = ["概念", "concept", "theme"] if kind == "concept" else ["行业", "industry", "sector"]
for candidate in candidates:
needle = candidate.lower()
for field in config.fields:
haystack = f"{field.name} {field.label}".lower()
if needle in haystack:
return field.name
return None
def _ext_files(data_dir, config: ExtConfig) -> list[str]:
base = data_dir / "ext_data" / config.id
if config.mode == "timeseries":
root = base / "timeseries"
return [str(p) for p in sorted(root.rglob("*.parquet")) if p.is_file()]
return [str(p) for p in sorted(base.glob("*.parquet")) if p.is_file()]
def _read_ext_rows(data_dir, config: ExtConfig, dimension_field: str) -> list[dict]:
files = _ext_files(data_dir, config)
if not files:
return []
try:
df = pl.read_parquet(files, hive_partitioning=True)
except TypeError:
try:
df = pl.read_parquet(files)
except Exception: # noqa: BLE001
return []
except Exception: # noqa: BLE001
return []
if df.is_empty() or dimension_field not in df.columns:
return []
if config.mode == "timeseries" and "date" in df.columns:
latest = df.get_column("date").max()
if latest is not None:
df = df.filter(pl.col("date") == latest)
symbol_cols = ["symbol", "code", "股票代码", "代码"]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
symbol_cols.append(str(mapping["col"]))
cols = []
for col in [dimension_field, *symbol_cols]:
if col in df.columns and col not in cols:
cols.append(col)
return df.select(cols).to_dicts()
def _dimension_values(raw: Any) -> list[str]:
if raw is None:
return []
values = [v.strip() for v in _DIMENSION_SEP.split(str(raw).strip()) if v.strip()]
return values
def _symbol_keys(row: dict, config: ExtConfig) -> list[str]:
fields = ["symbol", "code", "股票代码", "代码"]
for mapping in (config.symbol_map, config.code_map):
if isinstance(mapping, dict) and mapping.get("type") == "mapped" and mapping.get("col"):
fields.append(str(mapping["col"]))
keys: list[str] = []
for field in fields:
raw = row.get(field)
if raw is None:
continue
text = str(raw).strip().upper()
if not text:
continue
keys.append(text)
if "." in text:
keys.append(text.split(".", 1)[0])
return keys
def _dimension_rank(rows: list[dict], repo, kind: str, limit: int = 5, level: int | None = None) -> dict:
if not rows:
return {"leading": [], "lagging": []}
quote_map: dict[str, dict] = {}
for row in rows:
symbol = str(row.get("symbol") or "").strip().upper()
if not symbol:
continue
quote_map[symbol] = row
quote_map[symbol.split(".", 1)[0]] = row
store = ExtConfigStore(repo.store.data_dir)
groups: dict[str, dict[str, dict]] = {}
for config in store.load_all():
field = _dimension_field(config, kind)
if not field:
continue
for ext_row in _read_ext_rows(repo.store.data_dir, config, field):
quote = None
for key in _symbol_keys(ext_row, config):
quote = quote_map.get(key)
if quote:
break
if not quote:
continue
symbol = str(quote.get("symbol") or "")
for value in _dimension_values(ext_row.get(field)):
# 行业按 "-" 拆分级: "银行-银行-股份制银行" → level=2 取"银行"(二级)
if level is not None and "-" in value:
parts = value.split("-")
value = parts[level - 1] if level <= len(parts) else parts[-1]
groups.setdefault(value, {})[symbol] = quote
items = []
for name, by_symbol in groups.items():
stocks = list(by_symbol.values())
changes = [_finite(s.get("change_pct")) for s in stocks]
changes = [v for v in changes if v is not None]
if not changes:
continue
leader = max(stocks, key=lambda s: _finite(s.get("change_pct")) or -999)
items.append({
"name": name,
"count": len(stocks),
"avg_pct": sum(changes) / len(changes),
"up_count": sum(1 for v in changes if v > 0),
"down_count": sum(1 for v in changes if v < 0),
"amount": sum(_finite(s.get("amount")) or 0 for s in stocks),
"leader": {
"symbol": leader.get("symbol"),
"name": leader.get("name"),
"change_pct": _finite(leader.get("change_pct")),
},
})
leading = sorted(items, key=lambda x: x["avg_pct"], reverse=True)[:limit]
lagging = sorted(items, key=lambda x: x["avg_pct"])[:limit]
return {"leading": leading, "lagging": lagging}
# ================================================================
# Top 行 / 涨跌幅分桶
# ================================================================
def _top_rows(rows: list[dict], key: str, descending: bool, limit: int = 8) -> list[dict]:
filtered = [r for r in rows if _finite(r.get(key)) is not None]
filtered.sort(key=lambda r: _finite(r.get(key)) or 0, reverse=descending)
return [
{
"symbol": r.get("symbol"),
"name": r.get("name"),
"close": _finite(r.get("close")),
"change_pct": _finite(r.get("change_pct")),
"amount": _finite(r.get("amount")),
"turnover_rate": _finite(r.get("turnover_rate")),
"board": _board(str(r.get("symbol") or "")),
}
for r in filtered[:limit]
]
def _pct_band_rows(values: list[float]) -> list[dict]:
bands = [
("<-5%", None, -0.05),
("-5~-3%", -0.05, -0.03),
("-3~-1%", -0.03, -0.01),
("-1~0%", -0.01, 0),
("0~1%", 0, 0.01),
("1~3%", 0.01, 0.03),
("3~5%", 0.03, 0.05),
(">5%", 0.05, None),
]
total = len(values) or 1
out = []
for label, low, high in bands:
count = 0
for v in values:
if low is None and v < high:
count += 1
elif high is None and v >= low:
count += 1
elif low is not None and high is not None and low <= v < high:
count += 1
out.append({"label": label, "count": count, "pct": count / total * 100})
return out
# ================================================================
# 主装配入口
# ================================================================
def build_market_overview(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
) -> dict:
"""装配市场总览(与原 overview._build_overview 行为一致)。
Args:
repo: KlineRepository(必填)。
quote_service: QuoteService(可选;实时指数行情来源)。
depth_service: DepthService(可选;五档封板修正)。
as_of: 指定日期,None 则取最新有数据日。
"""
svc = ScreenerService(repo)
as_of = as_of or svc.latest_date()
status = _quote_status(quote_service)
indices = _index_quotes(repo, quote_service, as_of)
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 能力)
sealed_ready = False
fake_up = 0
fake_down = 0
if depth_service:
up_map = depth_service.get_sealed_map(as_of, is_down=False)
down_map = depth_service.get_sealed_map(as_of, is_down=True)
sealed_ready = bool(up_map or down_map) and depth_service.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, repo, "concept")
industry_rank = _dimension_rank(rows, repo, "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_pct,
"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,
})
+343
View File
@@ -0,0 +1,343 @@
"""AI 大盘复盘 —— 流式 LLM 复盘生成。
复刻 stock_analyzer.py 的 NDJSON 流式协议(meta/delta/error/done),
将「市场总览」聚合数据交给 LLM 生成结构化复盘报告。
数据来源:services.market_overview_builder.build_market_overview
(与 GET /api/overview/market 同源,保证复盘与看板数据口径一致)。
流式协议(与 stock_analyzer / financial_analyzer 一致,前端解析无差异):
{"type":"meta", "as_of", "emotion_score", "emotion_label", "summary"}
{"type":"delta","content":"..."} 逐 chunk 文本
{"type":"error","message":"..."}
{"type":"done"}
"""
from __future__ import annotations
import json
import logging
from datetime import date
from typing import AsyncIterator
from app.services.market_overview_builder import build_market_overview
logger = logging.getLogger(__name__)
# 指数简称映射:摘要里用简称(上/深/创/科),全称太长列表放不下。与前端 INDEX_SHORT 对齐。
_INDEX_SHORT = {
"上证指数": "",
"深证成指": "",
"创业板指": "",
"科创综指": "",
"科创50": "",
}
# ================================================================
# 系统提示词(市场策略师人格 + 固定七节模板)
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深市场策略师,擅长从指数结构、涨跌家数、连板梯队、板块轮动与资金情绪中提炼交易主线,产出可直接指导次日仓位与节奏的盘后复盘报告。
## 输出规范
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
### 1. 🎯 一句话定调(1-2 句)
用一句话概括今日市场的**核心矛盾与状态**(如"放量普涨、情绪修复,主线围绕科技扩散"/"指数虚高、个股杀跌,赚钱效应冰点")。结尾用【明日基调:进攻 / 均衡 / 防守】给出明确倾向。
### 2. 📊 盘面总览
- 三大指数(上证/深证/创业板)表现:谁强谁弱、量能配合
- 涨跌家数、涨停/跌停/炸板结构、两市成交额(放量/缩量判断)
- 情绪温度(强势/偏暖/震荡/偏冷/冰点)及一句话依据
### 3. 📈 指数结构
谁在护盘、谁在拖累;指数是否同步;关键支撑/压力位(基于当日点位推断);是否存在量价背离。
### 4. 🔥 板块主线
- 领涨板块:背后的逻辑(消息/业绩/资金/技术)、持续性判断、是否形成可交易主线
- 领跌板块:风险信号、是否扩散
- 连板梯队与投机情绪:最高连板、封板率、炸板率反映的资金激进程度
### 5. 💰 资金与情绪
成交额结构(增量/存量)、市场宽度(上涨占比、站上均线占比)、量能指标(量比)解读;风险偏好是修复还是转弱。
### 6. 📰 消息催化
结合提供的近期新闻,提炼真正影响明日交易节奏的催化或扰动,明确区分"已兑现""待发酵"。**若无新闻数据,则直接从量价异动推断可能的催化逻辑并给出结论,不要标注"[推断]"之类的过程标签,更不要编造具体消息。**
### 7. 🎯 明日交易计划
- 进攻 / 均衡 / 防守:基于今日盘面给出次日基调
- 仓位区间建议(轻仓/半仓/重仓的粗略指引)
- 关注方向(领涨延续 / 低吸 / 反包)与回避方向(高位滞涨 / 杀跌扩散)
- 一个明确的触发失效条件(如"若上证跌破 X 点则转为防守")
### 8. ⚠️ 风险提示
列出需要重点盯的风险点(如量能跟不上、外资流出、连板断层等)。末尾附一行:
"> ⚠️ 本报告由 AI 基于公开行情数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
## 分析准则(务必遵守)
0. **只输出结论,不输出思考过程**:禁止复述你的分析步骤或方法论。不要写"我先按...做结构化复盘""接下来看...""基于上述数据我认为"这类元话语——直接给结论。读者要的是复盘结果,不是你怎么推导出来的。
1. **数据说话**:每个判断引用具体数值,严禁空泛套话("情绪回暖"必须改成"涨停 68 家较前日 +22,封板率 75%")
2. **诚实中立**:看多就写多,看空就写空,不要骑墙;数据不支持时直言无法判断
3. **结构优先**:先看指数同步性与量能结构,再看板块与情绪,最后才是消息
4. **不重复数字**:正文负责解读表格数据背后的含义,不要照抄罗列已提供的大段原始数字
5. **风险前置**:任何进攻建议都要配触发失效条件
6. **简明实战**:用交易员能扫读的密度输出,总字数 1200-2000 字,重在可执行
现在请基于下方数据进行复盘。"""
# ================================================================
# 用户消息构建(精简切片,控制 token)
# ================================================================
def _fmt_pct(v, suffix="%") -> str:
if v is None:
return ""
return f"{v:+.2f}{suffix}" if suffix else f"{v:.2f}"
def _build_indices_block(overview: dict) -> str:
"""指数行情精简块。"""
indices = overview.get("indices") or []
if not indices:
return "(暂无指数)"
lines = []
for idx in indices:
name = idx.get("name") or idx.get("symbol")
price = idx.get("last_price")
chg = idx.get("change_pct")
price_s = f"{price:.2f}" if price is not None else ""
lines.append(f"- {name}: {price_s} {_fmt_pct(chg)}")
return "\n".join(lines)
def _build_breadth_block(overview: dict) -> str:
b = overview.get("breadth") or {}
amt = overview.get("amount") or {}
lim = overview.get("limit") or {}
tr = overview.get("trend") or {}
act = overview.get("activity") or {}
total_amount = amt.get("total") or 0
# 成交额单位换算为亿元(原始为元)
amount_yi = total_amount / 1e8 if total_amount else 0
lines = [
f"- 上涨/下跌/平盘: {b.get('up',0)} / {b.get('down',0)} / {b.get('flat',0)}"
f" (上涨占比 {b.get('up_pct',0):.1f}%)",
f"- 涨停/炸板/跌停: {lim.get('limit_up',0)} / {lim.get('broken',0)} / {lim.get('limit_down',0)}"
f" (封板率 {lim.get('seal_rate',0):.0f}%, 最高连板 {lim.get('max_boards',0)})",
]
if lim.get("tiers"):
tiers_str = "".join(f"{t['boards']}板×{t['count']}" for t in lim["tiers"][:5])
lines.append(f"- 连板梯队: {tiers_str}")
lines.append(f"- 两市成交额: {amount_yi:.0f} 亿元")
lines.append(
f"- 均线站位: MA5 {tr.get('above_ma5_pct',0):.0f}% / "
f"MA20 {tr.get('above_ma20_pct',0):.0f}% / MA60 {tr.get('above_ma60_pct',0):.0f}%"
)
lines.append(
f"- 量能: 平均换手 {act.get('avg_turnover',0):.2f}%, "
f"量比5日均 {act.get('vol_ratio',1):.2f}"
)
return "\n".join(lines)
def _build_sector_block(rank: dict, label: str) -> str:
"""板块排名精简块(领涨/领跌 top5)。"""
if not rank:
return f"### {label}\n(暂无数据)"
def _fmt(items):
if not items:
return ""
return "".join(
f"{it.get('name')}({(it.get('avg_pct') or 0)*100:+.2f}%,领涨:{it.get('leader',{}).get('name','')})"
for it in items[:5]
)
return (
f"- 领涨{label}: {_fmt(rank.get('leading'))}\n"
f"- 领跌{label}: {_fmt(rank.get('lagging'))}"
)
def _build_emotion_block(overview: dict) -> str:
emo = overview.get("emotion") or {}
radar = overview.get("radar") or []
score = emo.get("score", 50)
label = emo.get("label", "")
lines = [f"- 情绪温度: {score} ({label})"]
if radar:
dims = "".join(f"{r.get('label')}{r.get('value',0)}" for r in radar)
lines.append(f"- 六维雷达: {dims}")
return "\n".join(lines)
def _build_user_prompt(overview: dict, news: list[dict], focus: str) -> str:
"""构建用户消息:复盘日期 + 市场数据精简切片 + 新闻 + 关注点。"""
as_of = overview.get("as_of") or "今日"
parts: list[str] = [
f"复盘日期: {as_of}",
"",
"## 主要指数",
_build_indices_block(overview),
"",
"## 盘面数据",
_build_breadth_block(overview),
"",
"## 市场情绪",
_build_emotion_block(overview),
"",
"## 概念板块排名",
_build_sector_block(overview.get("concept_rank"), "概念"),
"",
"## 行业板块排名",
_build_sector_block(overview.get("industry_rank"), "行业"),
]
if news:
news_lines = []
for i, n in enumerate(news[:8], 1):
title = (n.get("title") or "").strip()
snippet = (n.get("snippet") or "").strip()
source = (n.get("source") or "").strip()
pub = (n.get("published_date") or "").strip()
meta = " / ".join(p for p in (source, pub) if p)
news_lines.append(f"{i}. {title} ({meta})\n {snippet}" if meta else f"{i}. {title}\n {snippet}")
parts.extend(["", "## 近期市场新闻", "\n".join(news_lines)])
else:
parts.extend([
"",
"## 近期市场新闻",
"(暂无新闻数据:本功能新闻检索能力将在后续版本接入。"
"消息催化一节请直接从量价异动给出可能的催化逻辑结论,不要编造具体消息,也不要复述本说明。)",
])
if focus.strip():
parts.extend(["", f"本次复盘请特别关注: {focus.strip()}"])
return "\n".join(parts)
# ================================================================
# 摘要生成(供 meta 事件 / 历史报告 summary)
# ================================================================
def _recap_summary(overview: dict) -> str:
"""一句话摘要(供 meta 事件与历史列表展示)。
指数用简称(上/深/创/科),与前端摘要条一致,避免列表里全称放不下。
"""
indices = overview.get("indices") or []
emo = overview.get("emotion") or {}
lim = overview.get("limit") or {}
amt = overview.get("amount") or {}
total_amount = (amt.get("total") or 0) / 1e8
idx_str = "".join(
f"{_INDEX_SHORT.get(i.get('name') or '', i.get('name') or '')}{(i.get('change_pct') or 0):+.2f}%"
for i in indices[:4]
) or "指数缺失"
return (
f"{idx_str} | 情绪{emo.get('score',50)}({emo.get('label','')}) | "
f"涨停{lim.get('limit_up',0)} | 成交{total_amount:.0f}亿"
)
# ================================================================
# 流式主入口
# ================================================================
async def recap_market_stream(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
) -> AsyncIterator[str]:
"""流式大盘复盘:yield 出每个 NDJSON 事件。
Args:
repo: KlineRepository(必填)。
quote_service / depth_service: 可选,数据装配依赖。
as_of: 复盘日期,None 取最新有数据日。
focus: 用户追加的复盘关注点。
news: 预检索的新闻列表(P1 不传,留 None 走降级说明;P3 由 news_search 注入)。
"""
# 1. 装配市场总览
overview = build_market_overview(repo, quote_service, depth_service, as_of)
as_of_str = overview.get("as_of")
if not as_of_str:
yield json.dumps({
"type": "error",
"message": "暂无市场数据,请先在「数据」页同步日 K 与指数后再复盘",
}, ensure_ascii=False)
return
emo = overview.get("emotion") or {}
# 2. meta 事件(前端据此先渲染信号灯/看板)
yield json.dumps({
"type": "meta",
"as_of": as_of_str,
"emotion_score": emo.get("score", 50),
"emotion_label": emo.get("label", ""),
"summary": _recap_summary(overview),
}, ensure_ascii=False)
# 3+4. 构建 prompt + 流式调用 LLM(整体 try-except,任何异常 yield error,避免前端卡死)
try:
from app.services.ai_provider import stream_ai_text
user_prompt = _build_user_prompt(overview, news or [], focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI market recap failed for %s: %s", as_of_str, e)
yield json.dumps({"type": "error", "message": f"AI 复盘失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
async def recap_market_once(
repo,
quote_service=None,
depth_service=None,
as_of: date | None = None,
focus: str = "",
news: list[dict] | None = None,
) -> tuple[str | None, dict]:
"""非流式版本(供定时任务调用):累积全部 delta,返回 (content, meta)。
content 为完整 Markdown 文本;失败时为 None。
meta 含 as_of / emotion_score / emotion_label / summary(即使失败也尽量回填)。
"""
content_parts: list[str] = []
meta: dict = {"as_of": as_of.isoformat() if as_of else None}
async for evt in recap_market_stream(repo, quote_service, depth_service, as_of, focus, news):
try:
obj = json.loads(evt)
except Exception: # noqa: BLE001
continue
t = obj.get("type")
if t == "meta":
meta = obj
elif t == "delta":
content_parts.append(obj.get("content", ""))
elif t == "error":
logger.warning("market recap error event: %s", obj.get("message"))
return None, meta
return "".join(content_parts), meta
@@ -0,0 +1,92 @@
"""AI 大盘复盘报告持久化存储。
与 stock_reports.py(个股分析报告)/ ai_reports.py(财务分析报告)完全独立 ——
单独的文件、字段、上限,互不影响。刻意不复用,避免引入 kind 判别字段与分支
(解耦 > 抽象)。
存储位置: data/user_data/ai_market_recaps.json (数组,按 created_at 降序)
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "mkr_xxx", # 唯一 id(market-recap-report)
"as_of": "2026-06-27", # 复盘日期
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "三大指数齐涨...", # 一句话摘要
"emotion_score": 68, # 情绪分(0-100, 复盘生成时的市场情绪雷达均分)
"emotion_label": "偏暖", # 情绪标签(强势/偏暖/震荡/偏冷/冰点)
"created_at": "2026-06-27T15:35:00"
}
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 20
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_market_recaps.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
except Exception as e: # noqa: BLE001
logger.warning("ai_market_recaps.json malformed: %s", e)
return []
def _save_all(reports: list[dict]) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports()
if not report.get("id"):
report["id"] = f"mkr_{int(time.time() * 1000)}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("Market recap saved: %s (as_of=%s), total %d",
report.get("id"), report.get("as_of"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
return True
return False
def _now_iso() -> str:
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+30 -6
View File
@@ -1,12 +1,12 @@
"""系统通知适配器 — 调用操作系统原生通知命令
"""系统通知适配器 — 三平台原生通知中心
职责: 把后端产生的告警事件推送到操作系统通知中心。
窗口最小化 / 被遮挡 / 后台运行时都能弹通知。
窗口最小化 / 被遮挡 / 后台运行时都能弹通知 (不依赖前端 WebView)
平台实现:
- macOS: osascript (系统已内置)
- Linux: notify-send (系统已内置)
- Windows: 暂不支持原生通知中心 (无额外依赖实现)
- Windows: winotify (进现代操作中心, 支持图标)
- macOS: osascript (系统已内置, 无需额外依赖)
- Linux: notify-send (系统已内置) / plyer 兜底
设计: 失败静默降级, 绝不因通知失败阻断告警主流程 (落盘 / SSE 推送)。
通知去重不在本层做, 复用 MonitorRuleEngine 的 cooldown 逻辑。
@@ -34,7 +34,15 @@ def _detect_backend() -> str | None:
return _backend_cache if _backend_cache != "none" else None
backend = None
if sys.platform == "darwin":
if sys.platform == "win32":
try:
import winotify # type: ignore[import-not-found] # noqa: F401
backend = "winotify"
except ImportError:
logger.debug("winotify 不可用, Windows 通知降级")
backend = None
elif sys.platform == "darwin":
backend = "osascript"
elif sys.platform.startswith("linux"):
backend = "notify-send"
@@ -71,6 +79,8 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
return False
try:
if backend == "winotify":
return _notify_winotify(title, message)
if backend == "osascript":
return _notify_osascript(title, message)
if backend == "notify-send":
@@ -82,6 +92,20 @@ def notify(title: str, message: str, icon: Path | None = None) -> bool:
return False
def _notify_winotify(title: str, message: str) -> bool:
"""Windows 通知 (winotify) — 进现代操作中心。"""
from winotify import Notifier # type: ignore[import-not-found]
Notifier().create_notification(
title=title,
msg=message,
# winotify 要求 duration 为 "short" 或 "long"
duration="short",
# 无可点击动作 (桌面版不实现"点击回到窗口"的复杂交互)
).show()
return True
def _notify_osascript(title: str, message: str) -> bool:
"""macOS 通知 (osascript) — 调用系统 AppleScript。"""
# 转义双引号, 避免 AppleScript 注入
+272
View File
@@ -53,6 +53,29 @@ def get_realtime_quote_interval() -> float:
return load().get("realtime_quote_interval", 10.0)
def get_realtime_watchlist_symbols() -> list[str]:
"""Free 档自选实时监控标的:直接取自选页前 5 个。"""
try:
from app.services import watchlist
rows = watchlist.list_symbols()
except Exception as e: # noqa: BLE001
logger.warning("load watchlist for realtime failed: %s", e)
return []
out: list[str] = []
for row in rows:
symbol = str((row or {}).get("symbol") or "").strip().upper()
if symbol and symbol not in out:
out.append(symbol)
if len(out) >= 5:
break
return out
def set_realtime_watchlist_symbols(symbols: list[str]) -> list[str]: # noqa: ARG001
"""兼容旧接口: Free 实时标的现在由自选页前 5 个决定。"""
return get_realtime_watchlist_symbols()
def set_realtime_quote_interval(interval: float) -> float:
"""保存行情轮询间隔(不在此做 min/max 校验,由调用方按档位限制)。"""
current = load()
@@ -71,6 +94,83 @@ def get_minute_sync_days() -> int:
return max(1, min(30, load().get("minute_sync_days", 5)))
# ===== 数据源选择 (默认 TickFlow;第一阶段仅日K切换入口) =====
_ALLOWED_DATA_PROVIDERS = {"tickflow"}
def get_daily_data_provider() -> str:
provider = str(load().get("daily_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_adj_factor_provider() -> str:
provider = str(load().get("adj_factor_provider", "same_as_daily") or "same_as_daily").lower()
if provider == "same_as_daily":
return provider
return provider if provider in _ALLOWED_DATA_PROVIDERS else "same_as_daily"
def get_minute_data_provider() -> str:
provider = str(load().get("minute_data_provider", "tickflow") or "tickflow").lower()
return provider if provider in _ALLOWED_DATA_PROVIDERS else "tickflow"
def get_realtime_data_provider() -> str:
# 盘中实时现阶段仅支持 TickFlow。
return "tickflow"
# ===== 盘后管道拉取内容开关 (A股 / ETF / 指数 独立控制) =====
def get_pipeline_pull_a_share() -> bool:
"""A 股日K固定拉取。"""
return True
def get_pipeline_pull_etf() -> bool:
"""是否拉取 ETF 日K。默认 False(标的多,首次较慢)。"""
return load().get("pipeline_pull_etf", False)
def get_pipeline_pull_index() -> bool:
"""是否拉取指数日K。默认 True。"""
return load().get("pipeline_pull_index", True)
_PIPELINE_PULL_KEYS = ("pipeline_pull_etf", "pipeline_pull_index")
def get_pipeline_pull_types() -> dict:
"""返回三个拉取开关的当前值。"""
return {
"pipeline_pull_a_share": get_pipeline_pull_a_share(),
"pipeline_pull_etf": get_pipeline_pull_etf(),
"pipeline_pull_index": get_pipeline_pull_index(),
}
def set_pipeline_pull_types(cfg: dict) -> dict:
"""批量保存拉取开关。只接受白名单内的布尔字段。"""
updates = {
k: bool(v) for k, v in cfg.items()
if k in _PIPELINE_PULL_KEYS and v is not None
}
save(updates)
return get_pipeline_pull_types()
def get_pipeline_index_symbols() -> str:
"""指数自定义拉取代码(逗号/换行/空格分隔)。空串表示全量。"""
return str(load().get("pipeline_index_symbols", "") or "").strip()
def set_pipeline_index_symbols(symbols: str) -> str:
"""保存指数自定义代码,返回规范化后的字符串。"""
save({"pipeline_index_symbols": symbols})
return get_pipeline_index_symbols()
def get_pipeline_schedule() -> dict:
"""返回盘后管道调度时间 {"hour": 15, "minute": 30}。"""
d = load().get("pipeline_schedule", {"hour": 15, "minute": 30})
@@ -165,6 +265,73 @@ def set_depth_finalize_time(hour: int, minute: int) -> dict:
return {"hour": h, "minute": m}
# 复盘推送可选渠道白名单 (微信等暂未实现, 不在白名单内, 前端仅作占位)
# 多选: 不推送 = 空数组, 而非 'none'
REVIEW_PUSH_CHANNELS = {"feishu"}
def get_review_schedule() -> dict:
"""定时复盘调度 {"enabled": False, "hour": 15, "minute": 10}。默认关闭。
A股 15:00 收盘, 默认时间设为 15:10(收盘后即时复盘), 强制下限 15:00。
"""
d = load().get("review_schedule", {"enabled": False, "hour": 15, "minute": 10})
return {
"enabled": bool(d.get("enabled", False)),
"hour": d.get("hour", 15),
"minute": d.get("minute", 10),
}
def set_review_schedule(enabled: bool, hour: int, minute: int) -> dict:
"""保存定时复盘调度。强制时间下限 15:00(A股收盘)。
enabled=False 时时间仍保存(下次开启可沿用), 但调度器不会注册 job。
"""
h = max(0, min(23, hour))
m = max(0, min(59, minute))
# 下限 15:00: A股 15:00 收盘, 收盘后才有当日完整数据复盘
if h * 60 + m < 15 * 60:
h, m = 15, 0
save({"review_schedule": {"enabled": bool(enabled), "hour": h, "minute": m}})
return {"enabled": bool(enabled), "hour": h, "minute": m}
def get_review_push_channels() -> list[str]:
"""复盘推送渠道(多选) — 选定的外部工具列表, 复盘归档后逐个推送。
与 review_schedule / 实时行情完全独立, 常驻可单独设置。
空列表 = 不推送; ['feishu'] = 推送到飞书(复用监控中心全局 feishu_webhook_url/secret)。
向后兼容:
- 老多版本单选 review_push_channel=='feishu' → ['feishu']
- 更老布尔 review_push_enabled==True → ['feishu']
"""
d = load()
raw = d.get("review_push_channels")
if isinstance(raw, list):
return [c for c in raw if c in REVIEW_PUSH_CHANNELS]
# 兼容老单选字符串
if d.get("review_push_channel") == "feishu":
return ["feishu"]
# 兼容更老布尔开关
if d.get("review_push_enabled") is True:
return ["feishu"]
return []
def set_review_push_channels(channels: list[str]) -> list[str]:
"""保存复盘推送渠道(多选)。过滤白名单外的值、去重、保序。空列表 = 不推送。"""
seen: set[str] = set()
cleaned: list[str] = []
for c in channels or []:
if c in REVIEW_PUSH_CHANNELS and c not in seen:
seen.add(c)
cleaned.append(c)
save({"review_push_channels": cleaned})
return cleaned
# ===== 实时监控 =====
@@ -178,6 +345,59 @@ SSE_REFRESH_PAGES_DEFAULT = {
SIDEBAR_INDEX_SYMBOLS_DEFAULT = ["000001.SH", "399001.SZ", "399006.SZ", "000680.SH"]
# ===== 盘中实时行情范围 (独立于盘后管道范围) =====
def get_realtime_pull_stock() -> bool:
return load().get("realtime_pull_stock", True)
def get_realtime_pull_etf() -> bool:
# 老用户兼容: ETF 实时默认关闭,避免升级后请求量/写盘量突然增加。
return load().get("realtime_pull_etf", False)
def get_realtime_pull_index() -> bool:
return load().get("realtime_pull_index", True)
def get_realtime_index_mode() -> str:
mode = str(load().get("realtime_index_mode", "core") or "core").lower()
return mode if mode in {"core", "all"} else "core"
def get_realtime_index_symbols() -> list[str]:
stored = load().get("realtime_index_symbols", SIDEBAR_INDEX_SYMBOLS_DEFAULT)
if isinstance(stored, str):
import re
stored = [s.strip() for s in re.split(r"[,\s]+", stored) if s.strip()]
return [str(s) for s in stored if str(s).strip()]
def set_realtime_quote_scope(cfg: dict) -> dict:
updates = {}
for key in ("realtime_pull_stock", "realtime_pull_etf", "realtime_pull_index"):
if key in cfg and cfg[key] is not None:
updates[key] = bool(cfg[key])
if "realtime_index_mode" in cfg and cfg["realtime_index_mode"] in {"core", "all"}:
updates["realtime_index_mode"] = cfg["realtime_index_mode"]
if "realtime_index_symbols" in cfg and cfg["realtime_index_symbols"] is not None:
updates["realtime_index_symbols"] = cfg["realtime_index_symbols"]
if updates:
save(updates)
return get_realtime_quote_scope()
def get_realtime_quote_scope() -> dict:
return {
"realtime_pull_stock": get_realtime_pull_stock(),
"realtime_pull_etf": get_realtime_pull_etf(),
"realtime_pull_index": get_realtime_pull_index(),
"realtime_index_mode": get_realtime_index_mode(),
"realtime_index_symbols": get_realtime_index_symbols(),
}
def get_sse_refresh_pages() -> dict[str, bool]:
"""返回每个页面的 SSE 刷新开关。"""
stored = load().get("sse_refresh_pages", {})
@@ -216,6 +436,43 @@ def set_system_notify_enabled(enabled: bool) -> bool:
return bool(enabled)
def get_feishu_webhook_url() -> str:
"""飞书自定义机器人 Webhook 地址 — 全局共用一处, 所有启用推送的规则都推到这一个群。"""
return load().get("feishu_webhook_url", "")
def get_feishu_webhook_secret() -> str:
"""飞书自定义机器人签名密钥 — 机器人启用「签名校验」时必填, 留空表示不验签。"""
return load().get("feishu_webhook_secret", "")
def set_feishu_webhook_url(url: str) -> str:
"""保存飞书 Webhook 地址。传入空串表示清空配置。"""
save({"feishu_webhook_url": str(url or "").strip()})
return get_feishu_webhook_url()
def set_feishu_webhook_secret(secret: str) -> str:
"""保存飞书签名密钥。传入空串表示不验签。"""
save({"feishu_webhook_secret": str(secret or "").strip()})
return get_feishu_webhook_secret()
def get_webhook_enabled_default() -> bool:
"""新建监控规则时是否默认勾选「飞书推送」。
数据模型当前只有一个 webhook_enabled 布尔 (即飞书), QMT/ptrade 待定。
此默认值供规则编辑器新建规则时预填, 单条规则仍可独立修改。
"""
return load().get("webhook_enabled_default", False)
def set_webhook_enabled_default(enabled: bool) -> bool:
"""保存飞书推送默认勾选态。"""
save({"webhook_enabled_default": bool(enabled)})
return get_webhook_enabled_default()
def get_screener_auto_run() -> bool:
"""选股页进入时是否自动运行所有策略 (获取命中数)。默认开。"""
return load().get("screener_auto_run", True)
@@ -311,3 +568,18 @@ def set_onboarding_completed(done: bool = True) -> bool:
"""标记首次使用向导完成状态。"""
save({"onboarding_completed": bool(done)})
return bool(done)
# ===== 财务数据同步时间(持久化,重启不丢失) =====
# 结构: { "metrics": "2026-06-25T10:00:00+08:00", "income": ..., ... }
def get_financial_sync_times() -> dict[str, str]:
"""返回各财务表的最后同步时间(ISO 字符串)。未同步过的表不在返回值中。"""
return load().get("financial_sync_times", {}) or {}
def set_financial_sync_time(table: str, iso_ts: str) -> None:
"""更新单张财务表的最后同步时间(合并写入,不清除其他表)。"""
times = get_financial_sync_times()
times[table] = iso_ts
save({"financial_sync_times": times})
+310 -118
View File
@@ -3,7 +3,7 @@
集中管理全市场行情拉取 + enriched 缓存,供盘中选股、自选股等所有模块复用。
架构:
- 后台线程轮询数据源 get_by_universes(["CN_Equity_A", "CN_Index"])
- 后台线程轮询 TickFlow get_by_universes(["CN_Equity_A", "CN_Index"])
- 拉取行情 → 写 kline_daily (不复权) + 增量计算 enriched → 写盘 + 更新缓存
- _enriched_cache 是唯一的盘中数据源 (OHLCV + 全套技术指标)
- _live_agg_cache 是递推状态 (只加载一次, 盘中不变)
@@ -43,6 +43,7 @@ class QuoteService:
"expert": 1.0,
"pro": 2.0,
"starter": 3.0,
"free": 6.0,
}
DEFAULT_INTERVAL = 10.0
MAX_INTERVAL = 60.0
@@ -59,6 +60,10 @@ class QuoteService:
self._depth_update_event = threading.Event() # SSE 通知: depth 五档修正后 set (刷新连板梯队)
self._pending_alerts: list[dict] = [] # 待推送的告警
self._max_pending_alerts: int = 1000 # 背压上限: 超出丢弃最旧
# 复盘进度 SSE 通道: 定时复盘流式生成时, 把 meta/delta/done 事件推给开着页面的前端
self._review_event = threading.Event() # SSE 通知: 有复盘进度事件时 set
self._pending_review: list[str] = [] # 待推送的复盘事件(JSON 字符串)
self._max_pending_review: int = 200 # 背压上限: 超出丢弃最旧
self._strategy_monitor = None # 延迟注入
self._app_state = None # 延迟注入 (FastAPI app.state)
@@ -68,6 +73,7 @@ class QuoteService:
self._fetched_at: float = 0.0 # 拉取完成的 Unix 时间戳 (毫秒)
self._symbol_count: int = 0
self._index_symbol_count: int = 0
self._etf_symbol_count: int = 0
self._index_quotes_cache: pl.DataFrame | None = None
# ================================================================
@@ -102,11 +108,11 @@ class QuoteService:
def enable(self) -> bool:
"""开启自动行情 (不立即启动线程,等下一个交易时段)。
none/free 档无实时行情权限,拒绝开启并返回 False;
starter+ 正常启动。返回值表示是否真正开启。
none 档无实时行情权限,拒绝开启并返回 False;
free 档开启自选股实时,starter+ 开启全市场实时。返回值表示是否真正开启。
"""
if not self.is_realtime_allowed():
logger.warning("实时行情开启被拒:当前档位(none/free)无实时行情权限")
logger.warning("实时行情开启被拒:当前档位(none)无实时行情权限")
return False
self._enabled = True
self._save_enabled(True)
@@ -126,14 +132,14 @@ class QuoteService:
def boot_check(self) -> None:
"""启动时检查 preferences,若 enabled 则自动启动。
none/free 档无实时行情权限:即使 preferences 标记为 enabled,
none 档无实时行情权限:即使 preferences 标记为 enabled,
也不启动,并同步 preferences 为关闭(避免 UI 误显示已开启)。
"""
from app.services import preferences
if not self.is_realtime_allowed():
if preferences.get_realtime_quotes_enabled():
self._save_enabled(False)
logger.info("实时行情未启动:当前档位(none/free)无实时行情权限")
logger.info("实时行情未启动:当前档位(none)无实时行情权限")
return
if preferences.get_realtime_quotes_enabled():
self.start()
@@ -188,6 +194,34 @@ class QuoteService:
self._pending_alerts = []
return alerts
# ================================================================
# 复盘进度 SSE 通道 — 定时复盘流式生成时, 把事件实时推给前端
# ================================================================
def push_review_event(self, event_json: str) -> None:
"""追加一条复盘进度事件(JSON 字符串), 并唤醒 SSE generator。
事件格式与 recap_market_stream 的产出一致(meta/delta/error/done),
前端 reviewStore 直接消费。背压: 超过上限丢弃最旧(复盘流几百条 delta, 200 够用)。
"""
with self._lock:
self._pending_review.append(event_json)
if len(self._pending_review) > self._max_pending_review:
overflow = len(self._pending_review) - self._max_pending_review
self._pending_review = self._pending_review[overflow:]
self._review_event.set()
def wait_for_review(self, timeout: float = 30.0) -> bool:
"""阻塞等待复盘进度事件 (供 SSE 线程使用)。"""
self._review_event.clear()
return self._review_event.wait(timeout=timeout)
def pop_review_events(self) -> list[str]:
"""取走所有待推送的复盘事件 (线程安全)。"""
with self._lock:
events = self._pending_review
self._pending_review = []
return events
# ================================================================
# 档位感知间隔限制
# ================================================================
@@ -199,13 +233,19 @@ class QuoteService:
return tier_label().split()[0].split("+")[0].strip().lower()
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。
def realtime_mode(cls) -> str:
"""当前实时行情模式: none / watchlist / full_market。"""
tier = cls._current_tier()
if tier == "none":
return "none"
if tier == "free":
return "watchlist"
return "full_market"
none/free 档走 free-api 服务器,无实时行情权限 → 不允许;
starter+ 付费档走付费端点,有实时行情 → 允许。
"""
return cls._current_tier() not in ("none", "free")
@classmethod
def is_realtime_allowed(cls) -> bool:
"""当前档位是否允许使用实时行情。"""
return cls.realtime_mode() != "none"
@classmethod
def _tier_min_interval(cls) -> float:
@@ -251,7 +291,7 @@ class QuoteService:
return df
def get_index_quotes(self, symbols: list[str] | None = None) -> pl.DataFrame:
"""返回实时指数行情缓存。不会触发数据源请求。"""
"""返回实时指数行情缓存。不会触发 TickFlow 请求。"""
with self._lock:
df = self._index_quotes_cache.clone() if self._index_quotes_cache is not None else pl.DataFrame()
if df.is_empty():
@@ -262,13 +302,19 @@ class QuoteService:
def status(self) -> dict:
"""返回行情服务状态。"""
from app.services import preferences
age = (time.perf_counter() - self._fetch_time) * 1000 if self._fetch_time else -1
mode = self.realtime_mode()
return {
"enabled": self._enabled,
"running": self._running,
"mode": mode,
"realtime_allowed": mode != "none",
"watchlist_symbol_count": len(preferences.get_realtime_watchlist_symbols()),
"interval_s": self._interval,
"symbol_count": self._symbol_count,
"index_symbol_count": self._index_symbol_count,
"etf_symbol_count": self._etf_symbol_count,
"quote_age_ms": round(age, 0) if age >= 0 else None,
"is_trading_hours": self._is_trading_hours(),
"last_fetch_ms": round(self._fetched_at, 0) if self._fetched_at else None,
@@ -299,17 +345,47 @@ class QuoteService:
waited += 0.5
def _fetch_quotes(self) -> None:
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存"""
from app.tickflow.client import get_client
"""按当前档位拉取行情"""
if self.realtime_mode() == "watchlist":
self._fetch_watchlist_quotes()
return
self._fetch_full_market_quotes()
tf = get_client()
def _fetch_full_market_quotes(self) -> None:
"""拉取全市场行情 → 写 daily + 计算 enriched + 更新缓存。"""
from app.tickflow.client import get_paid_realtime_client
tf = get_paid_realtime_client()
if tf is None:
logger.warning("实时行情拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
from app.services import preferences
all_index_symbols = set(self._repo.get_index_symbol_set()) if self._repo else set()
all_index_symbols.update(self.CORE_INDEX_SYMBOLS)
resp = tf.quotes.get_by_universes(universes=["CN_Equity_A", "CN_Index"])
core_index_symbols = set(preferences.get_realtime_index_symbols() or self.CORE_INDEX_SYMBOLS)
all_index_symbols.update(core_index_symbols)
all_etf_symbols = set()
if self._repo:
etf_inst = self._repo.get_etf_instruments()
if not etf_inst.is_empty() and "symbol" in etf_inst.columns:
all_etf_symbols = set(etf_inst["symbol"].cast(pl.Utf8).to_list())
universes: list[str] = []
if preferences.get_realtime_pull_stock():
universes.append("CN_Equity_A")
if preferences.get_realtime_pull_etf() and all_etf_symbols:
universes.append("CN_ETF")
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "all":
universes.append("CN_Index")
resp = []
if universes:
resp.extend(tf.quotes.get_by_universes(universes=universes) or [])
if preferences.get_realtime_pull_index() and preferences.get_realtime_index_mode() == "core":
resp.extend(tf.quotes.get(symbols=sorted(core_index_symbols)) or [])
except Exception as e: # noqa: BLE001
logger.warning("行情拉取失败: %s", e)
return
@@ -349,7 +425,11 @@ class QuoteService:
})
index_records = [r for r in records if r.get("symbol") in all_index_symbols]
stock_records = [r for r in records if r.get("symbol") not in all_index_symbols]
etf_records = [r for r in records if r.get("symbol") in all_etf_symbols]
stock_records = [
r for r in records
if r.get("symbol") not in all_index_symbols and r.get("symbol") not in all_etf_symbols
]
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
@@ -361,9 +441,10 @@ class QuoteService:
self._fetched_at = fetched_at
self._symbol_count = len(stock_records)
self._index_symbol_count = len(index_records)
self._etf_symbol_count = len(etf_records)
self._index_quotes_cache = self._build_index_quotes(index_records)
logger.info("行情刷新: %d 只股票, %d 只指数, 耗时 %.0fms", len(stock_records), len(index_records), fetch_ms)
logger.info("行情刷新: %d 只股票, %d 只ETF, %d 只指数, 耗时 %.0fms", len(stock_records), len(etf_records), len(index_records), fetch_ms)
# ---- 写 kline_daily (不复权原始价格, 只有 OHLCV) ----
daily_df = self._build_daily(stock_records)
@@ -373,12 +454,22 @@ class QuoteService:
except Exception as e: # noqa: BLE001
logger.warning("日K写盘失败: %s", e)
etf_daily_df = self._build_daily(etf_records)
if not etf_daily_df.is_empty() and self._repo:
try:
self._repo.flush_live_daily_asset("etf", etf_daily_df)
except Exception as e: # noqa: BLE001
logger.warning("ETF 日K写盘失败: %s", e)
# ---- 构建 API 直接值的补充表 (不写 daily, 只用于 enriched 计算) ----
quote_extra = self._build_quote_extra(stock_records)
etf_quote_extra = self._build_quote_extra(etf_records)
# ---- 增量计算 enriched + 写盘 + 更新缓存 ----
if not daily_df.is_empty() and self._repo:
self._flush_live_enriched(daily_df, quote_extra)
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock")
if not etf_daily_df.is_empty() and self._repo:
self._flush_live_enriched(etf_daily_df, etf_quote_extra, asset_type="etf")
# ---- 通知 SSE ----
self._update_event.set()
@@ -386,6 +477,87 @@ class QuoteService:
# ---- 策略监控 + 告警评估 ----
self._evaluate_monitors(daily_df, quote_extra)
def _fetch_watchlist_quotes(self) -> None:
"""Free 档自选股实时: 只拉取最多 5 个 symbols。"""
from app.services import preferences
from app.tickflow.client import get_paid_realtime_client
symbols = preferences.get_realtime_watchlist_symbols()
if not symbols:
logger.info("自选实时未配置标的, 跳过行情拉取")
return
tf = get_paid_realtime_client()
if tf is None:
logger.warning("自选实时拉取失败:未配置付费服务器 API Key")
return
t0 = time.perf_counter()
now_ts = time.perf_counter()
try:
resp = tf.quotes.get(symbols=symbols) or []
except Exception as e: # noqa: BLE001
logger.warning("自选实时拉取失败: %s", e)
return
if not resp:
logger.warning("自选实时行情数据为空")
return
records = []
for q in resp:
ext = q.get("ext") or {}
last_price = q.get("last_price")
prev_close = q.get("prev_close")
change_amount = ext.get("change_amount")
change_pct = ext.get("change_pct")
if change_amount is None and last_price is not None and prev_close is not None:
change_amount = float(last_price) - float(prev_close)
if change_pct is None and change_amount is not None and prev_close not in (None, 0):
change_pct = float(change_amount) / float(prev_close) * 100
records.append({
"symbol": q.get("symbol"),
"name": q.get("name") or ext.get("name"),
"last_price": last_price,
"prev_close": prev_close,
"open": q.get("open"),
"high": q.get("high"),
"low": q.get("low"),
"volume": q.get("volume"),
"amount": q.get("amount"),
"change_pct": change_pct,
"change_amount": change_amount,
"amplitude": ext.get("amplitude"),
"turnover_rate": ext.get("turnover_rate"),
"timestamp": q.get("timestamp"),
"session": q.get("session"),
})
fetch_ms = (time.perf_counter() - t0) * 1000
fetched_at = time.time() * 1000
with self._lock:
self._fetch_time = now_ts
self._fetch_ms = fetch_ms
self._fetched_at = fetched_at
self._symbol_count = len(records)
self._index_symbol_count = 0
self._etf_symbol_count = 0
self._index_quotes_cache = None
logger.info("自选实时刷新: %d 只股票, 耗时 %.0fms", len(records), fetch_ms)
daily_df = self._build_daily(records)
quote_extra = self._build_quote_extra(records)
if not daily_df.is_empty() and self._repo:
try:
self._repo.merge_live_daily_asset("stock", daily_df)
except Exception as e: # noqa: BLE001
logger.warning("自选实时日K写盘失败: %s", e)
self._flush_live_enriched(daily_df, quote_extra, asset_type="stock", merge=True)
self._update_event.set()
self._evaluate_monitors(daily_df, quote_extra)
# ================================================================
# 工具
# ================================================================
@@ -495,6 +667,8 @@ class QuoteService:
return
all_alerts: list[dict] = []
rule_events: list[dict] = []
engine = None
# 通用监控规则评估 (统一引擎: signal/price/market/strategy)
if self._app_state:
@@ -511,7 +685,11 @@ class QuoteService:
})
except Exception as e: # noqa: BLE001
logger.debug("name_map 构建失败 (不影响监控): %s", e)
rule_events = engine.evaluate(enriched_today)
# 连板梯队封单监控: 有 ladder 规则时, 从 depth_service 注入封单量到 enriched
eval_df = enriched_today
if engine.has_rule_type("ladder"):
eval_df = self._inject_sealed_vol(enriched_today, enriched_date)
rule_events = engine.evaluate(eval_df)
if rule_events:
# 落盘到 alerts.jsonl
try:
@@ -535,11 +713,13 @@ class QuoteService:
"change_pct": ev["change_pct"],
"signals": ev["signals"],
"severity": ev.get("severity", "info"),
"conditions": ev.get("conditions") or [],
"logic": ev.get("logic") or "and",
})
# 刷新策略结果缓存 (实时行情开启时,每轮行情更新后自动重算)
if self._enabled and self._app_state:
self._refresh_strategy_cache(enriched_today, enriched_date)
# 策略页实时回显: 不写文件 (实时行情每轮更新 enriched, 写文件会被 read_cache
# 的 mtime 校验判过期, 反复读不到)。监控引擎本轮已算出的结果存在内存
# (latest_strategy_results), 由 /api/screener/cached 端点直接叠加读取。
# 推入待推送队列 + 通知 SSE (含背压保护)
if all_alerts:
@@ -556,9 +736,95 @@ class QuoteService:
# cooldown 去重已在 MonitorRuleEngine 做过, 这里只负责转发。
self._maybe_send_system_notifications(all_alerts)
# Webhook 推送 (飞书等外部 IM, 由规则 webhook_enabled 开关控制)。
# 紧随系统通知, 同样静默降级不阻断主流程。
if rule_events:
self._maybe_send_webhook(rule_events, engine)
except Exception as e: # noqa: BLE001
logger.warning("监控评估失败: %s", e)
def _inject_sealed_vol(self, enriched_today: pl.DataFrame, enriched_date) -> pl.DataFrame:
"""从 depth_service 取封单量, 作为临时列 _sealed_vol 注入 enriched 副本。
涨停封单(买一量) + 跌停封单(卖一量)合并, 供 ladder 规则评估。
depth 未就绪时返回原 df (不注入, ladder 规则安全降级不触发)。
"""
try:
depth_svc = getattr(self._app_state, "depth_service", None)
if not depth_svc:
return enriched_today
# enriched_date 可能是 date 或字符串, 统一为 date
from datetime import date as date_cls
target_date = enriched_date if isinstance(enriched_date, date_cls) else date_cls.fromisoformat(str(enriched_date))
# 取涨停 + 跌停封单, 合并 {symbol: vol}
up_map = depth_svc.get_sealed_map(target_date, is_down=False)
down_map = depth_svc.get_sealed_map(target_date, is_down=True)
sealed: dict[str, int] = {}
for m in (up_map, down_map):
for sym, info in m.items():
vol = (info or {}).get("vol")
if vol and vol > 0:
sealed[sym] = vol # 后者覆盖前者 (同 symbol 不可能在涨跌停都封单)
if not sealed:
return enriched_today
# 构造 (symbol, _sealed_vol) DataFrame, join 到 enriched 副本
sealed_df = pl.DataFrame({
"symbol": list(sealed.keys()),
"_sealed_vol": list(sealed.values()),
})
# 若已有残留列先移除 (避免重复 join 报错)
df = enriched_today.drop("_sealed_vol") if "_sealed_vol" in enriched_today.columns else enriched_today
return df.join(sealed_df, on="symbol", how="left")
except Exception as e: # noqa: BLE001
logger.debug("封单注入失败 (ladder 规则将不触发): %s", e)
return enriched_today
def _maybe_send_webhook(self, rule_events: list[dict], engine) -> None:
"""把告警通过 Webhook 推送到外部 IM (由规则 webhook_enabled 开关控制)。
- 全局飞书 URL 未配置: 直接返回
- 仅推送 webhook_enabled=True 的规则触发的告警
- 失败静默, 不阻断主流程
- 去重: 复用 MonitorRuleEngine 的 cooldown, 此处不重复去重
注意: 用 rule_events (含 rule_id) 而非重建后的 all_alerts,
以便反查引擎规则判断是否启用推送。
"""
try:
from app.services import preferences
from app.services import webhook_adapter
url = preferences.get_feishu_webhook_url()
if not url:
return
secret = preferences.get_feishu_webhook_secret()
# 反查规则, 过滤出启用推送的事件
source_labels = {
"strategy": "策略", "signal": "信号",
"price": "价格", "market": "异动",
}
rules = engine.rules if engine is not None else {}
pushed = 0
for ev in rule_events:
rule = rules.get(ev.get("rule_id"))
if not rule or not rule.get("webhook_enabled"):
continue
source = ev.get("source", "")
source_label = source_labels.get(source, source or "通知")
symbol = ev.get("symbol") or ""
name = ev.get("name") or ""
message = ev.get("message") or ""
title = f"TickFlow · {source_label}"
body = f"{symbol} {name} {message}".strip() if symbol else (message or name)
if webhook_adapter.send_feishu(url, title, body, secret):
pushed += 1
if pushed:
logger.info("飞书 Webhook 推送: %d", pushed)
except Exception as e: # noqa: BLE001
logger.debug("Webhook 推送异常 (不影响告警主流程): %s", e)
def _maybe_send_system_notifications(self, all_alerts: list[dict]) -> None:
"""把告警转发到操作系统通知中心 (由 preferences 开关控制)。
@@ -592,95 +858,11 @@ class QuoteService:
else:
body = message or name
title = f"Stock Panel · {source_label}"
title = f"TickFlow · {source_label}"
notify_adapter.notify(title, body)
except Exception as e: # noqa: BLE001
logger.debug("系统通知发送异常 (不影响告警主流程): %s", e)
def _refresh_strategy_cache(self, enriched_today: pl.DataFrame, enriched_date: date | None) -> None:
"""利用已计算好的 enriched 数据,运行策略池并写入缓存。"""
import math
from dataclasses import asdict
from app.services import strategy_cache
from app.services.screener import PRESET_STRATEGIES, ScreenerService
from app.strategy import config as strategy_config
try:
if enriched_date is None:
return
as_of = enriched_date
data_dir = self._repo.store.data_dir
svc = ScreenerService(self._repo)
engine = getattr(self._app_state, "strategy_engine", None)
# 确定要运行的策略: 策略监控池中的策略
monitor_ids = self._get_monitor_pool_ids()
if not monitor_ids:
return
# 一次加载所有 override
all_overrides = strategy_config.list_overrides(data_dir)
# 历史策略: 只在需要时加载
shared_history = None
history_strats = []
if engine:
id_set = set(monitor_ids)
history_strats = [
(sid, s) for sid, s in engine._strategies.items()
if s.filter_history_fn and sid in id_set
]
if history_strats:
max_lb = max(s.lookback_days for _, s in history_strats)
shared_history = svc._load_enriched_history(as_of, max(1, max_lb))
results: dict[str, dict] = {}
for sid in monitor_ids:
try:
overrides = all_overrides.get(sid, {})
bf = overrides.get("basic_filter") if overrides else None
dl = overrides.get("display_limit") if overrides else None
if dl is None and overrides and "display_limit" in overrides:
dl = 0
if sid in PRESET_STRATEGIES:
r = svc.run_preset(sid, as_of=as_of, precomputed=enriched_today, basic_filter=bf, display_limit=dl)
elif engine:
r = engine.run(
sid, as_of, overrides=overrides or None,
precomputed=enriched_today, precomputed_history=shared_history,
)
if dl is not None and dl > 0:
r.rows = r.rows[:dl]
r.total = min(r.total, dl)
else:
continue
# sanitize NaN/Inf
rows = []
for row_dict in asdict(r).get("rows", []):
for k, v in list(row_dict.items()):
if isinstance(v, float) and not math.isfinite(v):
row_dict[k] = None
rows.append(row_dict)
results[sid] = {"total": r.total, "as_of": str(as_of), "rows": rows}
except Exception: # noqa: BLE001
continue
if results:
strategy_cache.write_cache(data_dir, str(as_of), results)
except Exception as e: # noqa: BLE001
logger.warning("策略缓存刷新失败: %s", e)
def _get_monitor_pool_ids(self) -> list[str]:
"""获取策略监控池中的策略 ID 列表。"""
from app.services import preferences
ids = preferences.get_strategy_monitor_ids()
if not ids:
return []
return [sid for sid in ids if sid]
@staticmethod
def _get_strategy_monitor():
"""获取 StrategyMonitorService — 不再使用, 改用 _app_state 注入。"""
@@ -690,7 +872,7 @@ class QuoteService:
# enriched 增量计算
# ================================================================
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None) -> None:
def _flush_live_enriched(self, daily_df: pl.DataFrame, quote_extra: pl.DataFrame = None, asset_type: str = "stock", merge: bool = False) -> None:
"""增量计算今天的 enriched: 用昨天的递推状态 + 今天 OHLCV → 只算今天 5500 行。
quote_extra: API 直接提供的补充字段 (prev_close, change_pct 等),
@@ -701,11 +883,16 @@ class QuoteService:
t0 = time.perf_counter()
# ---- 尝试增量路径 ----
live_agg = self._repo.get_live_agg()
prev_enriched, prev_date = self._repo.get_enriched_latest()
live_agg = self._repo.get_live_agg() if asset_type == "stock" else pl.DataFrame()
prev_enriched, prev_date = (
self._repo.get_enriched_latest()
if asset_type == "stock"
else self._repo.get_enriched_latest_asset(asset_type)
)
use_incremental = (
not live_agg.is_empty()
asset_type == "stock"
and not live_agg.is_empty()
and not prev_enriched.is_empty()
and prev_date is not None
)
@@ -736,7 +923,8 @@ class QuoteService:
"ok" if not live_agg.is_empty() else "", prev_date)
cutoff = today - timedelta(days=90)
daily_glob = str(self._repo.store.data_dir / "kline_daily" / "**" / "*.parquet")
table = "kline_etf_daily" if asset_type == "etf" else "kline_daily"
daily_glob = str(self._repo.store.data_dir / table / "**" / "*.parquet")
ohlcv_cols = ["symbol", "date", "open", "high", "low", "close", "volume", "amount"]
hist_df = (
pl.scan_parquet(daily_glob)
@@ -753,14 +941,15 @@ class QuoteService:
full_df = pl.concat([hist_df, daily_ohlcv], how="diagonal_relaxed")
full_df = full_df.sort(["symbol", "date"])
factor_path = self._repo.store.data_dir / "adj_factor" / "all.parquet"
factor_dir = "adj_factor_etf" if asset_type == "etf" else "adj_factor"
factor_path = self._repo.store.data_dir / factor_dir / "all.parquet"
factors = pl.DataFrame()
if factor_path.exists():
try:
factors = pl.read_parquet(factor_path)
except Exception:
pass
instruments = self._repo.get_instruments()
instruments = self._repo.get_instruments() if asset_type == "stock" else None
enriched_full = compute_enriched(full_df, factors=factors, instruments=instruments)
enriched_today = enriched_full.filter(pl.col("date") == today)
@@ -769,7 +958,10 @@ class QuoteService:
return
# ---- 写盘 + 更新缓存 ----
self._repo.flush_live_enriched(enriched_today)
if merge:
self._repo.merge_live_enriched_asset(asset_type, enriched_today)
else:
self._repo.flush_live_enriched_asset(asset_type, enriched_today)
elapsed = time.perf_counter() - t0
mode_label = "增量" if use_incremental else "全量"
+214
View File
@@ -0,0 +1,214 @@
"""概念涨幅轮动矩阵 service。
输出「每列(日期)各自把所有概念按当天涨幅从高到低排序」的矩阵,供前端
「概念分析 → 涨幅RPS轮动」对话框渲染。
数据来源全部复用现有资产, 不引入新数据源:
- 个股历史涨跌幅: repo.get_enriched_range(..., columns=["symbol","date","change_pct"])
命中启动时构建的 _enriched_history_cache (0ms, 含 change_pct 小数列)
- 概念成分股映射: 复用 market_overview_builder 的 _dimension_field / _read_ext_rows /
_symbol_keys / _dimension_values, 与看板/复盘的概念聚合口径完全一致
性能: 387 概念 × 30 天的 group_by + sort 是 polars 内存操作, 实测 <50ms;
另加进程级结果缓存 (_CACHE_TTL=120s), 重复请求 <1ms。
"""
from __future__ import annotations
import logging
import time
from datetime import date, timedelta
import polars as pl
from app.services.market_overview_builder import (
_dimension_field,
_dimension_values,
_read_ext_rows,
_symbol_keys,
)
from app.services.ext_data import ExtConfigStore
logger = logging.getLogger(__name__)
# 进程级结果缓存 (照搬 overview.py:18 的模式, TTL 拉长到 120s —— 轮动矩阵
# 不像看板那样需要近实时, 盘后数据稳定, 缓存久一点无妨)
_CACHE_TTL = 120.0
_cache: dict[str, dict] = {}
_cache_ts: dict[str, float] = {}
def invalidate_cache() -> None:
"""清空轮动矩阵结果缓存(数据管道完成后调用, 避免返回旧数据)。"""
_cache.clear()
_cache_ts.clear()
def _latest_enriched_date(repo) -> date | None:
"""取 enriched 缓存里的最新交易日(矩阵的右端=最新日期)。"""
cache = repo._enriched_history_cache # noqa: SLF001 —— 缓存字段无公开 getter
if cache is None or cache.is_empty() or "date" not in cache.columns:
return None
return cache["date"].max()
def _load_concept_map_df(repo) -> tuple[pl.DataFrame, int]:
"""构建并缓存 {symbol_upper → 概念} 的已展开 polars 映射表。
复用 market_overview_builder 的概念识别 + 成分股读取逻辑(_dimension_field /
_read_ext_rows / _symbol_keys / _dimension_values), 但要的是「反向映射」
(symbol → 概念), 且直接产出 polars DataFrame 供 join 使用。
返回 (map_df, concept_count):
- map_df: 两列 (_sym_up: 大写 symbol, concept: 概念名), 已 explode, 一个
symbol 属多概念时有多行。无概念数据时返回空 DataFrame。
- concept_count: 去重概念总数。
缓存: 概念成分股是 snapshot, 进程内不变, 缓存 600s。
直接缓存 DataFrame 而非 Python dict —— 后续 join 时省掉每次 ~1s 的 dict→DataFrame
重建开销(这是结果缓存失效后重算的主要瓶颈)。
"""
global _concept_map_cache, _concept_map_count, _concept_map_ts
now = time.time()
if _concept_map_cache is not None and (now - _concept_map_ts) < 600:
return _concept_map_cache, _concept_map_count
data_dir = repo.store.data_dir
store = ExtConfigStore(data_dir)
# 先收集成扁平的 (sym, concept) 行, 再一次性构造 DataFrame(比 list 列快得多)
pairs: list[tuple[str, str]] = []
concepts_seen: set[str] = set()
for config in store.load_all():
field = _dimension_field(config, "concept")
if not field:
continue
for ext_row in _read_ext_rows(data_dir, config, field):
concepts = _dimension_values(ext_row.get(field))
if not concepts:
continue
keys = _symbol_keys(ext_row, config)
for key in keys:
for c in concepts:
pairs.append((key, c))
concepts_seen.add(c)
if pairs:
# 去重: 同一 (symbol, concept) 对会因多 key 形式(SZ/000001)和
# 多 config 重复出现, 去重后从 ~48万 行降到 ~14万, join 快 3x+
_concept_map_cache = pl.DataFrame(
{"_sym_up": [p[0] for p in pairs], "concept": [p[1] for p in pairs]},
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8},
).unique()
_concept_map_count = len(concepts_seen)
else:
_concept_map_cache = pl.DataFrame(
schema={"_sym_up": pl.Utf8, "concept": pl.Utf8}
)
_concept_map_count = 0
_concept_map_ts = now
return _concept_map_cache, _concept_map_count
_concept_map_cache: pl.DataFrame | None = None
_concept_map_count: int = 0
_concept_map_ts: float = 0.0
def build_rps_rotation(repo, days: int = 12) -> dict:
"""构建概念涨幅轮动矩阵。
Args:
repo: KlineRepository(含 _enriched_history_cache 内存历史)。
days: 取最近 N 个交易日, 范围 [7, 30], 默认 12。
Returns:
{
"dates": ["2026-06-30", ...], # 最新在最前, 长度 ≤ days
"columns": {"2026-06-30": [[概念, 涨幅], ...], ...}, # 每列各自排序(高→低)
"concept_count": 387, # 去重概念总数(0 表示无概念数据)
}
涨幅是小数(0.0522 = +5.22%)。无数据时返回空 columns。
"""
days = max(7, min(30, days))
# 结果缓存: 同 days(→ 同 start/end)的请求在 TTL 内直接返回
latest = _latest_enriched_date(repo)
if latest is None:
return {"dates": [], "columns": {}, "concept_count": 0}
cache_key = latest.isoformat()
now = time.time()
cached = _cache.get(cache_key)
if cached and (now - _cache_ts.get(cache_key, 0)) < _CACHE_TTL:
# 缓存的是所有日期, 按需要的 days 截取(避免不同 days 各存一份)
return _slice_cached(cached, days)
# 1. 概念映射(symbol → 概念), 已缓存为 polars DataFrame
map_df, concept_count = _load_concept_map_df(repo)
if map_df.is_empty():
logger.info("rps_rotation: no concept data (ext_gn_ths not fetched yet)")
return {"dates": [], "columns": {}, "concept_count": 0}
# 2. 取最近 N 交易日的个股 change_pct(命中内存缓存)
start = latest - timedelta(days=days * 2 + 10) # 日历天 ≈ 2/3 交易日, 多取余量
df = repo.get_enriched_range(
start, latest, columns=["symbol", "date", "change_pct"]
)
if df is None or df.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 3. 把个股 symbol 映射到概念, 一只股票拆成多行(每个概念一行)
# symbol 大写匹配(map_df 的 _sym_up 已大写)
df = df.with_columns(pl.col("symbol").str.to_uppercase().alias("_sym_up"))
joined = df.join(map_df, on="_sym_up", how="inner").drop("_sym_up")
if joined.is_empty():
return {"dates": [], "columns": {}, "concept_count": 0}
# 4. 按 (date, concept) 聚合 avg change_pct —— 与 _dimension_rank:288 的简单平均口径一致
agg = joined.group_by(["date", "concept"]).agg(
pl.col("change_pct").mean().alias("avg_pct")
)
# 去掉 NaN/Null(停牌等无行情的概念日)
agg = agg.filter(pl.col("avg_pct").is_not_null() & pl.col("avg_pct").is_not_nan())
# 5. 每个日期内按 avg_pct 降序排, 再 group_by 把每组的 (concept, avg_pct)
# 收集成并行 list —— 一次 polars 操作拿到全部列, 避免 partition_by 的 tuple key 歧义
agg = agg.sort(["date", "avg_pct"], descending=[False, True])
grouped = agg.group_by("date", maintain_order=True).agg(
pl.col("concept"), pl.col("avg_pct")
)
# 最新日期排最前
grouped = grouped.sort("date", descending=True)
columns: dict[str, list[list]] = {}
all_dates_sorted: list[str] = []
for row in grouped.iter_rows(named=True):
d_str = str(row["date"])
all_dates_sorted.append(d_str)
columns[d_str] = list(zip(row["concept"], row["avg_pct"]))
full = {
"dates": [str(d) for d in all_dates_sorted],
"columns": columns,
"concept_count": concept_count,
}
# 写缓存(存全量, 按需 slice)
_cache[cache_key] = full
_cache_ts[cache_key] = now
return _slice_cached(full, days)
def _slice_cached(full: dict, days: int) -> dict:
"""从全量缓存截取最近 N 天(days)。"""
dates_all = full["dates"]
if len(dates_all) <= days:
return full
keep_dates = dates_all[:days]
return {
"dates": keep_dates,
"columns": {d: full["columns"][d] for d in keep_dates},
"concept_count": full["concept_count"],
}
@@ -0,0 +1,309 @@
"""AI 个股分析服务 — 技术面 / 基本面 / 财务面 / 消息面 四维综合分析。
职责:
组合一只股票的 K 线(含已算好的技术指标)+ 财务表 + 关键价位 →
拼装"实战派交易员"级系统提示词 → 流式调用 LLM → 逐 chunk 吐给前端。
与 financial_analyzer.py 的区别(刻意区分,非复用):
- 角色:A 股实战派交易员 / 技术分析师(非 CFA 财务分析师)
- 数据源:K 线 + 技术指标为主,财务表为辅(财务分析以财务表为主)
- 输出框架:技术面→基本面→财务面→消息面(四维),落点是买卖区间与操作建议
(财务分析的落点是财务质量评级)
不知道: HTTP、前端、配置持久化。
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import AsyncIterator
import polars as pl
from app.indicators.levels import compute_levels, summarize_levels
from app.services.financial_sync import get_financial_df
logger = logging.getLogger(__name__)
# 注入最近多少根日 K(技术面分析样本)
_KLINE_WINDOW = 90
# 注入财务表的最近期数
_MAX_PERIODS = 4
# ================================================================
# 数据加载
# ================================================================
def _load_kline(repo, symbol: str) -> pl.DataFrame:
"""读取该标的最近 N 根日 K(已含技术指标 / 信号)。
repo: KlineRepository;走内存缓存,性能可控。
"""
from datetime import date, timedelta
end = date.today()
start = end - timedelta(days=_KLINE_WINDOW * 2) # 多取一些保证交易日够
df = repo.get_daily(symbol, start, end)
if df.is_empty():
return df
return df.tail(_KLINE_WINDOW)
def _clean_rows(df: pl.DataFrame, keep_cols: list[str]) -> list[dict]:
"""把 DataFrame 转成 JSON 安全的 dict 列表(只保留关键列 + 清洗 NaN/Inf + date→字符串)。
polars 的 date 列会变成 Python datetime.date,json.dumps 无法直接序列化,
必须转成 ISO 字符串,否则 json.dumps 抛 TypeError 让整个流静默失败。
"""
import datetime
import math
cols = [c for c in keep_cols if c in df.columns]
sub = df.select(cols)
rows = []
for rec in sub.to_dicts():
clean = {}
for k, v in rec.items():
if isinstance(v, float):
clean[k] = None if not math.isfinite(v) else round(v, 4)
elif isinstance(v, (datetime.date, datetime.datetime)):
clean[k] = v.isoformat()
else:
clean[k] = v
rows.append(clean)
return rows
def _load_financials(data_dir: Path, symbol: str) -> dict[str, list[dict]]:
"""读取该标的核心财务指标 + 利润表(只取最有信息量的两张表)。
财务面只需要关键指标(ROE / 增速 / 毛利率 等),不需要把 4 张表全塞进上下文
(那是 financial_analyzer 的职责)。这里取轻量,留给技术面更多 token。
"""
out: dict[str, list[dict]] = {}
for table in ("metrics", "income"):
df = get_financial_df(data_dir, table)
if df.is_empty():
out[table] = []
continue
df = df.filter(pl.col("symbol") == symbol)
if df.is_empty():
out[table] = []
continue
if "period_end" in df.columns:
df = df.sort("period_end", descending=True).head(2) # 只取最近 2 期
import math
rows = []
for rec in df.to_dicts():
clean = {}
for k, v in rec.items():
if k == "symbol":
continue
if isinstance(v, float):
clean[k] = None if not math.isfinite(v) else v
else:
clean[k] = v
rows.append(clean)
out[table] = rows
return out
# ================================================================
# 系统提示词 —— 实战派交易员四维框架(与财务分析明确区分)
# ================================================================
_SYSTEM_PROMPT = """你是一位拥有 15 年 A 股一线实战经验的资深交易员兼技术分析师,擅长从 K 线、量价、关键价位与基本面交叉验证中把握买卖时机。你的任务是:基于提供的个股数据,产出一份**实战、可直接指导交易决策**的综合分析报告。
## 输出规范
用 **Markdown** 格式输出,严格遵循以下结构。不要输出任何 JSON 或代码块,直接输出 Markdown 正文。
### 1. 🎯 一句话定调(1-2 句)
用一句话概括该股当前的**技术状态与交易属性**(如"高位放量滞涨,需警惕回调"/"底部筹码集中,放量突破在即")。结尾用【操作建议:观望 / 轻仓试探 / 逢低吸纳 / 持有 / 减仓 / 规避】给出明确倾向。
### 2. 📈 技术面分析(核心维度)
这是你的主战场,务必深入:
- **趋势判断**:均线多头/空头排列、20/60 日均线方向、价格在均线之上/下
- **形态结构**:近期是否有突破/破位/双底/双顶/旗形等关键形态
- **指标信号**:MACD 金叉/死叉/背离、KDJ 超买超卖、RSI 强弱、布林通道位置
- **量价配合**:放量上涨/缩量回调/量价背离/换手率异动
每条结论必须引用具体数值(如"MACD 在 6/12 出现金叉,DIF 0.32 上穿 DEA 0.18")。
### 3. 💰 关键价位(买卖区间)
基于提供的关键价位数据,明确指出:
- **上方压力位**(逐档列出,标注强度):第一压力、第二压力
- **下方支撑位**(逐档列出,标注强度):第一支撑、第二支撑
- 给出**建议买入区间**与**止损位**(基于支撑位)
用数据说话,引用提供的压力/支撑(成交密集区)/枢轴点数值。
### 4. 🏭 基本面与财务面(辅助验证)
简要点评(2-4 句,不展开长篇):
- 盈利质量(ROE / 毛利率水平)、成长性(营收/利润增速)
- 与技术面的**交叉验证**:好公司 + 技术面走坏 → 仍需谨慎;差公司 + 技术面强势 → 警惕炒作风险
**当用户消息中标注了"该标的暂无财务数据"时**,本节请输出:
> 📌 财务面分析能力正在接入中。当前版本(Free)未同步该标的的财务报表,基本面维度暂无法评估。
> 技术面分析不依赖财务数据,以下结论依然有效;升级套餐或等待财务数据同步后可补充本维度。
**绝对不要**在无数据时编造 ROE / 增速等数字。
### 5. 📰 消息面(价量异动推断)
**注意:本期无直接新闻数据输入。** 请基于 K 线的**异动信号**进行推断(如:
- 涨停/连板/炸板 → 可能有利好或资金炒作
- 放量暴跌 → 可能有未公开利空
- 突破放量 → 可能有催化剂
明确标注"[推断]",告诉用户这是基于价量的推测,真实消息面数据待接入。若无明显异动,直说"近期价量平稳,无明显消息面信号"
### 6. ⚖️ 综合研判与操作建议
2-3 段:
- 该股当前处于(底部启动 / 上升途中 / 高位震荡 / 下跌趋势 / 底部企稳)哪个阶段
- 风险收益比评估(距支撑位的空间 vs 距压力位的空间)
- **明确操作建议**:激进型 / 稳健型 / 保守型 分别怎么应对
- **需要重点盯的信号**(如跌破 X 支撑止损、站上 Y 压力加仓)
## 分析准则(务必遵守)
1. **技术面优先**:作为交易员,技术面和量价是主要依据,基本面是验证手段,主次分明
2. **数据说话**:每个判断引用具体数值,严禁空泛套话("走势良好"必须改成"连续 3 日站稳 20 日均线且放量")
3. **诚实中立**:看多就写多,看空就写空,不要模棱两可骑墙;数据不支持时直言无法判断
4. **价位精确**:买卖区间必须落到具体价格,基于提供的关键价位数据推演
5. **风险前置**:任何买入建议都要配止损位;提示潜在风险不回避
6. **简明实战**:用交易员能扫读的密度输出,总字数 1000-1800 字,重在可执行
## 重要免责
报告末尾附一行:"> ⚠️ 本报告由 AI 基于公开行情与财务数据生成,仅供参考,不构成任何投资建议。交易有风险,入市需谨慎。"
现在请基于下方数据进行分析。"""
# ================================================================
# 用户消息构建
# ================================================================
def _build_user_prompt(
kline_tail: list[dict],
fins: dict[str, list[dict]],
levels: dict[str, list[dict]],
close: float | None,
symbol: str,
focus: str,
) -> str:
"""构建用户消息:标的 + 价位摘要 + 技术指标 JSON + 财务摘要 + 关注点。"""
parts: list[str] = [
f"标的标准代码: {symbol}",
f"关键价位概览: {summarize_levels(levels, close)}",
"",
"以下是该标的最近日 K 数据(JSON,含 OHLCV 与已计算的技术指标。"
f"最近 {_KLINE_WINDOW} 个交易日,升序):",
"```json",
json.dumps(kline_tail, ensure_ascii=False),
"```",
]
has_fin = any(fins.values())
if has_fin:
parts.extend([
"",
"以下是该标的最新财务数据(JSON,核心指标 + 利润表,金额单位为元):",
"```json",
json.dumps(fins, ensure_ascii=False),
"```",
])
else:
parts.extend([
"",
"(该标的暂无财务数据:当前为 Free 模式或尚未同步财务报表。"
"请按系统提示词第 4 节的说明,在基本面/财务面维度给出\"接入中\"的友好提示,不要编造数据。)",
])
if focus.strip():
parts.extend(["", f"本次分析请特别关注: {focus.strip()}"])
return "\n".join(parts)
# ================================================================
# 关键列筛选(控制上下文体积)
# ================================================================
_KLINE_KEEP_COLS = [
"date", "open", "high", "low", "close", "volume", "change_pct",
"ma5", "ma10", "ma20", "ma60",
"macd_dif", "macd_dea", "macd_hist",
"kdj_k", "kdj_d", "kdj_j",
"rsi_6", "rsi_14", "rsi_24",
"boll_upper", "boll_mid", "boll_lower",
"atr_14", "vol_ratio_5d", "turnover_rate",
"consecutive_limit_ups",
# 信号类(布尔)——只挑对消息面推断有用的几个
"signal_limit_up", "signal_broken_limit_up", "signal_macd_golden",
"signal_macd_death", "signal_ma_golden_5_20", "signal_volume_surge",
"signal_boll_breakout_upper", "signal_boll_breakout_lower",
]
# ================================================================
# 流式分析入口
# ================================================================
async def analyze_stock_stream(
repo,
data_dir: Path,
symbol: str,
focus: str = "",
) -> AsyncIterator[str]:
"""流式个股分析:yield 出每个 NDJSON 事件。
协议(与 financial_analyzer 一致,前端解析无差异):
{"type":"meta","symbol","summary","levels"} 数据 + 价位摘要
{"type":"delta","content":"..."} 逐 chunk 文本
{"type":"error","message":"..."}
{"type":"done"}
"""
# 1. 加载 K 线
df = _load_kline(repo, symbol)
if df.is_empty():
yield json.dumps({
"type": "error",
"message": f"标的 {symbol} 暂无日 K 数据,请先同步",
}, ensure_ascii=False)
return
# 2. 价位计算(基于 K 线)
levels = compute_levels(df)
close = float(df.tail(1)["close"][0]) if "close" in df.columns else None
# 3. 财务(辅助)
fins = _load_financials(data_dir, symbol)
# 4. meta
yield json.dumps({
"type": "meta",
"symbol": symbol,
"summary": summarize_levels(levels, close),
"levels": levels,
"close": close,
}, ensure_ascii=False)
# 5+6. 构建提示词 + 流式调用 LLM(整体 try-except,任何异常都 yield error,避免前端卡死)
try:
from app.services.ai_provider import stream_ai_text
kline_tail = _clean_rows(df, _KLINE_KEEP_COLS)
user_prompt = _build_user_prompt(kline_tail, fins, levels, close, symbol, focus)
async for delta in stream_ai_text(
[
{"role": "system", "content": _SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
temperature=0.5,
max_tokens=4500,
):
yield json.dumps({"type": "delta", "content": delta}, ensure_ascii=False)
except Exception as e: # noqa: BLE001
logger.exception("AI stock analysis failed for %s: %s", symbol, e)
yield json.dumps({"type": "error", "message": f"AI 分析失败: {e}"}, ensure_ascii=False)
return
yield json.dumps({"type": "done"}, ensure_ascii=False)
@@ -0,0 +1,91 @@
"""AI 个股分析报告持久化存储。
与 ai_reports.py(财务分析报告)完全独立 —— 单独的文件、字段、上限,
互不影响。刻意不复用,避免引入 kind 判别字段与分支(解耦 > 抽象)。
存储位置: data/user_data/ai_stock_reports.json (数组,按 created_at 降序)
保留最近 MAX_REPORTS 条;超出自动裁剪最旧的。
每条报告结构:
{
"id": "sar_xxx", # 唯一 id(stock-analysis-report)
"symbol": "600519.SH",
"name": "贵州茅台",
"focus": "", # 用户追加的关心点(可为空)
"content": "# ...markdown", # 报告正文
"summary": "当前价 12.3 · 压力位...", # 价位/数据摘要
"levels": {...}, # 报告生成时的关键价位(供图表回放)
"close": 12.3, # 报告生成时的收盘价
"created_at": "2026-06-26T10:00:00"
}
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
logger = logging.getLogger(__name__)
MAX_REPORTS = 50
def _path() -> Path:
from app.config import settings
p = settings.data_dir / "user_data" / "ai_stock_reports.json"
p.parent.mkdir(parents=True, exist_ok=True)
return p
def list_reports() -> list[dict]:
"""返回全部报告(按 created_at 降序)。"""
p = _path()
if not p.exists():
return []
try:
data = json.loads(p.read_text(encoding="utf-8"))
if isinstance(data, list):
return sorted(data, key=lambda r: r.get("created_at", ""), reverse=True)
except Exception as e: # noqa: BLE001
logger.warning("ai_stock_reports.json malformed: %s", e)
return []
def _save_all(reports: list[dict]) -> None:
"""全量写入(裁剪到 MAX_REPORTS)。"""
reports.sort(key=lambda r: r.get("created_at", ""), reverse=True)
if len(reports) > MAX_REPORTS:
reports = reports[:MAX_REPORTS]
_path().write_text(
json.dumps(reports, indent=2, ensure_ascii=False), encoding="utf-8",
)
def save_report(report: dict) -> dict:
"""新增一条报告并持久化。返回保存后的报告(含 id / created_at)。"""
reports = list_reports()
if not report.get("id"):
report["id"] = f"sar_{int(time.time() * 1000)}_{report.get('symbol', 'x')}"
if not report.get("created_at"):
report["created_at"] = _now_iso()
reports.append(report)
_save_all(reports)
logger.info("Stock report saved: %s (%s), total %d", report.get("symbol"), report.get("id"), len(reports))
return report
def delete_report(report_id: str) -> bool:
"""删除指定报告。返回是否删除成功。"""
reports = list_reports()
before = len(reports)
reports = [r for r in reports if r.get("id") != report_id]
if len(reports) < before:
_save_all(reports)
return True
return False
def _now_iso() -> str:
from datetime import datetime
return datetime.now().isoformat(timespec="seconds")
+10 -11
View File
@@ -54,7 +54,14 @@ def _get_enriched_mtime(data_dir: Path, as_of: str) -> float | None:
def read_cache(data_dir: Path) -> dict | None:
"""读取策略缓存文件。返回 None 表示无缓存读取失败或 enriched 数据已更新导致缓存过期。"""
"""读取策略缓存文件。返回 None 表示无缓存读取失败
说明: 原先有 enriched mtime 过期校验 (数据文件变化 → 判过期返回 None),
但在有实时行情的系统里, enriched parquet 每轮被刷新 → mtime 必然变化 →
缓存被永久判死, 策略页读不到数据。且判过期后不触发重算, 只能让用户手动重跑,
保护价值有限。故移除: 盘后缓存总能读出, 实时新鲜度由 /api/screener/cached
端点叠加监控引擎的内存实时结果 (latest_strategy_results) 来保证。
"""
path = _cache_path(data_dir)
if not path.exists():
return None
@@ -67,15 +74,6 @@ def read_cache(data_dir: Path) -> dict | None:
logger.warning("读取策略缓存失败: %s", e)
return None
# 校验 enriched mtime: 数据文件变化 → 缓存过期
as_of = cached.get("as_of")
stored_mtime = cached.get("enriched_mtime")
if as_of and stored_mtime:
current_mtime = _get_enriched_mtime(data_dir, as_of)
if current_mtime is not None and current_mtime != stored_mtime:
logger.info("策略缓存过期: enriched 数据已更新 (as_of=%s)", as_of)
return None
return cached
@@ -130,7 +128,8 @@ def write_cache(
# 从 ever_rows 提取 symbol 列表 (用于快速计数)
today_ever_matched = {sid: sorted(maps.keys()) for sid, maps in today_ever_rows.items()}
# 记录 enriched parquet 文件的 mtime,用于后续校验缓存是否过期
# enriched_mtime: 盘后缓存写入时记录 (向后兼容旧字段)。read_cache 已不再用它
# 做过期校验, 实时新鲜度改由 /cached 端点叠加监控引擎内存结果保证。
enriched_mtime = _get_enriched_mtime(data_dir, as_of)
payload = {
+14
View File
@@ -63,6 +63,20 @@ def remove(symbol: str) -> list[dict]:
return df.to_dicts()
def move_to_top(symbol: str) -> list[dict]:
p = _path()
if not p.exists():
return []
df = pl.read_parquet(p)
if df.is_empty() or symbol not in df["symbol"].to_list():
return df.to_dicts()
target = df.filter(pl.col("symbol") == symbol)
rest = df.filter(pl.col("symbol") != symbol)
out = pl.concat([target, rest], how="diagonal_relaxed")
out.write_parquet(p)
return out.to_dicts()
def clear() -> int:
"""清空自选列表。返回移除的数量。"""
p = _path()
@@ -0,0 +1,170 @@
"""Webhook 推送适配器 — 把告警事件推送到外部 IM / 量化软件。
职责: 把后端产生的告警事件, 通过用户配置的 Webhook 地址推送到外部。
目前支持飞书群机器人; QMT / ptrade 等量化通道为待定。
飞书自定义机器人接入:
1. 飞书群 → 群设置 → 群机器人 → 添加「自定义机器人」
2. 复制生成的 Webhook 地址 (形如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)
3. (可选) 安全设置 → 启用「签名校验」, 记录签名密钥(secret)
4. 填入设置页「飞书 Webhook」配置
设计: 失败静默降级, 绝不因推送失败阻断告警主流程 (落盘 / SSE 推送)。
去重不在本层做, 复用 MonitorRuleEngine 的 cooldown。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
logger = logging.getLogger(__name__)
# 单次推送最长字符 (飞书单条文本消息上限 30KB, 这里保守截断避免刷屏)
_MAX_LEN = 500
# 卡片消息正文最长字符 (飞书 interactive 卡片上限 30KB, 保守留余量给标题/结构)
_CARD_MAX_LEN = 28000
# 飞书自定义机器人 Webhook 前缀 (用于 URL 合法性校验)
FEISHU_HOOK_PREFIX = "https://open.feishu.cn/open-apis/bot/v2/hook/"
def _truncate(text: str) -> str:
"""截断超长文本。"""
text = (text or "").strip()
return text[:_MAX_LEN] + ("" if len(text) > _MAX_LEN else "")
def is_valid_feishu_url(url: str) -> bool:
"""校验是否为合法的飞书自定义机器人 Webhook 地址。"""
return bool(url) and url.startswith(FEISHU_HOOK_PREFIX)
def _gen_sign(timestamp: str, secret: str) -> str:
"""计算飞书自定义机器人签名。
算法 (官方): 把 `timestamp + "\\n" + secret` 作为签名字符串 (key),
用 HmacSHA256 计算空字符串的签名结果, 再 Base64 编码。
"""
string_to_sign = f"{timestamp}\n{secret}"
hmac_code = hmac.new(
string_to_sign.encode("utf-8"),
digestmod=hashlib.sha256,
).digest()
return base64.b64encode(hmac_code).decode("utf-8")
def _truncate_card(text: str) -> str:
"""截断卡片正文 (留余量给标题与卡片结构)。"""
text = (text or "").strip()
return text[:_CARD_MAX_LEN] + ("" if len(text) > _CARD_MAX_LEN else "")
def _post_feishu(webhook_url: str, payload: dict, secret: str) -> bool:
"""发送一次飞书 webhook 请求并判定成败 (供 text / card 共用)。
成功响应: HTTP 200 且业务 code=0 (或非 JSON 的 200)。失败静默返回 False。
"""
try:
import httpx
# 启用签名校验时, 请求体须带 timestamp + sign (秒级时间戳)
if secret:
timestamp = str(int(time.time()))
payload["timestamp"] = timestamp
payload["sign"] = _gen_sign(timestamp, secret)
resp = httpx.post(webhook_url, json=payload, timeout=5.0)
# 飞书成功响应: {"code":0,"msg":"success"} (或 StatusCode 200 + Extra)
if resp.status_code == 200:
try:
data = resp.json()
# code=0 表示飞书业务侧成功; 部分版本无 code 字段则按 msg 判断
if isinstance(data, dict):
code = data.get("code", data.get("StatusCode", 0))
if code == 0:
return True
logger.debug("飞书推送业务失败: %s", data)
return False
except ValueError:
# 非 JSON 响应但 HTTP 200, 视为成功
return True
logger.debug("飞书推送 HTTP %s: %s", resp.status_code, resp.text[:200])
return False
except Exception as e: # noqa: BLE001
logger.debug("飞书 Webhook 推送失败: %s", e)
return False
def send_feishu(webhook_url: str, title: str, body: str, secret: str = "") -> bool:
"""推送一条文本消息到飞书群机器人。
Args:
webhook_url: 飞书自定义机器人 Webhook 地址
title: 消息标题 (与正文拼接为一条文本)
body: 消息正文
secret: 签名密钥 (机器人启用了「签名校验」时必填; 留空则不带签名)
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (Webhook 是辅助通道, 不能阻断告警主流程)。
"""
if not is_valid_feishu_url(webhook_url):
return False
text = _truncate(f"{title}\n{body}".strip())
if not text:
return False
payload: dict = {"msg_type": "text", "content": {"text": text}}
return _post_feishu(webhook_url, payload, secret)
def send_feishu_card(webhook_url: str, title: str, subtitle: str, body_md: str, secret: str = "") -> bool:
"""推送一条 interactive 卡片消息到飞书群机器人 —— 用 lark_md 渲染完整 markdown 报告。
飞书「自定义机器人」webhook 不支持文件附件, 但 interactive 卡片的 lark_md 元素
可渲染 markdown, 能承载完整复盘报告(通常 2-5KB, 远小于卡片 30KB 上限)。
Args:
webhook_url: 飞书自定义机器人 Webhook 地址
title: 卡片标题 (显示在蓝色 header)
subtitle: 副标题 (加粗显示, 如日期/情绪标签; 留空则省略)
body_md: 卡片正文 markdown (报告全文)
secret: 签名密钥 (启用签名校验时必填)
Returns:
True=成功送达, False=失败或 URL 非法。
失败静默, 不抛异常 (与 send_feishu 一致, 不阻断告警主流程)。
"""
if not is_valid_feishu_url(webhook_url):
return False
body = _truncate_card(body_md)
elements: list[dict] = []
if subtitle.strip():
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": f"**{subtitle.strip()}**"},
})
elements.append({"tag": "hr"})
elements.append({
"tag": "div",
"text": {"tag": "lark_md", "content": body},
})
payload: dict = {
"msg_type": "interactive",
"card": {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": title},
"template": "blue",
},
"elements": elements,
},
}
return _post_feishu(webhook_url, payload, secret)