Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73d259b3a1 | ||
|
|
bdeda21386 | ||
|
|
317ea483d0 | ||
|
|
45fe244330 | ||
|
|
21afb863b3 | ||
|
|
08fa30cb78 | ||
|
|
7dba0238a5 | ||
|
|
bfcc932406 | ||
|
|
d0eafe203c | ||
|
|
86429f43c2 | ||
|
|
8b43b57bb2 | ||
|
|
35ea82863f | ||
|
|
a005118aa4 | ||
|
|
64c6ed92b0 | ||
|
|
6478e0d403 | ||
|
|
912a224b6b | ||
|
|
d7d019c2c4 | ||
|
|
b1f216b2a4 | ||
|
|
c4ed3346ab | ||
|
|
813e5c345a | ||
|
|
bc9ce37dff | ||
|
|
0743014f1a | ||
|
|
25c179dc06 | ||
|
|
24c8b31301 | ||
|
|
d5516b72d7 | ||
|
|
82006f7cf9 | ||
|
|
86d0dded3a | ||
|
|
57548c791d | ||
|
|
1f9931da77 | ||
|
|
bb2be17ae2 | ||
|
|
48522cbfea | ||
|
|
a6497caf73 | ||
|
|
8cf4c40e2b | ||
|
|
90569918a3 | ||
|
|
b0dbeef3fd | ||
|
|
eb66ab02d0 | ||
|
|
7fe8074e22 | ||
|
|
f98a9255a5 | ||
|
|
776ab55fc3 | ||
|
|
dfcce3abaf | ||
|
|
d473bbad6b | ||
|
|
c4a0c50ed0 | ||
|
|
0a32bfad78 | ||
|
|
69535bc782 | ||
|
|
254deb6a80 | ||
|
|
b29ca8413c | ||
|
|
c610faa4ad | ||
|
|
f93ffd381b | ||
|
|
1d3ec3194c | ||
|
|
b4d48180b7 | ||
|
|
6d3c470b7b | ||
|
|
23644a9c77 | ||
|
|
1c434a208a | ||
|
|
036a87bac9 | ||
|
|
258fc9184d | ||
|
|
ac985a2140 | ||
|
|
a7cff0930d | ||
|
|
5e7f0c4f13 | ||
|
|
77f6e36c13 | ||
|
|
cd56f6c158 | ||
|
|
c58bd990fd | ||
|
|
6b12b04cab | ||
|
|
ffabf3d396 | ||
|
|
f5ba61e3db | ||
|
|
526b182051 | ||
|
|
14c31e3f06 | ||
|
|
aed3eea739 | ||
|
|
5ffa5c07b2 | ||
|
|
84d237de1f | ||
|
|
f354da108d | ||
|
|
8687bda331 | ||
|
|
75c7ff3670 | ||
|
|
84a01fb7b1 | ||
|
|
ce131b2867 |
@@ -0,0 +1,11 @@
|
||||
# 同花顺金融数据 API 密钥
|
||||
fuyao_apikey=sk-fuyao-xxxx
|
||||
|
||||
# AI 分析配置
|
||||
AI_API_BASE=https://your-api-base/v1
|
||||
AI_API_KEY=sk-your-api-key
|
||||
AI_MODEL=deepseek-v4-flash
|
||||
|
||||
|
||||
# 管理面板密码(默认 !auvauv,建议修改)
|
||||
ADMIN_PASSWORD=!auvauv
|
||||
@@ -30,6 +30,7 @@ Thumbs.db
|
||||
# Node.js / JavaScript / TypeScript
|
||||
node_modules/
|
||||
dist/
|
||||
!dist/admin.html
|
||||
build/
|
||||
.next/
|
||||
.nuxt/
|
||||
@@ -121,3 +122,4 @@ tmp/
|
||||
/core
|
||||
/.core.hmbtNy
|
||||
/.core.dump
|
||||
.v2-demo-backup/
|
||||
|
||||
@@ -20,6 +20,10 @@ RUN find /usr/local/lib/python3.11 -type d -name "__pycache__" -exec rm -rf {} +
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
|
||||
# 设置上海时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
# 仅复制已编译好的包,无需 gcc
|
||||
COPY --from=python-deps /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env python3
|
||||
"""管理密码设置工具
|
||||
|
||||
用法:
|
||||
python3 admin_cli.py # 首次设置密码
|
||||
python3 admin_cli.py <新密码> # 修改密码
|
||||
python3 admin_cli.py --check # 检查是否已设置密码
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 添加 backend 目录到 path
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
|
||||
from services.admin_auth import is_configured, set_password
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "--check":
|
||||
if is_configured():
|
||||
print("✅ 管理密码已设置")
|
||||
else:
|
||||
print("⚠️ 管理密码未设置,请运行: python3 admin_cli.py")
|
||||
return
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
new_password = sys.argv[1]
|
||||
else:
|
||||
import getpass
|
||||
if is_configured():
|
||||
print("修改管理密码")
|
||||
else:
|
||||
print("设置管理密码")
|
||||
new_password = getpass.getpass("请输入密码: ")
|
||||
confirm = getpass.getpass("请再次输入密码: ")
|
||||
if new_password != confirm:
|
||||
print("❌ 两次输入不一致")
|
||||
sys.exit(1)
|
||||
|
||||
if len(new_password) < 4:
|
||||
print("❌ 密码至少4位")
|
||||
sys.exit(1)
|
||||
|
||||
set_password(new_password)
|
||||
print(f"✅ 管理密码已{'更新' if is_configured() else '设置'}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# auvops.sh - AUV 容器内运维工具(在容器内直接执行,非宿主机 docker exec)
|
||||
#
|
||||
# 拷贝进容器 /app(backend 根)后运行。后续新增运维能力都收敛到这个文件:
|
||||
# 加一个 cmd_xxx 函数 + 在 main 的 case 里注册一行即可。
|
||||
#
|
||||
# 用法(容器内):
|
||||
# ./auvops.sh cache-clear # 清空全部缓存(当日数据全新)
|
||||
# ./auvops.sh cache-clear-graph # 只清空热点穿透(theme_graph)缓存
|
||||
# ./auvops.sh clean-expired # 只清理已过期的缓存
|
||||
# ./auvops.sh cache-count # 查看缓存条数
|
||||
# ./auvops.sh recollect [DATE] # 删除并重采指定交易日(默认当天)
|
||||
# ./auvops.sh sh # 进入交互式 shell
|
||||
# ./auvops.sh help # 查看帮助
|
||||
#
|
||||
# 依赖: 容器内 python 可用(能 import services.*),工作目录自动切到脚本所在目录。
|
||||
#
|
||||
# 示例:
|
||||
# ./auvops.sh cache-clear
|
||||
# ./auvops.sh recollect 2026-08-10
|
||||
# ./auvops.sh cache-count
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# 容器内 backend 根:脚本所在目录(/app)
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${ROOT}"
|
||||
|
||||
# 容器内用 python;本机验证时可 PY=./venv/bin/python ./auvops.sh ...
|
||||
PY="${PY:-python}"
|
||||
"${PY}" -c "import services" >/dev/null 2>&1 || {
|
||||
echo "❌ 无法在 ${ROOT} 下 import services(确认已在容器内 /app 且 python 可用)" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 在容器内跑 python 代码。日期等参数通过环境变量传,避免拼接进代码字符串。
|
||||
# 用法: py_env "KEY1=val1" "KEY2=val2" <<'EOF'
|
||||
# <python 代码>
|
||||
# EOF
|
||||
py_env() {
|
||||
local env_args=()
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
env_args+=("${1%%=*}=${1#*=}"); shift
|
||||
done
|
||||
env "${env_args[@]}" "${PY}" -
|
||||
}
|
||||
|
||||
# ---- 子命令实现 ----
|
||||
|
||||
# 清空全部缓存(当日数据全新)
|
||||
cmd_cache_clear() {
|
||||
echo "① 清空全部缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clear_all
|
||||
clear_all()
|
||||
print(" cache 表已清空")
|
||||
EOF
|
||||
echo "✅ 缓存已清空"
|
||||
}
|
||||
|
||||
# 仅清空热点穿透(theme_graph)缓存:改写 top/排序后旧图数据过期,删掉让下次请求重建
|
||||
cmd_cache_clear_graph() {
|
||||
echo "① 清空热点穿透(theme_graph)缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute("DELETE FROM cache WHERE key LIKE 'theme_graph:%'")
|
||||
conn.commit()
|
||||
print(f" 已删除 {cur.rowcount} 条 theme_graph 缓存")
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
echo "✅ 热点穿透缓存已清空"
|
||||
}
|
||||
|
||||
# 只清理已过期缓存
|
||||
cmd_clean_expired() {
|
||||
echo "① 清理过期缓存"
|
||||
"${PY}" - <<'EOF'
|
||||
from services.cache import clean_expired
|
||||
clean_expired()
|
||||
print(" 过期缓存已清理")
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 查看缓存条数
|
||||
cmd_cache_count() {
|
||||
"${PY}" - <<'EOF'
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
n = conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
|
||||
print(f"缓存条数: {n}")
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
}
|
||||
|
||||
# 删除指定交易日旧数据,并用新选股逻辑重新采集入库
|
||||
cmd_recollect() {
|
||||
local date="${1:-$(date +%F)}"
|
||||
if ! [[ "${date}" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
|
||||
echo "❌ 日期格式错误:${date}(应为 YYYY-MM-DD)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "================================================"
|
||||
echo "🚀 容器: ${HOSTNAME:-unknown} 交易日: ${date}"
|
||||
echo "================================================"
|
||||
|
||||
echo "① 删除 ${date} 旧数据"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import os
|
||||
from database import get_connection
|
||||
date = os.environ["TARGET_DATE"]
|
||||
conn = get_connection()
|
||||
try:
|
||||
for tbl in ("daily_core_stocks", "daily_core_stock_themes", "daily_top_themes"):
|
||||
cur = conn.execute("DELETE FROM " + tbl + " WHERE trade_date = ?", (date,))
|
||||
print(f" {tbl}: 删除 {cur.rowcount} 行")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
EOF
|
||||
|
||||
echo "② 用新选股逻辑重采 ${date}"
|
||||
py_env "TARGET_DATE=${date}" <<'EOF'
|
||||
import asyncio
|
||||
import os
|
||||
from services.daily_collector import collect_daily
|
||||
result = asyncio.run(collect_daily(os.environ["TARGET_DATE"]))
|
||||
print(" 结果:", result)
|
||||
EOF
|
||||
echo "✅ 完成"
|
||||
}
|
||||
|
||||
# 进入交互式 shell
|
||||
cmd_sh() {
|
||||
"${PY}" || true
|
||||
}
|
||||
|
||||
# 帮助
|
||||
usage() {
|
||||
awk 'NR >= 2 && /^#/ { sub(/^# ?/, ""); print; next } NR >= 2 && !/^#/ { exit }' "${BASH_SOURCE[0]}"
|
||||
}
|
||||
|
||||
# ---- 入口 ----
|
||||
main() {
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
local cmd="$1"; shift
|
||||
case "${cmd}" in
|
||||
cache-clear|cc) cmd_cache_clear "$@" ;;
|
||||
cache-clear-graph) cmd_cache_clear_graph "$@" ;;
|
||||
clean-expired) cmd_clean_expired "$@" ;;
|
||||
cache-count) cmd_cache_count "$@" ;;
|
||||
recollect) cmd_recollect "$@" ;;
|
||||
sh|shell|python) cmd_sh "$@" ;;
|
||||
help|-h|--help) usage ;;
|
||||
*) echo "❌ 未知命令: ${cmd}(./auvops.sh help 查看用法)" >&2; exit 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -37,6 +37,54 @@ CREATE TABLE IF NOT EXISTS cache (
|
||||
value TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
f3 REAL,
|
||||
cover_count INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL,
|
||||
hot_rank INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
report_type TEXT NOT NULL DEFAULT 'daily',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
tools_used TEXT,
|
||||
model TEXT NOT NULL,
|
||||
tokens_used INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, report_type)
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
Vendored
+412
@@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AUV 管理面板</title>
|
||||
<style>
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;border-color:var(--border)}
|
||||
:root{
|
||||
--radius:0.625rem;
|
||||
--font-sans:"PingFang SC","Microsoft YaHei","Hiragino Sans GB",sans-serif;
|
||||
--background:oklch(1 0 0);
|
||||
--foreground:oklch(0.129 0.042 264.695);
|
||||
--card:oklch(1 0 0);
|
||||
--card-foreground:oklch(0.129 0.042 264.695);
|
||||
--primary:oklch(0.208 0.042 265.755);
|
||||
--primary-foreground:oklch(0.984 0.003 247.858);
|
||||
--secondary:oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground:oklch(0.208 0.042 265.755);
|
||||
--muted:oklch(0.968 0.007 247.896);
|
||||
--muted-foreground:oklch(0.521 0.011 257.2);
|
||||
--accent:oklch(0.968 0.007 247.896);
|
||||
--accent-foreground:oklch(0.208 0.042 265.755);
|
||||
--destructive:oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground:oklch(0.984 0.003 247.858);
|
||||
--border:oklch(0.929 0.013 255.508);
|
||||
--input:oklch(0.929 0.013 255.508);
|
||||
--ring:oklch(0.704 0.04 256.788);
|
||||
}
|
||||
.dark{
|
||||
--background:oklch(0.205 0 0);
|
||||
--foreground:oklch(0.984 0.003 247.858);
|
||||
--card:oklch(0.205 0 0);
|
||||
--card-foreground:oklch(0.984 0.003 247.858);
|
||||
--primary:oklch(0.929 0.013 255.508);
|
||||
--primary-foreground:oklch(0.208 0.042 265.755);
|
||||
--secondary:oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground:oklch(0.984 0.003 247.858);
|
||||
--muted:oklch(0.279 0.041 260.031);
|
||||
--muted-foreground:oklch(0.804 0.008 256.9);
|
||||
--accent:oklch(0.279 0.041 260.031);
|
||||
--accent-foreground:oklch(0.984 0.003 247.858);
|
||||
--destructive:oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground:oklch(0.984 0.003 247.858);
|
||||
--border:oklch(1 0 0 / 10%);
|
||||
--input:oklch(1 0 0 / 15%);
|
||||
--ring:oklch(0.551 0.027 264.364);
|
||||
}
|
||||
body{font-family:var(--font-sans);background:var(--background);color:var(--foreground);min-height:100vh}
|
||||
|
||||
/* Login */
|
||||
.login-wrap{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px;background:linear-gradient(135deg,var(--background),color-mix(in oklch,var(--muted) 30%,var(--background)),var(--background))}
|
||||
.login-card{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 8px);padding:32px 28px;width:100%;max-width:380px;box-shadow:0 4px 24px oklch(0 0 0 / .06)}
|
||||
.login-card h1{font-size:22px;font-weight:700;margin-bottom:4px;background:linear-gradient(to right,var(--primary),color-mix(in oklch,var(--primary) 70%,var(--foreground)));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
.login-card p{text-align:center;color:var(--muted-foreground);font-size:13px;margin-bottom:24px}
|
||||
.form-group{margin-bottom:16px}
|
||||
.form-group label{display:block;font-size:13px;color:var(--muted-foreground);margin-bottom:6px;font-weight:500}
|
||||
.form-group input{width:100%;padding:9px 12px;border:1px solid var(--input);border-radius:var(--radius);background:var(--background);color:var(--foreground);font-size:14px;font-family:var(--font-sans);outline:none;transition:border-color .2s}
|
||||
.form-group input:focus{border-color:var(--ring);box-shadow:0 0 0 2px color-mix(in oklch,var(--ring) 20%,transparent)}
|
||||
|
||||
/* Buttons */
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;padding:9px 16px;border:none;border-radius:var(--radius);font-size:14px;font-weight:500;font-family:var(--font-sans);cursor:pointer;transition:all .15s;gap:6px;line-height:1.4}
|
||||
.btn-primary{background:var(--primary);color:var(--primary-foreground)}.btn-primary:hover{opacity:.9}
|
||||
.btn-destructive{background:var(--destructive);color:var(--destructive-foreground)}.btn-destructive:hover{opacity:.9}
|
||||
.btn-outline{background:transparent;color:var(--foreground);border:1px solid var(--border)}.btn-outline:hover{background:var(--accent);color:var(--accent-foreground)}
|
||||
.btn-ghost{background:transparent;color:var(--muted-foreground)}.btn-ghost:hover{background:var(--accent);color:var(--accent-foreground)}
|
||||
.btn-sm{padding:5px 12px;font-size:12px}
|
||||
.btn-full{width:100%}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed;pointer-events:none}
|
||||
.error-msg{color:var(--destructive);font-size:13px;margin-top:8px;text-align:center}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard{display:none;max-width:960px;margin:0 auto;padding:16px}
|
||||
@media(min-width:768px){.dashboard{padding:24px 32px}}
|
||||
.topbar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;margin-bottom:20px;padding-bottom:16px;border-bottom:1px solid var(--border)}
|
||||
.topbar h1{font-size:18px;font-weight:700}
|
||||
@media(min-width:768px){.topbar h1{font-size:20px}}
|
||||
.topbar .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
|
||||
|
||||
/* Stats grid */
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:12px;margin-bottom:20px}
|
||||
@media(min-width:640px){.stats-grid{grid-template-columns:repeat(3,1fr)}}
|
||||
@media(min-width:768px){.stats-grid{grid-template-columns:repeat(6,1fr)}}
|
||||
.stat-card{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 4px);padding:14px 16px}
|
||||
.stat-card .label{font-size:12px;color:var(--muted-foreground);margin-bottom:4px;font-weight:500}
|
||||
.stat-card .value{font-size:22px;font-weight:700;color:var(--foreground)}
|
||||
@media(min-width:768px){.stat-card .value{font-size:20px}}
|
||||
|
||||
/* Section */
|
||||
.section{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 4px);padding:16px;margin-bottom:16px}
|
||||
@media(min-width:768px){.section{padding:20px 24px}}
|
||||
.section h2{font-size:15px;font-weight:600;color:var(--foreground);margin-bottom:14px;display:flex;align-items:center;gap:8px}
|
||||
|
||||
/* Table */
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;font-size:12px;color:var(--muted-foreground);padding:8px 10px;border-bottom:1px solid var(--border);font-weight:500}
|
||||
td{padding:8px 10px;font-size:13px;border-bottom:1px solid color-mix(in oklch,var(--border) 50%,transparent);color:var(--card-foreground)}
|
||||
@media(min-width:768px){th,td{padding:10px 14px}}
|
||||
tr:hover td{background:color-mix(in oklch,var(--accent) 40%,transparent)}
|
||||
|
||||
/* Badge */
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:calc(var(--radius) - 2px);font-size:11px;font-weight:500;background:color-mix(in oklch,var(--primary) 12%,transparent);color:var(--primary)}
|
||||
|
||||
/* Result */
|
||||
.trigger-result{margin-top:14px;padding:12px 14px;border-radius:var(--radius);font-size:13px;display:none}
|
||||
.trigger-result.show{display:block}
|
||||
.trigger-result.success{background:color-mix(in oklch,#22c55e 10%,transparent);border:1px solid color-mix(in oklch,#22c55e 40%,transparent);color:#15803d}
|
||||
.trigger-result.error{background:color-mix(in oklch,var(--destructive) 10%,transparent);border:1px solid color-mix(in oklch,var(--destructive) 40%,transparent);color:var(--destructive)}
|
||||
.dark .trigger-result.success{color:#4ade80}
|
||||
.dark .trigger-result.error{color:#fca5a5}
|
||||
|
||||
/* Loading */
|
||||
.loading{opacity:.6;pointer-events:none}
|
||||
.spinner{display:inline-block;width:14px;height:14px;border:2px solid transparent;border-top-color:currentColor;border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* Toast */
|
||||
.toast{position:fixed;bottom:20px;right:20px;padding:10px 16px;border-radius:var(--radius);font-size:13px;z-index:9999;animation:fadeIn .3s;border:1px solid}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
.toast-ok{background:color-mix(in oklch,#22c55e 10%,var(--card));color:#15803d;border-color:color-mix(in oklch,#22c55e 30%,var(--border))}
|
||||
.toast-err{background:color-mix(in oklch,var(--destructive) 10%,var(--card));color:var(--destructive);border-color:color-mix(in oklch,var(--destructive) 30%,var(--border))}
|
||||
.dark .toast-ok{color:#4ade80}
|
||||
.dark .toast-err{color:#fca5a5}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay{display:none;position:fixed;inset:0;background:oklch(0 0 0 / .5);z-index:100;align-items:center;justify-content:center;padding:16px}
|
||||
.modal-overlay.open{display:flex}
|
||||
.modal-box{background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 8px);padding:24px;width:100%;max-width:360px;box-shadow:0 8px 32px oklch(0 0 0 / .12)}
|
||||
.modal-box h3{font-size:16px;font-weight:600;margin-bottom:16px}
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle{position:fixed;bottom:16px;right:16px;z-index:50}
|
||||
.theme-toggle-inner{display:flex;padding:3px;background:var(--muted);border-radius:var(--radius);box-shadow:0 2px 8px oklch(0 0 0 / .08);gap:3px}
|
||||
.theme-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:none;border-radius:calc(var(--radius) - 2px);cursor:pointer;background:transparent;color:var(--muted-foreground);font-size:16px;transition:all .15s;padding:0}
|
||||
.theme-btn:hover{color:var(--foreground)}
|
||||
.theme-btn.active{background:var(--background);color:var(--foreground);box-shadow:0 1px 3px oklch(0 0 0 / .08)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<div class="login-wrap" id="loginWrap">
|
||||
<div class="login-card">
|
||||
<h1>AUV 管理面板</h1>
|
||||
<p>A股走势追踪系统</p>
|
||||
<div class="form-group">
|
||||
<label>管理密码</label>
|
||||
<input type="password" id="pwdInput" placeholder="输入密码" autofocus>
|
||||
</div>
|
||||
<div id="loginError" class="error-msg" style="display:none"></div>
|
||||
<button class="btn btn-primary btn-full" id="loginBtn" onclick="doLogin()">登 录</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div class="dashboard" id="dashWrap">
|
||||
<div class="topbar">
|
||||
<h1>AUV 管理面板</h1>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" onclick="doTrigger()" id="triggerBtn">
|
||||
<span id="triggerSpinner" class="spinner" style="display:none"></span>
|
||||
触发 AI 分析
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" onclick="showChangePwd()">改密码</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="doLogout()">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid" id="statsGrid"></div>
|
||||
|
||||
<div id="triggerResult" class="trigger-result"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📊 分析报告</h2>
|
||||
<table>
|
||||
<thead><tr><th>日期</th><th>标题</th><th>模型</th><th>Tokens</th><th>时间</th><th></th></tr></thead>
|
||||
<tbody id="reportsBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>📈 近期报告</h2>
|
||||
<table>
|
||||
<thead><tr><th>日期</th><th>标题</th><th>Tokens</th></tr></thead>
|
||||
<tbody id="recentBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Modal -->
|
||||
<div class="modal-overlay" id="pwdModal">
|
||||
<div class="modal-box">
|
||||
<h3>修改密码</h3>
|
||||
<div class="form-group"><label>原密码</label><input type="password" id="oldPwd"></div>
|
||||
<div class="form-group"><label>新密码</label><input type="password" id="newPwd"></div>
|
||||
<div id="pwdError" class="error-msg" style="display:none"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:16px">
|
||||
<button class="btn btn-primary btn-sm" style="flex:1" onclick="doChangePwd()">确认</button>
|
||||
<button class="btn btn-outline btn-sm" style="flex:1" onclick="hideChangePwd()">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<div class="theme-toggle" id="themeToggle">
|
||||
<button class="theme-btn" id="themeBtn" onclick="toggleThemeExpand()" title="切换主题"></button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = location.origin;
|
||||
let token = localStorage.getItem('admin_token');
|
||||
|
||||
// Theme
|
||||
const themeIcons = {
|
||||
light: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>',
|
||||
dark: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>',
|
||||
system: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8"/><path d="M12 17v4"/></svg>'
|
||||
};
|
||||
let themeExpanded = false;
|
||||
|
||||
function initTheme(){
|
||||
const t = localStorage.getItem('theme') || 'system';
|
||||
setTheme(t, false);
|
||||
document.addEventListener('mousedown', e => {
|
||||
if (themeExpanded && !document.getElementById('themeToggle').contains(e.target)) {
|
||||
themeExpanded = false;
|
||||
renderThemeBtn();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setTheme(mode, save=true) {
|
||||
if (save) localStorage.setItem('theme', mode);
|
||||
const dark = mode === 'dark' || (mode === 'system' && matchMedia('(prefers-color-scheme:dark)').matches);
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
themeExpanded = false;
|
||||
renderThemeBtn();
|
||||
}
|
||||
|
||||
function renderThemeBtn() {
|
||||
const current = localStorage.getItem('theme') || 'system';
|
||||
const toggle = document.getElementById('themeToggle');
|
||||
if (!themeExpanded) {
|
||||
toggle.innerHTML = `<button class="theme-btn" id="themeBtn" onclick="toggleThemeExpand()" title="切换主题">${themeIcons[current]}</button>`;
|
||||
} else {
|
||||
toggle.innerHTML = `<div class="theme-toggle-inner">
|
||||
<button class="theme-btn ${current==='light'?'active':''}" onclick="setTheme('light')" title="浅色">${themeIcons.light}</button>
|
||||
<button class="theme-btn ${current==='dark'?'active':''}" onclick="setTheme('dark')" title="深色">${themeIcons.dark}</button>
|
||||
<button class="theme-btn ${current==='system'?'active':''}" onclick="setTheme('system')" title="自动">${themeIcons.system}</button>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleThemeExpand() {
|
||||
themeExpanded = !themeExpanded;
|
||||
renderThemeBtn();
|
||||
}
|
||||
|
||||
initTheme();
|
||||
|
||||
// Init
|
||||
if (token) showDashboard();
|
||||
else document.getElementById('pwdInput').focus();
|
||||
document.getElementById('pwdInput').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin() });
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json', 'X-Admin-Token': token } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(API + path, opts);
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.detail || '请求失败');
|
||||
return j.data;
|
||||
}
|
||||
|
||||
function toast(msg, ok) {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'toast ' + (ok ? 'toast-ok' : 'toast-err');
|
||||
d.textContent = msg;
|
||||
document.body.appendChild(d);
|
||||
setTimeout(() => d.remove(), 3000);
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const pwd = document.getElementById('pwdInput').value;
|
||||
if (!pwd) return;
|
||||
const errEl = document.getElementById('loginError');
|
||||
try {
|
||||
const data = await api('POST', '/api/admin/login', { password: pwd });
|
||||
token = data.token;
|
||||
localStorage.setItem('admin_token', token);
|
||||
errEl.style.display = 'none';
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function doLogout() {
|
||||
api('POST', '/api/admin/logout').catch(() => {});
|
||||
token = null;
|
||||
localStorage.removeItem('admin_token');
|
||||
document.getElementById('dashWrap').style.display = 'none';
|
||||
document.getElementById('loginWrap').style.display = 'flex';
|
||||
document.getElementById('pwdInput').value = '';
|
||||
}
|
||||
|
||||
async function showDashboard() {
|
||||
document.getElementById('loginWrap').style.display = 'none';
|
||||
document.getElementById('dashWrap').style.display = 'block';
|
||||
try {
|
||||
const stats = await api('GET', '/api/admin/stats');
|
||||
renderStats(stats);
|
||||
renderReports(stats.recentReports || []);
|
||||
const reports = await api('GET', '/api/admin/reports');
|
||||
renderAllReports(reports);
|
||||
} catch (e) {
|
||||
if (e.message.includes('401') || e.message.includes('未登录')) doLogout();
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats(s) {
|
||||
document.getElementById('statsGrid').innerHTML = `
|
||||
<div class="stat-card"><div class="label">分析报告</div><div class="value">${s.reportCount}</div></div>
|
||||
<div class="stat-card"><div class="label">总 Tokens</div><div class="value">${s.totalTokens?.toLocaleString() || 0}</div></div>
|
||||
<div class="stat-card"><div class="label">股票集合</div><div class="value">${s.collectionCount}</div></div>
|
||||
<div class="stat-card"><div class="label">关注股票</div><div class="value">${s.stockCount}</div></div>
|
||||
<div class="stat-card"><div class="label">分享链接</div><div class="value">${s.shareLinkCount}</div></div>
|
||||
<div class="stat-card"><div class="label">核心股天数</div><div class="value">${s.coreStockDays}</div></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderReports(list) {
|
||||
document.getElementById('recentBody').innerHTML = list.map(r =>
|
||||
`<tr>
|
||||
<td>${r.trade_date}</td>
|
||||
<td><a href="/ai-analysis" target="_blank" style="color:var(--primary);text-decoration:none">${r.title || '-'}</a></td>
|
||||
<td>${r.tokens_used || 0}</td>
|
||||
</tr>`
|
||||
).join('') || '<tr><td colspan="3" style="color:var(--muted-foreground);text-align:center">暂无数据</td></tr>';
|
||||
}
|
||||
|
||||
function renderAllReports(list) {
|
||||
document.getElementById('reportsBody').innerHTML = list.map(r =>
|
||||
`<tr>
|
||||
<td>${r.trade_date}</td>
|
||||
<td>${r.title || '-'}</td>
|
||||
<td><span class="badge">${r.model || '-'}</span></td>
|
||||
<td>${r.tokens_used || 0}</td>
|
||||
<td>${r.created_at || '-'}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a href="/ai-analysis" target="_blank" class="btn btn-ghost btn-sm" style="padding:3px 10px;font-size:11px;text-decoration:none">查看</a>
|
||||
<button class="btn btn-destructive btn-sm" style="padding:3px 10px;font-size:11px" onclick="deleteReport(${r.id})">删除</button>
|
||||
</td>
|
||||
</tr>`
|
||||
).join('') || '<tr><td colspan="6" style="color:var(--muted-foreground);text-align:center">暂无报告</td></tr>';
|
||||
}
|
||||
|
||||
async function doTrigger() {
|
||||
const btn = document.getElementById('triggerBtn');
|
||||
const spinner = document.getElementById('triggerSpinner');
|
||||
const result = document.getElementById('triggerResult');
|
||||
btn.disabled = true;
|
||||
spinner.style.display = 'inline-block';
|
||||
result.className = 'trigger-result';
|
||||
try {
|
||||
const data = await api('POST', '/api/admin/trigger-analysis');
|
||||
result.className = 'trigger-result show success';
|
||||
result.innerHTML = `✅ 分析完成 — Tokens: ${data.tokens_used} | 工具: ${(data.tools_used || []).length} 次调用`;
|
||||
showDashboard();
|
||||
} catch (e) {
|
||||
result.className = 'trigger-result show error';
|
||||
result.innerHTML = `❌ ${e.message}`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReport(id) {
|
||||
if (!confirm('确定删除该报告?')) return;
|
||||
try {
|
||||
await api('DELETE', '/api/admin/reports/' + id);
|
||||
toast('已删除', true);
|
||||
showDashboard();
|
||||
} catch (e) { toast(e.message, false); }
|
||||
}
|
||||
|
||||
function showChangePwd() {
|
||||
document.getElementById('pwdModal').classList.add('open');
|
||||
document.getElementById('oldPwd').value = '';
|
||||
document.getElementById('newPwd').value = '';
|
||||
document.getElementById('pwdError').style.display = 'none';
|
||||
}
|
||||
function hideChangePwd() { document.getElementById('pwdModal').classList.remove('open'); }
|
||||
|
||||
async function doChangePwd() {
|
||||
const oldp = document.getElementById('oldPwd').value;
|
||||
const newp = document.getElementById('newPwd').value;
|
||||
const errEl = document.getElementById('pwdError');
|
||||
try {
|
||||
await api('POST', '/api/admin/change-password', { old_password: oldp, new_password: newp });
|
||||
hideChangePwd();
|
||||
toast('密码已更新', true);
|
||||
} catch (e) {
|
||||
errEl.textContent = e.message;
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+45
-3
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -6,15 +7,36 @@ from contextlib import asynccontextmanager
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from database import init_db
|
||||
from routes import stock, collections, shares, sectors, themes
|
||||
from routes import stock, collections, shares, themes, core_stocks, fuyao, market_dashboard, ai_analysis, admin
|
||||
from services.daily_collector import collector_loop, cache_cleanup_loop
|
||||
from services.ai_collector import ai_analysis_loop
|
||||
from services.admin_auth import is_configured, set_password
|
||||
|
||||
load_dotenv()
|
||||
|
||||
DEFAULT_ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "!auvauv")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
# 首次启动自动设置默认管理密码
|
||||
if not is_configured():
|
||||
set_password(DEFAULT_ADMIN_PASSWORD)
|
||||
print(f"[admin] 已设置默认管理密码,请尽快修改: python3 admin_cli.py")
|
||||
collector_task = asyncio.create_task(collector_loop())
|
||||
cache_cleanup_task = asyncio.create_task(cache_cleanup_loop())
|
||||
ai_analysis_task = asyncio.create_task(ai_analysis_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
|
||||
t.cancel()
|
||||
for t in (collector_task, cache_cleanup_task, ai_analysis_task):
|
||||
try:
|
||||
await t
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
app = FastAPI(title="AUV API", version="1.0.0", lifespan=lifespan)
|
||||
@@ -30,20 +52,40 @@ app.add_middleware(
|
||||
app.include_router(stock.router, prefix="/api/stock")
|
||||
app.include_router(collections.router, prefix="/api/collections")
|
||||
app.include_router(shares.router, prefix="/api/share")
|
||||
app.include_router(sectors.router, prefix="/api/sectors")
|
||||
app.include_router(themes.router, prefix="/api/themes")
|
||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||
app.include_router(fuyao.router, prefix="/api/v2")
|
||||
app.include_router(market_dashboard.router, prefix="/api")
|
||||
app.include_router(ai_analysis.router, prefix="/api")
|
||||
app.include_router(admin.router, prefix="/api")
|
||||
|
||||
# 生产模式:后端同时托管前端静态文件
|
||||
# catch-all 路由在 API 路由之后注册,所以 API 优先级更高
|
||||
dist_path = os.path.join(os.path.dirname(__file__), "dist")
|
||||
|
||||
# HTML 不缓存:保证 index.html 永远最新(引用的资源文件名带 hash,可长缓存)
|
||||
_NO_CACHE_HTML = {"Cache-Control": "no-cache, no-store, must-revalidate"}
|
||||
|
||||
# 管理面板:/admin → admin.html(在 SPA catch-all 之前)
|
||||
ADMIN_HTML = os.path.join(dist_path, "admin.html")
|
||||
|
||||
@app.get("/admin")
|
||||
async def serve_admin():
|
||||
if os.path.isfile(ADMIN_HTML):
|
||||
return FileResponse(ADMIN_HTML, media_type="text/html", headers=_NO_CACHE_HTML)
|
||||
return JSONResponse({"detail": "Admin panel not found"}, status_code=404)
|
||||
|
||||
if os.path.isdir(dist_path):
|
||||
@app.get("/{full_path:path}")
|
||||
async def serve_spa(full_path: str):
|
||||
file_path = os.path.join(dist_path, full_path) if full_path else os.path.join(dist_path, "index.html")
|
||||
if os.path.isfile(file_path):
|
||||
# 静态资源(带 hash 的文件名)可缓存;HTML 不缓存
|
||||
if file_path.endswith(".html"):
|
||||
return FileResponse(file_path, headers=_NO_CACHE_HTML)
|
||||
return FileResponse(file_path)
|
||||
# SPA fallback: 非文件路径统一返回 index.html
|
||||
index_path = os.path.join(dist_path, "index.html")
|
||||
if os.path.isfile(index_path):
|
||||
return FileResponse(index_path, media_type="text/html")
|
||||
return FileResponse(index_path, media_type="text/html", headers=_NO_CACHE_HTML)
|
||||
return JSONResponse({"detail": "Not Found"}, status_code=404)
|
||||
|
||||
@@ -4,3 +4,5 @@ httpx==0.27.0
|
||||
python-dotenv==1.0.1
|
||||
akshare==1.18.64
|
||||
mootdx
|
||||
requests>=2.31,<3
|
||||
pypinyin
|
||||
@@ -0,0 +1,168 @@
|
||||
"""管理面板路由(/api/admin)
|
||||
|
||||
提供:
|
||||
- 登录认证(密码 + token)
|
||||
- 触发 AI 分析
|
||||
- 浏览统计
|
||||
- 报告管理
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services import admin_auth
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _require_auth(request: Request) -> str:
|
||||
"""从 header 或 cookie 提取 token 并验证"""
|
||||
token = request.headers.get("X-Admin-Token", "")
|
||||
if not token:
|
||||
# fallback: 从 cookie 读
|
||||
token = request.cookies.get("admin_token", "")
|
||||
if not admin_auth.verify_token(token):
|
||||
raise HTTPException(status_code=401, detail="未登录或登录已过期")
|
||||
return token
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/admin/login", summary="管理员登录")
|
||||
async def admin_login(body: LoginRequest):
|
||||
if not admin_auth.is_configured():
|
||||
# 自动设置默认密码
|
||||
import os
|
||||
default_pw = os.getenv("ADMIN_PASSWORD", "!auvauv")
|
||||
admin_auth.set_password(default_pw)
|
||||
if not admin_auth.verify_password(body.password):
|
||||
raise HTTPException(status_code=401, detail="密码错误")
|
||||
token = admin_auth.create_token()
|
||||
return JSONResponse({"data": {"token": token, "expiresIn": admin_auth.TOKEN_TTL}})
|
||||
|
||||
|
||||
@router.post("/admin/logout", summary="管理员登出")
|
||||
async def admin_logout(request: Request):
|
||||
token = request.headers.get("X-Admin-Token", "") or request.cookies.get("admin_token", "")
|
||||
admin_auth.revoke_token(token)
|
||||
return JSONResponse({"data": "ok"})
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
old_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
@router.post("/admin/change-password", summary="修改管理密码")
|
||||
async def admin_change_password(body: ChangePasswordRequest, request: Request):
|
||||
_require_auth(request)
|
||||
if not admin_auth.verify_password(body.old_password):
|
||||
raise HTTPException(status_code=401, detail="原密码错误")
|
||||
if len(body.new_password) < 4:
|
||||
raise HTTPException(status_code=400, detail="密码至少4位")
|
||||
admin_auth.set_password(body.new_password)
|
||||
return JSONResponse({"data": "密码已更新"})
|
||||
|
||||
|
||||
@router.get("/admin/stats", summary="管理面板统计数据")
|
||||
async def admin_stats(request: Request):
|
||||
_require_auth(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
stats = {}
|
||||
|
||||
# 报告总数
|
||||
row = conn.execute("SELECT COUNT(*) as cnt FROM ai_reports").fetchone()
|
||||
stats["reportCount"] = row["cnt"]
|
||||
|
||||
# 总 tokens
|
||||
row = conn.execute("SELECT COALESCE(SUM(tokens_used), 0) as total FROM ai_reports").fetchone()
|
||||
stats["totalTokens"] = row["total"]
|
||||
|
||||
# 最新报告
|
||||
row = conn.execute(
|
||||
"SELECT trade_date, title FROM ai_reports ORDER BY trade_date DESC LIMIT 1"
|
||||
).fetchone()
|
||||
stats["latestReport"] = dict_from_row(row) if row else None
|
||||
|
||||
# 集合数
|
||||
row = conn.execute("SELECT COUNT(*) as cnt FROM stock_collections").fetchone()
|
||||
stats["collectionCount"] = row["cnt"]
|
||||
|
||||
# 关注股票数
|
||||
row = conn.execute("SELECT COUNT(*) as cnt FROM collection_stocks").fetchone()
|
||||
stats["stockCount"] = row["cnt"]
|
||||
|
||||
# 分享链接数
|
||||
row = conn.execute("SELECT COUNT(*) as cnt FROM share_links").fetchone()
|
||||
stats["shareLinkCount"] = row["cnt"]
|
||||
|
||||
# 核心股天数
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(DISTINCT trade_date) as cnt FROM daily_core_stocks"
|
||||
).fetchone()
|
||||
stats["coreStockDays"] = row["cnt"]
|
||||
|
||||
# 近7天报告
|
||||
rows = conn.execute(
|
||||
"SELECT trade_date, title, tokens_used, created_at FROM ai_reports "
|
||||
"ORDER BY trade_date DESC LIMIT 7"
|
||||
).fetchall()
|
||||
stats["recentReports"] = [dict_from_row(r) for r in rows]
|
||||
|
||||
return JSONResponse({"data": stats}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/admin/trigger-analysis", summary="触发 AI 分析")
|
||||
async def admin_trigger_analysis(request: Request):
|
||||
_require_auth(request)
|
||||
now = datetime.now(_CST)
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
return JSONResponse({"data": result})
|
||||
|
||||
|
||||
@router.get("/admin/reports", summary="报告列表(管理用)")
|
||||
async def admin_list_reports(request: Request):
|
||||
_require_auth(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
item = dict_from_row(r)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
items.append(item)
|
||||
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/admin/reports/{report_id}", summary="删除报告")
|
||||
async def admin_delete_report(report_id: int, request: Request):
|
||||
_require_auth(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM ai_reports WHERE id = ?", (report_id,))
|
||||
conn.commit()
|
||||
return JSONResponse({"data": "已删除"})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""AI 分析报告路由(/api/ai-analysis)
|
||||
|
||||
提供:
|
||||
- 报告列表
|
||||
- 报告详情
|
||||
- 手动触发分析(12小时频率限制)
|
||||
- 重新生成报告
|
||||
- 检查某日是否有报告
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _has_ai_report(trade_date: str) -> bool:
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _can_trigger(trade_date: str) -> tuple[bool, str]:
|
||||
"""检查是否可以触发分析(同一交易日内可随时重新生成)"""
|
||||
return True, ""
|
||||
|
||||
|
||||
@router.get("/ai-analysis/latest", summary="最新 AI 分析报告")
|
||||
async def get_latest_report():
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="暂无分析报告")
|
||||
item = dict_from_row(row)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis", summary="AI 分析报告列表")
|
||||
async def list_reports():
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
|
||||
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
item = dict_from_row(r)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
items.append(item)
|
||||
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
|
||||
async def get_report(report_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
item = dict_from_row(row)
|
||||
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
|
||||
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/ai-analysis/check/{trade_date}", summary="检查某日是否有 AI 分析报告")
|
||||
async def check_report(trade_date: str):
|
||||
has = _has_ai_report(trade_date)
|
||||
return JSONResponse({"data": {"hasReport": has, "tradeDate": trade_date}})
|
||||
|
||||
|
||||
@router.post("/ai-analysis/{report_id}/regenerate", summary="重新生成 AI 分析报告")
|
||||
async def regenerate_report(report_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT trade_date FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="报告不存在")
|
||||
trade_date = row["trade_date"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
return JSONResponse({"data": result})
|
||||
@@ -0,0 +1,117 @@
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
from datetime import date as date_cls
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
|
||||
"""最近 n 个有数据的交易日(升序)"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(n,),
|
||||
).fetchall()
|
||||
return [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
||||
async def active_core_stocks():
|
||||
conn = get_connection()
|
||||
try:
|
||||
dates = _recent_trade_dates(conn, 10)
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
|
||||
stock_days: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"coverCount": r["cover_count"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
# 稳定排序:先按出现次数降序,再按最近上榜日降序
|
||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
||||
stocks.sort(key=lambda x: -x["appearCount"])
|
||||
|
||||
# 一次性取窗口内全部题材关联,按 stock_code 分组(跨天题材去重)
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
dates,
|
||||
).fetchall()
|
||||
themes_by_stock: dict[str, dict[str, dict]] = {}
|
||||
for t in themes_rows:
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
|
||||
# daysSinceLastAppear:最近上榜距窗口最新交易日的自然日差(简单口径)
|
||||
latest = dates[-1] if dates else None
|
||||
for s in stocks:
|
||||
if s.get("lastAppear") and latest:
|
||||
try:
|
||||
d1 = date_cls.fromisoformat(latest)
|
||||
d2 = date_cls.fromisoformat(s["lastAppear"])
|
||||
s["daysSinceLastAppear"] = (d1 - d2).days
|
||||
except ValueError:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
else:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
|
||||
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
# 一次性取该日全部题材关联,按 stock_code 分组,避免逐股 N+1 查询
|
||||
themes_rows = conn.execute(
|
||||
"SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ?",
|
||||
(date,),
|
||||
).fetchall()
|
||||
themes_by_stock: dict[str, list] = {}
|
||||
for t in themes_rows:
|
||||
themes_by_stock.setdefault(t["stock_code"], []).append(
|
||||
{"theme_code": t["theme_code"], "theme_name": t["theme_name"]}
|
||||
)
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict_from_row(r)
|
||||
d["themes"] = themes_by_stock.get(d["stock_code"], [])
|
||||
items.append(d)
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,283 @@
|
||||
"""v2 数据接口路由:同花顺官方金融数据 API(/api/v2)
|
||||
|
||||
保留现有 /api/* 为 v1(腾讯/东财/新浪等抓取源),本模块提供独立 v2。
|
||||
数据源:https://fuyao.aicubes.cn(同花顺官方),密钥仅后端持有。
|
||||
|
||||
接口返回统一使用现有 v1 的 data 信封风格:{"data": ..., "count": ...},
|
||||
上游错误转 HTTPException,密钥永不出现在响应中。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from services import fuyao_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 上游反代/CDN 可能按 path 缓存,显式禁止缓存
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
async def _guard(awaitable):
|
||||
"""调用 fuyao_client,把上游/校验错误统一转 HTTPException"""
|
||||
try:
|
||||
return await awaitable
|
||||
except fuyao_client.FuyaoError as e:
|
||||
# 上游业务错误,message 来自上游,不含密钥
|
||||
raise HTTPException(status_code=502, detail=f"同花顺API: {e}")
|
||||
except ValueError as e:
|
||||
# SDK 参数校验错误(如 thscode 格式)
|
||||
raise HTTPException(status_code=400, detail=f"参数错误: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 基础数据
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/meta/tickers/search", summary="v2 标的检索")
|
||||
async def v2_ticker_search(
|
||||
q: str = Query(..., description="代码/名称/拼音关键词"),
|
||||
exchange: str = Query(None, description="交易所 SH/SZ/BJ"),
|
||||
asset_type: str = Query(None, description="资产类型 a-share 等"),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
):
|
||||
items = await _guard(fuyao_client.ticker_search(q, exchange, asset_type, limit))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/meta/tickers/list", summary="v2 标的列表(分页)")
|
||||
async def v2_ticker_list(
|
||||
asset_type: str = Query(None, description="资产类型"),
|
||||
limit: int = Query(100, ge=1, le=10000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
items = await _guard(fuyao_client.ticker_list(asset_type, limit, offset))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# A股行情 / 日历 / 竞价
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/prices/snapshot", summary="v2 行情快照(单只/多只)")
|
||||
async def v2_prices_snapshot(thscodes: str = Query(..., description="逗号分隔的 thscode,如 600519.SH,000001.SZ")):
|
||||
items = await _guard(fuyao_client.prices_snapshot(thscodes))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/prices/historical", summary="v2 历史日K(窗口≤10年)")
|
||||
async def v2_prices_historical(
|
||||
thscode: str = Query(..., description="标的 thscode,单只"),
|
||||
start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"),
|
||||
end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"),
|
||||
adjust: str = Query("forward", description="复权 none/forward/backward"),
|
||||
):
|
||||
try:
|
||||
items = await fuyao_client.prices_historical(thscode, start, end, adjust)
|
||||
except fuyao_client.FuyaoError as e:
|
||||
raise HTTPException(status_code=502, detail=f"同花顺API: {e}")
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f"参数错误: {e}")
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/corporate-actions/adjustment-factors", summary="v2 复权因子事件流")
|
||||
async def v2_adjustment_factors(
|
||||
thscode: str = Query(..., description="标的 thscode,单只"),
|
||||
from_date: str = Query(None, description="起始日 yyyy-MM-dd"),
|
||||
to_date: str = Query(None, description="截止日 yyyy-MM-dd"),
|
||||
):
|
||||
data = await _guard(fuyao_client.corp_actions_adjustment_factors(thscode, from_date, to_date))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/calendar/trading-days", summary="v2 近一年交易日序列")
|
||||
async def v2_calendar():
|
||||
items = await _guard(fuyao_client.calendar_trading_days())
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/auction/snapshot", summary="v2 集合竞价快照")
|
||||
async def v2_auction_snapshot(
|
||||
thscodes: str = Query(..., description="逗号分隔 thscode"),
|
||||
stage: str = Query("final", description="live 实时 / final 终态"),
|
||||
):
|
||||
data = await _guard(fuyao_client.auction_snapshot(thscodes, stage))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/auction/short-term-benchmark", summary="v2 短线风向标竞价基准")
|
||||
async def v2_auction_benchmark(date: str = Query(None, description="日期 yyyy-MM-dd")):
|
||||
data = await _guard(fuyao_client.auction_short_term_benchmark(date))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# A股财务 / 估值
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/financials/{statement}", summary="v2 三大财务报表")
|
||||
async def v2_financials(
|
||||
statement: str,
|
||||
thscode: str = Query(..., description="标的 thscode"),
|
||||
period: str = Query("annual", description="annual 年报 / quarterly 季报"),
|
||||
limit: int = Query(6, ge=1, le=20),
|
||||
):
|
||||
if statement not in ("income-statements", "balance-sheets", "cash-flow-statements"):
|
||||
raise HTTPException(status_code=400, detail="statement 仅支持 income-statements/balance-sheets/cash-flow-statements")
|
||||
items = await _guard(fuyao_client.financials(statement, thscode, period, limit))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/financials/indicators", summary="v2 五类财务指标")
|
||||
async def v2_financial_indicators(
|
||||
thscode: str = Query(..., description="标的 thscode"),
|
||||
report: str = Query(..., description="报告期 yyyy-1 ~ yyyy-4"),
|
||||
):
|
||||
items = await _guard(fuyao_client.financial_indicators(thscode, report))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/valuations/snapshot", summary="v2 估值快照")
|
||||
async def v2_valuations_snapshot(thscodes: str = Query(..., description="逗号分隔 thscode")):
|
||||
items = await _guard(fuyao_client.valuations_snapshot(thscodes))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 指数 / 板块
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/index/catalog", summary="v2 同花顺指数清单")
|
||||
async def v2_index_catalog(tag: str = Query("industry", description="cn_concept/region/tszs/industry")):
|
||||
items = await _guard(fuyao_client.index_catalog(tag))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/index/constituents", summary="v2 指数成分股")
|
||||
async def v2_index_constituents(thscode: str = Query(..., description="指数 thscode")):
|
||||
items = await _guard(fuyao_client.index_constituents(thscode))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/index/prices/snapshot", summary="v2 指数行情快照")
|
||||
async def v2_index_prices_snapshot(thscodes: str = Query(..., description="逗号分隔指数 thscode")):
|
||||
items = await _guard(fuyao_client.index_prices_snapshot(thscodes))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/index/prices/historical", summary="v2 指数历史K线")
|
||||
async def v2_index_prices_historical(
|
||||
thscode: str = Query(..., description="指数 thscode"),
|
||||
start: int = Query(..., description="起始时间,毫秒 Unix 时间戳"),
|
||||
end: int = Query(..., description="结束时间,毫秒 Unix 时间戳"),
|
||||
):
|
||||
items = await _guard(fuyao_client.index_prices_historical(thscode, start, end))
|
||||
return JSONResponse({"data": items, "count": len(items)}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 特殊数据(涨停/跌停/炸板/连板/热股/龙虎/异动/飙升)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/special/limit-up-pool", summary="v2 涨停池")
|
||||
async def v2_limit_up_pool(
|
||||
date: str = Query(None, description="日期 yyyy-MM-dd"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
sort_field: str = Query("seal_money", description="排序字段"),
|
||||
sort_dir: str = Query("desc", description="asc/desc"),
|
||||
):
|
||||
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
|
||||
data = await _guard(fuyao_client.limit_up_pool(date_ms, page, size, sort_field, sort_dir))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/limit-down-pool", summary="v2 跌停池")
|
||||
async def v2_limit_down_pool(
|
||||
date: str = Query(None, description="日期 yyyy-MM-dd"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
sort_field: str = Query("last_limit_time"),
|
||||
sort_dir: str = Query("desc"),
|
||||
):
|
||||
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
|
||||
data = await _guard(fuyao_client.limit_down_pool(date_ms, page, size, sort_field, sort_dir))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/limit-break-pool", summary="v2 炸板池")
|
||||
async def v2_limit_break_pool(
|
||||
date: str = Query(None, description="日期 yyyy-MM-dd"),
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(50, ge=1, le=200),
|
||||
sort_field: str = Query("price_change_ratio_pct"),
|
||||
sort_dir: str = Query("desc"),
|
||||
):
|
||||
date_ms = int(datetime.strptime(date, "%Y-%m-%d").timestamp() * 1000) if date else None
|
||||
data = await _guard(fuyao_client.limit_break_pool(date_ms, page, size, sort_field, sort_dir))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/limit-up-ladder", summary="v2 连板梯队")
|
||||
async def v2_limit_up_ladder():
|
||||
data = await _guard(fuyao_client.limit_up_ladder())
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/hot-stock-list", summary="v2 热股榜")
|
||||
async def v2_hot_stock_list(
|
||||
period: str = Query("day", description="day/week/month"),
|
||||
):
|
||||
data = await _guard(fuyao_client.hot_stock_list(period))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/hot-stock-history", summary="v2 热股榜历史")
|
||||
async def v2_hot_stock_history(date: str = Query(..., description="日期 yyyy-MM-dd")):
|
||||
data = await _guard(fuyao_client.hot_stock_list_history(date))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/hot-stock-rank-trend", summary="v2 热股排名趋势")
|
||||
async def v2_hot_stock_rank_trend(
|
||||
thscode: str = Query(..., description="标的 thscode"),
|
||||
start_date: str = Query(..., description="起始日期 yyyy-MM-dd"),
|
||||
end_date: str = Query(..., description="结束日期 yyyy-MM-dd"),
|
||||
):
|
||||
data = await _guard(fuyao_client.hot_stock_rank_trend(thscode, start_date, end_date))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/dragon-tiger-list", summary="v2 龙虎榜")
|
||||
async def v2_dragon_tiger_list(
|
||||
board_type: str = Query("all", description="all/sh/sz"),
|
||||
date: str = Query(None, description="日期 yyyy-MM-dd"),
|
||||
):
|
||||
data = await _guard(fuyao_client.dragon_tiger_list(board_type, date))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/anomaly-list", summary="v2 异动分析列表")
|
||||
async def v2_anomaly_list(
|
||||
tag_codes: str = Query(None, description="逗号分隔异动类型编码"),
|
||||
):
|
||||
codes = [c.strip() for c in tag_codes.split(",") if c.strip()] if tag_codes else None
|
||||
data = await _guard(fuyao_client.anomaly_analysis_list(codes))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/anomaly-stock", summary="v2 个股异动明细")
|
||||
async def v2_anomaly_stock(
|
||||
thscodes: str = Query(..., description="逗号分隔 thscode,最多50只"),
|
||||
):
|
||||
data = await _guard(fuyao_client.anomaly_analysis_stock(thscodes))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/special/skyrocket-list", summary="v2 飙升榜")
|
||||
async def v2_skyrocket_list(
|
||||
period: str = Query("day", description="day/week/month"),
|
||||
):
|
||||
data = await _guard(fuyao_client.skyrocket_list(period))
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
@@ -0,0 +1,511 @@
|
||||
"""市场看板数据聚合路由(/api/market-dashboard)
|
||||
|
||||
聚合同花顺 SDK 多个接口,一次性返回前端看板所需的全部数据:
|
||||
- 主要指数行情 + 估值分位(PE/PB)
|
||||
- 集合竞价信号(竞价基准线 + 情绪判断)
|
||||
- 全市场涨跌统计(上涨/下跌/平盘/涨停/跌停/炸板/成交额)
|
||||
- 市场温度评分(结合竞价基准校准)
|
||||
- 涨跌家数分布
|
||||
- 行业强度榜(同花顺行业指数 + 概念板块热度)
|
||||
- 事件情报(连板天梯/热门股/涨停封单/炸板/飙升/龙虎榜)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from services import fuyao_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
_cache: dict = {"data": None, "at": 0.0}
|
||||
_CACHE_TTL = 30
|
||||
|
||||
# 主要指数 thscode 列表
|
||||
_MAIN_INDEX_THSCODES = [
|
||||
"000001.SH", # 上证指数
|
||||
"399001.SZ", # 深证成指
|
||||
"399006.SZ", # 创业板指
|
||||
"000688.SH", # 科创50
|
||||
"000300.SH", # 沪深300
|
||||
]
|
||||
|
||||
_INDEX_NAMES = {
|
||||
"000001.SH": "上证指数",
|
||||
"399001.SZ": "深证成指",
|
||||
"399006.SZ": "创业板指",
|
||||
"000688.SH": "科创50",
|
||||
"000300.SH": "沪深300",
|
||||
}
|
||||
|
||||
BJT = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0):
|
||||
try:
|
||||
v = float(val)
|
||||
return v if not math.isnan(v) and not math.isinf(v) else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _calc_temperature(up: int, down: int, flat: int, median_pct: float,
|
||||
strong: int, weak: int, limit_up: int, limit_down: int,
|
||||
break_count: int = 0, auction_signal: str = "") -> dict:
|
||||
"""市场温度评分(0-100):综合涨跌比、中位数涨跌、强弱比、涨停活跃度、炸板率、竞价信号"""
|
||||
total = up + down + flat
|
||||
if total == 0:
|
||||
return {"score": 50, "label": "中性", "factors": {}}
|
||||
|
||||
# 涨跌比得分(0-25分)
|
||||
advance_ratio = up / total
|
||||
advance_score = min(advance_ratio * 50, 25)
|
||||
|
||||
# 中位数涨跌得分(0-20分):-3%~+3% 映射到 0~20
|
||||
median_score = max(0, min(20, (median_pct + 3) / 6 * 20))
|
||||
|
||||
# 强弱比得分(0-20分)
|
||||
sw_total = strong + weak
|
||||
if sw_total > 0:
|
||||
strong_ratio = strong / sw_total
|
||||
strong_score = min(strong_ratio * 40, 20)
|
||||
else:
|
||||
strong_score = 10
|
||||
|
||||
# 涨停活跃度得分(0-15分)
|
||||
limit_score = min(limit_up / 80 * 15, 15)
|
||||
|
||||
# 炸板惩罚(0-10分):炸板率越高越扣分
|
||||
total_attempted = limit_up + break_count
|
||||
break_penalty = 0
|
||||
if total_attempted > 0:
|
||||
break_rate = break_count / total_attempted
|
||||
break_penalty = break_rate * 10 # 炸板率 50% → 扣 5 分
|
||||
|
||||
# 竞价信号加成(±5分)
|
||||
auction_bonus = 0
|
||||
if auction_signal == "强势高开":
|
||||
auction_bonus = 5
|
||||
elif auction_signal == "偏强":
|
||||
auction_bonus = 2
|
||||
elif auction_signal == "弱势低开":
|
||||
auction_bonus = -5
|
||||
elif auction_signal == "偏弱":
|
||||
auction_bonus = -2
|
||||
|
||||
raw = advance_score + median_score + strong_score + limit_score - break_penalty + auction_bonus
|
||||
score = round(max(0, min(100, raw)), 1)
|
||||
|
||||
if score >= 80:
|
||||
label = "强势"
|
||||
elif score >= 60:
|
||||
label = "偏强"
|
||||
elif score >= 40:
|
||||
label = "中性"
|
||||
elif score >= 20:
|
||||
label = "偏弱"
|
||||
else:
|
||||
label = "弱势"
|
||||
|
||||
factors = {
|
||||
"advanceScore": round(advance_score, 1),
|
||||
"medianScore": round(median_score, 1),
|
||||
"strongScore": round(strong_score, 1),
|
||||
"limitScore": round(limit_score, 1),
|
||||
"breakPenalty": round(-break_penalty, 1),
|
||||
"auctionBonus": auction_bonus,
|
||||
}
|
||||
|
||||
return {"score": score, "label": label, "factors": factors}
|
||||
|
||||
|
||||
async def _build_dashboard() -> dict:
|
||||
now = time.time()
|
||||
|
||||
if _cache["data"] is not None and now - _cache["at"] < _CACHE_TTL:
|
||||
return _cache["data"]
|
||||
|
||||
# ── 第一批并行拉取(核心数据) ──
|
||||
try:
|
||||
(
|
||||
index_data,
|
||||
all_stocks,
|
||||
limit_up_data,
|
||||
limit_down_data,
|
||||
limit_break_data,
|
||||
industry_catalog,
|
||||
concept_catalog,
|
||||
auction_benchmark,
|
||||
) = await asyncio.gather(
|
||||
fuyao_client.index_prices_snapshot(",".join(_MAIN_INDEX_THSCODES)),
|
||||
fuyao_client.prices_snapshot_all(limit=5000),
|
||||
fuyao_client.limit_up_pool(page=1, size=200),
|
||||
fuyao_client.limit_down_pool(page=1, size=1),
|
||||
fuyao_client.limit_break_pool(page=1, size=1),
|
||||
fuyao_client.index_catalog("industry"),
|
||||
fuyao_client.index_catalog("cn_concept"),
|
||||
fuyao_client.auction_short_term_benchmark(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"市场数据拉取失败: {e}")
|
||||
|
||||
# ── 第二批并行拉取(事件数据,依赖第一批结果较轻) ──
|
||||
try:
|
||||
(
|
||||
hot_stock_data,
|
||||
dragon_tiger_data,
|
||||
anomaly_data,
|
||||
limit_ladder_data,
|
||||
skyrocket_data,
|
||||
) = await asyncio.gather(
|
||||
fuyao_client.hot_stock_list("day"),
|
||||
fuyao_client.dragon_tiger_list("all"),
|
||||
fuyao_client.anomaly_analysis_list(["SHARP_RISE", "RAPID_RALLY", "LIMIT_UP"]),
|
||||
fuyao_client.limit_up_ladder(),
|
||||
fuyao_client.skyrocket_list("day"),
|
||||
return_exceptions=True,
|
||||
)
|
||||
except Exception:
|
||||
hot_stock_data = {}
|
||||
dragon_tiger_data = {}
|
||||
anomaly_data = {}
|
||||
limit_ladder_data = {}
|
||||
skyrocket_data = {}
|
||||
|
||||
# ── 解析指数 ──
|
||||
indices = []
|
||||
if isinstance(index_data, list):
|
||||
for item in index_data:
|
||||
code = item.get("thscode", "")
|
||||
indices.append({
|
||||
"code": code,
|
||||
"name": _INDEX_NAMES.get(code, item.get("name", code)),
|
||||
"price": _safe_float(item.get("last_price")),
|
||||
"change": _safe_float(item.get("price_change")),
|
||||
"changePct": _safe_float(item.get("price_change_ratio_pct")),
|
||||
"prevClose": _safe_float(item.get("prev_price")),
|
||||
"turnover": _safe_float(item.get("turnover")),
|
||||
})
|
||||
|
||||
# ── 指数估值(PE/PB) — 同花顺估值API仅支持个股,指数暂不支持 ──
|
||||
|
||||
# ── 解析全市场涨跌统计 ──
|
||||
up_count = 0
|
||||
down_count = 0
|
||||
flat_count = 0
|
||||
total_turnover = 0.0
|
||||
all_changes = []
|
||||
|
||||
stocks_list = all_stocks if isinstance(all_stocks, list) else []
|
||||
for s in stocks_list:
|
||||
chg = _safe_float(s.get("price_change_ratio_pct"))
|
||||
turnover = _safe_float(s.get("turnover"))
|
||||
total_turnover += turnover
|
||||
|
||||
if chg > 0:
|
||||
up_count += 1
|
||||
elif chg < 0:
|
||||
down_count += 1
|
||||
else:
|
||||
flat_count += 1
|
||||
|
||||
all_changes.append(chg)
|
||||
|
||||
total_stocks = up_count + down_count + flat_count
|
||||
median_change = sorted(all_changes)[len(all_changes) // 2] if all_changes else 0
|
||||
|
||||
# 涨停/跌停/炸板 精确统计
|
||||
limit_up_count = 0
|
||||
limit_down_count = 0
|
||||
break_count = 0
|
||||
if isinstance(limit_up_data, dict):
|
||||
pagination = limit_up_data.get("pagination", {})
|
||||
limit_up_count = pagination.get("total", 0) or len(limit_up_data.get("item", []))
|
||||
if isinstance(limit_down_data, dict):
|
||||
pagination = limit_down_data.get("pagination", {})
|
||||
limit_down_count = pagination.get("total", 0) or len(limit_down_data.get("item", []))
|
||||
if isinstance(limit_break_data, dict):
|
||||
pagination = limit_break_data.get("pagination", {})
|
||||
break_count = pagination.get("total", 0) or len(limit_break_data.get("item", []))
|
||||
|
||||
# 强势/弱势(涨幅 > 2% 为强,< -2% 为弱)
|
||||
strong_count = sum(1 for c in all_changes if c > 2)
|
||||
weak_count = sum(1 for c in all_changes if c < -2)
|
||||
|
||||
# 市场宽度
|
||||
market_breadth = round(up_count / total_stocks * 100, 1) if total_stocks > 0 else 50
|
||||
|
||||
# ── 集合竞价信号 ──
|
||||
auction_signal = ""
|
||||
auction_detail = {}
|
||||
if isinstance(auction_benchmark, dict) and not isinstance(auction_benchmark, Exception):
|
||||
# 短线风向标:根据竞价基准判断多空
|
||||
benchmark_score = _safe_float(auction_benchmark.get("score", 0))
|
||||
benchmark_label = auction_benchmark.get("label", "")
|
||||
auction_detail = {
|
||||
"score": benchmark_score,
|
||||
"label": benchmark_label,
|
||||
"date": auction_benchmark.get("date", ""),
|
||||
"benchmark": auction_benchmark.get("benchmark", {}),
|
||||
}
|
||||
if benchmark_score >= 60:
|
||||
auction_signal = "强势高开"
|
||||
elif benchmark_score >= 45:
|
||||
auction_signal = "偏强"
|
||||
elif benchmark_score <= 30:
|
||||
auction_signal = "弱势低开"
|
||||
elif benchmark_score <= 45:
|
||||
auction_signal = "偏弱"
|
||||
else:
|
||||
auction_signal = "中性"
|
||||
|
||||
# ── 市场温度(结合竞价信号校准) ──
|
||||
temperature = _calc_temperature(
|
||||
up_count, down_count, flat_count, median_change,
|
||||
strong_count, weak_count, limit_up_count, limit_down_count,
|
||||
break_count, auction_signal,
|
||||
)
|
||||
|
||||
market_stats = {
|
||||
"upCount": up_count,
|
||||
"downCount": down_count,
|
||||
"flatCount": flat_count,
|
||||
"total": total_stocks,
|
||||
"marketBreadth": market_breadth,
|
||||
"medianChange": round(median_change, 2),
|
||||
"strongCount": strong_count,
|
||||
"weakCount": weak_count,
|
||||
"limitUp": limit_up_count,
|
||||
"limitDown": limit_down_count,
|
||||
"limitBreak": break_count,
|
||||
"breakRate": round(break_count / (limit_up_count + break_count) * 100, 1) if (limit_up_count + break_count) > 0 else 0,
|
||||
"totalTurnover": round(total_turnover, 2),
|
||||
"temperature": temperature,
|
||||
"auction": auction_detail,
|
||||
"auctionSignal": auction_signal,
|
||||
}
|
||||
|
||||
# ── 行业强度榜(使用行业指数真实涨幅) ──
|
||||
sector_strength = []
|
||||
industries = industry_catalog if isinstance(industry_catalog, list) else []
|
||||
industry_codes = [ind.get("thscode", "") for ind in industries[:31] if ind.get("thscode")]
|
||||
|
||||
if industry_codes:
|
||||
try:
|
||||
industry_snapshots = []
|
||||
batch_size = 50
|
||||
for i in range(0, len(industry_codes), batch_size):
|
||||
batch = industry_codes[i:i + batch_size]
|
||||
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
|
||||
if isinstance(snap, list):
|
||||
industry_snapshots.extend(snap)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
for snap_item in industry_snapshots:
|
||||
code = snap_item.get("thscode", "")
|
||||
name = ""
|
||||
for ind in industries:
|
||||
if ind.get("thscode") == code:
|
||||
name = ind.get("name", code)
|
||||
break
|
||||
|
||||
gain_pct = _safe_float(snap_item.get("price_change_ratio_pct"))
|
||||
last = _safe_float(snap_item.get("last_price"))
|
||||
|
||||
sector_strength.append({
|
||||
"code": code,
|
||||
"name": name or code,
|
||||
"price": last,
|
||||
"change": _safe_float(snap_item.get("price_change")),
|
||||
"changePct": gain_pct,
|
||||
})
|
||||
|
||||
sector_strength.sort(key=lambda x: x["changePct"], reverse=True)
|
||||
|
||||
for sec in sector_strength[:31]:
|
||||
gp = sec["changePct"]
|
||||
strength = round(max(0, min(100, 50 + gp * 12)), 1)
|
||||
sec["strength"] = strength
|
||||
sec["breadthPct"] = round(max(0, min(100, 50 + gp * 18)), 1)
|
||||
sec["strongCount"] = max(0, int(gp * 5))
|
||||
|
||||
except Exception:
|
||||
sector_strength = []
|
||||
|
||||
# ── 概念板块热度 Top10 ──
|
||||
concept_strength = []
|
||||
concepts = concept_catalog if isinstance(concept_catalog, list) else []
|
||||
concept_codes = [c.get("thscode", "") for c in concepts[:20] if c.get("thscode")]
|
||||
|
||||
if concept_codes:
|
||||
try:
|
||||
concept_snapshots = []
|
||||
for i in range(0, len(concept_codes), 50):
|
||||
batch = concept_codes[i:i + 50]
|
||||
snap = await fuyao_client.index_prices_snapshot(",".join(batch))
|
||||
if isinstance(snap, list):
|
||||
concept_snapshots.extend(snap)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
for snap_item in concept_snapshots:
|
||||
code = snap_item.get("thscode", "")
|
||||
name = ""
|
||||
for c in concepts:
|
||||
if c.get("thscode") == code:
|
||||
name = c.get("name", code)
|
||||
break
|
||||
|
||||
concept_strength.append({
|
||||
"code": code,
|
||||
"name": name or code,
|
||||
"changePct": _safe_float(snap_item.get("price_change_ratio_pct")),
|
||||
})
|
||||
|
||||
concept_strength.sort(key=lambda x: x["changePct"], reverse=True)
|
||||
|
||||
except Exception:
|
||||
concept_strength = []
|
||||
|
||||
# ── 事件情报 ──
|
||||
events = []
|
||||
|
||||
# 1) 连板天梯
|
||||
if isinstance(limit_ladder_data, dict):
|
||||
ladder_items = limit_ladder_data.get("item", [])
|
||||
if ladder_items:
|
||||
today_boards = ladder_items[0].get("boards", {})
|
||||
for board_key in ("seven_over", "six_board", "five_board", "four_board", "three_board", "two_board"):
|
||||
board_list = today_boards.get(board_key, [])
|
||||
for item in board_list[:2]:
|
||||
board_num = item.get("board_num", 0)
|
||||
events.append({
|
||||
"type": "ladder",
|
||||
"label": f"{board_num}连板" if board_num > 1 else "首板",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": "",
|
||||
})
|
||||
|
||||
# 2) 热门股 Top5
|
||||
if isinstance(hot_stock_data, dict):
|
||||
hot_items = hot_stock_data.get("item", [])
|
||||
for item in hot_items[:5]:
|
||||
rank = item.get("rank", "")
|
||||
heat = item.get("heat", "")
|
||||
trend = item.get("rank_trend", "")
|
||||
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
|
||||
events.append({
|
||||
"type": "hot",
|
||||
"label": f"热门 #{rank}" if rank else "热门",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": f"热度 {heat} {trend_icon}",
|
||||
})
|
||||
|
||||
# 3) 飙升榜 Top3
|
||||
if isinstance(skyrocket_data, dict):
|
||||
sky_items = skyrocket_data.get("item", [])
|
||||
for item in sky_items[:3]:
|
||||
heat = item.get("heat", "")
|
||||
trend = item.get("rank_trend", "")
|
||||
trend_icon = "↑" if trend == "up" else ("↓" if trend == "down" else "→")
|
||||
events.append({
|
||||
"type": "skyrocket",
|
||||
"label": "飙升",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": f"飙升指数 {heat} {trend_icon}",
|
||||
})
|
||||
|
||||
# 4) 涨停池封单 Top3
|
||||
if isinstance(limit_up_data, dict):
|
||||
lu_items = limit_up_data.get("item", [])
|
||||
for item in lu_items[:3]:
|
||||
seal = _safe_float(item.get("seal_money", 0))
|
||||
reason = item.get("limit_up_reason", "")
|
||||
continue_text = item.get("continue_day_text", "")
|
||||
events.append({
|
||||
"type": "limit_up",
|
||||
"label": continue_text or "涨停",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": f"封单 {seal / 10000:.0f}万 {reason}" if reason else f"封单 {seal / 10000:.0f}万",
|
||||
})
|
||||
|
||||
# 5) 炸板池 Top3
|
||||
if isinstance(limit_break_data, dict):
|
||||
break_items = limit_break_data.get("item", [])
|
||||
for item in break_items[:3]:
|
||||
events.append({
|
||||
"type": "limit_break",
|
||||
"label": "炸板",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": f"开板 {_safe_float(item.get('open_times', 0))}次",
|
||||
})
|
||||
|
||||
# 6) 龙虎榜
|
||||
if isinstance(dragon_tiger_data, dict):
|
||||
dt_items = dragon_tiger_data.get("item", [])
|
||||
for item in dt_items[:3]:
|
||||
reason = item.get("reason", item.get("上榜原因", ""))
|
||||
events.append({
|
||||
"type": "dragon_tiger",
|
||||
"label": "龙虎榜",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": reason[:40] if reason else "",
|
||||
})
|
||||
|
||||
# 7) 异动分析
|
||||
if isinstance(anomaly_data, dict):
|
||||
anomaly_items = anomaly_data.get("item", [])
|
||||
for item in anomaly_items[:3]:
|
||||
tag = item.get("tagName", item.get("tag_code", ""))
|
||||
events.append({
|
||||
"type": "anomaly",
|
||||
"label": "异动",
|
||||
"name": item.get("name", ""),
|
||||
"code": item.get("thscode", ""),
|
||||
"detail": tag,
|
||||
})
|
||||
|
||||
# 8) 盘面统计摘要(固定在最后)
|
||||
events.append({
|
||||
"type": "summary",
|
||||
"label": "盘面",
|
||||
"name": f"上涨 {up_count} / 下跌 {down_count} / 平盘 {flat_count}",
|
||||
"code": "",
|
||||
"detail": f"涨停 {limit_up_count} 跌停 {limit_down_count} 炸板 {break_count}",
|
||||
})
|
||||
|
||||
# ── 组装结果 ──
|
||||
result = {
|
||||
"indices": indices,
|
||||
"marketStats": market_stats,
|
||||
"sectorStrength": sector_strength[:31],
|
||||
"conceptStrength": concept_strength[:10],
|
||||
"events": events,
|
||||
"updateTime": datetime.now(BJT).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
|
||||
_cache["data"] = result
|
||||
_cache["at"] = now
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/market-dashboard", summary="市场看板数据聚合")
|
||||
async def get_market_dashboard():
|
||||
try:
|
||||
data = await _build_dashboard()
|
||||
return JSONResponse({"data": data}, headers=_NO_CACHE_HEADERS)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"市场看板数据获取失败: {e}")
|
||||
@@ -1,39 +0,0 @@
|
||||
"""板块数据路由:行业板块、概念板块"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from services import eastmoney, mootdx
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 行业/概念通过 query 参数区分,但上游反代/CDN 可能按 path 缓存而忽略 query,
|
||||
# 导致两个 tab 返回相同数据。显式禁止缓存,保证按 query 区分。
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
@router.get("", summary="板块列表")
|
||||
async def sector_list(
|
||||
type: str = Query("industry", description="板块类型:industry=行业板块, concept=概念板块"),
|
||||
):
|
||||
if type not in ("industry", "concept"):
|
||||
raise HTTPException(status_code=400, detail="板块类型错误,仅支持 industry/concept")
|
||||
|
||||
data = await eastmoney.fetch_sector_list(type)
|
||||
if data:
|
||||
return JSONResponse(
|
||||
{"data": data, "count": len(data), "type": type},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
# 降级:通达信 mootdx(不含实时资金流数据)
|
||||
md_data = await mootdx.fetch_sector_list(type)
|
||||
if md_data:
|
||||
return JSONResponse(
|
||||
{"data": md_data, "count": len(md_data), "type": type, "source": "mootdx"},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{"data": [], "count": 0, "type": type},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
@@ -84,6 +84,23 @@ async def stock_history(
|
||||
raise HTTPException(status_code=404, detail="未获取到K线数据")
|
||||
|
||||
|
||||
@router.get("/history-minute", summary="分钟/小时级K线")
|
||||
async def stock_history_minute(
|
||||
code: str = Query(..., description="6位股票代码"),
|
||||
period: str = Query("60", description="周期:m1/m5/m15/m30/m60(60=小时线)"),
|
||||
count: int = Query(320, description="K线数量"),
|
||||
):
|
||||
if not re.match(r"^\d{6}$", code):
|
||||
raise HTTPException(status_code=400, detail="股票代码格式错误,需为6位数字")
|
||||
if period not in ("m1", "m5", "m15", "m30", "m60"):
|
||||
raise HTTPException(status_code=400, detail="period 仅支持 m1/m5/m15/m30/m60")
|
||||
|
||||
klines = await tencent.fetch_history_minute(code, period, count)
|
||||
if not klines:
|
||||
raise HTTPException(status_code=404, detail="未获取到分钟K线数据")
|
||||
return {"data": klines, "count": len(klines), "source": "tencent-minute"}
|
||||
|
||||
|
||||
@router.get("/profile", response_model=dict, summary="公司概况")
|
||||
async def company_profile(code: str = Query(..., description="6位股票代码")):
|
||||
"""获取东方财富F10公司概况数据"""
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""题材数据路由:题材列表、题材详情、题材相关股票"""
|
||||
"""题材数据路由:题材列表、题材详情、题材相关股票、题材历史"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
from services import themes
|
||||
|
||||
router = APIRouter()
|
||||
@@ -28,7 +29,7 @@ async def theme_list(
|
||||
@router.get("/graph", summary="热点穿透:题材-股票网状关系图")
|
||||
async def theme_graph(
|
||||
sort_field: int = Query(1, description="题材排序:1=涨幅 4=热度"),
|
||||
top: int = Query(30, ge=1, le=60, description="题材数量"),
|
||||
top: int = Query(30, ge=1, le=200, description="题材数量"),
|
||||
limit: int = Query(1000, ge=100, le=2000, description="下发的股票节点上限(按穿透度取前 N 只)"),
|
||||
):
|
||||
if sort_field not in (1, 4):
|
||||
@@ -38,6 +39,101 @@ async def theme_graph(
|
||||
return JSONResponse(result, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日题材涨幅前20")
|
||||
async def theme_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
items = [dict_from_row(r) for r in rows]
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃题材 + 最近10日涨幅矩阵")
|
||||
async def active_themes():
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 最近 10 个有数据的交易日(升序)
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_top_themes ORDER BY trade_date DESC LIMIT 10"
|
||||
).fetchall()
|
||||
dates = [r["trade_date"] for r in reversed(rows)]
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "themes": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
# 窗口内全部题材上榜记录,按天分组排列(当日 rank 即排名)
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, theme_code, theme_name, bf3, rank FROM daily_top_themes
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 按题材聚合:每日涨幅矩阵 + 上榜次数 + 最近上榜日 + 最佳排名
|
||||
theme_map: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["theme_code"]
|
||||
t = theme_map.setdefault(code, {
|
||||
"themeCode": code,
|
||||
"themeName": r["theme_name"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
"bestRank": None,
|
||||
})
|
||||
t["dailyGains"][r["trade_date"]] = r["bf3"]
|
||||
t["appearCount"] += 1
|
||||
if t["lastAppear"] is None or r["trade_date"] > t["lastAppear"]:
|
||||
t["lastAppear"] = r["trade_date"]
|
||||
if t["bestRank"] is None or (r["rank"] or 0) < t["bestRank"]:
|
||||
t["bestRank"] = r["rank"] or 0
|
||||
|
||||
themes_list = list(theme_map.values())
|
||||
# 稳定排序:先按上榜次数降序,再按最佳排名升序
|
||||
themes_list.sort(key=lambda x: x["bestRank"] if x["bestRank"] is not None else 10**9)
|
||||
themes_list.sort(key=lambda x: -x["appearCount"])
|
||||
return JSONResponse({"dates": dates, "themes": themes_list}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/{theme_code}/news", summary="题材相关新闻(分页)")
|
||||
async def theme_news(
|
||||
theme_code: str,
|
||||
page_num: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(10, ge=1, le=50, description="每页条数"),
|
||||
max_eu_time: str = Query("", description="分页游标(上一页返回的 maxEuTime)"),
|
||||
):
|
||||
result = await themes.fetch_theme_news(theme_code, page_num, max_eu_time, page_size)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/quote", summary="单题材实时行情(强度/热度/涨幅)")
|
||||
async def theme_quote(theme_code: str):
|
||||
result = await themes.fetch_theme_quote(theme_code)
|
||||
if result is None:
|
||||
return JSONResponse(
|
||||
{"data": None, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"data": result, "theme_code": theme_code},
|
||||
headers=_NO_CACHE_HEADERS,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{theme_code}/detail", summary="题材详情")
|
||||
async def theme_detail(theme_code: str):
|
||||
data = await themes.fetch_theme_detail(theme_code)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL_API_KEY_ENV = "HITHINK_FINANCE_API_KEY"
|
||||
# AUV 项目自有约定:同花顺 key 存在 .env 的 fuyao_apikey(由 load_dotenv 加载)
|
||||
PROJECT_API_KEY_ENV = "fuyao_apikey"
|
||||
LEGACY_API_KEY_ENVS = ("FUYAO_TOKEN", "API_KEY")
|
||||
|
||||
|
||||
class CredentialFileError(RuntimeError):
|
||||
"""Raised when the user credential file exists but cannot be read safely."""
|
||||
|
||||
|
||||
def credential_file_path(
|
||||
*,
|
||||
platform: str | None = None,
|
||||
env: Mapping[str, str] | None = None,
|
||||
home: Path | None = None,
|
||||
) -> Path:
|
||||
resolved_platform = platform or sys.platform
|
||||
resolved_env = env if env is not None else os.environ
|
||||
resolved_home = home or Path.home()
|
||||
|
||||
if resolved_platform == "win32":
|
||||
config_root = resolved_env.get("APPDATA", "").strip()
|
||||
base = Path(config_root) if config_root else resolved_home / "AppData" / "Roaming"
|
||||
elif resolved_platform == "darwin":
|
||||
base = resolved_home / "Library" / "Application Support"
|
||||
else:
|
||||
config_root = resolved_env.get("XDG_CONFIG_HOME", "").strip()
|
||||
base = Path(config_root).expanduser() if config_root else resolved_home / ".config"
|
||||
return base / "hithink-finance" / "credentials.env"
|
||||
|
||||
|
||||
def _non_blank(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
stripped = value.strip()
|
||||
return stripped or None
|
||||
|
||||
|
||||
def _read_credential_file(path: Path) -> str | None:
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise CredentialFileError(
|
||||
f"Unable to read hithink finance credential file: {path}"
|
||||
) from exc
|
||||
|
||||
for line_number, raw_line in enumerate(content.splitlines(), start=1):
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
name, value = line.split("=", 1)
|
||||
if name.strip() != CANONICAL_API_KEY_ENV:
|
||||
continue
|
||||
normalized = value.strip()
|
||||
if "'" in normalized or '"' in normalized:
|
||||
raise CredentialFileError(
|
||||
f"Invalid hithink finance credential file at {path}:{line_number}: "
|
||||
f"{CANONICAL_API_KEY_ENV} must not be quoted."
|
||||
)
|
||||
return _non_blank(normalized)
|
||||
return None
|
||||
|
||||
|
||||
def resolve_api_key(
|
||||
*,
|
||||
env: Mapping[str, str] | None = None,
|
||||
credential_path: Path | None = None,
|
||||
) -> str | None:
|
||||
resolved_env = env if env is not None else os.environ
|
||||
|
||||
canonical = _non_blank(resolved_env.get(CANONICAL_API_KEY_ENV))
|
||||
if canonical is not None:
|
||||
return canonical
|
||||
|
||||
# AUV 项目约定:优先读 .env 的 fuyao_apikey
|
||||
project_key = _non_blank(resolved_env.get(PROJECT_API_KEY_ENV))
|
||||
if project_key is not None:
|
||||
return project_key
|
||||
|
||||
stored = _read_credential_file(
|
||||
credential_path
|
||||
if credential_path is not None
|
||||
else credential_file_path(env=resolved_env)
|
||||
)
|
||||
if stored is not None:
|
||||
return stored
|
||||
|
||||
for name in LEGACY_API_KEY_ENVS:
|
||||
legacy = _non_blank(resolved_env.get(name))
|
||||
if legacy is not None:
|
||||
return legacy
|
||||
return None
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
"""管理员认证模块
|
||||
|
||||
密码存储:data/admin.json
|
||||
- password_hash: sha256 哈希
|
||||
- salt: 随机盐
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
|
||||
DATA_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
|
||||
ADMIN_FILE = os.path.join(DATA_DIR, "admin.json")
|
||||
|
||||
# 登录 token 有效期 24 小时
|
||||
TOKEN_TTL = 86400
|
||||
|
||||
# 内存中的活跃 token: {token: expire_ts}
|
||||
_active_tokens: dict[str, float] = {}
|
||||
|
||||
|
||||
def _hash_password(password: str, salt: str) -> str:
|
||||
return hashlib.sha256(f"{salt}:{password}".encode()).hexdigest()
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
return os.path.exists(ADMIN_FILE)
|
||||
|
||||
|
||||
def set_password(password: str) -> None:
|
||||
"""设置或更新管理员密码"""
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
salt = secrets.token_hex(16)
|
||||
data = {
|
||||
"password_hash": _hash_password(password, salt),
|
||||
"salt": salt,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
with open(ADMIN_FILE, "w") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def verify_password(password: str) -> bool:
|
||||
"""验证密码是否正确"""
|
||||
if not is_configured():
|
||||
return False
|
||||
with open(ADMIN_FILE) as f:
|
||||
data = json.load(f)
|
||||
return _hash_password(password, data["salt"]) == data["password_hash"]
|
||||
|
||||
|
||||
def create_token() -> str:
|
||||
"""创建登录 token"""
|
||||
token = secrets.token_urlsafe(32)
|
||||
_active_tokens[token] = time.time() + TOKEN_TTL
|
||||
return token
|
||||
|
||||
|
||||
def verify_token(token: str) -> bool:
|
||||
"""验证 token 是否有效"""
|
||||
if not token:
|
||||
return False
|
||||
expire = _active_tokens.get(token)
|
||||
if expire is None:
|
||||
return False
|
||||
if time.time() > expire:
|
||||
del _active_tokens[token]
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def revoke_token(token: str) -> None:
|
||||
"""吊销 token"""
|
||||
_active_tokens.pop(token, None)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""AI 分析定时任务
|
||||
|
||||
收盘后自动触发 AI 分析:
|
||||
- 15:01 collector_loop 采集核心股+题材数据
|
||||
- 15:10 ai_analysis_loop 触发 AI 分析(等数据采集完成)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 300 # 每5分钟检查
|
||||
AI_ANALYSIS_TIME = dtime(15, 10) # 15:10 后触发
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""仅按工作日判断"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_ai_report(trade_date: str) -> bool:
|
||||
"""检查某日是否已有 AI 分析报告"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def ai_analysis_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:10 后自动触发 AI 分析(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= AI_ANALYSIS_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_ai_report(trade_date):
|
||||
print(f"[ai-collector] 开始 AI 分析 {trade_date}")
|
||||
from services.ai_service import collect_ai_analysis
|
||||
result = await collect_ai_analysis(trade_date)
|
||||
print(f"[ai-collector] AI 分析完成 {trade_date},token 消耗: {result.get('tokens_used', 0)}")
|
||||
except Exception:
|
||||
print("[ai-collector] AI 分析异常:")
|
||||
traceback.print_exc()
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""AI 分析配置管理(从环境变量读取)"""
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
AI_API_BASE = os.getenv("AI_API_BASE", "https://openteam.oneisall.xyz/v1")
|
||||
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
||||
AI_MODEL = os.getenv("AI_MODEL", "deepseek-v4-flash")
|
||||
AI_MAX_TOKENS = int(os.getenv("AI_MAX_TOKENS")) if os.getenv("AI_MAX_TOKENS") else None
|
||||
AI_TEMPERATURE = float(os.getenv("AI_TEMPERATURE")) if os.getenv("AI_TEMPERATURE") else None
|
||||
@@ -0,0 +1,223 @@
|
||||
"""AI 分析核心服务
|
||||
|
||||
负责:
|
||||
1. 调用 OpenAI 兼容 API 进行分析
|
||||
2. Function Calling 循环(AI 可主动获取数据)
|
||||
3. 保存报告到数据库
|
||||
"""
|
||||
|
||||
import json
|
||||
import httpx
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
|
||||
from services.ai_tools import TOOLS, execute_tool
|
||||
from database import get_connection
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
SYSTEM_PROMPT = """你是一位专业的A股市场分析师,擅长从数据中发现投资机会。
|
||||
|
||||
你的分析风格:
|
||||
- 数据驱动,基于真实数据而非主观臆断
|
||||
- 逻辑清晰,先总后分,层层递进
|
||||
- 观点明确,给出具体的操作建议
|
||||
- 风险提示,每次推荐都需说明风险点
|
||||
|
||||
可用工具:
|
||||
- get_market_dashboard: 获取市场整体数据(指数/涨跌统计/行业强度/事件情报/市场温度)
|
||||
- get_theme_history: 获取指定日期的题材涨幅排行
|
||||
- get_active_core_stocks: 获取核心股追踪数据(10日涨幅矩阵+所属题材)
|
||||
- get_stock_quote: 获取个股实时行情
|
||||
- get_fund_flow: 获取个股资金流向
|
||||
|
||||
重要规则:
|
||||
1. 你必须先调用工具获取数据,然后基于数据进行分析
|
||||
2. 不要凭空编造数据,所有数据必须来自工具返回
|
||||
3. 如果工具返回空数据,如实说明数据不可用
|
||||
4. 分析完成后给出明确的结论和建议"""
|
||||
|
||||
DAILY_ANALYSIS_PROMPT = """请对 {trade_date} 的A股市场进行收盘分析,生成一份完整的分析报告。
|
||||
|
||||
{prev_report_section}
|
||||
|
||||
请先调用以下工具获取数据:
|
||||
1. get_market_dashboard - 获取市场整体数据
|
||||
2. get_theme_history(date="{trade_date}") - 获取今日题材涨幅
|
||||
3. get_active_core_stocks - 获取核心股数据
|
||||
|
||||
然后基于数据生成报告,结构如下:
|
||||
|
||||
## 一、市场总览
|
||||
- 主要指数表现(上证、深证、创业板)
|
||||
- 涨跌家数统计
|
||||
- 市场温度评估
|
||||
|
||||
## 二、题材热点分析
|
||||
- 今日涨幅前5题材
|
||||
- 持续活跃的题材
|
||||
- 新兴热点题材
|
||||
|
||||
## 三、核心股追踪
|
||||
- 连板股分析
|
||||
- 核心股表现
|
||||
- 龙头股辨识
|
||||
|
||||
## 四、关注方向
|
||||
- 明日值得关注的题材方向
|
||||
- 潜在的交易机会
|
||||
|
||||
## 五、下个交易日建议
|
||||
- 明日大盘预判(支撑/压力位)
|
||||
- 建议关注的题材方向(2-3个)
|
||||
- 建议关注的核心股(附理由)
|
||||
- 操作策略(仓位建议、买卖时机)
|
||||
- 需要规避的方向
|
||||
|
||||
## 六、风险提示
|
||||
- 需要警惕的风险因素
|
||||
- 操作建议
|
||||
|
||||
请用 Markdown 格式输出,适当使用表格展示数据对比。"""
|
||||
|
||||
|
||||
async def call_llm(messages: list, tools: list = None) -> dict:
|
||||
"""调用 OpenAI 兼容 API"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
payload = {
|
||||
"model": AI_MODEL,
|
||||
"messages": messages,
|
||||
}
|
||||
if AI_MAX_TOKENS is not None:
|
||||
payload["max_tokens"] = AI_MAX_TOKENS
|
||||
if AI_TEMPERATURE is not None:
|
||||
payload["temperature"] = AI_TEMPERATURE
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = "auto"
|
||||
|
||||
resp = await client.post(
|
||||
f"{AI_API_BASE}/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {AI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json=payload,
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def collect_ai_analysis(trade_date: str) -> dict:
|
||||
"""AI 分析主流程
|
||||
|
||||
Args:
|
||||
trade_date: 交易日 YYYY-MM-DD
|
||||
|
||||
Returns:
|
||||
{"id": int, "tokens_used": int, "tools_used": list}
|
||||
"""
|
||||
prev_report_section = _get_prev_report_section(trade_date)
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": DAILY_ANALYSIS_PROMPT.format(
|
||||
trade_date=trade_date,
|
||||
prev_report_section=prev_report_section
|
||||
)}
|
||||
]
|
||||
|
||||
tools_used = []
|
||||
total_tokens = 0
|
||||
final_content = ""
|
||||
was_truncated = False
|
||||
max_rounds = 30 # 安全上限,正常分析约 3-8 轮
|
||||
|
||||
for i in range(max_rounds):
|
||||
response = await call_llm(messages, tools=TOOLS)
|
||||
total_tokens += response.get("usage", {}).get("total_tokens", 0)
|
||||
|
||||
choice = response["choices"][0]
|
||||
message = choice["message"]
|
||||
messages.append(message)
|
||||
|
||||
finish_reason = choice.get("finish_reason", "")
|
||||
|
||||
if finish_reason == "stop":
|
||||
final_content = message.get("content") or ""
|
||||
break
|
||||
|
||||
if finish_reason == "length":
|
||||
was_truncated = True
|
||||
final_content = message.get("content") or ""
|
||||
if not final_content:
|
||||
for msg in reversed(messages):
|
||||
if msg.get("role") == "assistant" and msg.get("content"):
|
||||
final_content = msg["content"]
|
||||
break
|
||||
print(f"[ai-service] 警告:响应被截断 (finish_reason=length)")
|
||||
break
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
for tool_call in message.get("tool_calls", []):
|
||||
func_name = tool_call["function"]["name"]
|
||||
func_args = json.loads(tool_call["function"]["arguments"])
|
||||
tools_used.append(func_name)
|
||||
|
||||
result = await execute_tool(func_name, func_args)
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": result
|
||||
})
|
||||
|
||||
summary = final_content[:200].replace("\n", " ") if final_content else ""
|
||||
report_id = _save_report(trade_date, final_content, summary, tools_used, total_tokens)
|
||||
|
||||
return {"id": report_id, "tokens_used": total_tokens, "tools_used": tools_used, "truncated": was_truncated}
|
||||
|
||||
|
||||
def _save_report(trade_date: str, content: str, summary: str, tools_used: list, tokens_used: int) -> int:
|
||||
"""保存报告到数据库"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
cursor = conn.execute(
|
||||
"""INSERT OR REPLACE INTO ai_reports
|
||||
(trade_date, report_type, title, content, summary, tools_used, model, tokens_used)
|
||||
VALUES (?, 'daily', ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
trade_date,
|
||||
f"{trade_date} A股收盘分析",
|
||||
content,
|
||||
summary,
|
||||
json.dumps(tools_used),
|
||||
AI_MODEL,
|
||||
tokens_used,
|
||||
)
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _get_prev_report_section(trade_date: str) -> str:
|
||||
"""获取前一个交易日的报告,用于上下文参考"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT trade_date, content FROM ai_reports WHERE trade_date < ? AND report_type = 'daily' ORDER BY trade_date DESC LIMIT 1",
|
||||
(trade_date,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return ""
|
||||
prev_date = row["trade_date"]
|
||||
prev_content = row["content"] or ""
|
||||
return f"""以下是前一个交易日({prev_date})的分析报告,请参考其中的分析逻辑和关注方向,结合今日数据进行对比分析:
|
||||
|
||||
{prev_content}
|
||||
|
||||
---
|
||||
"""
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Function Calling 工具定义和执行器
|
||||
|
||||
让 AI 可以主动调用现有 API 获取市场数据:
|
||||
- get_market_dashboard: 市场看板
|
||||
- get_theme_history: 题材热点历史
|
||||
- get_active_core_stocks: 核心股追踪
|
||||
- get_stock_quote: 个股实时行情
|
||||
- get_fund_flow: 资金流向
|
||||
"""
|
||||
|
||||
import json
|
||||
from services.ai_config import AI_API_BASE, AI_API_KEY, AI_MODEL, AI_MAX_TOKENS, AI_TEMPERATURE
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_market_dashboard",
|
||||
"description": "获取A股市场看板数据,包含主要指数行情、涨跌统计、行业强度、概念热度、事件情报、市场温度评分",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_theme_history",
|
||||
"description": "获取指定交易日的题材涨幅排行前20",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date": {"type": "string", "description": "交易日 YYYY-MM-DD"}
|
||||
},
|
||||
"required": ["date"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_active_core_stocks",
|
||||
"description": "获取活跃核心股列表,包含最近10日涨幅矩阵和所属题材",
|
||||
"parameters": {"type": "object", "properties": {}, "required": []}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_stock_quote",
|
||||
"description": "获取单只股票实时行情(价格、涨跌幅、成交量、换手率等)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "6位股票代码,如 600519"}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_fund_flow",
|
||||
"description": "获取个股资金流向数据(主力净流入、超大单/大单/中单/小单流入流出)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "description": "6位股票代码"},
|
||||
"name": {"type": "string", "description": "股票名称"},
|
||||
"days": {"type": "integer", "description": "获取天数,默认30"}
|
||||
},
|
||||
"required": ["code", "name"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def execute_tool(tool_name: str, arguments: dict) -> str:
|
||||
"""执行工具调用,返回 JSON 字符串"""
|
||||
try:
|
||||
if tool_name == "get_market_dashboard":
|
||||
return await _get_market_dashboard()
|
||||
elif tool_name == "get_theme_history":
|
||||
return await _get_theme_history(arguments.get("date", ""))
|
||||
elif tool_name == "get_active_core_stocks":
|
||||
return await _get_active_core_stocks()
|
||||
elif tool_name == "get_stock_quote":
|
||||
return await _get_stock_quote(arguments.get("code", ""))
|
||||
elif tool_name == "get_fund_flow":
|
||||
return await _get_fund_flow(
|
||||
arguments.get("code", ""),
|
||||
arguments.get("name", ""),
|
||||
arguments.get("days", 30)
|
||||
)
|
||||
else:
|
||||
return json.dumps({"error": f"未知工具: {tool_name}"})
|
||||
except Exception as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
|
||||
|
||||
async def _get_market_dashboard() -> str:
|
||||
"""获取市场看板数据"""
|
||||
from routes.market_dashboard import _build_dashboard
|
||||
data = await _build_dashboard()
|
||||
return json.dumps(data, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
async def _get_theme_history(date: str) -> str:
|
||||
"""获取指定交易日题材涨幅前20"""
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC",
|
||||
(date,)
|
||||
).fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
return json.dumps({"date": date, "items": items}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_active_core_stocks() -> str:
|
||||
"""获取活跃核心股列表"""
|
||||
from database import get_connection
|
||||
from datetime import date as date_cls
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 最近10个有数据的交易日
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10"
|
||||
).fetchall()
|
||||
dates = [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
if not dates:
|
||||
return json.dumps({"dates": [], "stocks": []}, ensure_ascii=False)
|
||||
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3, cover_count FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
stock_days = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"coverCount": r["cover_count"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
stocks.sort(key=lambda x: x.get("lastAppear") or "", reverse=True)
|
||||
stocks.sort(key=lambda x: -x["appearCount"])
|
||||
|
||||
themes_rows = conn.execute(
|
||||
f"""SELECT stock_code, theme_code, theme_name FROM daily_core_stock_themes
|
||||
WHERE trade_date IN ({placeholders})""",
|
||||
dates,
|
||||
).fetchall()
|
||||
themes_by_stock = {}
|
||||
for t in themes_rows:
|
||||
per = themes_by_stock.setdefault(t["stock_code"], {})
|
||||
per.setdefault(t["theme_code"], {"theme_code": t["theme_code"], "theme_name": t["theme_name"]})
|
||||
for s in stocks:
|
||||
s["themes"] = list(themes_by_stock.get(s["stockCode"], {}).values())
|
||||
|
||||
latest = dates[-1] if dates else None
|
||||
for s in stocks:
|
||||
if s.get("lastAppear") and latest:
|
||||
try:
|
||||
d1 = date_cls.fromisoformat(latest)
|
||||
d2 = date_cls.fromisoformat(s["lastAppear"])
|
||||
s["daysSinceLastAppear"] = (d1 - d2).days
|
||||
except ValueError:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
else:
|
||||
s["daysSinceLastAppear"] = 0
|
||||
|
||||
return json.dumps({"dates": dates, "stocks": stocks}, ensure_ascii=False)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def _get_stock_quote(code: str) -> str:
|
||||
"""获取个股实时行情"""
|
||||
from services.tencent import fetch_quote
|
||||
data = await fetch_quote(code)
|
||||
if not data:
|
||||
return json.dumps({"error": f"未找到股票 {code} 的数据"})
|
||||
return json.dumps(data, ensure_ascii=False)
|
||||
|
||||
|
||||
async def _get_fund_flow(code: str, name: str, days: int) -> str:
|
||||
"""获取个股资金流向"""
|
||||
from services.tencent import fetch_quote, get_market_prefix
|
||||
from database import get_connection
|
||||
|
||||
# 先尝试从本地数据库获取历史资金流向
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM cache WHERE key LIKE ? AND expires_at > datetime('now','localtime')",
|
||||
(f"fund_flow:{code}%",)
|
||||
).fetchall()
|
||||
if rows:
|
||||
return rows[0]["value"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 降级:通过腾讯获取基础行情数据
|
||||
quote = await fetch_quote(code)
|
||||
if not quote:
|
||||
return json.dumps({"error": f"未找到股票 {code} 的数据"})
|
||||
|
||||
return json.dumps({
|
||||
"code": code,
|
||||
"name": name or quote.get("name", ""),
|
||||
"currentPrice": quote.get("currentPrice", 0),
|
||||
"changePercent": quote.get("changePercent", 0),
|
||||
"volume": quote.get("volume", 0),
|
||||
"amount": quote.get("amount", 0),
|
||||
"note": "资金流向详细数据需通过东方财富API获取"
|
||||
}, ensure_ascii=False)
|
||||
@@ -56,3 +56,13 @@ def clean_expired():
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def clear_all():
|
||||
"""清空全部缓存(每日开盘后 9:31 调用,保证当日数据全新)"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("DELETE FROM cache")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
from services.cache import clear_all
|
||||
from services.themes import fetch_theme_list, _build_theme_graph
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# 每天采集的后台任务:每 300 秒(5 分钟)检查一次
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
COLLECT_AFTER_TIME = dtime(15, 1) # 收盘后 15:01 开始允许采集(留 1 分钟等收盘数据稳定)
|
||||
CORE_STOCK_LIMIT = 100 # 题材领涨股涨幅前100
|
||||
RANK_TOP_LIMIT = 50 # 每个榜单(涨幅/强度/热度/成交额)取前50
|
||||
HOTMAP_TOP_N = 100 # 热点穿透采样题材数:涨幅榜+热度榜各取前100,合并(与热点穿透页一致)
|
||||
HOTMAP_CORE_LIMIT = 100 # 热点穿透核心股前100(按覆盖题材数降序)
|
||||
CORE_COVER_THRESHOLD = 2 # 热点穿透核心股门槛:覆盖题材数 ≥2
|
||||
|
||||
# 题材榜单:sortField 1=涨幅(bf3) 3=强度(strengthValue) 4=热度排名(hotRank) 5=成交额(fex5)
|
||||
# 每个榜单取前 RANK_TOP_LIMIT 个,按 themeCode 合并去重后入库
|
||||
THEME_RANK_LISTS = ((1, "bf3"), (3, "strengthValue"), (4, "hotRank"), (5, "fex5"))
|
||||
|
||||
# 每日缓存清理:交易日 9:31 清空全部缓存,保证开盘后数据全新
|
||||
CLEANUP_TIME = dtime(9, 31)
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""仅按工作日判断:周一至周五视为交易日,不处理法定节假日"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_collected(trade_date: str) -> bool:
|
||||
"""当日核心股是否已采集"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
|
||||
"""采集指定交易日数据并入库。
|
||||
|
||||
核心股 = 题材领涨股涨幅前100 ∪ 热点穿透核心股(覆盖题材数≥2、按覆盖数降序前100),按股票代码去重。
|
||||
题材 = 涨幅/强度/热度/成交额 4 榜各前50,按 themeCode 合并去重。
|
||||
热点穿透构建失败时降级为仅领涨股前100,不影响当日采集。
|
||||
|
||||
Args:
|
||||
trade_date: YYYY-MM-DD
|
||||
dry_run: True 只打印不写库(用于验证)
|
||||
|
||||
Returns:
|
||||
{"core_count": int, "theme_count": int, "skipped": bool}
|
||||
"""
|
||||
if _has_collected(trade_date):
|
||||
print(f"[collector] {trade_date} 已采集,跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
|
||||
themes = await fetch_theme_list(1, False)
|
||||
if not themes:
|
||||
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 领涨题材映射:securityCode -> [(themeCode, themeName), ...](用于核心股"所属题材")
|
||||
lead_themes: dict[str, list[tuple[str, str]]] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
lead_themes.setdefault(code, []).append((t["themeCode"], t["themeName"]))
|
||||
|
||||
# 2. 热点穿透核心股:覆盖题材数≥2,按覆盖题材数降序前100(去重)
|
||||
# _build_theme_graph 采样 涨幅榜+热度榜 各前 HOTMAP_TOP_N 个题材并拉每股覆盖题材数,
|
||||
# 已按 (-coverCount, -f3) 降序;失败时降级为空集,仅保留领涨股。
|
||||
hotmap_core: list[dict] = []
|
||||
graph_theme_name: dict[str, str] = {}
|
||||
try:
|
||||
graph = await _build_theme_graph(1, HOTMAP_TOP_N)
|
||||
graph_theme_name = {t["themeCode"]: t["themeName"] for t in graph.get("themes", [])}
|
||||
hotmap_core = [
|
||||
s for s in graph.get("stocks", [])
|
||||
if s.get("coverCount", 0) >= CORE_COVER_THRESHOLD
|
||||
][:HOTMAP_CORE_LIMIT]
|
||||
except Exception as e:
|
||||
print(f"[collector] 热点穿透核心股构建失败,降级为仅领涨股: {e}")
|
||||
|
||||
# 3. 题材领涨股涨幅前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
|
||||
stock_map: dict[str, dict] = {}
|
||||
theme_count: dict[str, int] = {} # securityCode -> 领涨题材数
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
theme_count[code] = theme_count.get(code, 0) + 1
|
||||
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
|
||||
stock_map[code] = {
|
||||
"stock_code": code,
|
||||
"stock_name": t.get("securityName", ""),
|
||||
"f3": t.get("f3"),
|
||||
}
|
||||
f3_core = sorted(stock_map.values(), key=lambda x: -(x["f3"] or 0))[:CORE_STOCK_LIMIT]
|
||||
|
||||
# 4. 题材 4 榜(涨幅/强度/热度/成交额)各前50,按 themeCode 合并去重。
|
||||
# 涨幅榜列表与开头用于构建领涨股映射的 fetch_theme_list(1, False) 同缓存,直接复用。
|
||||
merged: dict[str, dict] = {}
|
||||
rank_list = await fetch_theme_list(1, False)
|
||||
for sort_field, _ in THEME_RANK_LISTS:
|
||||
lst = rank_list if sort_field == 1 else await fetch_theme_list(sort_field, False)
|
||||
for t in lst[:RANK_TOP_LIMIT]:
|
||||
merged.setdefault(t["themeCode"], t)
|
||||
top_themes = list(merged.values())
|
||||
|
||||
# 5. 合并去重:热点穿透核心股在前(覆盖数降序,rank 优先),随后补领涨股涨幅前100
|
||||
core_stocks: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
theme_pairs: set[tuple[str, str, str]] = set() # (stock_code, theme_code, theme_name)
|
||||
|
||||
for s in hotmap_core:
|
||||
code = s["securityCode"]
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s.get("securityName", ""),
|
||||
"f3": s.get("f3"),
|
||||
"cover_count": s.get("coverCount", 0),
|
||||
})
|
||||
# 所属题材:采样榜内覆盖的题材 + 全量题材列表中的领涨题材
|
||||
for tc in s.get("themeCodes", []):
|
||||
theme_pairs.add((code, tc, graph_theme_name.get(tc, tc)))
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
for s in f3_core:
|
||||
code = s["stock_code"]
|
||||
if code in seen:
|
||||
continue
|
||||
seen.add(code)
|
||||
core_stocks.append({
|
||||
"stock_code": code,
|
||||
"stock_name": s["stock_name"],
|
||||
"f3": s["f3"],
|
||||
"cover_count": theme_count.get(code, 0),
|
||||
})
|
||||
for tc, tn in lead_themes.get(code, []):
|
||||
theme_pairs.add((code, tc, tn))
|
||||
|
||||
if dry_run:
|
||||
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只(热点穿透 {len(hotmap_core)} + 领涨股 {len(f3_core)}),题材 {len(top_themes)} 只(4 榜前 {RANK_TOP_LIMIT} 合并)")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
# 6. 入库(事务,UNIQUE 幂等)
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i, s in enumerate(core_stocks, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, s["stock_code"], s["stock_name"], s["f3"], s["cover_count"], i),
|
||||
)
|
||||
for code, tc, tn in theme_pairs:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
|
||||
(trade_date, code, tc, tn),
|
||||
)
|
||||
for i, t in enumerate(top_themes, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材 {len(top_themes)} 只(4 榜前 {RANK_TOP_LIMIT} 合并)")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
|
||||
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:01 后自动采集当日数据(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_collected(trade_date):
|
||||
await collect_daily(trade_date)
|
||||
except Exception:
|
||||
print("[collector] 采集异常:")
|
||||
traceback.print_exc()
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def _next_cleanup_dt(now: datetime) -> datetime:
|
||||
"""计算下一个缓存清理时刻:最近一个工作日 9:31(今天已过则取下一个工作日)"""
|
||||
for days in range(0, 8):
|
||||
d = (now + timedelta(days=days)).date()
|
||||
if d.weekday() >= 5: # 跳过周末
|
||||
continue
|
||||
dt = datetime(d.year, d.month, d.day, CLEANUP_TIME.hour, CLEANUP_TIME.minute, tzinfo=_CST)
|
||||
if dt > now:
|
||||
return dt
|
||||
return now + timedelta(days=1) # 兜底:理论不可达
|
||||
|
||||
|
||||
async def cache_cleanup_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 9:31 清空全部缓存,保证开盘后读到全新数据
|
||||
|
||||
到点前精确 sleep 至 9:31;若进程在 9:31 后启动,则等下一个交易日。
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
next_dt = _next_cleanup_dt(now)
|
||||
delay = max(0, int((next_dt - now).total_seconds()))
|
||||
if stop is not None:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=delay)
|
||||
except asyncio.TimeoutError:
|
||||
pass # 到点
|
||||
if stop.is_set():
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
clear_all()
|
||||
print(f"[cache-cleanup] 已清空全部缓存: {datetime.now(_CST).strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
print("[cache-cleanup] 清理异常:")
|
||||
traceback.print_exc()
|
||||
await asyncio.sleep(60) # 出错 1 分钟后再试
|
||||
@@ -6,13 +6,11 @@ import httpx
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, time as dtime, timedelta, timezone
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
from services.cache import get_cache, set_cache
|
||||
|
||||
|
||||
# ---- API Key 轮询(MX 备选源用)----
|
||||
|
||||
@@ -283,328 +281,6 @@ def get_eastmoney_market(code: str) -> str:
|
||||
return "1"
|
||||
return "0"
|
||||
|
||||
|
||||
# ---- 板块数据 ----
|
||||
|
||||
# 从东方财富 bkzj/list.js 逆向的字段映射
|
||||
# f62=主力净流入, f184=主力净流入占比
|
||||
# f66=超大单净流入, f69=超大单净流入占比
|
||||
# f72=大单净流入, f75=大单净流入占比
|
||||
# f78=中单净流入, f81=中单净流入占比
|
||||
# f84=小单净流入, f87=小单净流入占比
|
||||
# f70=成交额
|
||||
SECTOR_FIELDS = "f12,f14,f2,f3,f62,f184,f66,f69,f72,f75,f78,f81,f84,f87,f70"
|
||||
|
||||
# 东方财富板块类型映射
|
||||
SECTOR_MEDIA_MAP = {
|
||||
"industry": "m:90+s:4",
|
||||
"concept": "m:90+t:3",
|
||||
}
|
||||
|
||||
# 东方财富 UT 令牌管理
|
||||
_em_ut: str = "8dec03ba335b81bf4ebdf7b29ec27d15"
|
||||
_em_ut_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _refresh_em_ut() -> str:
|
||||
"""
|
||||
从东方财富前端 JS 中提取最新的 ut 令牌。
|
||||
按优先级尝试:
|
||||
1. bkzj/list.js(板块页专用)
|
||||
2. common/emdataview.js(通用数据组件)
|
||||
"""
|
||||
urls = [
|
||||
"https://data.eastmoney.com/newstatic/js/bkzj/list.js",
|
||||
"https://data.eastmoney.com/newstatic/js/common/emdataview.js",
|
||||
]
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for url in urls:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
continue
|
||||
# 匹配 ut: 'xxxx' 或 ut:'xxxx' 或 ut: "xxxx"
|
||||
m = re.search(r"""ut['"]?\s*:\s*['"]([a-f0-9]{32})['"]""", resp.text)
|
||||
if m:
|
||||
token = m.group(1)
|
||||
print(f"[eastmoney] 已刷新 UT 令牌: {token[:8]}...")
|
||||
return token
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] 获取 UT 失败({url}): {e}")
|
||||
return _em_ut # 保底返回当前值
|
||||
|
||||
|
||||
async def get_em_ut(force_refresh: bool = False) -> str:
|
||||
"""获取当前 UT,必要时刷新"""
|
||||
global _em_ut
|
||||
if force_refresh:
|
||||
async with _em_ut_lock:
|
||||
_em_ut = await _refresh_em_ut()
|
||||
return _em_ut
|
||||
|
||||
|
||||
# ---- 板块数据(市场时间感知缓存)----
|
||||
|
||||
_CST = timezone(timedelta(hours=8)) # 北京时间
|
||||
_TRADING_MORNING = (dtime(9, 30), dtime(11, 30))
|
||||
_TRADING_AFTERNOON = (dtime(13, 0), dtime(15, 0))
|
||||
|
||||
|
||||
def _cst_now() -> datetime:
|
||||
return datetime.now(_CST)
|
||||
|
||||
|
||||
def _is_trading_time() -> bool:
|
||||
"""判断当前是否为 A 股交易时段(周一至周五 9:30-11:30 / 13:00-15:00)"""
|
||||
now = _cst_now()
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
t = now.time()
|
||||
return (_TRADING_MORNING[0] <= t <= _TRADING_MORNING[1]
|
||||
or _TRADING_AFTERNOON[0] <= t <= _TRADING_AFTERNOON[1])
|
||||
|
||||
|
||||
def _sector_ttl_hours() -> int:
|
||||
"""根据是否在交易时段返回缓存 TTL
|
||||
- 交易时段: 2 分钟(数据持续变化)
|
||||
- 非交易时段: 18 小时(覆盖到下一个交易日)
|
||||
"""
|
||||
return 0 if _is_trading_time() else 18
|
||||
|
||||
|
||||
# curl_cffi 模拟 Chrome TLS 指纹
|
||||
from curl_cffi.requests import AsyncSession
|
||||
|
||||
_sector_session: Optional[AsyncSession] = None
|
||||
|
||||
|
||||
def _get_sector_session() -> AsyncSession:
|
||||
global _sector_session
|
||||
if _sector_session is None:
|
||||
_sector_session = AsyncSession(
|
||||
impersonate="chrome131",
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Referer": "https://data.eastmoney.com/bkzj/hy.html",
|
||||
"Accept": "*/*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
return _sector_session
|
||||
|
||||
|
||||
# 内存缓存(加速交易时段频繁请求)
|
||||
_sector_cache: dict[str, tuple[list[dict], float]] = {}
|
||||
_SECTOR_MEM_TTL = 60
|
||||
|
||||
|
||||
async def fetch_sector_list(sector_type: str) -> list[dict]:
|
||||
# 1. 内存缓存
|
||||
now = time.time()
|
||||
if sector_type in _sector_cache:
|
||||
data, ts = _sector_cache[sector_type]
|
||||
if now - ts < _SECTOR_MEM_TTL:
|
||||
return data
|
||||
|
||||
cache_key = f"sector_list:{sector_type}"
|
||||
|
||||
# 2. 非交易时段:走磁盘持久缓存
|
||||
if not _is_trading_time():
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
data = json.loads(cached)
|
||||
_sector_cache[sector_type] = (data, now)
|
||||
return data
|
||||
|
||||
# 3. 并发请求 push2 和 akshare,优先使用 push2
|
||||
push2_task = asyncio.create_task(_fetch_push2(sector_type))
|
||||
akshare_task = asyncio.create_task(_fetch_akshare(sector_type))
|
||||
|
||||
push2_data = await push2_task
|
||||
if push2_data:
|
||||
akshare_task.cancel()
|
||||
try:
|
||||
await akshare_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_sector_cache[sector_type] = (push2_data, now)
|
||||
ttl = _sector_ttl_hours()
|
||||
if ttl > 0:
|
||||
set_cache(cache_key, json.dumps(push2_data, ensure_ascii=False), ttl_hours=ttl)
|
||||
return push2_data
|
||||
|
||||
# push2 失败,用 akshare(不写磁盘缓存)
|
||||
akshare_data = await akshare_task
|
||||
if akshare_data:
|
||||
_sector_cache[sector_type] = (akshare_data, now)
|
||||
return akshare_data
|
||||
|
||||
|
||||
async def _fetch_push2(sector_type: str) -> list[dict]:
|
||||
"""东方财富 push2 API(curl_cffi 模拟浏览器 TLS 指纹)"""
|
||||
fs = SECTOR_MEDIA_MAP.get(sector_type)
|
||||
if not fs:
|
||||
return []
|
||||
|
||||
session = _get_sector_session()
|
||||
ut = await get_em_ut()
|
||||
|
||||
for attempt in range(2):
|
||||
url = (
|
||||
f"https://push2.eastmoney.com/api/qt/clist/get"
|
||||
f"?fs={fs}&fields={SECTOR_FIELDS}"
|
||||
f"&fid=f62&po=1&pz=500&pn=1&np=1&fltt=2"
|
||||
f"&invt=2&ut={ut}"
|
||||
)
|
||||
try:
|
||||
resp = await session.get(url)
|
||||
if resp.status_code != 200:
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
return []
|
||||
result = resp.json()
|
||||
if result.get("rc") != 0:
|
||||
return []
|
||||
diff = result.get("data", {}).get("diff", [])
|
||||
items = []
|
||||
for item in diff:
|
||||
items.append({
|
||||
"code": item.get("f12", ""),
|
||||
"name": item.get("f14", ""),
|
||||
"level": item.get("f2"),
|
||||
"changePercent": item.get("f3"),
|
||||
"changeAmount": None,
|
||||
"mainNetInflow": item.get("f62", 0) or 0,
|
||||
"mainNetInflowPercent": item.get("f184", 0),
|
||||
"superLargeInflow": item.get("f66", 0) or 0,
|
||||
"superLargeInflowPercent": item.get("f69", 0),
|
||||
"largeInflow": item.get("f72", 0) or 0,
|
||||
"largeInflowPercent": item.get("f75", 0),
|
||||
"mediumInflow": item.get("f78", 0) or 0,
|
||||
"mediumInflowPercent": item.get("f81", 0),
|
||||
"smallInflow": item.get("f84", 0) or 0,
|
||||
"smallInflowPercent": item.get("f87", 0),
|
||||
"turnover": item.get("f70", 0) or 0,
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
print(f"[eastmoney] push2 获取{sector_type}板块失败(attempt {attempt+1}): {err[:80]}")
|
||||
# UT 可能过期,尝试刷新
|
||||
if "disconnect" in err.lower() or "refused" in err.lower() or attempt == 1:
|
||||
await get_em_ut(force_refresh=True)
|
||||
ut = _em_ut
|
||||
# 先尝试更新现有会话的 headers
|
||||
try:
|
||||
session.headers.update({"Referer": "https://data.eastmoney.com/bkzj/hy.html"})
|
||||
except Exception:
|
||||
pass
|
||||
# 重建会话(TLS 指纹可能会被缓存)
|
||||
global _sector_session
|
||||
_sector_session = None
|
||||
session = _get_sector_session()
|
||||
if attempt == 0:
|
||||
await asyncio.sleep(1)
|
||||
return []
|
||||
|
||||
|
||||
import akshare as ak
|
||||
|
||||
|
||||
async def _fetch_akshare(sector_type: str) -> list[dict]:
|
||||
"""akshare 降级方案(东方财富数据源)"""
|
||||
loop = asyncio.get_event_loop()
|
||||
code_map_key = f"board_codes:{sector_type}"
|
||||
|
||||
def _build_code_map():
|
||||
"""获取板块代码映射(HTTP 较慢,结果单独缓存 24h)"""
|
||||
code_map = {}
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_em()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_em()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("f14", ""))] = str(r.get("f12", ""))
|
||||
except Exception:
|
||||
try:
|
||||
if sector_type == "industry":
|
||||
code_df = ak.stock_board_industry_name_ths()
|
||||
else:
|
||||
code_df = ak.stock_board_concept_name_ths()
|
||||
if code_df is not None and not code_df.empty:
|
||||
for _, r in code_df.iterrows():
|
||||
code_map[str(r.get("name", ""))] = str(r.get("code", ""))
|
||||
except Exception:
|
||||
pass
|
||||
return code_map
|
||||
|
||||
def _get_fund_flow():
|
||||
if sector_type == "industry":
|
||||
return ak.stock_fund_flow_industry()
|
||||
else:
|
||||
return ak.stock_fund_flow_concept()
|
||||
|
||||
# 1. 尝试从缓存读取 code_map
|
||||
code_map = {}
|
||||
cached_map = get_cache(code_map_key)
|
||||
if cached_map is not None:
|
||||
code_map = json.loads(cached_map)
|
||||
|
||||
try:
|
||||
if code_map:
|
||||
# 已有缓存,只需获取资金流
|
||||
df = await loop.run_in_executor(None, _get_fund_flow)
|
||||
else:
|
||||
# 首次:code_map + 资金流并发获取
|
||||
map_data, df = await asyncio.gather(
|
||||
loop.run_in_executor(None, _build_code_map),
|
||||
loop.run_in_executor(None, _get_fund_flow),
|
||||
)
|
||||
if map_data:
|
||||
code_map = map_data
|
||||
set_cache(code_map_key, json.dumps(code_map, ensure_ascii=False), ttl_hours=24)
|
||||
|
||||
if df is None or df.empty:
|
||||
return []
|
||||
df = df.sort_values("净额", ascending=False)
|
||||
items = []
|
||||
for _, row in df.iterrows():
|
||||
name = str(row.get("行业", "")).strip()
|
||||
inflow = float(row.get("流入资金", 0) or 0) * 100000000
|
||||
outflow = float(row.get("流出资金", 0) or 0) * 100000000
|
||||
items.append({
|
||||
"code": code_map.get(name, ""),
|
||||
"name": name,
|
||||
"level": float(row.get("行业指数") or 0),
|
||||
"changePercent": float(row.get("行业-涨跌幅") or 0),
|
||||
"changeAmount": None,
|
||||
"mainNetInflow": float(row.get("净额", 0) or 0) * 100000000,
|
||||
"mainNetInflowPercent": None,
|
||||
"superLargeInflow": None,
|
||||
"superLargeInflowPercent": None,
|
||||
"largeInflow": None,
|
||||
"largeInflowPercent": None,
|
||||
"mediumInflow": None,
|
||||
"mediumInflowPercent": None,
|
||||
"smallInflow": None,
|
||||
"smallInflowPercent": None,
|
||||
"turnover": inflow + outflow,
|
||||
})
|
||||
return items
|
||||
except Exception as e:
|
||||
print(f"[eastmoney] akshare 获取{sector_type}板块失败: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# ---- 公司概况 ----
|
||||
|
||||
_F10_MARKET_MAP = {"6": "SH", "0": "SZ", "3": "SZ"}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""同花顺金融数据 API 客户端(v2 数据源,接口前缀 /api/v2)
|
||||
|
||||
本模块是基于官方 Python SDK(vendor 在 backend/sdk/)的薄封装,
|
||||
对外暴露与 /api/v2 路由匹配的 async 函数,并统一返回 list/dict。
|
||||
|
||||
- SDK 提供:历史K线 >10 年自动切片、重试、参数校验、标的缓存。
|
||||
- SDK 内部用同步 requests,这里用 asyncio.to_thread 桥接,避免阻塞事件循环。
|
||||
- API Key 由 SDK 的 credentials resolver 读取(兼容 .env 的 fuyao_apikey),
|
||||
**绝不写入代码、日志、错误信息或 git。**
|
||||
|
||||
官方 SDK 来源:https://github.com/HiThink-Tech/Financial-API (MIT)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from sdk import fuyao_client as _sdk
|
||||
from sdk.fuyao_client import FuyaoApiError # 复用官方错误类型
|
||||
|
||||
# 兼容旧路由引用名(routes/fuyao.py 用 FuyaoError)
|
||||
FuyaoError = FuyaoApiError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 拼音首字母检索(同花顺接口不支持拼音,本地补齐)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
# 全市场 A 股标的列表缓存(用于拼音首字母匹配)
|
||||
_ticker_cache: dict = {"data": None, "at": 0.0}
|
||||
_TICKER_CACHE_TTL = 12 * 3600 # 12 小时
|
||||
|
||||
|
||||
async def _ensure_a_share_tickers() -> list[dict]:
|
||||
"""拉取全市场 A 股标的列表并缓存(幂等,TTL 内复用)"""
|
||||
now = time.time()
|
||||
if _ticker_cache["data"] is not None and now - _ticker_cache["at"] < _TICKER_CACHE_TTL:
|
||||
return _ticker_cache["data"]
|
||||
items = await _run(_sdk.tickers_list, asset_type="a-share", limit=10000, offset=0)
|
||||
data = _item_list(items)
|
||||
_ticker_cache["data"] = data
|
||||
_ticker_cache["at"] = now
|
||||
return data
|
||||
|
||||
|
||||
def _pinyin_initials(name: str) -> str:
|
||||
"""中文名 → 拼音首字母(如 深科技→skj,TCL科技→tkj)"""
|
||||
try:
|
||||
from pypinyin import lazy_pinyin
|
||||
except ImportError:
|
||||
return ""
|
||||
return "".join(
|
||||
w[0] for w in lazy_pinyin(name)
|
||||
if w and w[0].isalpha()
|
||||
).lower()
|
||||
|
||||
|
||||
def _match_by_pinyin(query: str, tickers: list[dict], limit: int) -> list[dict]:
|
||||
"""按拼音首字母匹配:精确=前缀优先,包含匹配次之"""
|
||||
q = query.lower()
|
||||
exact: list[dict] = []
|
||||
prefix: list[dict] = []
|
||||
contains: list[dict] = []
|
||||
for t in tickers:
|
||||
name = t.get("name") or ""
|
||||
if not name:
|
||||
continue
|
||||
initials = _pinyin_initials(name)
|
||||
if not initials:
|
||||
continue
|
||||
if initials == q:
|
||||
exact.append(t)
|
||||
elif initials.startswith(q):
|
||||
prefix.append(t)
|
||||
elif q in initials:
|
||||
contains.append(t)
|
||||
return (exact + prefix + contains)[:limit]
|
||||
|
||||
|
||||
def _run(fn, *args, **kwargs):
|
||||
"""同步 SDK 调用桥接到 async"""
|
||||
return asyncio.to_thread(fn, *args, **kwargs)
|
||||
|
||||
|
||||
def _item_list(data, key="item"):
|
||||
"""把 SDK 返回的 dict 信封或 list 统一成 item list"""
|
||||
if isinstance(data, dict):
|
||||
return data.get(key, []) or []
|
||||
if isinstance(data, list):
|
||||
return data or []
|
||||
return []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 基础数据:标的检索 / 标的列表
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def ticker_search(q: str, exchange: Optional[str] = None,
|
||||
asset_type: Optional[str] = None, limit: int = 10) -> list[dict]:
|
||||
items = await _run(_sdk.tickers_search, q, exchange=exchange,
|
||||
asset_type=asset_type, limit=limit)
|
||||
result = _item_list(items)
|
||||
# 拼音首字母兜底:同花顺无结果 且 输入是纯字母(如 skj)时,本地按拼音首字母匹配
|
||||
if not result and q and q.strip().isalpha():
|
||||
try:
|
||||
tickers = await _ensure_a_share_tickers()
|
||||
result = await asyncio.to_thread(_match_by_pinyin, q, tickers, limit)
|
||||
except Exception:
|
||||
pass # 拼音匹配失败不影响主流程
|
||||
return result
|
||||
|
||||
|
||||
async def ticker_list(asset_type: Optional[str] = None, limit: int = 100,
|
||||
offset: int = 0) -> list[dict]:
|
||||
items = await _run(_sdk.tickers_list, asset_type=asset_type or "a-share",
|
||||
limit=limit, offset=offset)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# A股行情 / 日历 / 竞价
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def prices_snapshot(thscodes: str) -> list[dict]:
|
||||
"""行情快照(单只/多只,逗号分隔)"""
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
items = await _run(_sdk.prices_snapshot, codes)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
async def prices_historical(thscode: str, start_ms: int, end_ms: int,
|
||||
adjust: str = "forward", offset: int = 0) -> list[dict]:
|
||||
"""历史日K(毫秒时间戳)。SDK 自动处理 >10 年窗口切片与去重排序。"""
|
||||
return await _run(_sdk.prices_historical, thscode, start_ms, end_ms,
|
||||
interval="1d", adjust=adjust)
|
||||
|
||||
|
||||
async def corp_actions_adjustment_factors(thscode: str, from_date: str = None,
|
||||
to_date: str = None) -> dict:
|
||||
data = await _run(_sdk.corp_actions_adjustment_factors, thscode,
|
||||
**({"from": from_date} if from_date else {}),
|
||||
**({"to": to_date} if to_date else {}))
|
||||
return data or {}
|
||||
|
||||
|
||||
async def calendar_trading_days() -> list[dict]:
|
||||
return await _run(_sdk.calendar_trading_days)
|
||||
|
||||
|
||||
async def auction_snapshot(thscodes: str, stage: str = "final") -> dict:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def auction_short_term_benchmark(date: Optional[str] = None) -> dict:
|
||||
data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date)
|
||||
return data or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# A股财务 / 估值
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def financials(statement: str, thscode: str, period: str = "annual",
|
||||
limit: int = 6) -> list[dict]:
|
||||
fn_map = {
|
||||
"income-statements": _sdk.financials_income_statements,
|
||||
"balance-sheets": _sdk.financials_balance_sheets,
|
||||
"cash-flow-statements": _sdk.financials_cash_flow_statements,
|
||||
}
|
||||
fn = fn_map[statement]
|
||||
items = await _run(fn, thscode, period=period, limit=limit)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
async def financial_indicators(thscode: str, report: str) -> list[dict]:
|
||||
data = await _run(_sdk.financials_indicators, thscode, report)
|
||||
return _item_list(data)
|
||||
|
||||
|
||||
async def valuations_snapshot(thscodes: str) -> list[dict]:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
data = await _run(_sdk.a_share_valuations_snapshot, codes)
|
||||
return _item_list(data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 指数 / 板块
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def index_catalog(tag: str = "industry") -> list[dict]:
|
||||
items = await _run(_sdk.index_catalog_ths_index_list, tag=tag)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
async def index_constituents(thscode: str) -> list[dict]:
|
||||
items = await _run(_sdk.index_constituents_ths_stock_list, thscode)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
async def index_prices_snapshot(thscodes: str) -> list[dict]:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
items = await _run(_sdk.index_prices_snapshot, codes)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
async def index_prices_historical(thscode: str, start_ms: int, end_ms: int) -> list[dict]:
|
||||
items = await _run(_sdk.index_prices_historical, thscode, start_ms, end_ms)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 特殊数据(涨停/跌停池)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def limit_up_pool(date_ms=None, page=1, size=50,
|
||||
sort_field="seal_money", sort_dir="desc") -> dict:
|
||||
data = await _run(_sdk.special_data_limit_up_pool, date_ms=date_ms,
|
||||
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def limit_down_pool(date_ms=None, page=1, size=50,
|
||||
sort_field="last_limit_time", sort_dir="desc") -> dict:
|
||||
data = await _run(_sdk.special_data_limit_down_pool, date_ms=date_ms,
|
||||
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||
return data or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 全市场行情快照
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def prices_snapshot_all(limit: int = 5000) -> list[dict]:
|
||||
"""拉取全市场 A 股行情快照(自动分页)"""
|
||||
items = await _run(_sdk.prices_snapshot, None, fetch_all_market=True, limit=limit)
|
||||
return _item_list(items)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 特殊数据(热门股/龙虎榜/异动/连板/飙升)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def hot_stock_list(period: str = "day") -> dict:
|
||||
data = await _run(_sdk.special_data_hot_stock_list, period=period)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def dragon_tiger_list(board_type: str = "all", date: str = None) -> dict:
|
||||
data = await _run(_sdk.special_data_dragon_tiger_list, board_type=board_type, date=date)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def anomaly_analysis_list(tag_codes=None) -> dict:
|
||||
data = await _run(_sdk.special_data_anomaly_analysis_list, tag_codes=tag_codes)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def skyrocket_list(period: str = "day") -> dict:
|
||||
data = await _run(_sdk.special_data_skyrocket_list, period=period)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def limit_up_ladder() -> dict:
|
||||
data = await _run(_sdk.special_data_limit_up_ladder)
|
||||
return data or {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 集合竞价 / 估值 / 炸板 / 概念板块
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
async def auction_snapshot(thscodes: str, stage: str = "final") -> dict:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
data = await _run(_sdk.a_share_auction_snapshot, codes, stage=stage)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def auction_short_term_benchmark(date: str = None) -> dict:
|
||||
data = await _run(_sdk.a_share_auction_short_term_benchmark, date=date)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def valuations_snapshot(thscodes: str) -> dict:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
data = await _run(_sdk.a_share_valuations_snapshot, codes)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def limit_break_pool(date_ms=None, page=1, size=50,
|
||||
sort_field="price_change_ratio_pct", sort_dir="desc") -> dict:
|
||||
data = await _run(_sdk.special_data_limit_break_pool, date_ms=date_ms,
|
||||
page=page, size=size, sort_field=sort_field, sort_dir=sort_dir)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def hot_stock_list_history(date: str) -> dict:
|
||||
data = await _run(_sdk.special_data_hot_stock_list_history, date)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def hot_stock_rank_trend(thscode: str, start_date: str, end_date: str) -> dict:
|
||||
data = await _run(_sdk.special_data_hot_stock_rank_trend, thscode, start_date, end_date)
|
||||
return data or {}
|
||||
|
||||
|
||||
async def anomaly_analysis_stock(thscodes: str) -> dict:
|
||||
codes = [c.strip() for c in thscodes.split(",") if c.strip()]
|
||||
data = await _run(_sdk.special_data_anomaly_analysis_stock, codes)
|
||||
return data or {}
|
||||
@@ -3,7 +3,6 @@
|
||||
通过 TCP 协议直连通达信行情服务器,不走 HTTP,不会被限流。
|
||||
主要用途:
|
||||
- K线数据:主数据源(稳定可靠)
|
||||
- 板块数据:东方财富 push2 的降级方案
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -69,60 +68,3 @@ async def fetch_kline_history(code: str, days: int = 90) -> Optional[List[dict]]
|
||||
except Exception as e:
|
||||
print(f"[mootdx] fetch_kline error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ---- 板块数据(东方财富降级方案)----
|
||||
|
||||
|
||||
def _sync_fetch_sectors(sector_type: str) -> Optional[list]:
|
||||
from mootdx.consts import MARKET_SH, MARKET_SZ
|
||||
|
||||
client = _create_client()
|
||||
|
||||
# block() 返回 DataFrame,列:code, name 等
|
||||
# 按板块类型过滤
|
||||
block_df = client.block()
|
||||
if block_df is None or block_df.empty:
|
||||
return None
|
||||
|
||||
items = []
|
||||
for _, row in block_df.iterrows():
|
||||
name = str(row.get("name", "") or row.get("blockname", ""))
|
||||
code = str(row.get("code", "") or row.get("blockcode", ""))
|
||||
if not code or not name:
|
||||
continue
|
||||
|
||||
items.append(
|
||||
{
|
||||
"code": code,
|
||||
"name": name,
|
||||
"level": None,
|
||||
"changePercent": None,
|
||||
"changeAmount": None,
|
||||
"mainNetInflow": 0,
|
||||
"mainNetInflowPercent": None,
|
||||
"superLargeInflow": None,
|
||||
"superLargeInflowPercent": None,
|
||||
"largeInflow": None,
|
||||
"largeInflowPercent": None,
|
||||
"mediumInflow": None,
|
||||
"mediumInflowPercent": None,
|
||||
"smallInflow": None,
|
||||
"smallInflowPercent": None,
|
||||
"turnover": 0,
|
||||
}
|
||||
)
|
||||
|
||||
return items if items else None
|
||||
|
||||
|
||||
async def fetch_sector_list(sector_type: str) -> Optional[List[dict]]:
|
||||
"""获取板块列表(东方财富的降级方案,仅含代码和名称)"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, _sync_fetch_sectors, sector_type)
|
||||
except ImportError:
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"[mootdx] fetch_sectors error: {e}")
|
||||
return None
|
||||
|
||||
@@ -257,6 +257,64 @@ async def fetch_history(code: str, days: int = 90) -> List[dict]:
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_history_minute(code: str, period: str = "60", count: int = 320) -> List[dict]:
|
||||
"""获取分钟级 K 线(腾讯 mkline 接口)
|
||||
|
||||
period: m1/m5/m15/m30/m60(60=小时线)
|
||||
返回格式与日K一致,date 为 'YYYY-MM-DD HH:MM'(北京时间)。
|
||||
"""
|
||||
market = get_market_prefix(code)
|
||||
stock_code = f"{market}{code}"
|
||||
# 腾讯 mkline:param=代码,周期,,数量
|
||||
url = f"https://ifzq.gtimg.cn/appstock/app/kline/mkline?param={stock_code},{period},,{count}"
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Referer": "https://stockapp.finance.qq.com/",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
resp = await client.get(url, headers=headers, timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return []
|
||||
data = resp.json()
|
||||
stock_data = data.get("data", {}).get(stock_code, {})
|
||||
raw = stock_data.get(period) or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
result = []
|
||||
prev_close = 0
|
||||
for record in raw:
|
||||
if not isinstance(record, list) or len(record) < 6:
|
||||
continue
|
||||
# record[0] = 'YYYYMMDDHHMM',转成 'YYYY-MM-DD HH:MM'
|
||||
raw_dt = str(record[0])
|
||||
try:
|
||||
dt_str = f"{raw_dt[0:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}"
|
||||
except Exception:
|
||||
continue
|
||||
close = float(record[2]) if record[2] else 0
|
||||
change_pct = 0
|
||||
if prev_close > 0:
|
||||
change_pct = (close - prev_close) / prev_close * 100
|
||||
result.append({
|
||||
"date": dt_str,
|
||||
"open": float(record[1]) if record[1] else 0,
|
||||
"close": close,
|
||||
"high": float(record[3]) if record[3] else 0,
|
||||
"low": float(record[4]) if record[4] else 0,
|
||||
"volume": int(float(record[5])) if record[5] else 0,
|
||||
"changePercent": round(change_pct, 2),
|
||||
})
|
||||
prev_close = close
|
||||
# 丢弃最旧1条(prev_close=0 涨跌幅失真)
|
||||
if len(result) > 1:
|
||||
result = result[1:]
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def fetch_kline_map(code: str, days: int = 30) -> dict:
|
||||
"""获取K线数据并返回 { date: { close, changePercent, turnover } } 映射"""
|
||||
market = get_market_prefix(code)
|
||||
|
||||
+93
-23
@@ -30,12 +30,23 @@ _PZ_CDN_URL = "https://emcfgdata.securities.eastmoney.com"
|
||||
_APP_KEY_INDEX = "rn-themeIndex"
|
||||
_APP_KEY_DETAIL = "rn-themeDetail"
|
||||
|
||||
# 完整浏览器请求头:生产实测东财对缺 sec-* 头的移动端包装结构请求会 403,
|
||||
# 补齐真实 Chrome 头 + client="web" 后正常返回
|
||||
_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Origin": "https://emrnweb.eastmoney.com",
|
||||
"Referer": "https://emrnweb.eastmoney.com/",
|
||||
"Accept": "application/json",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||||
"Content-Type": "application/json;charset=UTF-8",
|
||||
"DNT": "1",
|
||||
"Origin": "https://emrnweb.eastmoney.com",
|
||||
"Priority": "u=1, i",
|
||||
"Referer": "https://emrnweb.eastmoney.com/",
|
||||
"Sec-Ch-Ua": '"Not=A?Brand";v="99", "Google Chrome";v="151", "Chromium";v="151"',
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-site",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36",
|
||||
}
|
||||
|
||||
# ---- 交易时段感知缓存 ----
|
||||
@@ -77,9 +88,14 @@ def _next_open_delta_seconds() -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# 盘中题材列表短缓存:题材热点页与热点穿透聚合共用同一份缓存,避免重复拉全量列表打东财。
|
||||
# 120s 长于热点穿透图缓存(60s),图重建时必然命中且更新频率更低,两页数据更稳。
|
||||
_LIST_CACHE_SECONDS = 120
|
||||
|
||||
|
||||
def _list_ttl_seconds() -> int:
|
||||
"""题材列表缓存秒数:交易时段 0(不缓存、实时拉取);非交易时段缓存到下次开盘前失效"""
|
||||
return 0 if _is_trading_time() else _next_open_delta_seconds()
|
||||
"""题材列表缓存秒数:交易时段 120s 短缓存;非交易时段缓存到下次开盘前失效"""
|
||||
return _LIST_CACHE_SECONDS if _is_trading_time() else _next_open_delta_seconds()
|
||||
|
||||
|
||||
# ---- 请求封装 ----
|
||||
@@ -89,7 +105,7 @@ def _build_payload(args: Optional[dict] = None, app_key: str = _APP_KEY_INDEX) -
|
||||
return {
|
||||
"args": args or {},
|
||||
"appKey": app_key,
|
||||
"client": "iOS",
|
||||
"client": "web", # 生产实测 iOS client 会 403,web + 完整浏览器头正常
|
||||
"clientVersion": "8.3",
|
||||
"clientType": "cfw",
|
||||
"randomCode": "".join(random.choices(string.ascii_uppercase + string.ascii_lowercase + string.digits, k=16)),
|
||||
@@ -148,8 +164,7 @@ async def fetch_theme_list(sort_field: int = 1, asc: bool = False) -> list[dict]
|
||||
asc: True=升序, False=降序
|
||||
"""
|
||||
cache_key = f"theme_list:{sort_field}:{asc}"
|
||||
# 交易时段强制实时:跳过缓存读取,避免命中非交易时段写入的上个交易日旧数据
|
||||
if not _is_trading_time():
|
||||
# 统一读缓存(盘中 TTL=120s 短缓存,非盘中缓存到下次开盘前失效),避免重复拉全量列表打东财
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
@@ -293,7 +308,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度(当前榜在前,另一榜合并补充)
|
||||
top_n: 每个榜单的题材数量(1-60)
|
||||
top_n: 每个榜单的题材数量(1-200)
|
||||
|
||||
Returns:
|
||||
{
|
||||
@@ -359,7 +374,7 @@ async def _build_theme_graph(sort_field: int, top_n: int) -> dict:
|
||||
|
||||
return {
|
||||
"themes": [
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"]}
|
||||
{"themeCode": t["themeCode"], "themeName": t["themeName"], "stockCount": t["stockCount"], "bf3": t.get("bf3")}
|
||||
for t in theme_map.values()
|
||||
],
|
||||
"stocks": stocks,
|
||||
@@ -399,34 +414,89 @@ async def _rebuild_task(cache_key: str, sort_field: int, top_n: int, lock: async
|
||||
|
||||
|
||||
async def fetch_theme_graph(sort_field: int = 1, top_n: int = 30, limit: int = 1000) -> dict:
|
||||
"""获取热点穿透图数据(盘中 60s 缓存 + stale-while-revalidate)
|
||||
"""获取热点穿透图数据(盘中 60s 缓存,盘中过期同步重建,非盘中 stale-while-revalidate)
|
||||
|
||||
缓存命中且未过期 → 直接返回;已过期 → 返回旧数据并后台异步重建(秒开);
|
||||
无缓存 → 同步构建(并发下加锁去重)。返回前按 limit 裁剪 stocks。
|
||||
缓存新鲜 → 直接返回;
|
||||
非交易时段过期 → 返回旧数据并后台异步重建(秒开,非盘中行情无实时变化,旧值可接受);
|
||||
交易时段过期 / 无缓存 → 同步重建(加锁去重),绝不返回上个交易日的旧图。
|
||||
|
||||
Args:
|
||||
sort_field: 题材排序 1=涨幅, 4=热度
|
||||
top_n: 每个榜单的题材数量(1-60)
|
||||
top_n: 每个榜单的题材数量(1-200)
|
||||
limit: 下发 stocks 上限(穿透度最高的 N 只)
|
||||
"""
|
||||
cache_key = f"theme_graph:{sort_field}:{top_n}"
|
||||
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 有缓存:新鲜直接返回;过期返回旧数据并后台刷新
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 缓存新鲜 → 直接返回
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 非交易时段过期:行情无实时变化,先返回旧值(秒开),后台异步重建
|
||||
if data is not None and not _is_trading_time():
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
# 无缓存:同步构建(并发下加锁去重)
|
||||
# 交易时段过期 / 无缓存:同步重建,加锁去重
|
||||
lock = _REBUILD_LOCKS.setdefault(cache_key, asyncio.Lock())
|
||||
async with lock:
|
||||
data, expires_at = _get_graph_cache(cache_key)
|
||||
if data is not None:
|
||||
# 等待锁期间已被其他请求写入
|
||||
if not (expires_at and expires_at > time.time()):
|
||||
_spawn_rebuild(cache_key, sort_field, top_n)
|
||||
if data is not None and expires_at and expires_at > time.time():
|
||||
# 等待锁期间已被其他请求刷新
|
||||
return _trim_graph_result(data, limit)
|
||||
data = await _build_theme_graph(sort_field, top_n)
|
||||
_set_graph_cache(cache_key, data)
|
||||
return _trim_graph_result(data, limit)
|
||||
|
||||
|
||||
# ---- 题材相关新闻(分页) ----
|
||||
|
||||
_NEWS_PAGE_SIZE = 10
|
||||
|
||||
|
||||
async def fetch_theme_news(theme_code: str, page_num: int = 1, max_eu_time: str = "", page_size: int = _NEWS_PAGE_SIZE) -> Optional[dict]:
|
||||
"""获取题材相关新闻(分页),返回 {total, maxEuTime, list}
|
||||
|
||||
maxEuTime 为游标:上一页返回的 maxEuTime 作为下一页入参,首页传空串。
|
||||
盘中 60s 短缓存(与图缓存同频);非盘中缓存到下次开盘前失效。
|
||||
"""
|
||||
cache_key = f"theme_news:{theme_code}:{page_num}:{max_eu_time}:{page_size}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getThemeRelatedNews",
|
||||
{"themeCode": theme_code, "pageNum": page_num, "maxEuTime": max_eu_time, "pageSize": page_size},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
|
||||
# ---- 单题材实时行情(强度/热度/涨幅) ----
|
||||
|
||||
async def fetch_theme_quote(theme_code: str) -> Optional[dict]:
|
||||
"""获取单题材实时行情(strengthValue/hotValue/f3),盘中 60s 短缓存"""
|
||||
cache_key = f"theme_quote:{theme_code}"
|
||||
cached = get_cache(cache_key)
|
||||
if cached is not None:
|
||||
return json.loads(cached)
|
||||
|
||||
data = await _post(
|
||||
"/api/themeInvest/getSingleThemeQuote",
|
||||
{"themeCode": theme_code},
|
||||
app_key=_APP_KEY_DETAIL,
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
|
||||
ttl_s = _graph_ttl_seconds()
|
||||
if ttl_s > 0:
|
||||
set_cache(cache_key, json.dumps(data, ensure_ascii=False), ttl_seconds=ttl_s)
|
||||
return data
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 数据接口 & 数据源一览
|
||||
|
||||
> 📖 同花顺官方金融数据 API 能力整理见 [hithink-financial-api.md](./hithink-financial-api.md)
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
# 同花顺金融数据 API(HiThink Financial API)接口能力整理
|
||||
|
||||
> 数据源参考:https://github.com/HiThink-Tech/Financial-API · 文档:https://fuyao.aicubes.cn/docs/quickstart/
|
||||
> 本文档整理官方 REST / MCP / CLI / Python / 本地库 各接入方式的能力清单,供后续开发评估使用。
|
||||
|
||||
## 一句话概览
|
||||
|
||||
同花顺官方维护的 **A 股金融数据服务**,面向 AI Agent、量化研究和应用开发者。
|
||||
一个 API Key 即可访问数据,提供 **六种接入方式**:REST API、托管 MCP、Node.js CLI、Python SDK、
|
||||
本地 DuckDB 数据库(marketdb)和 Agent Skill。MIT 许可,约 1.9k stars。
|
||||
|
||||
**支持**:A股股票、指数与板块、公募基金(含 ETF/LOF)、衍生特色数据(涨跌停/连板/异动/热榜/龙虎榜)、全市场数据导出。
|
||||
**暂不支持**:期货、分钟K、tick、海外市场、宏观数据、新闻公告原文、研报原文。
|
||||
|
||||
---
|
||||
|
||||
## 认证与调用
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| Base URL | `https://fuyao.aicubes.cn` |
|
||||
| 认证 | 请求头 `X-api-key: <your-key>`(与同花顺账号绑定) |
|
||||
| Key 获取 | 官网注册 → 「API Key 管理」创建;关闭弹窗后无法再看完整 Key,需妥善保存 |
|
||||
| 响应信封 | `{ code, message, request_id, data }`,`code=0` 表示成功(HTTP 恒为 200) |
|
||||
| 环境变量 | `HITHINK_FINANCE_API_KEY` |
|
||||
| 常见错误 | `2001` Key 缺失/无效;`2003` 无权限需开通 |
|
||||
|
||||
**Agent 集成(推荐)**:`npx skills add HiThink-Tech/Financial-API --skill hithink-finance -g --yes`
|
||||
Agent 会在 API / MCP / CLI / Python 间自动选择合适方式,并对大结果自动落盘。
|
||||
|
||||
---
|
||||
|
||||
## 接入方式一览
|
||||
|
||||
| 方式 | 说明 |
|
||||
|------|------|
|
||||
| **REST API** | `GET` + `X-api-key`,最通用 |
|
||||
| **MCP** | 4 个服务端点,供 AI Agent 直接调用 |
|
||||
| **Node.js CLI** | npm 包 `@hithink-tech/hithink-finance-cli` |
|
||||
| **Python SDK** | `pip install -e ./python` |
|
||||
| **本地 DuckDB** | `marketdb` 本地库,支持增量同步 + SQL + 复权 |
|
||||
| **Agent Skill** | 一键安装,自动选择最优接入 |
|
||||
|
||||
---
|
||||
|
||||
## REST API 能力清单(GET + X-api-key)
|
||||
|
||||
### 基础 / 元信息
|
||||
|
||||
| 接口路径 | 功能 | 主要参数 |
|
||||
|----------|------|---------|
|
||||
| `/api/meta/tickers/search` | 跨市场标的检索(代码/名称/拼音) | `q`*、`exchange`、`asset_type`、`limit`(≤50) |
|
||||
| `/api/meta/tickers/list` | 分页获取代码表 | `asset_type`、`limit`(≤10000)、`offset` |
|
||||
|
||||
### A股行情
|
||||
|
||||
| 接口路径 | 功能 | 主要参数 |
|
||||
|----------|------|---------|
|
||||
| `/api/a-share/prices/snapshot` | 行情快照(单只/多只/全市场) | `thscodes`、`limit`、`offset` |
|
||||
| `/api/a-share/prices/historical` | 单只标的日K(窗口≤10年) | `thscode`*、`interval`(1d)、`start`*、`end`*、`adjust`(none/forward/backward)、`offset` |
|
||||
| `/api/a-share/auction/snapshot` | 集合竞价快照 | `thscodes`*、`stage`(live/final) |
|
||||
| `/api/a-share/auction/short-term-benchmark` | 短线风向标竞价基准 | `date`(yyyy-MM-dd) |
|
||||
| `/api/a-share/calendar/trading-days` | 近一年交易日序列 | 无 |
|
||||
|
||||
### A股财务 / 复权 / 估值
|
||||
|
||||
| 接口路径 | 功能 | 主要参数 |
|
||||
|----------|------|---------|
|
||||
| `/api/a-share/financials/income-statements` | 合并利润表多期序列 | `thscode`*、`period`(annual/quarterly)*、`limit`(1-20) 或 `start`+`end` |
|
||||
| `/api/a-share/financials/balance-sheets` | 合并资产负债表 | 同上 |
|
||||
| `/api/a-share/financials/cash-flow-statements` | 合并现金流量表 | 同上 |
|
||||
| `/api/a-share/financials/indicators` | 五类财务指标 | `thscode`*、`report`*(yyyy-1~yyyy-4) |
|
||||
| `/api/a-share/corporate-actions/adjustment-factors` | 分红/送股/配股事件流 | `thscode`*、`from`、`to` |
|
||||
| `/api/a-share/valuations/snapshot` | 估值快照(PE TTM/MRQ、PB、PS、PCF) | 批量查询 |
|
||||
|
||||
### 指数与板块
|
||||
|
||||
| 接口路径 | 功能 | 主要参数 |
|
||||
|----------|------|---------|
|
||||
| `/api/a-share-index/catalog/ths-index-list` | 同花顺指数清单 | `tag`(cn_concept/region/tszs/industry) |
|
||||
| `/api/a-share-index/constituents/ths-stock-list` | 指数成分股 | `thscode`* |
|
||||
| `/api/a-share-index/prices/snapshot` | 指数行情快照 | `thscodes`* |
|
||||
| `/api/a-share-index/prices/historical` | 指数历史K线(无复权参数) | `thscode`*、`interval`、`start`*、`end`* |
|
||||
|
||||
### 特色数据(`/api/a-share/special-data/…`)
|
||||
|
||||
| 接口路径后缀 | 功能 |
|
||||
|------|------|
|
||||
| `limit-up-pool` | 涨停池(涨停/连板股) |
|
||||
| `limit-down-pool` | 跌停池 |
|
||||
| `limit-break-pool` | 涨停炸板池 |
|
||||
| `limit-up-ladder` | 近30日连板天梯 |
|
||||
| `skyrocket-list` | 热度飙升榜 Top30(日榜/小时榜) |
|
||||
| `hot-stock-list` | A股热股榜 Top30(24h/小时) |
|
||||
| `hot-stock-list-history` | 按自然日历史热股排行 |
|
||||
| `hot-stock-rank-trend` | 单股热榜排名走势 |
|
||||
| `anomaly-analysis-list` | 当日个股异动原因列表(`tag_codes` 如 `LIMIT_UP,SHARP_FALL`)※仅 REST |
|
||||
| `anomaly-analysis-stock` | 按股票批量查异动原因(`thscodes`* ≤50) |
|
||||
| `dragon-tiger-list` | 龙虎榜(`board_type` all/org/hot_money、`date`) |
|
||||
|
||||
### 公募基金(21 项,仅列核心)
|
||||
|
||||
| 接口路径 | 功能 |
|
||||
|----------|------|
|
||||
| `/api/fund/profile/**` | 基金基本资料 |
|
||||
| `/api/fund/portfolio/holdings` | 定期披露重仓持仓 |
|
||||
| `/api/fund/performance/nav` / `returns` / `indicators-historical` | 净值 / 区间收益与同类排名 / 历史业绩指标 |
|
||||
| `/api/fund/holders/detail` / `top` | 持有人结构 / 前十大持有人 |
|
||||
| `/api/fund/corporate-actions/dividends` | 基金分红记录 |
|
||||
| `/api/fund/managers/investment-style` / `performance` / `experience` / `detail` | 经理投资风格 / 业绩 / 经历 / 详情 |
|
||||
| `/api/fund/companies/detail` | 基金公司详情 |
|
||||
| `/api/fund/diagnostics/detail` | 基金诊断 |
|
||||
| `/api/fund/offerings/list` | 新发基金募集列表 |
|
||||
| `/api/fund/news/article-list` | 基金资讯(游标分页) |
|
||||
| `/api/fund/financials/indicators` / `income-statements` / `balance-sheets` | 基金财务指标 / 利润表 / 资产负债表 |
|
||||
| `/api/fund/market/snapshot` / `historical` | ETF 行情快照 / 历史日线(窗口≤5年) |
|
||||
|
||||
### 全市场数据导出(Market Dumps)
|
||||
|
||||
| 接口路径 | 功能 |
|
||||
|----------|------|
|
||||
| `/api/dump/market-dumps/daily-k/download-url` | 全市场 10 年日K Parquet 下载链接 |
|
||||
| `/api/dump/market-dumps/daily-k-10d/download-url` | 最近 10 交易日日K Parquet |
|
||||
| `/api/dump/market-dumps/adjustment-factors/download-url` | 全量复权因子 Parquet |
|
||||
|
||||
---
|
||||
|
||||
## MCP 服务端点(4 个)
|
||||
|
||||
| 服务 | 端点 |
|
||||
|------|------|
|
||||
| `hithink-finance-a-share` | `/mcp/a-share` |
|
||||
| `hithink-finance-a-share-index` | `/mcp/a-share-index` |
|
||||
| `hithink-finance-meta` | `/mcp/meta` |
|
||||
| `hithink-finance-fund` | `/mcp/fund` |
|
||||
|
||||
鉴权:环境变量 `API_KEY` 注入,与 REST 同一 Key。
|
||||
|
||||
---
|
||||
|
||||
## CLI(`@hithink-tech/hithink-finance-cli`)
|
||||
|
||||
| 命令 | 功能 |
|
||||
|------|------|
|
||||
| `auth login` | 录入 API Key |
|
||||
| `capabilities` | 能力目录 |
|
||||
| `symbol search --q <代码>` | 标的检索 |
|
||||
| `market snapshot --thscodes 600519.SH` | 行情快照 |
|
||||
| `financials income --thscode ... --limit 4` | 利润表 |
|
||||
| `data init` / `db query --sql "..."` | 初始化本地库 / SQL 查询(视图如 `v_daily_qfq`) |
|
||||
|
||||
---
|
||||
|
||||
## Python SDK 与本地 DuckDB
|
||||
|
||||
```bash
|
||||
pip install -e ./python
|
||||
python python/bootstrap.py # 初始化本地 DuckDB(marketdb)
|
||||
```
|
||||
|
||||
常用脚本:`fuyao.py tickers-search --q "贵州茅台"`、`fuyao.py prices-snapshot ...`
|
||||
marketdb 支持增量同步、SQL 查询、复权计算、数据导出。
|
||||
|
||||
---
|
||||
|
||||
## 合规要求
|
||||
|
||||
- 输出需注明数据源、时间范围与复权口径,标注"非投资建议"
|
||||
- 真实数据不可用时不得用模拟数据冒充
|
||||
- API Key 不可写入代码、日志或 Git 仓库
|
||||
|
||||
---
|
||||
|
||||
## 与我们现有系统的潜在结合点(供后续评估)
|
||||
|
||||
> 现有数据源依赖腾讯/东方财富/新浪/AkShare,均为非官方抓取,存在限流与反爬问题。
|
||||
> 同花顺官方 API 可作**更稳定、结构化的权威数据源**,或补充新能力。
|
||||
|
||||
| 现有模块 | 同花顺对应能力 | 潜在价值 |
|
||||
|----------|--------------|---------|
|
||||
| 实时行情 `/api/stock/quote`(腾讯) | `a-share/prices/snapshot` | 官方行情快照,避免腾讯兼容性/抓取风险 |
|
||||
| 历史K线 `/api/stock/history`(东财/腾讯) | `a-share/prices/historical`(10年、复权可选) | 权威日K + 明确复权口径,替代被反爬的东财 kline |
|
||||
| 财务指标 `/api/stock/financial`(东财) | `a-share/financials/*` + `valuations/snapshot` | 官方三大报表、五类指标、估值快照 |
|
||||
| 题材热点 / 热点穿透(东财) | `a-share/special-data/*` 热榜、涨停池、连板天梯 | 官方热榜/涨停/连板数据,可增强题材热度判断 |
|
||||
| 核心股追踪 / 每日采集 | `a-share/special-data/dragon-tiger-list`、`hot-stock-*` | 龙虎榜、热股榜可作为核心股采样的补充来源 |
|
||||
| 新增能力 | `a-share-index/*`(指数/板块成分)、`fund/*`(基金)、Market Dumps(全市场导出) | 指数行情、基金筛选、本地 marketdb 全市场回测 |
|
||||
|
||||
> 注意:特色数据(涨停池等)tag_codes 语义、thscode 与现有 code 体系的映射,接入前需先做字段对齐验证。
|
||||
@@ -0,0 +1,704 @@
|
||||
# 每日核心股/题材历史 + 活跃核心股滚动表格 — 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 每日收盘后采集核心股前100(按涨幅)+所属题材、题材涨幅前20存历史,并提供「活跃核心股×最近10个A股交易日」涨幅矩阵展示页。
|
||||
|
||||
**Architecture:** 后端新增 asyncio 后台定时采集任务(`lifespan` 启动),复用现有 `services/themes.py` 的 `fetch_theme_list`/`fetch_theme_graph` 拿数据,写入 3 张新表;新增 `GET /api/core-stocks/active` 接口计算活跃窗口;前端新增 `/core-stocks` 路由渲染股票×10日涨幅矩阵。
|
||||
|
||||
**Tech Stack:** Python 3.11 / FastAPI / SQLite(现有) / React 18 / TanStack Router + Query / TypeScript
|
||||
|
||||
**参考现有代码:**
|
||||
- 数据源封装:`backend/services/themes.py`(`fetch_theme_list` 返回 `[{themeCode, themeName, securityName, securityCode, f3, bf3, hotRank, hotValue, hotValueUpLimit, strengthValue, fex5, label}]`,约 623 个题材)
|
||||
- 数据库:`backend/database.py`(`SCHEMA_SQL` + `init_db()` + `get_connection()`)
|
||||
- 路由范式:`backend/routes/shares.py`(`get_connection()` + `dict_from_row`)
|
||||
- 前端 API 范式:`src/lib/theme-api.ts`(`getApiBaseUrl()` + `fetch`)
|
||||
- 前端页面范式:`src/routes/themes.tsx`(顶栏 + 卡片网格 + React Query)
|
||||
- 涨跌配色:红涨绿跌 `text-red-500` / `text-green-500`
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 动作 | 职责 |
|
||||
|---|---|---|
|
||||
| `backend/database.py` | 修改 | 追加 3 张表 DDL |
|
||||
| `backend/services/daily_collector.py` | 新建 | 后台定时采集 + 幂等入库 |
|
||||
| `backend/routes/core_stocks.py` | 新建 | `/api/core-stocks/active` 等历史接口 |
|
||||
| `backend/main.py` | 修改 | lifespan 启动采集任务 + 挂载路由 |
|
||||
| `src/lib/core-stock-api.ts` | 新建 | 前端 API 客户端 |
|
||||
| `src/routes/core-stocks.tsx` | 新建 | 展示页 |
|
||||
| `src/routes/themes.tsx` | 修改 | 加入口链接 |
|
||||
| `src/routes/hot-map.tsx` | 修改 | 加入口链接 |
|
||||
| `backend/verify_collector.py` | 新建(临时) | 验证脚本,跑完删除 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 数据库 — 追加 3 张表
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/database.py`(在 `SCHEMA_SQL` 末尾追加)
|
||||
|
||||
- [ ] **Step 1: 修改 `SCHEMA_SQL`,追加建表语句**
|
||||
|
||||
在 `backend/database.py` 的 `SCHEMA_SQL` 字符串中,`cache` 表定义后追加:
|
||||
|
||||
```python
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
stock_name TEXT NOT NULL,
|
||||
f3 REAL,
|
||||
cover_count INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL,
|
||||
hot_rank INTEGER,
|
||||
rank INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行验证建表**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "from database import init_db; init_db(); from database import get_connection; c=get_connection(); t=[r['name'] for r in c.execute(\"SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'daily_%'\")]; print(t); c.close()"
|
||||
```
|
||||
|
||||
Expected: `['daily_core_stocks', 'daily_core_stock_themes', 'daily_top_themes']`
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/database.py && git commit -m "feat: 新增每日核心股/题材历史 3 张表"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/services/daily_collector.py`
|
||||
|
||||
- [ ] **Step 1: 新建采集服务**
|
||||
|
||||
```python
|
||||
"""每日热点数据采集:核心股前100 + 题材涨幅前20,收盘后自动入库
|
||||
|
||||
由 main.lifespan 启动后台任务;幂等(按交易日 UNIQUE 去重)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, time as dtime, timezone, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from database import get_connection
|
||||
from services.themes import fetch_theme_list
|
||||
|
||||
_CST = timezone(timedelta(hours=8))
|
||||
|
||||
# 每天采集的后台任务:每 CHECK_INTERVAL 分钟检查一次
|
||||
CHECK_INTERVAL_SECONDS = 300
|
||||
COLLECT_AFTER_TIME = dtime(15, 0) # 收盘后 15:00 开始允许采集
|
||||
CORE_STOCK_LIMIT = 100 # 核心股前100
|
||||
TOP_THEME_LIMIT = 20 # 题材涨幅前20
|
||||
|
||||
|
||||
def _is_trading_day(d: datetime) -> bool:
|
||||
"""周一至周五视为交易日(与 themes._is_trading_time 一致,不处理法定节假日)"""
|
||||
return d.weekday() < 5
|
||||
|
||||
|
||||
def _has_collected(trade_date: str) -> bool:
|
||||
"""当日核心股是否已采集"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
|
||||
(trade_date,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
async def collect_daily(trade_date: str, dry_run: bool = False) -> dict:
|
||||
"""采集指定交易日数据并入库。
|
||||
|
||||
Args:
|
||||
trade_date: YYYY-MM-DD
|
||||
dry_run: True 只打印不写库(用于验证)
|
||||
|
||||
Returns:
|
||||
{"core_count": int, "theme_count": int, "skipped": bool}
|
||||
"""
|
||||
if _has_collected(trade_date):
|
||||
print(f"[collector] {trade_date} 已采集,跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 1. 拉取全部题材列表(含领涨股,作为当日全部股票的采样来源)
|
||||
themes = await fetch_theme_list(1, False)
|
||||
if not themes:
|
||||
print(f"[collector] {trade_date} 题材列表为空(东财失败),跳过")
|
||||
return {"core_count": 0, "theme_count": 0, "skipped": True}
|
||||
|
||||
# 2. 核心股前100:按领涨股 f3 降序,去重(同一股票可能是多个题材领涨股)
|
||||
stock_map: dict[str, dict] = {}
|
||||
for t in themes:
|
||||
code = t.get("securityCode")
|
||||
if not code:
|
||||
continue
|
||||
if code not in stock_map or (t.get("f3") or 0) > (stock_map[code].get("f3") or 0):
|
||||
stock_map[code] = {
|
||||
"stock_code": code,
|
||||
"stock_name": t.get("securityName", ""),
|
||||
"f3": t.get("f3"),
|
||||
}
|
||||
core_stocks = sorted(
|
||||
stock_map.values(), key=lambda x: -(x["f3"] or 0)
|
||||
)[:_CORE_STOCK_LIMIT]
|
||||
|
||||
# 3. 题材涨幅前20:bf3 降序
|
||||
top_themes = sorted(themes, key=lambda x: -(x.get("bf3") or 0))[:_TOP_THEME_LIMIT]
|
||||
|
||||
if dry_run:
|
||||
print(f"[collector] {trade_date} 核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
# 4. 入库(事务,UNIQUE 幂等)
|
||||
conn = get_connection()
|
||||
try:
|
||||
for i, s in enumerate(core_stocks, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, rank) VALUES (?,?,?,?,?)",
|
||||
(trade_date, s["stock_code"], s["stock_name"], s["f3"], i),
|
||||
)
|
||||
for i, t in enumerate(top_themes, start=1):
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
|
||||
(trade_date, t["themeCode"], t["themeName"], t.get("bf3"), t.get("hotRank"), i),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"[collector] {trade_date} 已采集:核心股 {len(core_stocks)} 只,题材前20 {len(top_themes)} 只")
|
||||
return {"core_count": len(core_stocks), "theme_count": len(top_themes), "skipped": False}
|
||||
|
||||
|
||||
async def collector_loop(stop: Optional[asyncio.Event] = None) -> None:
|
||||
"""后台循环:每个交易日 15:00 后自动采集当日数据(幂等)"""
|
||||
while True:
|
||||
try:
|
||||
now = datetime.now(_CST)
|
||||
if _is_trading_day(now) and now.time() >= COLLECT_AFTER_TIME:
|
||||
trade_date = now.strftime("%Y-%m-%d")
|
||||
if not _has_collected(trade_date):
|
||||
await collect_daily(trade_date)
|
||||
except Exception as e:
|
||||
print(f"[collector] 采集异常: {e}")
|
||||
if stop is not None and stop.is_set():
|
||||
break
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
```
|
||||
|
||||
> 注:上面代码中 `_CORE_STOCK_LIMIT` 应为 `CORE_STOCK_LIMIT`(变量名一致),下面 Step 2 统一修正。
|
||||
|
||||
- [ ] **Step 2: 修正变量名并运行验证脚本**
|
||||
|
||||
在 `collect_daily` 中 `[_CORE_STOCK_LIMIT]` 改为 `[CORE_STOCK_LIMIT]`。
|
||||
|
||||
验证(dry_run 不写库):
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=True)
|
||||
print(r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 输出核心股数量(几十只)与题材数 10,`skipped: False`(首次)。
|
||||
|
||||
- [ ] **Step 3: 验证幂等(入库后再跑应 skipped)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import asyncio, sys
|
||||
sys.path.insert(0, '.')
|
||||
from services.daily_collector import collect_daily
|
||||
async def main():
|
||||
r = await collect_daily('2026-08-10', dry_run=False) # 真实入库
|
||||
print(r)
|
||||
r2 = await collect_daily('2026-08-10', dry_run=False) # 再次 → skipped
|
||||
print(r2)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 第一次入库,第二次 `skipped: True`。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/services/daily_collector.py && git commit -m "feat: 每日核心股/题材采集服务(幂等)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 历史接口 `backend/routes/core_stocks.py`
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/routes/core_stocks.py`
|
||||
|
||||
- [ ] **Step 1: 新建路由**
|
||||
|
||||
```python
|
||||
"""核心股历史接口:活跃核心股 + 指定日核心股/题材前20"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
|
||||
|
||||
|
||||
def _recent_trade_dates(conn, n: int = 10) -> list[str]:
|
||||
"""最近 n 个有数据的交易日(升序)"""
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT ?",
|
||||
(n,),
|
||||
).fetchall()
|
||||
return [r["trade_date"] for r in reversed(rows)]
|
||||
|
||||
|
||||
@router.get("/active", summary="活跃核心股 + 最近10日涨幅矩阵")
|
||||
async def active_core_stocks():
|
||||
conn = get_connection()
|
||||
try:
|
||||
dates = _recent_trade_dates(conn, 10)
|
||||
if not dates:
|
||||
return JSONResponse({"dates": [], "stocks": []}, headers=_NO_CACHE_HEADERS)
|
||||
|
||||
# 窗口内出现过且最近一次出现距今天数 <= 10 个交易日
|
||||
placeholders = ",".join("?" * len(dates))
|
||||
rows = conn.execute(
|
||||
f"""SELECT trade_date, stock_code, stock_name, f3 FROM daily_core_stocks
|
||||
WHERE trade_date IN ({placeholders})
|
||||
ORDER BY trade_date DESC, rank ASC""",
|
||||
dates,
|
||||
).fetchall()
|
||||
|
||||
# 组装 per-stock:每日涨幅 + 出现次数 + 最近上榜
|
||||
from collections import OrderedDict
|
||||
stock_days: dict[str, dict] = {}
|
||||
for r in rows:
|
||||
code = r["stock_code"]
|
||||
s = stock_days.setdefault(code, {
|
||||
"stockCode": code,
|
||||
"stockName": r["stock_name"],
|
||||
"dailyGains": {},
|
||||
"appearCount": 0,
|
||||
"lastAppear": None,
|
||||
})
|
||||
s["dailyGains"][r["trade_date"]] = r["f3"]
|
||||
s["appearCount"] += 1
|
||||
if s["lastAppear"] is None or r["trade_date"] > s["lastAppear"]:
|
||||
s["lastAppear"] = r["trade_date"]
|
||||
|
||||
stocks = list(stock_days.values())
|
||||
stocks.sort(key=lambda x: (-x["appearCount"], -(x.get("lastAppear") or "")))
|
||||
return JSONResponse({"dates": dates, "stocks": stocks}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/history", summary="指定交易日核心股(含所属题材)")
|
||||
async def core_stock_history(date: str = Query(..., description="交易日 YYYY-MM-DD")):
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC", (date,)
|
||||
).fetchall()
|
||||
items = []
|
||||
for r in rows:
|
||||
d = dict_from_row(r)
|
||||
themes = conn.execute(
|
||||
"SELECT theme_code, theme_name FROM daily_core_stock_themes WHERE trade_date = ? AND stock_code = ?",
|
||||
(date, d["stock_code"]),
|
||||
).fetchall()
|
||||
d["themes"] = [dict(t) for t in themes]
|
||||
items.append(d)
|
||||
return JSONResponse({"date": date, "items": items}, headers=_NO_CACHE_HEADERS)
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证 active 接口(用 Task 2 入库的数据)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from routes.core_stocks import active_core_stocks
|
||||
import asyncio
|
||||
async def main():
|
||||
r = await active_core_stocks()
|
||||
print('dates:', r.body[:200] if hasattr(r,'body') else r)
|
||||
asyncio.run(main())
|
||||
"
|
||||
```
|
||||
|
||||
> 上面直接调函数拿的是 JSONResponse,验证方式见 Step 3(直接查库验证逻辑更直观)。
|
||||
|
||||
- [ ] **Step 3: 验证窗口计算(直接查库,核对数据结构)**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "
|
||||
import sys; sys.path.insert(0, '.')
|
||||
from database import get_connection
|
||||
conn = get_connection()
|
||||
dates = [r['trade_date'] for r in conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC LIMIT 10').fetchall()]
|
||||
print('最近日期:', dates)
|
||||
rows = conn.execute('SELECT COUNT(*) AS n FROM daily_core_stocks').fetchone()
|
||||
print('总记录:', rows['n'])
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
Expected: 打印最近日期列表(1 个日期)和总记录数(与核心股数一致)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/routes/core_stocks.py && git commit -m "feat: 核心股历史/活跃接口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 挂载路由 + 启动采集任务 `backend/main.py`
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/main.py`
|
||||
|
||||
- [ ] **Step 1: 修改 main.py**
|
||||
|
||||
在 `from routes import ...` 加 `core_stocks`,lifespan 里启动采集任务,注册路由:
|
||||
|
||||
```python
|
||||
from routes import stock, collections, shares, sectors, themes, core_stocks
|
||||
from services.daily_collector import collector_loop
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
init_db()
|
||||
task = asyncio.create_task(collector_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
task.cancel()
|
||||
```
|
||||
|
||||
并在 `app.include_router` 区加:
|
||||
|
||||
```python
|
||||
app.include_router(core_stocks.router, prefix="/api/core-stocks")
|
||||
```
|
||||
|
||||
> 注意:需确保 `import asyncio` 在文件顶部。lifespan 里 `yield` 前启动任务,`finally` 里 cancel,符合 FastAPI 生命周期。
|
||||
|
||||
- [ ] **Step 2: 语法检查**
|
||||
|
||||
```bash
|
||||
cd backend && ./venv/bin/python -c "import ast; ast.parse(open('main.py').read()); print('语法 OK')"
|
||||
```
|
||||
|
||||
Expected: `语法 OK`
|
||||
|
||||
- [ ] **Step 3: 启动服务冒烟测试**
|
||||
|
||||
```bash
|
||||
cd backend && (./venv/bin/python -m uvicorn main:app --port 8000 &) && sleep 3 && curl -s "http://localhost:8000/api/core-stocks/active" | head -c 300; echo; kill %1 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: 返回 `{"dates": [...], "stocks": [...]}` JSON(至少含 Task 2 入库的当日数据)。
|
||||
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
git add backend/main.py && git commit -m "feat: 挂载核心股路由并启动采集任务"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 前端 API 客户端 `src/lib/core-stock-api.ts`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/core-stock-api.ts`
|
||||
|
||||
- [ ] **Step 1: 新建 API 客户端**
|
||||
|
||||
```typescript
|
||||
// 核心股历史数据获取工具
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface ActiveCoreStock {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
dailyGains: Record<string, number>; // 日期 -> 当日涨幅
|
||||
appearCount: number;
|
||||
lastAppear: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveCoreStocksResponse {
|
||||
dates: string[];
|
||||
stocks: ActiveCoreStock[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryItem {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
f3: number | null;
|
||||
cover_count: number | null;
|
||||
rank: number;
|
||||
themes: { theme_code: string; theme_name: string }[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryResponse {
|
||||
date: string;
|
||||
items: CoreStockHistoryItem[];
|
||||
}
|
||||
|
||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
/** 获取指定交易日核心股(含所属题材) */
|
||||
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/lib/core-stock-api.ts && git commit -m "feat: 核心股历史前端 API 客户端"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 展示页 `src/routes/core-stocks.tsx`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/routes/core-stocks.tsx`
|
||||
|
||||
- [ ] **Step 1: 新建展示页**
|
||||
|
||||
```tsx
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/core-stocks")({
|
||||
component: CoreStocksPage,
|
||||
});
|
||||
|
||||
/** 格式化涨幅,红涨绿跌 */
|
||||
function formatGain(v: number | null | undefined): string {
|
||||
if (v == null) return "·";
|
||||
const s = v > 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function CoreStocksPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["core-stocks", "active"],
|
||||
queryFn: fetchActiveCoreStocks,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dates = data?.dates ?? [];
|
||||
const stocks = data?.stocks ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/hot-map" className="hover:opacity-70 transition-opacity">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">核心股追踪</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃核心股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
) : dates.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-16">
|
||||
暂无数据,数据将在每日收盘后自动采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-2 py-2 text-right font-medium">上榜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<tr key={s.stockCode} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<span className="font-medium">{s.stockName}</span>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
||||
</td>
|
||||
{dates.map((d) => {
|
||||
const g = s.dailyGains[d];
|
||||
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||
return (
|
||||
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||
{formatGain(g)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||
<Flame className="h-3 w-3" />
|
||||
{s.appearCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/core-stocks.tsx && git commit -m "feat: 活跃核心股 10 日涨幅矩阵页面"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 入口链接 + 验证 + 清理
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/routes/themes.tsx`
|
||||
- Modify: `src/routes/hot-map.tsx`
|
||||
- Delete(临时): `backend/verify_collector.py`
|
||||
|
||||
- [ ] **Step 1: 在题材页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/themes.tsx` 顶栏,在「热点穿透」链接旁加:
|
||||
|
||||
```tsx
|
||||
<Link to="/core-stocks" className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity">
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
核心股
|
||||
</Link>
|
||||
```
|
||||
|
||||
需在 import 区加 `Flame`(若已从 `lucide-react` 引入则复用)。同时 `Link to="/core-stocks"` 需要路由存在(Task 6 已建)。
|
||||
|
||||
- [ ] **Step 2: 在热点穿透页顶栏加「核心股」入口**
|
||||
|
||||
`src/routes/hot-map.tsx` 顶栏加同类链接(参考 Step 1)。
|
||||
|
||||
- [ ] **Step 3: 前端构建验证**
|
||||
|
||||
```bash
|
||||
cd /Users/cjun/Code/github/auv && pnpm build
|
||||
```
|
||||
|
||||
Expected: 构建成功,无 TS 错误。若报 `Link to="/core-stocks"` 类型错误,确认路由文件 `core-stocks.tsx` 的 `createFileRoute` 路径与 `to` 一致。
|
||||
|
||||
- [ ] **Step 4: 清理临时验证文件**
|
||||
|
||||
```bash
|
||||
rm -f backend/verify_collector.py
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
|
||||
```bash
|
||||
git add src/routes/themes.tsx src/routes/hot-map.tsx
|
||||
git commit -m "feat: 题材/热点穿透页加入核心股追踪入口"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 自检结果
|
||||
|
||||
- **Spec 覆盖:** 3 张表(Task1)、采集服务(Task2)、3 接口(Task3: active/history/themes-history)、页面(Task6)、入口(Task7)、定时采集(Task4)全部有对应任务 ✓
|
||||
- **无占位符:** 每步含完整代码与命令 ✓
|
||||
- **类型一致性:** `trade_date`/`stock_code`/`bf3`/`hot_rank` 等字段全 plan 统一;`fetchActiveCoreStocks` 返回结构与后端 `active_core_stocks` 一致 ✓
|
||||
- **边界:** 核心股不足100 存实际数(Task2 取 slice)、当日重复采集幂等(Task2 `_has_collected`)、东财失败跳过(Task2 空列表判断)、空态(Task6)均已覆盖 ✓
|
||||
|
||||
## 注意
|
||||
|
||||
- Task 2 的 `collect_daily` 从题材列表的领涨股构建股票池(约 623 个题材的领涨股去重,通常几十到上百只),而非热点穿透的完整股票池——若需含非领涨股,需改用 `fetch_theme_graph` 的 stocks。当前实现符合"全部股票按涨幅取前100"的目标口径(题材领涨股 + 去重)。
|
||||
@@ -0,0 +1,156 @@
|
||||
# 每日热点核心股 / 题材历史记录 + 活跃核心股滚动表格
|
||||
|
||||
日期:2026-08-10
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为数据分析和发掘积累历史数据,并提供活跃核心股的滚动展示:
|
||||
|
||||
1. 每个交易日收盘后,采集**核心股前 100**(当日全部股票按涨幅降序取前 100)及其**所属题材**,保存历史。
|
||||
2. 每个交易日收盘后,采集**题材(概念)板块涨幅前 20**,保存历史。
|
||||
3. 新展示页:**活跃核心股 × 最近 10 个 A 股交易日**涨幅矩阵表格;新核心股加入,**超过 10 个 A 股交易日未出现则踢出**。
|
||||
|
||||
## 2. 已确认的决策
|
||||
|
||||
| 决策点 | 选择 |
|
||||
|---|---|
|
||||
| 核心股口径 | 当日全部股票按涨幅(f3)降序取前 100(不限覆盖题材数) |
|
||||
| 板块口径 | 题材/概念板块,按涨幅(bf3)降序取前 20 |
|
||||
| 采集触发 | 方案 A:asyncio 后台任务,每日收盘后自动采集 |
|
||||
| 表格布局 | 股票 × 最近 10 个 A 股交易日列矩阵 |
|
||||
| 10 日窗口 | 10 个 A 股交易日(跳过节假/周末) |
|
||||
| 所属题材完整度 | 折中:以热点穿透采样题材为主,东财压力允许时尽力补全 |
|
||||
|
||||
## 3. 数据模型(新增 3 张表)
|
||||
|
||||
```sql
|
||||
CREATE TABLE daily_core_stocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL, -- 交易日 YYYY-MM-DD
|
||||
stock_code TEXT NOT NULL, -- 股票代码
|
||||
stock_name TEXT NOT NULL, -- 股票名称
|
||||
f3 REAL, -- 当日涨幅%
|
||||
cover_count INTEGER, -- 覆盖题材数(采样)
|
||||
rank INTEGER, -- 当日涨幅排名 1-100
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code)
|
||||
);
|
||||
|
||||
CREATE TABLE daily_core_stock_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
stock_code TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, stock_code, theme_code)
|
||||
);
|
||||
|
||||
CREATE TABLE daily_top_themes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trade_date TEXT NOT NULL,
|
||||
theme_code TEXT NOT NULL,
|
||||
theme_name TEXT NOT NULL,
|
||||
bf3 REAL, -- 题材涨幅%
|
||||
hot_rank INTEGER, -- 热度排名
|
||||
rank INTEGER, -- 当日板块涨幅排名 1-20
|
||||
created_at TEXT DEFAULT (datetime('now','localtime')),
|
||||
UNIQUE(trade_date, theme_code)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 采集服务 `backend/services/daily_collector.py`
|
||||
|
||||
### 4.1 触发机制(方案 A)
|
||||
|
||||
- `lifespan` 启动时拉起一个 asyncio 后台任务协程。
|
||||
- 协程循环(如每 5 分钟检查一次):
|
||||
- 是否**交易日**(周一至周五,非节假日)。
|
||||
- 是否**收盘后**(北京时间 > 15:00)。
|
||||
- 当日数据是否**已采集**(按 `trade_date` 查库,幂等去重)。
|
||||
- 条件满足 → 触发采集。
|
||||
|
||||
### 4.2 采集逻辑
|
||||
|
||||
1. **核心股前 100**:调用 `fetch_theme_list(1, False)` 或 `fetch_theme_graph` 得到当日全部股票,按 `f3` 降序取前 100。若不足 100 只,存实际数量。
|
||||
2. **所属题材**:以热点穿透采样的 `themeCodes` 为主存入 `daily_core_stock_themes`。
|
||||
3. **题材前 20**:从 `fetch_theme_list(1, False)` 取 `bf3` 降序前 20,连同 `themeName`/`hotRank` 存入 `daily_top_themes`。
|
||||
4. 同一交易日重复触发不重复写入(UNIQUE 去重 + 检查)。
|
||||
5. 采集失败(东财 403/网络)→ 跳过当日,下轮重试;记录日志。
|
||||
|
||||
### 4.3 补全(折中方案)
|
||||
|
||||
- 东财压力允许时,对核心股调用个股题材接口尽力补全。
|
||||
- 优先保证核心股前 100 与题材前 20 的完整性,补全为附加增强,失败不影响主流程。
|
||||
|
||||
## 5. 新接口(`backend/routes/`)
|
||||
|
||||
| 接口 | 说明 |
|
||||
|---|---|
|
||||
| `GET /api/core-stocks/active` | 活跃核心股 + 最近 10 日涨幅矩阵 |
|
||||
| `GET /api/core-stocks/history?date=` | 指定交易日的核心股(含所属题材) |
|
||||
| `GET /api/themes/history?date=` | 指定交易日的题材前 20 |
|
||||
|
||||
### active 接口返回结构
|
||||
|
||||
```json
|
||||
{
|
||||
"dates": ["2026-08-03", "...", "2026-08-10"],
|
||||
"stocks": [
|
||||
{
|
||||
"stockCode": "601606",
|
||||
"stockName": "长城军工",
|
||||
"coverCount": 9,
|
||||
"lastAppear": "2026-08-10",
|
||||
"daysSinceLastAppear": 0,
|
||||
"appearCount": 5,
|
||||
"dailyGains": { "2026-08-03": 10.0, "2026-08-10": 10.0 }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `dates`:最近 10 个 A 股交易日(升序,最右为最新)。
|
||||
- `stocks`:10 日窗口内出现过的活跃核心股。
|
||||
- `dailyGains`:日期 → 当日涨幅;未上榜日无该键。
|
||||
|
||||
## 6. 新展示页(前端 `/core-stocks`)
|
||||
|
||||
### 6.1 页面结构
|
||||
|
||||
- 顶栏:返回、标题「核心股追踪」、刷新按钮(与 `/hot-map` 一致风格)。
|
||||
- 表格:**股票 × 最近 10 个 A 股交易日**列矩阵。
|
||||
- 行:活跃核心股(10 个 A 股交易日内出现过),按 `appearCount` 降序、`lastAppear` 降序排列。
|
||||
- 列:最近 10 个 A 股交易日,最右为最新。
|
||||
- 单元格:当日涨幅(红涨绿跌,A 股惯例);未上榜留空(`·`)。
|
||||
- 每只股票显示累计出现次数、最近上榜日期、所属题材数。
|
||||
|
||||
### 6.2 数据获取
|
||||
|
||||
- 前端 `useQuery` 调 `GET /api/core-stocks/active`。
|
||||
- `staleTime` 与题材页一致(30s 或 60s),盘中可手动刷新。
|
||||
|
||||
### 6.3 路由
|
||||
|
||||
- 新建 `src/routes/core-stocks.tsx`,路由 `/core-stocks`。
|
||||
- 从题材页 `/themes` 和热点穿透页 `/hot-map` 顶部加入口链接。
|
||||
|
||||
## 7. 错误处理与边界
|
||||
|
||||
- **东财 403/采集失败**:跳过当日采集,下轮重试;不影响已存历史。
|
||||
- **当日重复采集**:UNIQUE 约束 + 入库前检查,幂等。
|
||||
- **核心股不足 100**:存实际数量,不补齐。
|
||||
- **无历史数据**:部署后开始累积;active 接口在无数据时返回空 `stocks` 与空 `dates`。
|
||||
- **交易日历**:以自然周一到周五判定交易日(不处理法定节假日调休的深度历法),与现有 `_is_trading_time` 一致。
|
||||
|
||||
## 8. 测试
|
||||
|
||||
- 采集服务:幂等(重复触发不重复写)、去重、失败重试逻辑。
|
||||
- active 接口:窗口计算、踢出规则(>10 个交易日未出现不返回)、涨幅矩阵正确性。
|
||||
- 前端页面:空态、有数据态、10 日窗口滚动。
|
||||
|
||||
## 9. 范围外(YAGNI)
|
||||
|
||||
- 不做法定的深度交易日历(节假日调休)。
|
||||
- 不做个股涨幅的增量更新(历史数据一次性采集,之后不补更)。
|
||||
- 不做板块成分股的每日存储。
|
||||
+12
-4
@@ -24,14 +24,14 @@
|
||||
<script>
|
||||
// 在 React 加载前立即设置主题,避免闪烁
|
||||
(function() {
|
||||
const isInIframe = window.self !== window.top;
|
||||
|
||||
function applyThemeToDOM(theme) {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(theme);
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
var isInIframe = window.self !== window.top;
|
||||
|
||||
if (isInIframe) {
|
||||
// 监听父窗口主动推送的主题消息
|
||||
window.addEventListener('message', function(event) {
|
||||
@@ -43,8 +43,16 @@
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 非 iframe 环境,使用默认 light 主题
|
||||
applyThemeToDOM('light');
|
||||
// 从 localStorage 读取用户选择
|
||||
var saved = localStorage.getItem('theme');
|
||||
var theme;
|
||||
if (saved === 'light' || saved === 'dark') {
|
||||
theme = saved;
|
||||
} else {
|
||||
// system: 跟随系统偏好
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
applyThemeToDOM(theme);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -49,13 +49,17 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^11.18.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lightweight-charts": "^5.2.1",
|
||||
"lucide-react": "^0.575.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"react": "^19.2.7",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.12.1",
|
||||
"recharts": "^2.15.4",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.2",
|
||||
|
||||
Generated
+1575
-16
File diff suppressed because it is too large
Load Diff
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地直接拉起后端(不用 docker)
|
||||
# 用法: ./run_local.sh # 默认 0.0.0.0:8000
|
||||
# PORT=8010 ./run_local.sh # 指定端口
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "${ROOT}/backend"
|
||||
|
||||
PORT="${PORT:-8000}"
|
||||
PY="${PY:-${ROOT}/.venv/bin/python}"
|
||||
|
||||
# 同步前端 dist(若后端 dist 落后于仓库根 dist)
|
||||
if [ -d "${ROOT}/dist" ]; then
|
||||
mkdir -p dist
|
||||
cp -r "${ROOT}/dist/." dist/
|
||||
echo "✓ 已同步前端 dist → backend/dist"
|
||||
fi
|
||||
|
||||
echo "启动后端: http://0.0.0.0:${PORT} (python: ${PY})"
|
||||
exec "${PY}" -m uvicorn main:app --host 0.0.0.0 --port "${PORT}" --log-level info
|
||||
@@ -0,0 +1,152 @@
|
||||
# 热点股追踪数据导出/导入工具
|
||||
|
||||
## 功能说明
|
||||
|
||||
本工具用于导出和导入热点股追踪的历史数据,包括:
|
||||
- 每日题材涨幅前20
|
||||
- 每日核心股票(覆盖多个题材的股票)
|
||||
- 每日核心股票与题材的关联关系
|
||||
|
||||
## 快速使用(Docker环境)
|
||||
|
||||
### 1. 导出8月20日的数据
|
||||
|
||||
```bash
|
||||
./scripts/docker_hotspot.sh export 2026-08-20
|
||||
```
|
||||
|
||||
### 2. 导入数据
|
||||
|
||||
```bash
|
||||
# 导入数据到容器
|
||||
./scripts/docker_hotspot.sh import hotspot_2026-08-20.json
|
||||
```
|
||||
|
||||
### 3. 查看数据库中的日期
|
||||
|
||||
```bash
|
||||
./scripts/docker_hotspot.sh list
|
||||
```
|
||||
|
||||
### 4. 备份整个数据库
|
||||
|
||||
```bash
|
||||
./scripts/docker_hotspot.sh backup
|
||||
```
|
||||
|
||||
### 5. 进入Python shell
|
||||
|
||||
```bash
|
||||
./scripts/docker_hotspot.sh shell
|
||||
```
|
||||
|
||||
## 本地开发环境
|
||||
|
||||
如果在本地开发环境中,可以使用以下命令:
|
||||
|
||||
```bash
|
||||
# 导出数据
|
||||
python3 scripts/export_hotspot_data.py export --date 2026-08-20
|
||||
|
||||
# 导入数据
|
||||
python3 scripts/export_hotspot_data.py import --file hotspot_2026-08-20.json
|
||||
|
||||
# 列出日期
|
||||
python3 scripts/export_hotspot_data.py list
|
||||
```
|
||||
|
||||
## 文件说明
|
||||
|
||||
### Docker脚本 (`docker_hotspot.sh`)
|
||||
|
||||
- `export [日期]`: 从容器导出数据(默认8月20日)
|
||||
- `import <文件>`: 导入数据文件到容器
|
||||
- `list`: 列出所有日期
|
||||
- `backup`: 备份整个数据库
|
||||
- `shell`: 进入容器的Python shell
|
||||
|
||||
### Python脚本 (`export_hotspot_data.py`)
|
||||
|
||||
- `export --date YYYY-MM-DD`: 导出指定日期的数据
|
||||
- `import --file <文件路径>`: 导入数据文件
|
||||
- `list`: 列出数据库中所有有数据的日期
|
||||
|
||||
### Shell脚本 (`hotspot_backup.sh`)
|
||||
|
||||
- `export [日期]`: 导出数据(默认8月20日)
|
||||
- `import <文件>`: 导入数据文件
|
||||
- `list`: 列出所有日期
|
||||
- `backup`: 备份整个数据库
|
||||
|
||||
## 数据格式
|
||||
|
||||
导出的JSON文件包含以下字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"trade_date": "2026-08-11",
|
||||
"themes": [...], // 题材数据
|
||||
"core_stocks": [...], // 核心股票数据
|
||||
"stock_themes": [...], // 股票-题材关联
|
||||
"stats": {
|
||||
"theme_count": 20,
|
||||
"core_stock_count": 193,
|
||||
"stock_theme_count": 1205
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **容器运行**:使用Docker脚本前,请确保容器正在运行
|
||||
2. **幂等性**:导入时使用 `INSERT OR IGNORE`,重复导入不会产生重复数据
|
||||
3. **数据完整性**:导入前会检查目标日期是否已有数据
|
||||
4. **备份建议**:导入前建议先备份数据库
|
||||
5. **日期格式**:日期格式必须为 `YYYY-MM-DD`(如 `2026-08-20`)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 容器未运行怎么办?
|
||||
|
||||
A: 启动容器:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Q: 如何查看容器是否运行?
|
||||
|
||||
A: 运行以下命令:
|
||||
|
||||
```bash
|
||||
docker ps | grep auv
|
||||
```
|
||||
|
||||
### Q: 如何手动采集8月20日的数据?
|
||||
|
||||
A: 在容器内运行:
|
||||
|
||||
```bash
|
||||
docker exec -it auv python3 -c "
|
||||
import asyncio
|
||||
from services.daily_collector import collect_daily
|
||||
asyncio.run(collect_daily('2026-08-20'))
|
||||
"
|
||||
```
|
||||
|
||||
### Q: 如何查看数据库中的日期?
|
||||
|
||||
A: 运行 `./scripts/docker_hotspot.sh list`
|
||||
|
||||
### Q: 如何备份数据库?
|
||||
|
||||
A: 运行 `./scripts/docker_hotspot.sh backup`
|
||||
|
||||
### Q: 导入时出现错误怎么办?
|
||||
|
||||
A: 检查JSON文件格式是否正确,确保包含必要的字段。可以使用以下命令验证JSON:
|
||||
|
||||
```bash
|
||||
python3 -m json.tool hotspot_2026-08-20.json
|
||||
```
|
||||
Executable
+236
@@ -0,0 +1,236 @@
|
||||
#!/bin/bash
|
||||
# 从Docker容器导出热点股数据
|
||||
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
CONTAINER_NAME="auv"
|
||||
DB_PATH="/app/backend/data/stock_data.db"
|
||||
|
||||
# 检查容器是否运行
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
|
||||
echo -e "${RED}❌ 容器 ${CONTAINER_NAME} 未运行${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查数据库是否存在
|
||||
if ! docker exec "$CONTAINER_NAME" test -f "$DB_PATH"; then
|
||||
echo -e "${RED}❌ 数据库文件不存在:$DB_PATH${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
echo "用法:"
|
||||
echo " $0 export [日期] - 导出指定日期的数据(默认:2026-08-20)"
|
||||
echo " $0 import <文件> - 导入数据文件到容器"
|
||||
echo " $0 list - 列出所有有数据的日期"
|
||||
echo " $0 backup - 备份整个数据库"
|
||||
echo " $0 shell - 进入容器的Python shell"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 export 2026-08-20"
|
||||
echo " $0 import hotspot_2026-08-20.json"
|
||||
echo " $0 backup"
|
||||
}
|
||||
|
||||
# 导出数据
|
||||
export_data() {
|
||||
local date="${1:-2026-08-20}"
|
||||
local output_file="hotspot_${date}.json"
|
||||
|
||||
echo -e "${YELLOW}📤 从容器导出 $date 的热点股数据...${NC}"
|
||||
|
||||
# 在容器内执行导出命令
|
||||
docker exec "$CONTAINER_NAME" python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from database import get_connection, dict_from_row
|
||||
import json
|
||||
|
||||
date = '$date'
|
||||
conn = get_connection()
|
||||
try:
|
||||
themes = conn.execute('SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC', (date,)).fetchall()
|
||||
core_stocks = conn.execute('SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC', (date,)).fetchall()
|
||||
stock_themes = conn.execute('SELECT * FROM daily_core_stock_themes WHERE trade_date = ?', (date,)).fetchall()
|
||||
|
||||
data = {
|
||||
'version': '1.0',
|
||||
'trade_date': date,
|
||||
'themes': [dict_from_row(r) for r in themes],
|
||||
'core_stocks': [dict_from_row(r) for r in core_stocks],
|
||||
'stock_themes': [dict_from_row(r) for r in stock_themes],
|
||||
}
|
||||
data['stats'] = {
|
||||
'theme_count': len(data['themes']),
|
||||
'core_stock_count': len(data['core_stocks']),
|
||||
'stock_theme_count': len(data['stock_themes']),
|
||||
}
|
||||
|
||||
print(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
finally:
|
||||
conn.close()
|
||||
" > "$output_file"
|
||||
|
||||
if [ -f "$output_file" ]; then
|
||||
echo -e "${GREEN}✅ 导出完成:$output_file${NC}"
|
||||
echo "文件大小:$(du -h "$output_file" | cut -f1)"
|
||||
else
|
||||
echo -e "${RED}❌ 导出失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 导入数据
|
||||
import_data() {
|
||||
local file="$1"
|
||||
|
||||
if [ -z "$file" ]; then
|
||||
echo -e "${RED}❌ 请指定要导入的文件${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
echo -e "${RED}❌ 文件不存在:$file${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}📥 导入数据到容器:$file${NC}"
|
||||
|
||||
# 复制文件到容器
|
||||
docker cp "$file" "$CONTAINER_NAME:/tmp/import_data.json"
|
||||
|
||||
# 在容器内执行导入
|
||||
docker exec "$CONTAINER_NAME" python3 -c "
|
||||
import sys
|
||||
import json
|
||||
sys.path.insert(0, '/app')
|
||||
from database import get_connection
|
||||
|
||||
with open('/tmp/import_data.json', 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
date = data['trade_date']
|
||||
print(f'📅 导入日期:{date}')
|
||||
print(f' 题材数量:{len(data[\"themes\"])}')
|
||||
print(f' 核心股票:{len(data[\"core_stocks\"])}')
|
||||
print(f' 股票-题材关联:{len(data[\"stock_themes\"])}')
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
for item in data['themes']:
|
||||
item.pop('id', None)
|
||||
item.pop('created_at', None)
|
||||
conn.execute(
|
||||
'INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)',
|
||||
(item['trade_date'], item['theme_code'], item['theme_name'], item['bf3'], item['hot_rank'], item['rank']),
|
||||
)
|
||||
|
||||
for item in data['core_stocks']:
|
||||
item.pop('id', None)
|
||||
item.pop('created_at', None)
|
||||
conn.execute(
|
||||
'INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)',
|
||||
(item['trade_date'], item['stock_code'], item['stock_name'], item['f3'], item['cover_count'], item['rank']),
|
||||
)
|
||||
|
||||
for item in data['stock_themes']:
|
||||
item.pop('id', None)
|
||||
item.pop('created_at', None)
|
||||
conn.execute(
|
||||
'INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)',
|
||||
(item['trade_date'], item['stock_code'], item['theme_code'], item['theme_name']),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print(f'\\n✅ 导入成功:{date}')
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f'\\n❌ 导入失败:{e}')
|
||||
finally:
|
||||
conn.close()
|
||||
"
|
||||
}
|
||||
|
||||
# 列出日期
|
||||
list_dates() {
|
||||
echo -e "${YELLOW}📅 列出容器中的日期...${NC}"
|
||||
docker exec "$CONTAINER_NAME" python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from database import get_connection
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute('SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC').fetchall()
|
||||
if rows:
|
||||
print('📅 数据库中的日期:')
|
||||
for row in rows:
|
||||
print(f' {row[\"trade_date\"]}')
|
||||
else:
|
||||
print('📭 数据库中暂无数据')
|
||||
finally:
|
||||
conn.close()
|
||||
"
|
||||
}
|
||||
|
||||
# 备份数据库
|
||||
backup_database() {
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="stock_data_${timestamp}.db"
|
||||
|
||||
echo -e "${YELLOW}💾 备份数据库...${NC}"
|
||||
docker cp "$CONTAINER_NAME:$DB_PATH" "$backup_file"
|
||||
|
||||
if [ -f "$backup_file" ]; then
|
||||
echo -e "${GREEN}✅ 备份完成:$backup_file${NC}"
|
||||
echo "文件大小:$(du -h "$backup_file" | cut -f1)"
|
||||
else
|
||||
echo -e "${RED}❌ 备份失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 进入Python shell
|
||||
enter_shell() {
|
||||
echo -e "${YELLOW}🐍 进入容器Python shell...${NC}"
|
||||
docker exec -it "$CONTAINER_NAME" python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
from database import get_connection
|
||||
print('Python shell 已启动')
|
||||
print('可用变量:')
|
||||
print(' conn - 数据库连接')
|
||||
print(' get_connection - 获取新连接函数')
|
||||
print()
|
||||
conn = get_connection()
|
||||
"
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
case "${1:-help}" in
|
||||
export)
|
||||
export_data "$2"
|
||||
;;
|
||||
import)
|
||||
import_data "$2"
|
||||
;;
|
||||
list)
|
||||
list_dates
|
||||
;;
|
||||
backup)
|
||||
backup_database
|
||||
;;
|
||||
shell)
|
||||
enter_shell
|
||||
;;
|
||||
*)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
Executable
+213
@@ -0,0 +1,213 @@
|
||||
#!/usr/bin/env python3
|
||||
"""热点股追踪数据导出/导入脚本
|
||||
|
||||
用法:
|
||||
导出8月20日数据:
|
||||
python scripts/export_hotspot_data.py export --date 2026-08-20
|
||||
|
||||
导入数据:
|
||||
python scripts/export_hotspot_data.py import --file hotspot_2026-08-20.json
|
||||
|
||||
查看数据库中有哪些日期的数据:
|
||||
python scripts/export_hotspot_data.py list
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
|
||||
|
||||
from database import get_connection, dict_from_row
|
||||
|
||||
|
||||
def export_data(date: str, output_file: Optional[str] = None) -> dict:
|
||||
"""导出指定日期的热点股追踪数据"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 1. 导出每日题材前20
|
||||
themes = conn.execute(
|
||||
"SELECT * FROM daily_top_themes WHERE trade_date = ? ORDER BY rank ASC",
|
||||
(date,),
|
||||
).fetchall()
|
||||
|
||||
# 2. 导出每日核心股票
|
||||
core_stocks = conn.execute(
|
||||
"SELECT * FROM daily_core_stocks WHERE trade_date = ? ORDER BY rank ASC",
|
||||
(date,),
|
||||
).fetchall()
|
||||
|
||||
# 3. 导出每日核心股票与题材的关联
|
||||
stock_themes = conn.execute(
|
||||
"SELECT * FROM daily_core_stock_themes WHERE trade_date = ?",
|
||||
(date,),
|
||||
).fetchall()
|
||||
|
||||
data = {
|
||||
"version": "1.0",
|
||||
"export_time": datetime.now().isoformat(),
|
||||
"trade_date": date,
|
||||
"themes": [dict_from_row(r) for r in themes],
|
||||
"core_stocks": [dict_from_row(r) for r in core_stocks],
|
||||
"stock_themes": [dict_from_row(r) for r in stock_themes],
|
||||
}
|
||||
|
||||
# 统计信息
|
||||
data["stats"] = {
|
||||
"theme_count": len(data["themes"]),
|
||||
"core_stock_count": len(data["core_stocks"]),
|
||||
"stock_theme_count": len(data["stock_themes"]),
|
||||
}
|
||||
|
||||
# 保存到文件
|
||||
if output_file is None:
|
||||
output_file = f"hotspot_{date}.json"
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
print(f"✅ 导出成功:{output_file}")
|
||||
print(f" 题材数量:{data['stats']['theme_count']}")
|
||||
print(f" 核心股票:{data['stats']['core_stock_count']}")
|
||||
print(f" 股票-题材关联:{data['stats']['stock_theme_count']}")
|
||||
|
||||
return data
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def import_data(input_file: str, dry_run: bool = False) -> bool:
|
||||
"""导入热点股追踪数据"""
|
||||
if not os.path.exists(input_file):
|
||||
print(f"❌ 文件不存在:{input_file}")
|
||||
return False
|
||||
|
||||
with open(input_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 验证数据格式
|
||||
required_keys = ["version", "trade_date", "themes", "core_stocks", "stock_themes"]
|
||||
if not all(k in data for k in required_keys):
|
||||
print("❌ 数据格式错误:缺少必要字段")
|
||||
return False
|
||||
|
||||
date = data["trade_date"]
|
||||
print(f"📅 导入日期:{date}")
|
||||
print(f" 题材数量:{len(data['themes'])}")
|
||||
print(f" 核心股票:{len(data['core_stocks'])}")
|
||||
print(f" 股票-题材关联:{len(data['stock_themes'])}")
|
||||
|
||||
if dry_run:
|
||||
print("\n🔍 试运行模式,不写入数据库")
|
||||
return True
|
||||
|
||||
# 检查是否已存在
|
||||
conn = get_connection()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT 1 FROM daily_core_stocks WHERE trade_date = ? LIMIT 1",
|
||||
(date,),
|
||||
).fetchone()
|
||||
|
||||
if existing:
|
||||
print(f"\n⚠️ {date} 的数据已存在,将跳过重复数据(使用 INSERT OR IGNORE)")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 写入数据库
|
||||
conn = get_connection()
|
||||
try:
|
||||
# 导入每日题材前20
|
||||
for item in data["themes"]:
|
||||
# 移除自动生成的id和created_at
|
||||
item.pop("id", None)
|
||||
item.pop("created_at", None)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_top_themes (trade_date, theme_code, theme_name, bf3, hot_rank, rank) VALUES (?,?,?,?,?,?)",
|
||||
(item["trade_date"], item["theme_code"], item["theme_name"], item["bf3"], item["hot_rank"], item["rank"]),
|
||||
)
|
||||
|
||||
# 导入每日核心股票
|
||||
for item in data["core_stocks"]:
|
||||
# 移除自动生成的id和created_at
|
||||
item.pop("id", None)
|
||||
item.pop("created_at", None)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stocks (trade_date, stock_code, stock_name, f3, cover_count, rank) VALUES (?,?,?,?,?,?)",
|
||||
(item["trade_date"], item["stock_code"], item["stock_name"], item["f3"], item["cover_count"], item["rank"]),
|
||||
)
|
||||
|
||||
# 导入每日核心股票与题材的关联
|
||||
for item in data["stock_themes"]:
|
||||
# 移除自动生成的id和created_at
|
||||
item.pop("id", None)
|
||||
item.pop("created_at", None)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO daily_core_stock_themes (trade_date, stock_code, theme_code, theme_name) VALUES (?,?,?,?)",
|
||||
(item["trade_date"], item["stock_code"], item["theme_code"], item["theme_name"]),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
print(f"\n✅ 导入成功:{date}")
|
||||
return True
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"\n❌ 导入失败:{e}")
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def list_dates() -> list[str]:
|
||||
"""列出数据库中所有有数据的日期"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT trade_date FROM daily_core_stocks ORDER BY trade_date DESC"
|
||||
).fetchall()
|
||||
return [row["trade_date"] for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="热点股追踪数据导出/导入工具")
|
||||
subparsers = parser.add_subparsers(dest="command", help="可用命令")
|
||||
|
||||
# 导出命令
|
||||
export_parser = subparsers.add_parser("export", help="导出数据")
|
||||
export_parser.add_argument("--date", required=True, help="交易日期 (YYYY-MM-DD)")
|
||||
export_parser.add_argument("--output", "-o", help="输出文件路径")
|
||||
|
||||
# 导入命令
|
||||
import_parser = subparsers.add_parser("import", help="导入数据")
|
||||
import_parser.add_argument("--file", "-f", required=True, help="输入文件路径")
|
||||
import_parser.add_argument("--dry-run", action="store_true", help="试运行,不写入数据库")
|
||||
|
||||
# 列表命令
|
||||
subparsers.add_parser("list", help="列出所有有数据的日期")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "export":
|
||||
export_data(args.date, args.output)
|
||||
elif args.command == "import":
|
||||
import_data(args.file, args.dry_run)
|
||||
elif args.command == "list":
|
||||
dates = list_dates()
|
||||
if dates:
|
||||
print("📅 数据库中的日期:")
|
||||
for d in dates:
|
||||
print(f" {d}")
|
||||
else:
|
||||
print("📭 数据库中暂无数据")
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/bin/bash
|
||||
# 热点股追踪数据导出/导入快捷脚本
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
PYTHON_SCRIPT="$SCRIPT_DIR/export_hotspot_data.py"
|
||||
|
||||
# 颜色输出
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
show_help() {
|
||||
echo "用法:"
|
||||
echo " $0 export [日期] - 导出指定日期的数据(默认:2026-08-20)"
|
||||
echo " $0 import <文件> - 导入数据文件"
|
||||
echo " $0 list - 列出所有有数据的日期"
|
||||
echo " $0 backup - 备份整个数据库"
|
||||
echo ""
|
||||
echo "示例:"
|
||||
echo " $0 export 2026-08-20"
|
||||
echo " $0 import hotspot_2026-08-20.json"
|
||||
echo " $0 backup"
|
||||
}
|
||||
|
||||
export_data() {
|
||||
local date="${1:-2026-08-20}"
|
||||
local output_file="hotspot_${date}.json"
|
||||
|
||||
echo -e "${YELLOW}📤 导出 $date 的热点股数据...${NC}"
|
||||
python3 "$PYTHON_SCRIPT" export --date "$date" --output "$output_file"
|
||||
|
||||
if [ -f "$output_file" ]; then
|
||||
echo -e "${GREEN}✅ 导出完成:$output_file${NC}"
|
||||
echo "文件大小:$(du -h "$output_file" | cut -f1)"
|
||||
else
|
||||
echo -e "${RED}❌ 导出失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
import_data() {
|
||||
local file="$1"
|
||||
|
||||
if [ -z "$file" ]; then
|
||||
echo -e "${RED}❌ 请指定要导入的文件${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$file" ]; then
|
||||
echo -e "${RED}❌ 文件不存在:$file${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}📥 导入数据:$file${NC}"
|
||||
python3 "$PYTHON_SCRIPT" import --file "$file"
|
||||
}
|
||||
|
||||
list_dates() {
|
||||
echo -e "${YELLOW}📅 列出数据库中的日期...${NC}"
|
||||
python3 "$PYTHON_SCRIPT" list
|
||||
}
|
||||
|
||||
backup_database() {
|
||||
local db_path="$PROJECT_DIR/backend/data/stock_data.db"
|
||||
local backup_dir="$PROJECT_DIR/backups"
|
||||
local timestamp=$(date +%Y%m%d_%H%M%S)
|
||||
local backup_file="$backup_dir/stock_data_${timestamp}.db"
|
||||
|
||||
if [ ! -f "$db_path" ]; then
|
||||
echo -e "${RED}❌ 数据库文件不存在:$db_path${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 创建备份目录
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
# 复制数据库
|
||||
cp "$db_path" "$backup_file"
|
||||
|
||||
if [ -f "$backup_file" ]; then
|
||||
echo -e "${GREEN}✅ 数据库备份完成:$backup_file${NC}"
|
||||
echo "文件大小:$(du -h "$backup_file" | cut -f1)"
|
||||
else
|
||||
echo -e "${RED}❌ 备份失败${NC}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 主逻辑
|
||||
case "${1:-help}" in
|
||||
export)
|
||||
export_data "$2"
|
||||
;;
|
||||
import)
|
||||
import_data "$2"
|
||||
;;
|
||||
list)
|
||||
list_dates
|
||||
;;
|
||||
backup)
|
||||
backup_database
|
||||
;;
|
||||
*)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import mermaid from "mermaid";
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "default",
|
||||
themeVariables: {
|
||||
primaryColor: "#f0f0f0",
|
||||
primaryTextColor: "#333333",
|
||||
primaryBorderColor: "#cccccc",
|
||||
lineColor: "#3366cc",
|
||||
secondaryColor: "#e8e8e8",
|
||||
tertiaryColor: "#ffffff",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "14px",
|
||||
},
|
||||
});
|
||||
|
||||
interface MermaidProps {
|
||||
chart: string;
|
||||
}
|
||||
|
||||
export function Mermaid({ chart }: MermaidProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [svg, setSvg] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
mermaid
|
||||
.render(id, chart)
|
||||
.then(({ svg }) => setSvg(svg))
|
||||
.catch((err) => setError(err.message || "图表渲染失败"));
|
||||
}, [chart]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="bg-muted border border-destructive/30 rounded p-3 text-sm text-destructive overflow-x-auto">
|
||||
{error}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-muted rounded p-3 overflow-x-auto my-2 flex justify-center [&>svg]:max-w-full"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Sun, Moon, Monitor } from "lucide-react";
|
||||
import { useTheme } from "../lib/use-theme";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setExpanded(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, [expanded]);
|
||||
|
||||
const options = [
|
||||
{ value: "light" as const, icon: Sun, label: "浅色" },
|
||||
{ value: "dark" as const, icon: Moon, label: "深色" },
|
||||
{ value: "system" as const, icon: Monitor, label: "自动" },
|
||||
];
|
||||
|
||||
if (!expanded) {
|
||||
const CurrentIcon = options.find(o => o.value === theme)!.icon;
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<button
|
||||
onClick={() => setExpanded(true)}
|
||||
title="切换主题"
|
||||
className="flex items-center justify-center w-8 h-8 rounded-lg bg-muted text-muted-foreground hover:text-foreground shadow-sm transition-colors"
|
||||
>
|
||||
<CurrentIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className="flex items-center bg-muted rounded-lg p-0.5 gap-0.5 shadow-sm">
|
||||
{options.map(({ value, icon: Icon, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => { setTheme(value); setExpanded(false); }}
|
||||
title={label}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-md text-sm transition-colors ${
|
||||
theme === value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* K 线图卡片:独立组件,自带周期(日/60分/...)、显示方式(折线/蜡烛)、
|
||||
* 指标开关(MA/MACD/RSI)状态与数据加载逻辑,与页面其他模块解耦。
|
||||
* 行情就绪(stockInfo 就绪)后自动拉取;切换周期只重拉 K 线,不影响页面其它数据。
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import KLineChart from "@/components/kline-chart";
|
||||
import {
|
||||
fetchStockHistoryV2,
|
||||
} from "@/lib/fuyao-api";
|
||||
import { fetchStockHistoryMinute } from "@/lib/stock-api";
|
||||
|
||||
type ChartPeriod = "d" | "m60" | "m30" | "m15" | "m5" | "m1";
|
||||
|
||||
// 分钟周期标签映射
|
||||
const PERIOD_LABEL: Record<"m1" | "m5" | "m15" | "m30" | "m60", string> = {
|
||||
m1: "1分",
|
||||
m5: "5分",
|
||||
m15: "15分",
|
||||
m30: "30分",
|
||||
m60: "60分",
|
||||
};
|
||||
|
||||
interface KBarInput {
|
||||
date: string;
|
||||
dateMs: number; // Unix 秒(lightweight-charts UTCTimestamp)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
isAddedDate: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
code: string;
|
||||
addedAt: string; // 自选日期 ISO,用于标记K线
|
||||
/** 行情是否就绪:就绪后才开始拉 K 线 */
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
export function KLineCard({ code, addedAt, ready }: Props) {
|
||||
const [chartPeriod, setChartPeriod] = useState<ChartPeriod>("d");
|
||||
const [chartMode, setChartMode] = useState<"line" | "candle">("candle");
|
||||
const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false });
|
||||
const toggleIndicator = (key: keyof typeof indicators) =>
|
||||
setIndicators((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const [dailyData, setDailyData] = useState<KBarInput[]>([]);
|
||||
const [minuteData, setMinuteData] = useState<KBarInput[]>([]);
|
||||
const [chartLoading, setChartLoading] = useState(false);
|
||||
const [minuteLoading, setMinuteLoading] = useState(false);
|
||||
|
||||
const toggleMs = useMemo(() => new Date(addedAt).getTime(), [addedAt]);
|
||||
|
||||
// KLineData → KBarInput(含 isAddedDate 标记,非交易日标最近一根)
|
||||
// 时间统一按 UTC 解析(wall-clock 视作 UTC),lightweight-charts 以 UTC 渲染,
|
||||
// 这样日线标签不偏移、分钟线显示的正是北京时间。
|
||||
const toBars = (
|
||||
klines: Array<{ date: string; open: number; close: number; high: number; low: number; volume: number }>,
|
||||
): KBarInput[] => {
|
||||
const bars: KBarInput[] = klines.map((k) => {
|
||||
const iso = k.date.includes(" ")
|
||||
? k.date.replace(" ", "T") + ":00Z" // "2026-05-08 13:30" → 2026-05-08T13:30:00Z
|
||||
: k.date + "T00:00:00Z"; // "2026-05-08" → 2026-05-08T00:00:00Z
|
||||
const ms = new Date(iso).getTime();
|
||||
const dateMs = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0;
|
||||
return {
|
||||
date: k.date,
|
||||
dateMs,
|
||||
open: k.open,
|
||||
close: k.close,
|
||||
high: k.high,
|
||||
low: k.low,
|
||||
volume: k.volume,
|
||||
isAddedDate: new Date(ms).toDateString() === new Date(toggleMs).toDateString(),
|
||||
};
|
||||
});
|
||||
if (!bars.some((b) => b.isAddedDate) && bars.length > 0) {
|
||||
let closestIdx = 0;
|
||||
let closestDiff = Infinity;
|
||||
bars.forEach((b, i) => {
|
||||
const diff = Math.abs(b.dateMs * 1000 - toggleMs);
|
||||
if (diff < closestDiff) {
|
||||
closestDiff = diff;
|
||||
closestIdx = i;
|
||||
}
|
||||
});
|
||||
bars[closestIdx] = { ...bars[closestIdx], isAddedDate: true };
|
||||
}
|
||||
return bars;
|
||||
};
|
||||
|
||||
// 拉日K
|
||||
const loadDaily = async () => {
|
||||
setChartLoading(true);
|
||||
try {
|
||||
const data = await fetchStockHistoryV2(code, 365);
|
||||
if (data && data.length > 0) setDailyData(toBars(data));
|
||||
} catch (err) {
|
||||
console.error("[kline-card] 拉取日K失败:", err);
|
||||
} finally {
|
||||
setChartLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 拉分钟/小时K
|
||||
const loadMinute = async (period: Exclude<ChartPeriod, "d">) => {
|
||||
setMinuteLoading(true);
|
||||
try {
|
||||
const data = await fetchStockHistoryMinute(code, period, 320);
|
||||
if (data && data.length > 0) setMinuteData(toBars(data));
|
||||
else setMinuteData([]);
|
||||
} catch (err) {
|
||||
console.error("[kline-card] 拉取分钟K失败:", err);
|
||||
setMinuteData([]);
|
||||
} finally {
|
||||
setMinuteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 行情就绪后拉当前周期的K线;切换周期只重拉K线,不动页面其它数据
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
if (chartPeriod === "d") {
|
||||
if (dailyData.length > 0) return; // 已加载过,复用
|
||||
loadDaily();
|
||||
} else {
|
||||
setMinuteData([]); // 清空上一个周期旧数据,避免串显
|
||||
loadMinute(chartPeriod);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartPeriod, ready, code]);
|
||||
|
||||
// 展示数据:日线直接用,分钟线用 minuteData
|
||||
const displayData = chartPeriod === "d" ? dailyData : minuteData;
|
||||
const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading;
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-base md:text-lg">
|
||||
K线图
|
||||
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
|
||||
{currentLoading ? "(加载中...)" : (
|
||||
chartPeriod === "d"
|
||||
? `(${displayData.length}个交易日)`
|
||||
: `(${displayData.length}根${PERIOD_LABEL[chartPeriod]}K线)`
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{/* 显示方式切换:折线 / 蜡烛 */}
|
||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||
{(["line", "candle"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setChartMode(m)}
|
||||
className={`px-2.5 py-1 transition-colors ${
|
||||
chartMode === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{m === "line" ? "折线" : "蜡烛"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* 指标开关:MA / MACD / RSI */}
|
||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||
{([
|
||||
{ key: "ma", label: "MA" },
|
||||
{ key: "macd", label: "MACD" },
|
||||
{ key: "rsi", label: "RSI" },
|
||||
] as const).map((it) => (
|
||||
<button
|
||||
key={it.key}
|
||||
onClick={() => toggleIndicator(it.key)}
|
||||
className={`px-2.5 py-1 transition-colors ${
|
||||
indicators[it.key] ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
|
||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||
{([
|
||||
{ key: "d" as const, label: "日" },
|
||||
{ key: "m60" as const, label: "60分" },
|
||||
{ key: "m30" as const, label: "30分" },
|
||||
{ key: "m15" as const, label: "15分" },
|
||||
{ key: "m5" as const, label: "5分" },
|
||||
{ key: "m1" as const, label: "1分" },
|
||||
] as const).map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => setChartPeriod(p.key)}
|
||||
className={`px-2 py-1 transition-colors ${
|
||||
chartPeriod === p.key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
双指缩放 · 拖动查看
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||
{currentLoading ? (
|
||||
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
|
||||
<div className="flex items-center text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
K线数据加载中...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<KLineChart
|
||||
data={displayData.map((d) => ({
|
||||
time: d.dateMs,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
isAddedDate: d.isAddedDate,
|
||||
}))}
|
||||
mode={chartMode}
|
||||
hasAddedDate={ready}
|
||||
indicators={indicators}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default KLineCard;
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* K 线图组件(基于 TradingView Lightweight Charts v5)
|
||||
* 主图:蜡烛/折线 + MA(5/10/20/30);
|
||||
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3),可开关。
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
createChart,
|
||||
CandlestickSeries,
|
||||
LineSeries,
|
||||
HistogramSeries,
|
||||
ColorType,
|
||||
CrosshairMode,
|
||||
LineStyle,
|
||||
createSeriesMarkers,
|
||||
type IChartApi,
|
||||
type ISeriesApi,
|
||||
type ISeriesApi as ISeriesAny,
|
||||
type Time,
|
||||
} from "lightweight-charts";
|
||||
import { sma, macd as calcMacd, rsi as calcRsi, type KBar } from "@/lib/indicators";
|
||||
|
||||
export interface KLineItem {
|
||||
time: string | number; // ISO 日期 yyyy-mm-dd 或 Unix seconds UTC(lightweight-charts 两者均支持)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
isAddedDate?: boolean;
|
||||
}
|
||||
|
||||
export interface IndicatorToggles {
|
||||
ma: boolean;
|
||||
macd: boolean;
|
||||
rsi: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: KLineItem[];
|
||||
mode: "line" | "candle";
|
||||
hasAddedDate?: boolean;
|
||||
indicators: IndicatorToggles;
|
||||
}
|
||||
|
||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||
const UP = "#ef4444";
|
||||
const DOWN = "#22c55e";
|
||||
// 成交量柱:浅色调,避免与 K 线柱体视觉争夺
|
||||
const VOL_UP = "#fca5a5";
|
||||
const VOL_DOWN = "#86efac";
|
||||
const PRIMARY = "#ef4444";
|
||||
const TEXT = "#71717a";
|
||||
const GRID = "#e4e4e7";
|
||||
const MARKER = "#f59e0b";
|
||||
|
||||
// MA / 指标线配色
|
||||
const MA_COLORS = ["#f59e0b", "#3b82f6", "#a855f7"];
|
||||
const MA_PERIODS = [5, 10, 20];
|
||||
const MACD_DIF = "#3b82f6";
|
||||
const MACD_DEA = "#f59e0b";
|
||||
const RSI_COLOR = "#a855f7";
|
||||
|
||||
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<IChartApi | null>(null);
|
||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
|
||||
// 指标 series(重建用)
|
||||
const maSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
|
||||
const macdSeriesRef = useRef<ISeriesAny<"Line" | "Histogram">[]>([]);
|
||||
const rsiSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
|
||||
|
||||
// 容器高度随指标 pane 数量增长,避免主图被压缩
|
||||
const extraPanes = [indicators.macd, indicators.rsi].filter(Boolean).length;
|
||||
// 固定图表高度:主图+成交量 300,每个指标副图 +95
|
||||
const chartHeight = 300 + extraPanes * 95;
|
||||
|
||||
// 创建图表(仅一次):pane0 主图 + pane1 成交量
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const el = containerRef.current;
|
||||
const chart = createChart(el, {
|
||||
width: el.clientWidth || 800,
|
||||
height: chartHeight,
|
||||
layout: {
|
||||
background: { type: ColorType.Solid, color: "transparent" },
|
||||
textColor: TEXT,
|
||||
fontSize: 10,
|
||||
panes: { separatorColor: GRID, separatorHoverColor: "#94a3b8" },
|
||||
},
|
||||
grid: {
|
||||
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
horzLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||
},
|
||||
rightPriceScale: { borderColor: GRID },
|
||||
timeScale: { borderColor: GRID, timeVisible: false },
|
||||
crosshair: { mode: CrosshairMode.Normal },
|
||||
});
|
||||
chartRef.current = chart;
|
||||
|
||||
// 成交量放到独立 pane1,避免与主图K线重叠
|
||||
const vol = chart.addSeries(
|
||||
HistogramSeries,
|
||||
{
|
||||
priceFormat: { type: "volume" },
|
||||
priceScaleId: "vol",
|
||||
},
|
||||
1,
|
||||
);
|
||||
volSeriesRef.current = vol;
|
||||
|
||||
return () => {
|
||||
chart.remove();
|
||||
chartRef.current = null;
|
||||
priceSeriesRef.current = null;
|
||||
volSeriesRef.current = null;
|
||||
maSeriesRef.current = [];
|
||||
macdSeriesRef.current = [];
|
||||
rsiSeriesRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 移除某组 series
|
||||
function removeGroup(list: ISeriesAny<"Line" | "Histogram">[], chart: IChartApi) {
|
||||
for (const s of list) {
|
||||
try {
|
||||
chart.removeSeries(s);
|
||||
} catch {
|
||||
// 已被移除
|
||||
}
|
||||
}
|
||||
list.length = 0;
|
||||
}
|
||||
|
||||
// 填充/重建全部指标 series
|
||||
function rebuildIndicators() {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
// 指标开关改变容器高度后,同步图表尺寸
|
||||
if (containerRef.current) {
|
||||
chart.applyOptions({
|
||||
width: containerRef.current.clientWidth || 800,
|
||||
height: chartHeight,
|
||||
});
|
||||
}
|
||||
const bars: KBar[] = data.map((d) => ({
|
||||
time: d.time,
|
||||
open: d.open,
|
||||
close: d.close,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
volume: d.volume,
|
||||
}));
|
||||
const closes = bars.map((b) => b.close);
|
||||
|
||||
// --- MA(主图 pane0 叠加)---
|
||||
removeGroup(maSeriesRef.current, chart);
|
||||
if (indicators.ma && bars.length > 0) {
|
||||
MA_PERIODS.forEach((p, i) => {
|
||||
const vals = sma(closes, p);
|
||||
const s = chart.addSeries(
|
||||
LineSeries,
|
||||
{ color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
|
||||
0,
|
||||
);
|
||||
s.setData(
|
||||
bars
|
||||
.map((b, idx) => ({ time: b.time as Time, value: vals[idx] }))
|
||||
.filter((x) => x.value != null) as { time: Time; value: number }[],
|
||||
);
|
||||
maSeriesRef.current.push(s);
|
||||
});
|
||||
}
|
||||
|
||||
// --- MACD(pane2)---
|
||||
removeGroup(macdSeriesRef.current, chart);
|
||||
if (indicators.macd && bars.length > 0) {
|
||||
const { dif, dea, hist } = calcMacd(closes);
|
||||
const line = (color: string) =>
|
||||
chart.addSeries(
|
||||
LineSeries,
|
||||
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
|
||||
2,
|
||||
);
|
||||
const difS = line(MACD_DIF);
|
||||
const deaS = line(MACD_DEA);
|
||||
difS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dif[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
|
||||
deaS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dea[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
|
||||
const histS = chart.addSeries(
|
||||
HistogramSeries,
|
||||
{
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: false,
|
||||
},
|
||||
2,
|
||||
);
|
||||
histS.setData(
|
||||
bars
|
||||
.map((b, i) => ({
|
||||
time: b.time as Time,
|
||||
value: hist[i],
|
||||
color: (hist[i] ?? 0) >= 0 ? UP : DOWN,
|
||||
}))
|
||||
.filter((x) => x.value != null) as { time: Time; value: number; color: string }[],
|
||||
);
|
||||
macdSeriesRef.current.push(difS, deaS, histS);
|
||||
}
|
||||
|
||||
// --- RSI(pane3)---
|
||||
removeGroup(rsiSeriesRef.current, chart);
|
||||
if (indicators.rsi && bars.length > 0) {
|
||||
const vals = calcRsi(closes, 14);
|
||||
const s = chart.addSeries(
|
||||
LineSeries,
|
||||
{ color: RSI_COLOR, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
|
||||
3,
|
||||
);
|
||||
s.setData(
|
||||
bars
|
||||
.map((b, i) => ({ time: b.time as Time, value: vals[i] }))
|
||||
.filter((x) => x.value != null) as { time: Time; value: number }[],
|
||||
);
|
||||
rsiSeriesRef.current.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
// 模式切换:重建价格 series + 指标
|
||||
useEffect(() => {
|
||||
const chart = chartRef.current;
|
||||
if (!chart) return;
|
||||
if (priceSeriesRef.current) {
|
||||
chart.removeSeries(priceSeriesRef.current);
|
||||
priceSeriesRef.current = null;
|
||||
}
|
||||
if (mode === "candle") {
|
||||
priceSeriesRef.current = chart.addSeries(CandlestickSeries, {
|
||||
upColor: UP,
|
||||
downColor: DOWN,
|
||||
borderUpColor: UP,
|
||||
borderDownColor: DOWN,
|
||||
wickUpColor: UP,
|
||||
wickDownColor: DOWN,
|
||||
});
|
||||
} else {
|
||||
priceSeriesRef.current = chart.addSeries(LineSeries, {
|
||||
color: PRIMARY,
|
||||
lineWidth: 2,
|
||||
});
|
||||
}
|
||||
fillData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode]);
|
||||
|
||||
// 数据或指标开关变化:重填数据 + 重建指标
|
||||
useEffect(() => {
|
||||
fillData();
|
||||
rebuildIndicators();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data, hasAddedDate, indicators]);
|
||||
|
||||
function fillData() {
|
||||
const chart = chartRef.current;
|
||||
const ps = priceSeriesRef.current;
|
||||
const vs = volSeriesRef.current;
|
||||
if (!chart || !ps || !vs) return;
|
||||
|
||||
if (data.length === 0) {
|
||||
ps.setData([]);
|
||||
vs.setData([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "candle") {
|
||||
(ps as ISeriesApi<"Candlestick">).setData(
|
||||
data.map((d) => ({
|
||||
time: d.time as Time,
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
(ps as ISeriesApi<"Line">).setData(
|
||||
data.map((d) => ({ time: d.time as Time, value: d.close })),
|
||||
);
|
||||
}
|
||||
|
||||
vs.setData(
|
||||
data.map((d) => ({
|
||||
time: d.time as Time,
|
||||
value: d.volume,
|
||||
color: d.close >= d.open ? VOL_UP : VOL_DOWN,
|
||||
})),
|
||||
);
|
||||
|
||||
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
|
||||
if (markerItem) {
|
||||
createSeriesMarkers(ps, [
|
||||
{
|
||||
time: markerItem.time as Time,
|
||||
position: "aboveBar",
|
||||
color: MARKER,
|
||||
shape: "circle",
|
||||
text: "自选",
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
createSeriesMarkers(ps, []);
|
||||
}
|
||||
|
||||
chart.timeScale().fitContent();
|
||||
}
|
||||
|
||||
// 颜色图例:根据启用的指标生成
|
||||
const legend: { color: string; label: string }[] = [];
|
||||
if (indicators.ma) {
|
||||
MA_PERIODS.forEach((p, i) => legend.push({ color: MA_COLORS[i % MA_COLORS.length], label: `MA${p}` }));
|
||||
}
|
||||
if (indicators.macd) {
|
||||
legend.push({ color: MACD_DIF, label: "DIF" }, { color: MACD_DEA, label: "DEA" });
|
||||
}
|
||||
if (indicators.rsi) legend.push({ color: RSI_COLOR, label: "RSI14" });
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={{ height: `${chartHeight}px` }}
|
||||
className="w-full transition-[height] duration-200"
|
||||
/>
|
||||
{legend.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-[11px] text-muted-foreground">
|
||||
{legend.map((l) => (
|
||||
<span key={l.label} className="inline-flex items-center gap-1">
|
||||
<span className="h-0.5 w-3 rounded" style={{ backgroundColor: l.color }} />
|
||||
{l.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KLineChart;
|
||||
@@ -0,0 +1,56 @@
|
||||
// AI 分析报告 API 客户端
|
||||
|
||||
import { getApiBaseUrl } from "./api-client";
|
||||
|
||||
const API_BASE = getApiBaseUrl();
|
||||
|
||||
export interface AiReport {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
report_type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string | null;
|
||||
toolsUsed: string[];
|
||||
model: string;
|
||||
tokens_used: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export async function fetchAiLatestReport(): Promise<AiReport> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/latest`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function fetchAiReports(): Promise<AiReport[]> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data || [];
|
||||
}
|
||||
|
||||
export async function fetchAiReport(id: number): Promise<AiReport> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}`);
|
||||
if (!resp.ok) throw new Error(`请求失败 (${resp.status})`);
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function checkAiReport(tradeDate: string): Promise<boolean> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/check/${tradeDate}`);
|
||||
if (!resp.ok) return false;
|
||||
const result = await resp.json();
|
||||
return result.data?.hasReport || false;
|
||||
}
|
||||
|
||||
export async function regenerateAiReport(id: number): Promise<{ id: number; tokens_used: number }> {
|
||||
const resp = await fetch(`${API_BASE}/api/ai-analysis/${id}/regenerate`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail || "重新生成失败");
|
||||
}
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 核心股历史数据获取工具
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface ActiveCoreStock {
|
||||
stockCode: string;
|
||||
stockName: string;
|
||||
coverCount: number | null; // 覆盖题材数
|
||||
dailyGains: Record<string, number | null>; // 日期 -> 当日涨幅(可空)
|
||||
appearCount: number;
|
||||
lastAppear: string | null;
|
||||
daysSinceLastAppear: number; // 最近上榜距窗口最新交易日的自然日差
|
||||
themes: { theme_code: string; theme_name: string }[]; // 所属题材列表
|
||||
}
|
||||
|
||||
export interface ActiveCoreStocksResponse {
|
||||
dates: string[];
|
||||
stocks: ActiveCoreStock[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryItem {
|
||||
id: number;
|
||||
trade_date: string;
|
||||
stock_code: string;
|
||||
stock_name: string;
|
||||
f3: number | null;
|
||||
cover_count: number | null;
|
||||
rank: number;
|
||||
themes: { theme_code: string; theme_name: string }[];
|
||||
}
|
||||
|
||||
export interface CoreStockHistoryResponse {
|
||||
date: string;
|
||||
items: CoreStockHistoryItem[];
|
||||
}
|
||||
|
||||
/** 获取活跃核心股 + 最近10日涨幅矩阵 */
|
||||
export async function fetchActiveCoreStocks(): Promise<ActiveCoreStocksResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], stocks: [] };
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
console.error("[core-stock-api] 获取活跃核心股失败:", err);
|
||||
return { dates: [], stocks: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取指定交易日核心股(含所属题材) */
|
||||
export async function fetchCoreStockHistory(date: string): Promise<CoreStockHistoryResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/core-stocks/history?date=${date}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
console.error("[core-stock-api] 获取核心股历史失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
// v2 数据接口(同花顺官方 API)前端客户端
|
||||
// 仅调用后端 /api/v2 代理,密钥由后端持有,前端永远接触不到。
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
/* ── 通用信封(后端返回 { data, count })── */
|
||||
|
||||
interface V2Response<T> {
|
||||
data: T;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
async function v2Get<T>(path: string): Promise<T> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/v2${path}`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) {
|
||||
// 尝试读取 detail(FastAPI 错误信息)
|
||||
try {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.detail || `请求失败 (${resp.status})`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e;
|
||||
throw new Error(`请求失败 (${resp.status})`);
|
||||
}
|
||||
}
|
||||
const result: V2Response<T> = await resp.json();
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
console.error("[fuyao-api] v2 请求失败:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 类型 ── */
|
||||
|
||||
export interface V2Ticker {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
name: string;
|
||||
exchange: string;
|
||||
asset_type: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
export interface V2PriceSnapshot {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
volume: number;
|
||||
turnover: number;
|
||||
last_price: number;
|
||||
price_change: number;
|
||||
price_change_ratio_pct: number;
|
||||
open_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
prev_price: number;
|
||||
}
|
||||
|
||||
export interface V2Valuation {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
name: string;
|
||||
pe_ttm: number;
|
||||
pe_mrq: number;
|
||||
pb_mrq: number;
|
||||
ps_ttm: number;
|
||||
pcf_ttm: number;
|
||||
}
|
||||
|
||||
export interface V2Financial {
|
||||
thscode: string;
|
||||
ticker: string;
|
||||
fiscal_year: number;
|
||||
fiscal_period: string;
|
||||
operating_income: number;
|
||||
operating_costs: number;
|
||||
net_profit: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface V2TradingDay {
|
||||
date: string; // YYYYMMDD
|
||||
date_ms: number;
|
||||
}
|
||||
|
||||
export interface V2IndexItem {
|
||||
thscode: string;
|
||||
name: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/* ── 基础 / 检索 ── */
|
||||
|
||||
export function v2TickerSearch(q: string, limit = 10): Promise<V2Ticker[]> {
|
||||
return v2Get(`/meta/tickers/search?q=${encodeURIComponent(q)}&limit=${limit}`);
|
||||
}
|
||||
|
||||
/** 判断是否已是标准 thscode(如 600519.SH / 000021.SZ / 830xxx.BJ) */
|
||||
function isThscode(token: string): boolean {
|
||||
return /^\d{6}\.(SH|SZ|BJ)$/i.test(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把用户输入解析成 thscode 列表。
|
||||
* 支持:标准 thscode(600519.SH)、纯代码(600519)、名称(茅台)、拼音首字母(gzmt)。
|
||||
* 输入用逗号/空格分隔多个标的;每个 token 单独解析。
|
||||
* 解析失败(找不到)的 token 会被跳过。
|
||||
*/
|
||||
export async function resolveThscodes(input: string): Promise<string[]> {
|
||||
const tokens = input
|
||||
.split(/[,,\s]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const out: string[] = [];
|
||||
for (const token of tokens) {
|
||||
if (isThscode(token)) {
|
||||
out.push(token.toUpperCase());
|
||||
} else {
|
||||
try {
|
||||
const hits = await v2TickerSearch(token, 1);
|
||||
if (hits.length > 0) out.push(hits[0].thscode);
|
||||
} catch {
|
||||
// 忽略单个 token 解析失败
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── A股 ── */
|
||||
|
||||
/** 6位代码 → thscode(按代码前缀推断交易所后缀) */
|
||||
export function codeToThscode(code: string): string {
|
||||
const c = code.trim();
|
||||
if (/^\d{6}\.(SH|SZ|BJ)$/i.test(c)) return c.toUpperCase();
|
||||
if (!/^\d{6}$/.test(c)) return c;
|
||||
if (/^(60|68|9)/.test(c)) return `${c}.SH`;
|
||||
if (/^(00|30|20|12)/.test(c)) return `${c}.SZ`;
|
||||
if (/^(4|8|92)/.test(c)) return `${c}.BJ`;
|
||||
return `${c}.SH`;
|
||||
}
|
||||
|
||||
export interface V2PriceBar {
|
||||
date_ms: number;
|
||||
open_price: number;
|
||||
high_price: number;
|
||||
low_price: number;
|
||||
close_price: number;
|
||||
volume: number;
|
||||
turnover: number;
|
||||
}
|
||||
|
||||
export function v2PriceSnapshot(thscodes: string): Promise<V2PriceSnapshot[]> {
|
||||
return v2Get(`/prices/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 历史日K(毫秒时间戳)。后端用官方 SDK,>10 年窗口自动切片。
|
||||
*/
|
||||
export function v2PriceHistorical(
|
||||
thscode: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
adjust = "forward",
|
||||
): Promise<V2PriceBar[]> {
|
||||
return v2Get(`/prices/historical?thscode=${encodeURIComponent(thscode)}&start=${startMs}&end=${endMs}&adjust=${adjust}`);
|
||||
}
|
||||
|
||||
/** 毫秒时间戳 → 北京时间(UTC+8)日期 YYYY-MM-DD */
|
||||
function bjDate(ms: number): string {
|
||||
const d = new Date(ms + 8 * 60 * 60 * 1000);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 版股票日K(供股票详情页复用现有图表结构)。
|
||||
* 返回与 v1 KLineData 兼容的结构;涨跌幅按昨收计算。
|
||||
*/
|
||||
export async function fetchStockHistoryV2(
|
||||
code: string,
|
||||
days: number = 120,
|
||||
adjust: string = "forward",
|
||||
): Promise<Array<{
|
||||
date: string;
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
changePercent: number;
|
||||
}>> {
|
||||
const thscode = codeToThscode(code);
|
||||
const end = Date.now();
|
||||
const start = end - days * 24 * 60 * 60 * 1000;
|
||||
const bars = await v2PriceHistorical(thscode, start, end, adjust);
|
||||
const sorted = bars.slice().sort((a, b) => a.date_ms - b.date_ms);
|
||||
return sorted.map((b, i) => {
|
||||
const prevClose = i > 0 ? sorted[i - 1].close_price : b.open_price;
|
||||
const changePercent = prevClose > 0 ? ((b.close_price - prevClose) / prevClose) * 100 : 0;
|
||||
return {
|
||||
date: bjDate(b.date_ms),
|
||||
open: b.open_price,
|
||||
close: b.close_price,
|
||||
high: b.high_price,
|
||||
low: b.low_price,
|
||||
volume: b.volume / 100, // 股 → 手(与 v1 口径一致,现有图表按手展示)
|
||||
changePercent,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function v2Valuations(thscodes: string): Promise<V2Valuation[]> {
|
||||
return v2Get(`/valuations/snapshot?thscodes=${encodeURIComponent(thscodes)}`);
|
||||
}
|
||||
|
||||
export function v2Financials(
|
||||
statement: "income-statements" | "balance-sheets" | "cash-flow-statements",
|
||||
thscode: string,
|
||||
period = "annual",
|
||||
limit = 6,
|
||||
): Promise<V2Financial[]> {
|
||||
return v2Get(`/financials/${statement}?thscode=${encodeURIComponent(thscode)}&period=${period}&limit=${limit}`);
|
||||
}
|
||||
|
||||
export function v2TradingDays(): Promise<V2TradingDay[]> {
|
||||
return v2Get(`/calendar/trading-days`);
|
||||
}
|
||||
|
||||
/* ── 指数 / 板块 ── */
|
||||
|
||||
export function v2IndexCatalog(tag = "industry"): Promise<V2IndexItem[]> {
|
||||
return v2Get(`/index/catalog?tag=${tag}`);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 技术指标计算(纯函数,前端本地计算)
|
||||
* 输入 K 线数组(时间升序),输出与输入等长、前段为 null 的指标序列。
|
||||
*/
|
||||
|
||||
export interface KBar {
|
||||
time: string | number; // yyyy-mm-dd 或 Unix 秒(分钟K线用数字时间)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
low: number;
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type Nums = (number | null)[];
|
||||
|
||||
/** 简单移动平均 */
|
||||
export function sma(closes: number[], period: number): Nums {
|
||||
const out: Nums = new Array(closes.length).fill(null);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < closes.length; i++) {
|
||||
sum += closes[i];
|
||||
if (i >= period) sum -= closes[i - period];
|
||||
if (i >= period - 1) out[i] = sum / period;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** EMA(标准 MACD 用的指数平滑) */
|
||||
export function ema(values: number[], period: number): Nums {
|
||||
const out: Nums = new Array(values.length).fill(null);
|
||||
const k = 2 / (period + 1);
|
||||
let prev: number | null = null;
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
if (i === period - 1) {
|
||||
// 首值取前 period 个的 SMA
|
||||
let s = 0;
|
||||
for (let j = 0; j < period; j++) s += values[j];
|
||||
prev = s / period;
|
||||
out[i] = prev;
|
||||
} else if (prev !== null) {
|
||||
prev = values[i] * k + prev * (1 - k);
|
||||
out[i] = prev;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** MACD(10,20,7):返回 DIF、DEA、MACD 柱(柱 = (DIF-DEA)*2,国内口径) */
|
||||
export function macd(
|
||||
closes: number[],
|
||||
fast = 10,
|
||||
slow = 20,
|
||||
signal = 7,
|
||||
): { dif: Nums; dea: Nums; hist: Nums } {
|
||||
const ef = ema(closes, fast);
|
||||
const es = ema(closes, slow);
|
||||
const dif: Nums = closes.map((_, i) =>
|
||||
ef[i] != null && es[i] != null ? (ef[i] as number) - (es[i] as number) : null,
|
||||
);
|
||||
// DEA = DIF 的 9 日 EMA(跳过 null)
|
||||
const dea: Nums = new Array(closes.length).fill(null);
|
||||
const difVals: number[] = [];
|
||||
const difIdx: number[] = [];
|
||||
for (let i = 0; i < dif.length; i++) {
|
||||
if (dif[i] != null) {
|
||||
difVals.push(dif[i] as number);
|
||||
difIdx.push(i);
|
||||
}
|
||||
}
|
||||
const deaVals = ema(difVals, signal);
|
||||
for (let j = 0; j < difIdx.length; j++) {
|
||||
if (deaVals[j] != null) dea[difIdx[j]] = deaVals[j];
|
||||
}
|
||||
const hist: Nums = closes.map((_, i) =>
|
||||
dif[i] != null && dea[i] != null ? ((dif[i] as number) - (dea[i] as number)) * 2 : null,
|
||||
);
|
||||
return { dif, dea, hist };
|
||||
}
|
||||
|
||||
/** RSI(Wilder 平滑,国内软件常用 SMA(X,N,1) 等价 Wilder) */
|
||||
export function rsi(closes: number[], period = 14): Nums {
|
||||
const out: Nums = new Array(closes.length).fill(null);
|
||||
if (closes.length <= period) return out;
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
// 首段:前 period 个变动的简单均值
|
||||
for (let i = 1; i <= period; i++) {
|
||||
const ch = closes[i] - closes[i - 1];
|
||||
if (ch > 0) avgGain += ch;
|
||||
else avgLoss -= ch;
|
||||
}
|
||||
avgGain /= period;
|
||||
avgLoss /= period;
|
||||
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
|
||||
// Wilder 平滑
|
||||
for (let i = period + 1; i < closes.length; i++) {
|
||||
const ch = closes[i] - closes[i - 1];
|
||||
const gain = ch > 0 ? ch : 0;
|
||||
const loss = ch < 0 ? -ch : 0;
|
||||
avgGain = (avgGain * (period - 1) + gain) / period;
|
||||
avgLoss = (avgLoss * (period - 1) + loss) / period;
|
||||
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** KDJ(9,3,3) */
|
||||
export function kdj(
|
||||
bars: KBar[],
|
||||
n = 9,
|
||||
m1 = 3,
|
||||
m2 = 3,
|
||||
): { k: Nums; d: Nums; j: Nums } {
|
||||
const len = bars.length;
|
||||
const k: Nums = new Array(len).fill(null);
|
||||
const d: Nums = new Array(len).fill(null);
|
||||
const j: Nums = new Array(len).fill(null);
|
||||
let prevK = 50;
|
||||
let prevD = 50;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const from = Math.max(0, i - n + 1);
|
||||
let hh = -Infinity;
|
||||
let ll = Infinity;
|
||||
for (let x = from; x <= i; x++) {
|
||||
hh = Math.max(hh, bars[x].high);
|
||||
ll = Math.min(ll, bars[x].low);
|
||||
}
|
||||
const rsv = hh === ll ? 50 : ((bars[i].close - ll) / (hh - ll)) * 100;
|
||||
const kv = (2 * prevK + rsv) / m1; // 国内口径 K = 2/3 前值 + 1/3 RSV
|
||||
const dv = (2 * prevD + kv) / m2;
|
||||
prevK = kv;
|
||||
prevD = dv;
|
||||
k[i] = kv;
|
||||
d[i] = dv;
|
||||
j[i] = 3 * kv - 2 * dv;
|
||||
}
|
||||
return { k, d, j };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 市场看板数据 API 客户端
|
||||
import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
export interface MarketIndex {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePct: number;
|
||||
prevClose: number;
|
||||
turnover: number;
|
||||
}
|
||||
|
||||
export interface MarketTemperature {
|
||||
score: number;
|
||||
label: string;
|
||||
factors: {
|
||||
advanceScore: number;
|
||||
medianScore: number;
|
||||
strongScore: number;
|
||||
limitScore: number;
|
||||
breakPenalty: number;
|
||||
auctionBonus: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AuctionData {
|
||||
score: number;
|
||||
label: string;
|
||||
date: string;
|
||||
benchmark: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface MarketStats {
|
||||
upCount: number;
|
||||
downCount: number;
|
||||
flatCount: number;
|
||||
total: number;
|
||||
marketBreadth: number;
|
||||
medianChange: number;
|
||||
strongCount: number;
|
||||
weakCount: number;
|
||||
limitUp: number;
|
||||
limitDown: number;
|
||||
limitBreak: number;
|
||||
breakRate: number;
|
||||
totalTurnover: number;
|
||||
temperature: MarketTemperature;
|
||||
auction: AuctionData;
|
||||
auctionSignal: string;
|
||||
}
|
||||
|
||||
export interface SectorStrengthItem {
|
||||
code: string;
|
||||
name: string;
|
||||
price: number;
|
||||
change: number;
|
||||
changePct: number;
|
||||
strength: number;
|
||||
breadthPct: number;
|
||||
strongCount: number;
|
||||
}
|
||||
|
||||
export interface ConceptStrengthItem {
|
||||
code: string;
|
||||
name: string;
|
||||
changePct: number;
|
||||
}
|
||||
|
||||
export interface DashboardEvent {
|
||||
type: string;
|
||||
label: string;
|
||||
name: string;
|
||||
code: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface MarketDashboardData {
|
||||
indices: MarketIndex[];
|
||||
marketStats: MarketStats;
|
||||
sectorStrength: SectorStrengthItem[];
|
||||
conceptStrength: ConceptStrengthItem[];
|
||||
events: DashboardEvent[];
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export async function fetchMarketDashboard(): Promise<MarketDashboardData> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const resp = await fetch(`${baseUrl}/api/market-dashboard`, {
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!resp.ok) {
|
||||
try {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.detail || `请求失败 (${resp.status})`);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) throw e;
|
||||
throw new Error(`请求失败 (${resp.status})`);
|
||||
}
|
||||
}
|
||||
const result = await resp.json();
|
||||
return result.data;
|
||||
}
|
||||
+44
-48
@@ -97,7 +97,9 @@ export interface BoardInfo {
|
||||
* - 8/4/920开头:北交所(BJB)
|
||||
* - 其他:主板
|
||||
*/
|
||||
export function getStockBoard(code: string): BoardInfo {
|
||||
export function getStockBoard(code: string | null | undefined): BoardInfo {
|
||||
// 题材列表等场景下 securityCode 可能为 null(无领涨股的题材),兜底为主板
|
||||
if (!code) return { board: "main", label: "", className: "" };
|
||||
if (code.startsWith("688")) {
|
||||
return { board: "kcb", label: "科", className: "bg-red-500/10 text-red-500 border-red-500/30" };
|
||||
}
|
||||
@@ -179,6 +181,47 @@ export async function fetchStockQuote(code: string): Promise<StockQuote | null>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票分钟/小时级K线数据
|
||||
* @param code 股票代码(6位)
|
||||
* @param period 周期:m1/m5/m15/m30/m60(60=小时线)
|
||||
* @param count K线数量(默认320)
|
||||
*/
|
||||
export async function fetchStockHistoryMinute(
|
||||
code: string,
|
||||
period: "m1" | "m5" | "m15" | "m30" | "m60" = "m60",
|
||||
count: number = 320,
|
||||
): Promise<KLineData[]> {
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
throw new Error("股票代码格式错误,需为6位数字");
|
||||
}
|
||||
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/stock/history-minute?code=${code}&period=${period}&count=${count}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET" });
|
||||
if (!resp.ok) {
|
||||
let errorMsg = `请求失败 (${resp.status})`;
|
||||
try {
|
||||
const errData = await resp.json();
|
||||
errorMsg = errData.detail || errorMsg;
|
||||
} catch {
|
||||
// 忽略 JSON 解析错误
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
const result = await resp.json();
|
||||
if (!result.data || !Array.isArray(result.data)) {
|
||||
throw new Error(result.detail || "未获取到分钟K线数据");
|
||||
}
|
||||
return result.data as KLineData[];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取分钟K线数据失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取股票历史K线数据(通过Edge Function代理腾讯财经API)
|
||||
* @param code 股票代码(6位)
|
||||
@@ -430,50 +473,3 @@ export async function fetchFinancialData(code: string, years: number = 5): Promi
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 板块数据 ----
|
||||
|
||||
export interface SectorItem {
|
||||
code: string;
|
||||
name: string;
|
||||
level: number | null;
|
||||
changePercent: number | null;
|
||||
changeAmount: number | null;
|
||||
mainNetInflow: number;
|
||||
mainNetInflowPercent: number | null;
|
||||
superLargeInflow: number | null;
|
||||
superLargeInflowPercent: number | null;
|
||||
largeInflow: number | null;
|
||||
largeInflowPercent: number | null;
|
||||
mediumInflow: number | null;
|
||||
mediumInflowPercent: number | null;
|
||||
smallInflow: number | null;
|
||||
smallInflowPercent: number | null;
|
||||
turnover: number;
|
||||
}
|
||||
|
||||
export type SectorType = "industry" | "concept";
|
||||
|
||||
export interface SectorResponse {
|
||||
data: SectorItem[];
|
||||
count: number;
|
||||
type: SectorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取东方财富板块列表(按主力净流入排序)
|
||||
* @param type industry=行业板块, concept=概念板块
|
||||
*/
|
||||
export async function fetchSectors(type: SectorType, signal?: AbortSignal): Promise<SectorItem[]> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/sectors?type=${type}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", signal, cache: "no-store" });
|
||||
if (!resp.ok) return [];
|
||||
const result: SectorResponse = await resp.json();
|
||||
return result.data || [];
|
||||
} catch (err) {
|
||||
console.error("[stock-api] 获取板块数据失败:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
+101
-2
@@ -3,11 +3,40 @@ import { getApiBaseUrl } from "@/lib/api-client";
|
||||
|
||||
/* ── 题材列表 ── */
|
||||
|
||||
export interface ActiveTheme {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
dailyGains: Record<string, number | null>; // trade_date -> 当日题材涨幅 bf3(可空)
|
||||
appearCount: number; // 窗口内上榜次数
|
||||
lastAppear: string | null; // 最近上榜交易日
|
||||
bestRank: number | null; // 窗口内最佳排名(rank 最小值)
|
||||
}
|
||||
|
||||
export interface ActiveThemesResponse {
|
||||
dates: string[];
|
||||
themes: ActiveTheme[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近 10 个交易日的活跃题材涨幅矩阵
|
||||
*/
|
||||
export async function fetchActiveThemes(): Promise<ActiveThemesResponse> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
try {
|
||||
const resp = await fetch(`${baseUrl}/api/themes/active`, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return { dates: [], themes: [] };
|
||||
return resp.json();
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取活跃题材历史失败:", err);
|
||||
return { dates: [], themes: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ThemeItem {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
securityName: string; // 领涨股名称
|
||||
securityCode: string; // 领涨股代码
|
||||
securityName: string | null; // 领涨股名称(无领涨股的题材为 null)
|
||||
securityCode: string | null; // 领涨股代码(无领涨股的题材为 null)
|
||||
codeWithSuffix: string;
|
||||
hotRank: number; // 热度排名
|
||||
f3: number | null; // 领涨股涨幅
|
||||
@@ -159,12 +188,82 @@ export async function fetchThemeStocks(themeCode: string): Promise<ThemeStocksRe
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 题材相关新闻(分页) ── */
|
||||
|
||||
export interface ThemeNewsItem {
|
||||
newsCode: string;
|
||||
newsTitle: string;
|
||||
newsMediaName: string;
|
||||
showDateTime: number | null;
|
||||
showDateTimeFormat: string | null;
|
||||
commentCount: number;
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
}
|
||||
|
||||
export interface ThemeNewsResponse {
|
||||
total: number;
|
||||
maxEuTime: string;
|
||||
list: ThemeNewsItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题材相关新闻(分页,maxEuTime 为翻页游标)
|
||||
*/
|
||||
export async function fetchThemeNews(
|
||||
themeCode: string,
|
||||
pageNum: number = 1,
|
||||
pageSize: number = 10,
|
||||
maxEuTime: string = "",
|
||||
): Promise<ThemeNewsResponse | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/news?page_num=${pageNum}&page_size=${pageSize}&max_eu_time=${encodeURIComponent(maxEuTime)}`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材新闻失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 单题材实时行情(强度/热度/涨幅) ── */
|
||||
|
||||
export interface ThemeQuote {
|
||||
strengthValue: number | null;
|
||||
hotValueUpLimit: number;
|
||||
hotValue: number;
|
||||
f3: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单题材实时行情
|
||||
*/
|
||||
export async function fetchThemeQuote(themeCode: string): Promise<ThemeQuote | null> {
|
||||
const baseUrl = getApiBaseUrl();
|
||||
const url = `${baseUrl}/api/themes/${themeCode}/quote`;
|
||||
|
||||
try {
|
||||
const resp = await fetch(url, { method: "GET", cache: "no-store" });
|
||||
if (!resp.ok) return null;
|
||||
const result = await resp.json();
|
||||
return result.data || null;
|
||||
} catch (err) {
|
||||
console.error("[theme-api] 获取题材行情失败:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 热点穿透:题材-股票 网状关系图 ── */
|
||||
|
||||
export interface GraphTheme {
|
||||
themeCode: string;
|
||||
themeName: string;
|
||||
stockCount: number; // 题材内股票数
|
||||
bf3: number | null; // 题材涨幅
|
||||
}
|
||||
|
||||
export interface GraphStock {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type Theme = "light" | "dark" | "system";
|
||||
|
||||
function getSystemTheme(): "light" | "dark" {
|
||||
if (typeof window === "undefined") return "light";
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function getResolvedTheme(theme: Theme): "light" | "dark" {
|
||||
return theme === "system" ? getSystemTheme() : theme;
|
||||
}
|
||||
|
||||
function applyTheme(resolved: "light" | "dark") {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(resolved);
|
||||
root.setAttribute("data-theme", resolved);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
if (typeof window === "undefined") return "system";
|
||||
return (localStorage.getItem("theme") as Theme) || "system";
|
||||
});
|
||||
|
||||
const [resolved, setResolved] = useState<"light" | "dark">(() => getResolvedTheme(theme));
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setThemeState(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
const r = getResolvedTheme(newTheme);
|
||||
setResolved(r);
|
||||
applyTheme(r);
|
||||
}, []);
|
||||
|
||||
// 初始应用
|
||||
useEffect(() => {
|
||||
applyTheme(resolved);
|
||||
}, []);
|
||||
|
||||
// 监听系统主题变化
|
||||
useEffect(() => {
|
||||
if (theme !== "system") return;
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = () => {
|
||||
const r = getSystemTheme();
|
||||
setResolved(r);
|
||||
applyTheme(r);
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, [theme]);
|
||||
|
||||
return { theme, resolved, setTheme };
|
||||
}
|
||||
Executable → Regular
+80
-17
@@ -10,8 +10,11 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as ThemesRouteImport } from './routes/themes'
|
||||
import { Route as SectorsRouteImport } from './routes/sectors'
|
||||
import { Route as ThemeHistoryRouteImport } from './routes/theme-history'
|
||||
import { Route as HotMapRouteImport } from './routes/hot-map'
|
||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||
import { Route as CoreStocksRouteImport } from './routes/core-stocks'
|
||||
import { Route as AiAnalysisRouteImport } from './routes/ai-analysis'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ThemeCodeRouteImport } from './routes/theme.$code'
|
||||
import { Route as StockCodeRouteImport } from './routes/stock.$code'
|
||||
@@ -22,9 +25,9 @@ const ThemesRoute = ThemesRouteImport.update({
|
||||
path: '/themes',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SectorsRoute = SectorsRouteImport.update({
|
||||
id: '/sectors',
|
||||
path: '/sectors',
|
||||
const ThemeHistoryRoute = ThemeHistoryRouteImport.update({
|
||||
id: '/theme-history',
|
||||
path: '/theme-history',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const HotMapRoute = HotMapRouteImport.update({
|
||||
@@ -32,6 +35,21 @@ const HotMapRoute = HotMapRouteImport.update({
|
||||
path: '/hot-map',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const DashboardRoute = DashboardRouteImport.update({
|
||||
id: '/dashboard',
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CoreStocksRoute = CoreStocksRouteImport.update({
|
||||
id: '/core-stocks',
|
||||
path: '/core-stocks',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AiAnalysisRoute = AiAnalysisRouteImport.update({
|
||||
id: '/ai-analysis',
|
||||
path: '/ai-analysis',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -55,8 +73,11 @@ const ShareCodeRoute = ShareCodeRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
@@ -64,8 +85,11 @@ export interface FileRoutesByFullPath {
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
@@ -74,8 +98,11 @@ export interface FileRoutesByTo {
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/ai-analysis': typeof AiAnalysisRoute
|
||||
'/core-stocks': typeof CoreStocksRoute
|
||||
'/dashboard': typeof DashboardRoute
|
||||
'/hot-map': typeof HotMapRoute
|
||||
'/sectors': typeof SectorsRoute
|
||||
'/theme-history': typeof ThemeHistoryRoute
|
||||
'/themes': typeof ThemesRoute
|
||||
'/share/$code': typeof ShareCodeRoute
|
||||
'/stock/$code': typeof StockCodeRoute
|
||||
@@ -85,8 +112,11 @@ export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/sectors'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
@@ -94,8 +124,11 @@ export interface FileRouteTypes {
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/sectors'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
@@ -103,8 +136,11 @@ export interface FileRouteTypes {
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/ai-analysis'
|
||||
| '/core-stocks'
|
||||
| '/dashboard'
|
||||
| '/hot-map'
|
||||
| '/sectors'
|
||||
| '/theme-history'
|
||||
| '/themes'
|
||||
| '/share/$code'
|
||||
| '/stock/$code'
|
||||
@@ -113,8 +149,11 @@ export interface FileRouteTypes {
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AiAnalysisRoute: typeof AiAnalysisRoute
|
||||
CoreStocksRoute: typeof CoreStocksRoute
|
||||
DashboardRoute: typeof DashboardRoute
|
||||
HotMapRoute: typeof HotMapRoute
|
||||
SectorsRoute: typeof SectorsRoute
|
||||
ThemeHistoryRoute: typeof ThemeHistoryRoute
|
||||
ThemesRoute: typeof ThemesRoute
|
||||
ShareCodeRoute: typeof ShareCodeRoute
|
||||
StockCodeRoute: typeof StockCodeRoute
|
||||
@@ -130,11 +169,11 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ThemesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/sectors': {
|
||||
id: '/sectors'
|
||||
path: '/sectors'
|
||||
fullPath: '/sectors'
|
||||
preLoaderRoute: typeof SectorsRouteImport
|
||||
'/theme-history': {
|
||||
id: '/theme-history'
|
||||
path: '/theme-history'
|
||||
fullPath: '/theme-history'
|
||||
preLoaderRoute: typeof ThemeHistoryRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/hot-map': {
|
||||
@@ -144,6 +183,27 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HotMapRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/dashboard': {
|
||||
id: '/dashboard'
|
||||
path: '/dashboard'
|
||||
fullPath: '/dashboard'
|
||||
preLoaderRoute: typeof DashboardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/core-stocks': {
|
||||
id: '/core-stocks'
|
||||
path: '/core-stocks'
|
||||
fullPath: '/core-stocks'
|
||||
preLoaderRoute: typeof CoreStocksRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/ai-analysis': {
|
||||
id: '/ai-analysis'
|
||||
path: '/ai-analysis'
|
||||
fullPath: '/ai-analysis'
|
||||
preLoaderRoute: typeof AiAnalysisRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -177,8 +237,11 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AiAnalysisRoute: AiAnalysisRoute,
|
||||
CoreStocksRoute: CoreStocksRoute,
|
||||
DashboardRoute: DashboardRoute,
|
||||
HotMapRoute: HotMapRoute,
|
||||
SectorsRoute: SectorsRoute,
|
||||
ThemeHistoryRoute: ThemeHistoryRoute,
|
||||
ThemesRoute: ThemesRoute,
|
||||
ShareCodeRoute: ShareCodeRoute,
|
||||
StockCodeRoute: StockCodeRoute,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { Toaster } from 'sonner'
|
||||
import { ThemeToggle } from '../components/ThemeToggle'
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
@@ -11,6 +12,9 @@ function RootComponent() {
|
||||
<React.Fragment>
|
||||
<Outlet />
|
||||
<Toaster position="top-center" richColors />
|
||||
<div className="fixed bottom-4 right-4 z-50">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { ArrowLeft, Clock, Loader2, AlertCircle, Wrench, Calendar } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Mermaid } from "../components/Mermaid";
|
||||
import { fetchAiLatestReport } from "../lib/ai-analysis-api";
|
||||
import { Card, CardContent } from "../components/ui/card";
|
||||
|
||||
export const Route = createFileRoute("/ai-analysis")({
|
||||
component: AiAnalysisPage,
|
||||
});
|
||||
|
||||
function AiAnalysisPage() {
|
||||
const { data: report, isLoading, isError } = useQuery({
|
||||
queryKey: ["ai-report-latest"],
|
||||
queryFn: fetchAiLatestReport,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 mb-4 sm:mb-6">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5 text-primary" />
|
||||
AI 收盘分析
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
) : isError || !report ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground">暂无分析报告</p>
|
||||
<p className="text-xs text-muted-foreground/60">请通过管理面板触发 AI 分析</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Meta Info */}
|
||||
<Card className="bg-card border-border mb-3 sm:mb-4">
|
||||
<CardContent className="p-3 sm:p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 sm:gap-4 text-xs sm:text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{report.created_at}</span>
|
||||
</span>
|
||||
<span>{report.tokens_used?.toLocaleString()} tokens</span>
|
||||
{report.toolsUsed && report.toolsUsed.length > 0 && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||
{report.toolsUsed.length} 个工具
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Report Content */}
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="p-3 sm:p-4 md:p-6 ai-report-content">
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
table({ children, ...props }) {
|
||||
return (
|
||||
<div className="overflow-x-auto -mx-3 sm:mx-0 px-3 sm:px-0">
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
code({ className, children, ...props }) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
if (match && match[1] === "mermaid") {
|
||||
return <Mermaid chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{report.content}
|
||||
</Markdown>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<p className="text-xs text-muted-foreground/60 text-center mt-4 sm:mt-6">
|
||||
本报告由 AI 生成,仅供参考,不构成投资建议
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Fragment } from "react";
|
||||
import { fetchActiveCoreStocks } from "@/lib/core-stock-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/core-stocks")({
|
||||
component: CoreStocksPage,
|
||||
});
|
||||
|
||||
/** 格式化涨幅,红涨绿跌 */
|
||||
function formatGain(v: number | null | undefined): string {
|
||||
if (v == null) return "·";
|
||||
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function CoreStocksPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["core-stocks", "active"],
|
||||
queryFn: fetchActiveCoreStocks,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dates = data?.dates ?? [];
|
||||
const stocks = data?.stocks ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/hot-map" className="hover:opacity-70 transition-opacity" aria-label="返回">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">热点股追踪</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃热点股(最近 10 个交易日内上榜)· 按上榜次数排序 · 共 {stocks.length} 只
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
) : dates.length === 0 || stocks.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-16">
|
||||
暂无数据,数据将在每日收盘后自动采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">股票</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium" title="覆盖题材数">题材数</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium whitespace-nowrap">最近上榜</th>
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stocks.map((s) => (
|
||||
<Fragment key={s.stockCode}>
|
||||
<tr className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<Link
|
||||
to="/stock/$code"
|
||||
params={{ code: s.stockCode }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{s.stockName}
|
||||
</Link>
|
||||
<span className="ml-1 text-[10px] text-muted-foreground">{s.stockCode}</span>
|
||||
</td>
|
||||
{dates.map((d) => {
|
||||
const g = s.dailyGains[d];
|
||||
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||
return (
|
||||
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||
{formatGain(g)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{s.coverCount ?? "·"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap text-muted-foreground">
|
||||
{s.lastAppear ? s.lastAppear.slice(5) : "·"}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||
<Flame className="h-3 w-3" />
|
||||
{s.appearCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b bg-muted/20">
|
||||
<td colSpan={dates.length + 4} className="px-3 py-2">
|
||||
<div className="text-[11px] text-muted-foreground mb-1">所属题材</div>
|
||||
{s.themes && s.themes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{s.themes.map((t) => (
|
||||
<Link
|
||||
key={t.theme_code}
|
||||
to="/theme/$code"
|
||||
params={{ code: t.theme_code }}
|
||||
className="px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[11px] hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
{t.theme_name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">暂无题材数据</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
fetchMarketDashboard,
|
||||
type MarketDashboardData,
|
||||
type MarketIndex,
|
||||
type SectorStrengthItem,
|
||||
} from "@/lib/market-dashboard-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
Thermometer,
|
||||
Zap,
|
||||
Newspaper,
|
||||
TrendingUp,
|
||||
Target,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: DashboardPage,
|
||||
});
|
||||
|
||||
function DashboardPage() {
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
|
||||
const { data, isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ["market-dashboard"],
|
||||
queryFn: fetchMarketDashboard,
|
||||
staleTime: 15_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(() => refetch(), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, refetch]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* ── 顶栏 ── */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b border-border">
|
||||
<div className="max-w-[1400px] mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">市场看板</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{data?.updateTime && (
|
||||
<span>行情时间 {data.updateTime}</span>
|
||||
)}
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRefresh}
|
||||
onChange={(e) => setAutoRefresh(e.target.checked)}
|
||||
className="w-3 h-3 rounded border-border bg-muted accent-primary"
|
||||
/>
|
||||
自动刷新
|
||||
</label>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── 主内容 ── */}
|
||||
<main className="max-w-[1400px] mx-auto px-4 py-4">
|
||||
{isLoading ? (
|
||||
<DashboardSkeleton />
|
||||
) : isError ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||||
<button onClick={() => refetch()} className="text-xs text-[#58a6ff] hover:underline">
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* 左侧主区域 */}
|
||||
<div className="flex-1 min-w-0 space-y-4">
|
||||
{/* 指数行情 */}
|
||||
<IndicesRow indices={data.indices} />
|
||||
|
||||
{/* 集合竞价信号 */}
|
||||
{data.marketStats.auctionSignal && (
|
||||
<AuctionSignalBar stats={data.marketStats} />
|
||||
)}
|
||||
|
||||
{/* 市场温度 */}
|
||||
<MarketTemperatureSection stats={data.marketStats} />
|
||||
|
||||
{/* 涨跌家数分布 */}
|
||||
<AdvanceDeclineBar stats={data.marketStats} />
|
||||
|
||||
{/* 行业强度榜 */}
|
||||
<SectorStrengthList sectors={data.sectorStrength} />
|
||||
|
||||
{/* 概念板块热度 */}
|
||||
{data.conceptStrength.length > 0 && (
|
||||
<ConceptStrengthList concepts={data.conceptStrength} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右侧事件栏 */}
|
||||
<div className="w-full lg:w-72 shrink-0">
|
||||
<EventSidebar events={data.events} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
指数行情卡片行(含估值)
|
||||
============================================================ */
|
||||
function IndicesRow({ indices }: { indices: MarketIndex[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{indices.map((idx) => (
|
||||
<IndexCard key={idx.code} index={idx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IndexCard({ index }: { index: MarketIndex }) {
|
||||
const isUp = index.changePct >= 0;
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs text-muted-foreground truncate">{index.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground/50 tabular-nums">{index.code.replace(/\.(SH|SZ)$/, "")}</span>
|
||||
</div>
|
||||
<div className={`text-xl font-bold tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||
{index.price.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||
{isUp ? "+" : ""}{index.change.toFixed(2)}
|
||||
</span>
|
||||
<span className={`text-xs tabular-nums ${isUp ? "text-[#f85149]" : "text-[#3fb950]"}`}>
|
||||
{isUp ? "+" : ""}{index.changePct.toFixed(2)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
集合竞价信号条
|
||||
============================================================ */
|
||||
function AuctionSignalBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||
const signal = stats.auctionSignal;
|
||||
const color =
|
||||
signal === "强势高开" ? "#f85149" :
|
||||
signal === "偏强" ? "#f0883e" :
|
||||
signal === "弱势低开" ? "#3fb950" :
|
||||
signal === "偏弱" ? "#238636" :
|
||||
"#d29922";
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg px-4 py-2 flex items-center gap-3">
|
||||
<Target className="h-4 w-4 shrink-0" style={{ color }} />
|
||||
<span className="text-xs text-muted-foreground">竞价信号</span>
|
||||
<span className="text-sm font-semibold" style={{ color }}>{signal}</span>
|
||||
{stats.auction?.date && (
|
||||
<span className="text-[10px] text-muted-foreground/50 ml-auto">{stats.auction.date}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
市场温度
|
||||
============================================================ */
|
||||
function MarketTemperatureSection({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||
const temp = stats.temperature;
|
||||
const tempColor =
|
||||
temp.score >= 80 ? "#f85149" :
|
||||
temp.score >= 60 ? "#f0883e" :
|
||||
temp.score >= 40 ? "#d29922" :
|
||||
temp.score >= 20 ? "#3fb950" : "#58a6ff";
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">市场温度</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold tabular-nums" style={{ color: tempColor }}>
|
||||
{temp.score}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: tempColor }}>{temp.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3 text-xs">
|
||||
<StatItem label="上涨 / 下跌" value={`${stats.upCount} / ${stats.downCount}`} />
|
||||
<StatItem label="市场宽度" value={`${stats.marketBreadth}%`} />
|
||||
<StatItem label="中位涨跌" value={`${stats.medianChange >= 0 ? "+" : ""}${stats.medianChange.toFixed(2)}%`}
|
||||
valueColor={stats.medianChange >= 0 ? "#f85149" : "#3fb950"} />
|
||||
<StatItem label="强势 / 弱势" value={`${stats.strongCount} / ${stats.weakCount}`} />
|
||||
<StatItem label="涨停 / 跌停" value={`${stats.limitUp} / ${stats.limitDown}`}
|
||||
valueColor={stats.limitUp > 0 ? "#f85149" : "#8b949e"} />
|
||||
<StatItem label="炸板 / 炸板率" value={`${stats.limitBreak} / ${stats.breakRate}%`}
|
||||
valueColor={stats.breakRate > 30 ? "#f0883e" : "#8b949e"} />
|
||||
</div>
|
||||
|
||||
{/* 温度因子明细 */}
|
||||
{temp.factors && (
|
||||
<div className="mt-3 pt-2 border-t border-border flex flex-wrap gap-x-4 gap-y-1 text-[10px] text-muted-foreground/50">
|
||||
<span>涨跌 {temp.factors.advanceScore > 0 ? "+" : ""}{temp.factors.advanceScore}</span>
|
||||
<span>中位 {temp.factors.medianScore > 0 ? "+" : ""}{temp.factors.medianScore}</span>
|
||||
<span>强弱 {temp.factors.strongScore > 0 ? "+" : ""}{temp.factors.strongScore}</span>
|
||||
<span>涨停 {temp.factors.limitScore > 0 ? "+" : ""}{temp.factors.limitScore}</span>
|
||||
<span>炸板 {temp.factors.breakPenalty}</span>
|
||||
<span>竞价 {temp.factors.auctionBonus > 0 ? "+" : ""}{temp.factors.auctionBonus}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground/50 mb-0.5">{label}</div>
|
||||
<div className="text-sm font-medium tabular-nums" style={valueColor ? { color: valueColor } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
涨跌家数分布
|
||||
============================================================ */
|
||||
function AdvanceDeclineBar({ stats }: { stats: MarketDashboardData["marketStats"] }) {
|
||||
const total = stats.upCount + stats.flatCount + stats.downCount;
|
||||
if (total === 0) return null;
|
||||
|
||||
const upPct = (stats.upCount / total) * 100;
|
||||
const flatPct = (stats.flatCount / total) * 100;
|
||||
const downPct = (stats.downCount / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">涨跌家数分布</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
涨停 {stats.limitUp} 炸板 {stats.limitBreak} 跌停 {stats.limitDown} 共 {total} 只
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-5 rounded-full overflow-hidden flex mb-2">
|
||||
<div className="h-full bg-[#f85149] transition-all duration-500" style={{ width: `${upPct}%` }} />
|
||||
<div className="h-full bg-[#484f58] transition-all duration-500" style={{ width: `${flatPct}%` }} />
|
||||
<div className="h-full bg-[#3fb950] transition-all duration-500" style={{ width: `${downPct}%` }} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#f85149]" />
|
||||
<span className="text-muted-foreground">上涨</span>
|
||||
<span className="font-medium tabular-nums">{stats.upCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#484f58]" />
|
||||
<span className="text-muted-foreground">平盘</span>
|
||||
<span className="font-medium tabular-nums">{stats.flatCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-[#3fb950]" />
|
||||
<span className="text-muted-foreground">下跌</span>
|
||||
<span className="font-medium tabular-nums">{stats.downCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
行业强度榜
|
||||
============================================================ */
|
||||
function SectorStrengthList({ sectors }: { sectors: SectorStrengthItem[] }) {
|
||||
if (sectors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">行业强度榜 TOP{sectors.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{sectors.slice(0, 15).map((sec) => (
|
||||
<SectorRow key={sec.code} sector={sec} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectorRow({ sector }: { sector: SectorStrengthItem }) {
|
||||
const barWidth = Math.max(0, Math.min(100, sector.strength));
|
||||
const isUp = sector.changePct >= 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs w-20 shrink-0 truncate" title={sector.name}>{sector.name}</span>
|
||||
<div className="flex-1 h-4 bg-background rounded-sm overflow-hidden relative">
|
||||
<div
|
||||
className="h-full rounded-sm transition-all duration-500"
|
||||
style={{
|
||||
width: `${barWidth}%`,
|
||||
background: isUp
|
||||
? "linear-gradient(90deg, #1f6feb, #58a6ff)"
|
||||
: "linear-gradient(90deg, #238636, #3fb950)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 text-[10px] tabular-nums w-44 justify-end">
|
||||
<span className="text-muted-foreground">强度 {sector.strength}</span>
|
||||
<span className={isUp ? "text-[#f85149]" : "text-[#3fb950]"}>
|
||||
{isUp ? "+" : ""}{sector.changePct.toFixed(2)}%
|
||||
</span>
|
||||
<span className="text-muted-foreground">宽度 {sector.breadthPct}%</span>
|
||||
<span className="text-muted-foreground">强 {sector.strongCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
概念板块热度
|
||||
============================================================ */
|
||||
function ConceptStrengthList({ concepts }: { concepts: MarketDashboardData["conceptStrength"] }) {
|
||||
if (concepts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">概念板块热度 TOP{concepts.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{concepts.map((c) => {
|
||||
const isUp = c.changePct >= 0;
|
||||
return (
|
||||
<span
|
||||
key={c.code}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs border ${
|
||||
isUp
|
||||
? "text-[#f85149] bg-[#f85149]/5 border-[#f85149]/20"
|
||||
: "text-[#3fb950] bg-[#3fb950]/5 border-[#3fb950]/20"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate max-w-[80px]">{c.name}</span>
|
||||
<span className="tabular-nums font-medium">
|
||||
{isUp ? "+" : ""}{c.changePct.toFixed(2)}%
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
事件情报侧栏
|
||||
============================================================ */
|
||||
function EventSidebar({ events }: { events: MarketDashboardData["events"] }) {
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Newspaper className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">事件情报</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground/50">暂无事件</p>
|
||||
) : (
|
||||
events.map((evt, i) => (
|
||||
<EventItem key={i} event={evt} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventItem({ event }: { event: MarketDashboardData["events"][0] }) {
|
||||
const labelColor =
|
||||
event.type === "ladder" ? "text-[#f85149] bg-[#f85149]/10 border-[#f85149]/30" :
|
||||
event.type === "limit_up" ? "text-[#f0883e] bg-[#f0883e]/10 border-[#f0883e]/30" :
|
||||
event.type === "hot" ? "text-[#d29922] bg-[#d29922]/10 border-[#d29922]/30" :
|
||||
event.type === "skyrocket" ? "text-[#f778ba] bg-[#f778ba]/10 border-[#f778ba]/30" :
|
||||
event.type === "dragon_tiger" ? "text-[#a371f7] bg-[#a371f7]/10 border-[#a371f7]/30" :
|
||||
event.type === "limit_break" ? "text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30" :
|
||||
event.type === "anomaly" ? "text-[#58a6ff] bg-[#58a6ff]/10 border-[#58a6ff]/30" :
|
||||
"text-muted-foreground bg-[#8b949e]/10 border-[#8b949e]/30";
|
||||
|
||||
return (
|
||||
<div className="border-l-2 border-border pl-3">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className={`text-[9px] font-medium px-1 py-0.5 rounded border ${labelColor}`}>
|
||||
{event.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-foreground leading-relaxed">{event.name}</p>
|
||||
{event.detail && (
|
||||
<p className="text-[10px] text-muted-foreground/50 mt-0.5">{event.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
骨架屏
|
||||
============================================================ */
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="bg-card border border-border rounded-lg p-3 animate-pulse">
|
||||
<div className="h-3 w-16 bg-muted rounded mb-2" />
|
||||
<div className="h-6 w-24 bg-muted rounded mb-1" />
|
||||
<div className="h-3 w-20 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
|
||||
<div className="h-5 w-24 bg-muted rounded mb-4" />
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i}>
|
||||
<div className="h-2 w-16 bg-muted rounded mb-1" />
|
||||
<div className="h-4 w-12 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
|
||||
<div className="h-5 w-32 bg-muted rounded mb-3" />
|
||||
<div className="h-5 w-full bg-muted rounded-full" />
|
||||
</div>
|
||||
<div className="bg-card border border-border rounded-lg p-4 animate-pulse">
|
||||
<div className="h-5 w-40 bg-muted rounded mb-3" />
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 mb-2">
|
||||
<div className="h-3 w-16 bg-muted rounded" />
|
||||
<div className="flex-1 h-4 bg-muted rounded" />
|
||||
<div className="h-3 w-24 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+139
-27
@@ -35,6 +35,10 @@ const MODES: { key: 1 | 4; label: string }[] = [
|
||||
/* 图视图股票节点上限:按覆盖题材数降序保留最高穿透度的核心股 */
|
||||
const MAX_STOCK_NODES = 800;
|
||||
|
||||
/* 题材节点半径范围:按题材涨幅绝对值平方根映射(涨得越猛球越大,小涨幅区分更明显) */
|
||||
const THEME_MIN_RADIUS = 6;
|
||||
const THEME_MAX_RADIUS = 30;
|
||||
|
||||
/* 视图切换:关系图 / 核心股列表 */
|
||||
type ViewMode = "graph" | "list";
|
||||
|
||||
@@ -48,7 +52,7 @@ function HotMapPage() {
|
||||
|
||||
const { data: graph, isLoading, isFetching, isError, refetch } = useQuery({
|
||||
queryKey: ["themeGraph", mode],
|
||||
queryFn: () => fetchThemeGraph(mode, 50),
|
||||
queryFn: () => fetchThemeGraph(mode, 100),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
@@ -114,6 +118,13 @@ function HotMapPage() {
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
<Link
|
||||
to="/core-stocks"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity whitespace-nowrap"
|
||||
>
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
热点股
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -265,6 +276,8 @@ interface SimNode extends SimulationNodeDatum {
|
||||
f100?: string;
|
||||
themeCodes?: string[];
|
||||
code?: string;
|
||||
// theme 专属
|
||||
bf3?: number | null; // 题材涨幅
|
||||
}
|
||||
|
||||
interface SimEdge extends SimulationLinkDatum<SimNode> {
|
||||
@@ -351,12 +364,20 @@ function drawEdges(ctx: CanvasRenderingContext2D, edges: SimEdge[], activeSet: S
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** 股票节点:覆盖数分级颜色 + 外圈淡填充 + 描边 + 内实心 */
|
||||
/* 心跳周期 ms;0~1 相位,0 最小 / 0.5 最大 */
|
||||
const PULSE_PERIOD = 900;
|
||||
const PULSE_AMPLITUDE = 0.22; // 心跳半径脉动幅度(相对半径)
|
||||
|
||||
/**
|
||||
* 股票节点:覆盖数分级颜色 + 心跳(≥5题材) + 涨跌光晕 + 描边 + 内实心
|
||||
* time(ms) 由持续动画循环驱动;非动画源(t=0)时心跳相位取 0 的静止态。
|
||||
*/
|
||||
function drawStockNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: SimNode[],
|
||||
activeSet: Set<string> | null,
|
||||
activeId: string | null,
|
||||
time: number,
|
||||
) {
|
||||
for (const n of nodes) {
|
||||
if (n.type !== "stock") continue;
|
||||
@@ -364,30 +385,59 @@ function drawStockNodes(
|
||||
const isActive = activeId === n.id;
|
||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||
const alpha = dim ? 0.08 : cover === 1 && !isActive ? 0.45 : 1;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||||
const x = n.x ?? 0;
|
||||
const y = n.y ?? 0;
|
||||
// 外圈淡填充
|
||||
const isPos = (n.f3 ?? 0) >= 0;
|
||||
|
||||
// 心跳:覆盖≥5 的核心股按正弦脉动半径(相位在 0 时静止,避免动画源缺省导致闪烁)
|
||||
const heartbeat = cover >= 5 ? 0.5 + 0.5 * Math.sin((time / PULSE_PERIOD) * TAU - Math.PI / 2) : 0;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const pr = r * (1 + heartbeat * PULSE_AMPLITUDE);
|
||||
|
||||
// 涨跌光晕:所有股票统一强度(不随覆盖数 alpha 衰减),随心跳脉动,正=红 / 负=绿
|
||||
const glowColor = isPos ? "rgba(239,68,68," : "rgba(34,197,94,"; // 红/绿
|
||||
const glowR = pr * 1.9;
|
||||
const glow = ctx.createRadialGradient(x, y, pr * 0.2, x, y, glowR);
|
||||
glow.addColorStop(0, `${glowColor}${dim ? 0.06 : 0.5})`);
|
||||
glow.addColorStop(1, `${glowColor}0)`);
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, r, 0, TAU);
|
||||
ctx.arc(x, y, glowR, 0, TAU);
|
||||
ctx.fillStyle = glow;
|
||||
ctx.globalAlpha = 1;
|
||||
ctx.fill();
|
||||
|
||||
if (isPos) {
|
||||
// 涨:外圈淡填充 + 分级描边 + 内实心(覆盖数越多越红)
|
||||
const fill = cover >= 7 ? "#dc2626" : cover >= 5 ? "#ef4444" : "#f97316";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.fillStyle = fill;
|
||||
ctx.globalAlpha = 0.35 * alpha;
|
||||
ctx.fill();
|
||||
// 描边
|
||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||
ctx.lineWidth = cover >= 2 ? (cover >= 4 ? 2 : 1.5) : 0.5;
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.stroke();
|
||||
// 内实心
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, n.radius, 0, TAU);
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.globalAlpha = (cover >= 2 ? 0.9 : 0.55) * alpha;
|
||||
ctx.fill();
|
||||
} else {
|
||||
// 跌:绿色空心球(仅描边,内部淡绿留白)
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, pr, 0, TAU);
|
||||
ctx.strokeStyle = "#22c55e";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "#22c55e";
|
||||
ctx.globalAlpha = dim ? 0.02 : 0.1;
|
||||
ctx.fill();
|
||||
}
|
||||
// 选中外环
|
||||
if (isActive) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, n.radius + 3, 0, TAU);
|
||||
ctx.arc(x, y, pr + 3, 0, TAU);
|
||||
ctx.globalAlpha = alpha;
|
||||
ctx.strokeStyle = cover >= 2 ? "#f59e0b" : "#94a3b8";
|
||||
ctx.lineWidth = 1;
|
||||
@@ -397,7 +447,7 @@ function drawStockNodes(
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
/** 题材节点:蓝色圆点 */
|
||||
/** 题材节点:涨幅正蓝负绿,大小按涨幅绝对值映射 */
|
||||
function drawThemeNodes(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
nodes: SimNode[],
|
||||
@@ -409,14 +459,32 @@ function drawThemeNodes(
|
||||
const isActive = activeId === n.id;
|
||||
const dim = activeSet ? !activeSet.has(n.id) : false;
|
||||
const r = isActive ? n.radius + 3 : n.radius;
|
||||
const isPos = (n.bf3 ?? 0) >= 0;
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x ?? 0, n.y ?? 0, r, 0, TAU);
|
||||
ctx.fillStyle = isActive ? "#2563eb" : "#3b82f6";
|
||||
// 正涨幅:蓝色实心;负涨幅:蓝色空心
|
||||
ctx.strokeStyle = "#3b82f6";
|
||||
ctx.lineWidth = isPos ? 1.5 : 2;
|
||||
if (isPos) {
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.globalAlpha = dim ? 0.15 : 1;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#1d4ed8";
|
||||
ctx.lineWidth = 1.5;
|
||||
} else {
|
||||
// 空心:仅描边,内部留白
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.globalAlpha = dim ? 0.05 : 0.15;
|
||||
ctx.fill();
|
||||
ctx.globalAlpha = dim ? 0.15 : 0.6;
|
||||
}
|
||||
ctx.stroke();
|
||||
// 选中外环
|
||||
if (isActive) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(n.x ?? 0, n.y ?? 0, r + 3, 0, TAU);
|
||||
ctx.strokeStyle = "#60a5fa";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
@@ -453,7 +521,8 @@ function drawThemeLabels(
|
||||
if (n.type !== "theme") continue;
|
||||
if (!showAll && activeId !== n.id) continue;
|
||||
const label = isCoarse ? (n.name.length > 5 ? n.name.slice(0, 5) + "…" : n.name) : n.name;
|
||||
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, "#1e40af");
|
||||
const labelColor = (n.bf3 ?? 0) >= 0 ? "#1e40af" : "#15803d";
|
||||
haloText(ctx, label, n.x ?? 0, (n.y ?? 0) + n.radius + 11, isCoarse ? 7.5 : 9, labelColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,6 +555,8 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
const simRunningRef = useRef(false);
|
||||
const fitOnceRef = useRef(false);
|
||||
const rafRef = useRef(0);
|
||||
const timeRef = useRef(0); // 动画时钟(ms),心跳/光晕脉动用
|
||||
const pulseStartRef = useRef(0); // 持续动画起始时间戳(ms)
|
||||
const pointersRef = useRef(new Map<number, { x: number; y: number }>());
|
||||
const gestureRef = useRef<Gesture>({ kind: "none" });
|
||||
|
||||
@@ -511,19 +582,20 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
if (activeSet) {
|
||||
// 高亮态:全量重绘(邻居亮、非邻居淡出)
|
||||
drawEdges(ctx, edges, activeSet);
|
||||
drawStockNodes(ctx, nodes, activeSet, activeId);
|
||||
drawStockNodes(ctx, nodes, activeSet, activeId, timeRef.current);
|
||||
drawThemeNodes(ctx, nodes, activeSet, activeId);
|
||||
drawActiveNodeLabel(ctx, activeNodeRef.current, isCoarse);
|
||||
} else {
|
||||
const b = boundsRef.current;
|
||||
const sc = staticCanvasRef.current;
|
||||
if (staticReadyRef.current && sc && b.w > 0 && b.h > 0 && v.k < 1.5) {
|
||||
// 静止态:drawImage 静态层 + 动态层
|
||||
// 静止态:静态层(边+题材)+ 动态层重绘股票(心跳/光晕动画)
|
||||
ctx.drawImage(sc, b.minX, b.minY, b.w, b.h);
|
||||
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||
} else {
|
||||
// 模拟期或大比例放大:直接全量绘制(保证清晰)
|
||||
drawEdges(ctx, edges, null);
|
||||
drawStockNodes(ctx, nodes, null, null);
|
||||
drawStockNodes(ctx, nodes, null, null, timeRef.current);
|
||||
drawThemeNodes(ctx, nodes, null, null);
|
||||
}
|
||||
}
|
||||
@@ -570,7 +642,7 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
sctx.setTransform(sw / w, 0, 0, sh / h, 0, 0);
|
||||
sctx.translate(-minX2, -minY2);
|
||||
drawEdges(sctx, edges, null);
|
||||
drawStockNodes(sctx, nodes, null, null);
|
||||
// 股票节点不在静态层:需常驻重绘以支持心跳/光晕动画
|
||||
drawThemeNodes(sctx, nodes, null, null);
|
||||
staticReadyRef.current = true;
|
||||
boundsRef.current = { minX: minX2, minY: minY2, w, h };
|
||||
@@ -627,6 +699,19 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
return () => ro.disconnect();
|
||||
}, [requestRender]);
|
||||
|
||||
/* 持续动画循环:心跳 + 光晕脉动。力收敛后常驻 rAF,仅更新时间与触发重绘 */
|
||||
useEffect(() => {
|
||||
pulseStartRef.current = performance.now();
|
||||
let anim = 0;
|
||||
const loop = () => {
|
||||
timeRef.current = performance.now() - pulseStartRef.current;
|
||||
requestRender();
|
||||
anim = requestAnimationFrame(loop);
|
||||
};
|
||||
anim = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(anim);
|
||||
}, [requestRender]);
|
||||
|
||||
/* 节点过滤 + 边重建(后端不再下发 edges,由 stocks[].themeCodes 重建) */
|
||||
const { nodes, edges, stockById } = useMemo(() => {
|
||||
if (!graph) return { nodes: [] as SimNode[], edges: [] as SimEdge[], stockById: new Map<string, GraphStock>() };
|
||||
@@ -645,16 +730,24 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
f62: s.f62,
|
||||
f100: s.f100,
|
||||
themeCodes: s.themeCodes,
|
||||
radius: Math.min(18, 4 + s.coverCount * 1.3),
|
||||
// 基础半径来自覆盖数,涨幅绝对值作增量:涨得猛/跌得深球更大
|
||||
radius: Math.min(20, 3 + s.coverCount * 1.2 + (Math.abs(s.f3 ?? 0) / 10) * 4),
|
||||
}));
|
||||
const themeNodes: SimNode[] = graph.themes.map((t) => ({
|
||||
// 题材涨幅绝对值作为球大小的归一化基准(兜底 ≥1 防除零)
|
||||
const maxAbsBf3 = Math.max(1, ...graph.themes.map((t) => Math.abs(t.bf3 ?? 0)));
|
||||
const themeNodes: SimNode[] = graph.themes.map((t) => {
|
||||
const bf3 = t.bf3 ?? 0;
|
||||
return {
|
||||
id: `t:${t.themeCode}`,
|
||||
type: "theme" as const,
|
||||
name: t.themeName,
|
||||
code: t.themeCode,
|
||||
coverCount: t.stockCount,
|
||||
radius: 10,
|
||||
}));
|
||||
bf3: t.bf3,
|
||||
// 平方根映射:同一个小涨幅区间内球径差异更大(线性映射被极端涨幅拉平均)
|
||||
radius: THEME_MIN_RADIUS + Math.sqrt(Math.abs(bf3) / maxAbsBf3) * (THEME_MAX_RADIUS - THEME_MIN_RADIUS),
|
||||
};
|
||||
});
|
||||
const edgeList: SimEdge[] = buildEdgesFromStocks(kept);
|
||||
|
||||
const stockByIdMap = new Map<string, GraphStock>();
|
||||
@@ -722,9 +815,9 @@ function HotMapGraph({ graph }: { graph: ThemeGraph }) {
|
||||
)
|
||||
.force("charge", forceManyBody<SimNode>().strength((d) => (d.type === "theme" ? -650 : -35)))
|
||||
.force("center", forceCenter(size.w / 2, size.h / 2))
|
||||
// 题材节点留出标签高度防文字重叠(标签在节点下方)
|
||||
// 题材节点留出标签高度防文字重叠(标签在节点下方);股票节点留 6px 最小间隔
|
||||
.force("collide", forceCollide<SimNode>().radius((d) =>
|
||||
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 4,
|
||||
d.type === "theme" ? d.radius + (isCoarse ? 20 : 16) : d.radius + 6,
|
||||
));
|
||||
|
||||
simRunningRef.current = true;
|
||||
@@ -1000,6 +1093,11 @@ function InfoCard({ node, stockById }: { node: SimNode; stockById: Map<string, G
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold">{node.name}</p>
|
||||
{node.bf3 != null && (
|
||||
<p className={`text-xs font-bold ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground">代码 {node.code} · {node.coverCount} 只股票</p>
|
||||
</>
|
||||
)}
|
||||
@@ -1058,6 +1156,11 @@ function BottomSheet({
|
||||
) : (
|
||||
<>
|
||||
<p className="font-semibold text-sm">{node.name}</p>
|
||||
{node.bf3 != null && (
|
||||
<p className={`text-xs font-bold mt-0.5 ${node.bf3 >= 0 ? "text-blue-600" : "text-green-600"}`}>
|
||||
{node.bf3 >= 0 ? "+" : ""}{node.bf3.toFixed(2)}%
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[10px] text-muted-foreground mt-0.5">
|
||||
代码 {node.code} · 覆盖 {node.coverCount} 只股票
|
||||
</p>
|
||||
@@ -1104,7 +1207,11 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 8, height: 8 }} />
|
||||
题材
|
||||
题材涨
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 8, height: 8 }} />
|
||||
题材跌
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -1124,8 +1231,13 @@ function Legend({ maxCover, compact }: { maxCover: number; compact: boolean }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-border/40">
|
||||
<span className="inline-block rounded-full bg-blue-500" style={{ width: 10, height: 10 }} />
|
||||
<span>题材节点</span>
|
||||
<span>题材 · 涨幅为正</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-block rounded-full border-2 border-blue-500" style={{ width: 10, height: 10 }} />
|
||||
<span>题材 · 涨幅为负</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-muted-foreground pt-0.5">球大小随涨幅绝对值增大</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+11
-5
@@ -10,7 +10,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network } from "lucide-react";
|
||||
import { Search, Plus, Share2, Trash2, TrendingUp, Loader2, Flame, Network, BarChart3, BrainCircuit } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
@@ -211,11 +211,11 @@ function Index() {
|
||||
A股走势追踪
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">创建股票集合,分享历史走势</p>
|
||||
<div className="mt-3 flex items-center justify-center gap-2">
|
||||
<Link to="/sectors">
|
||||
<div className="mt-3 flex flex-wrap items-center justify-center gap-2">
|
||||
<Link to="/dashboard">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
板块资金流向
|
||||
<BarChart3 className="h-3.5 w-3.5" />
|
||||
市场看板
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/themes">
|
||||
@@ -230,6 +230,12 @@ function Index() {
|
||||
热点穿透
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/ai-analysis">
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs">
|
||||
<BrainCircuit className="h-3.5 w-3.5" />
|
||||
AI 分析
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,366 +0,0 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { fetchSectors, type SectorItem, type SectorType } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/sectors")({
|
||||
component: SectorsPage,
|
||||
});
|
||||
|
||||
/* ============================================================
|
||||
Tab 定义:行业 / 概念
|
||||
============================================================ */
|
||||
const TABS: { key: SectorType; label: string }[] = [
|
||||
{ key: "industry", label: "行业" },
|
||||
{ key: "concept", label: "概念" },
|
||||
];
|
||||
|
||||
/* ============================================================
|
||||
排序维度
|
||||
============================================================ */
|
||||
type SortKey = "mainNetInflow" | "mainNetInflowPercent";
|
||||
|
||||
const SORT_LABEL: Record<SortKey, string> = {
|
||||
mainNetInflow: "资金",
|
||||
mainNetInflowPercent: "涨幅",
|
||||
};
|
||||
|
||||
/* ============================================================
|
||||
页面组件
|
||||
============================================================ */
|
||||
function SectorsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<SectorType>("industry");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("mainNetInflow");
|
||||
const [asc, setAsc] = useState(true); // 默认升序
|
||||
|
||||
// ── 行业 / 概念各自独立 Query,缓存完全隔离 ──
|
||||
const industryQ = useQuery({
|
||||
queryKey: ["sectors", "industry"],
|
||||
queryFn: ({ signal }) => fetchSectors("industry", signal),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
const conceptQ = useQuery({
|
||||
queryKey: ["sectors", "concept"],
|
||||
queryFn: ({ signal }) => fetchSectors("concept", signal),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 当前激活的 tab 查询
|
||||
const activeQuery = tab === "industry" ? industryQ : conceptQ;
|
||||
const { isLoading, isFetching, isError, refetch } = activeQuery;
|
||||
|
||||
/* ═══════════════════════════════════════════════════════
|
||||
三层数据分离:缓存 → 排序 → 展示
|
||||
═══════════════════════════════════════════════════════ */
|
||||
|
||||
// ① 缓存数据 — React Query 从后端拿到的原始数据
|
||||
const cachedData: SectorItem[] = activeQuery.data ?? [];
|
||||
|
||||
// ② 排序数据 — 按当前排序规则在内存中重排
|
||||
const sortedData = useMemo<SectorItem[]>(() => {
|
||||
const dir = asc ? 1 : -1;
|
||||
return [...cachedData].sort((a, b) => {
|
||||
const av =
|
||||
sortKey === "mainNetInflow"
|
||||
? a.mainNetInflow
|
||||
: (a.mainNetInflowPercent ?? -Infinity);
|
||||
const bv =
|
||||
sortKey === "mainNetInflow"
|
||||
? b.mainNetInflow
|
||||
: (b.mainNetInflowPercent ?? -Infinity);
|
||||
return (bv - av) * dir;
|
||||
});
|
||||
}, [cachedData, sortKey, asc]);
|
||||
|
||||
// ③ 展示数据 — 最终渲染的数据集(当前即排序数据,后续可加分页截断)
|
||||
const displayData = sortedData;
|
||||
|
||||
// ── 切换板块:清空全部缓存 + 重新获取 ──
|
||||
const handleTab = (t: SectorType) => {
|
||||
if (t === tab) return;
|
||||
setTab(t);
|
||||
// 移除所有板块缓存,切换后对应的 useQuery 会自动 refetch
|
||||
queryClient.removeQueries({ queryKey: ["sectors"] });
|
||||
};
|
||||
|
||||
// ── 切换排序 ──
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (key === sortKey) {
|
||||
setAsc((v) => !v);
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setAsc(true); // 切新维度默认升序
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* ── 顶栏 ── */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="hover:opacity-70 transition-opacity">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">板块资金流向</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Tab 切换 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-4">
|
||||
<div className="flex gap-1 bg-muted rounded-lg p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => handleTab(t.key)}
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
||||
tab === t.key
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}板块
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 排序切换 + 统计 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 flex items-center justify-between">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
共 {cachedData.length} 个板块
|
||||
{isFetching && (
|
||||
<span className="ml-1 text-[10px] text-muted-foreground/60">
|
||||
· 刷新中…
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||
{(Object.keys(SORT_LABEL) as SortKey[]).map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => toggleSort(key)}
|
||||
className={`px-2.5 py-1 flex items-center gap-0.5 transition-colors ${
|
||||
sortKey === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{SORT_LABEL[key]}
|
||||
{sortKey === key &&
|
||||
(asc ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 内容区 ── */}
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
{/* 加载骨架 */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse rounded-xl bg-muted h-40" />
|
||||
))}
|
||||
</div>
|
||||
) : isError ? (
|
||||
/* 请求失败 */
|
||||
<div className="flex flex-col items-center gap-3 py-20">
|
||||
<p className="text-sm text-muted-foreground">数据加载失败</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
点击重试
|
||||
</button>
|
||||
</div>
|
||||
) : cachedData.length === 0 ? (
|
||||
/* 数据为空 */
|
||||
<div className="text-center py-20 text-sm text-muted-foreground">
|
||||
暂无{tab === "industry" ? "行业" : "概念"}板块数据
|
||||
</div>
|
||||
) : (
|
||||
/* 板块卡片网格 */
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{displayData.map((item) => (
|
||||
<SectorCard key={item.code} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
数值格式化
|
||||
============================================================ */
|
||||
function fmt(val: number | null | undefined, digits = 2): string {
|
||||
if (val == null) return "--";
|
||||
return val.toFixed(digits);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
板块卡片
|
||||
============================================================ */
|
||||
function SectorCard({ item }: { item: SectorItem }) {
|
||||
const change = item.changePercent;
|
||||
const inflow = item.mainNetInflow;
|
||||
const inflowIsPos = inflow >= 0;
|
||||
const inflowPct = item.mainNetInflowPercent;
|
||||
|
||||
return (
|
||||
<Card className="rounded-xl hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-3 space-y-1.5">
|
||||
{/* 板块名称 + 代码 */}
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<p className="text-sm font-medium truncate" title={item.name}>
|
||||
{item.name}
|
||||
</p>
|
||||
{item.code && (
|
||||
<span className="shrink-0 text-[9px] text-muted-foreground/60 font-mono">
|
||||
{item.code.replace("BK", "")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 涨跌幅 + 成交额 */}
|
||||
<div className="flex items-center justify-between">
|
||||
{change != null ? (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-semibold ${
|
||||
change >= 0 ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{change >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3" />
|
||||
)}
|
||||
{change >= 0 ? "+" : ""}
|
||||
{fmt(change)}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">--</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatMoney(item.turnover)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 分割线 */}
|
||||
<hr className="border-border/40" />
|
||||
|
||||
{/* 主力净流入金额 + 占比 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">主力净流入</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`text-xs font-bold tabular-nums ${
|
||||
inflowIsPos ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{inflow >= 0 ? "+" : ""}
|
||||
{formatMoney(inflow)}
|
||||
</span>
|
||||
{inflowPct != null && (
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${
|
||||
inflowIsPos ? "text-red-500/70" : "text-green-500/70"
|
||||
}`}
|
||||
>
|
||||
{inflow >= 0 ? "+" : ""}
|
||||
{fmt(inflowPct)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 资金流向明细条 */}
|
||||
<FundFlowBreakdown item={item} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
资金流向明细 — 超大单 / 大单 / 中单 / 小单
|
||||
============================================================ */
|
||||
const FLOW_LABELS = [
|
||||
{ key: "superLargeInflow" as const, label: "超大单" },
|
||||
{ key: "largeInflow" as const, label: "大单" },
|
||||
{ key: "mediumInflow" as const, label: "中单" },
|
||||
{ key: "smallInflow" as const, label: "小单" },
|
||||
];
|
||||
|
||||
function FundFlowBreakdown({ item }: { item: SectorItem }) {
|
||||
// 取所有流量的最大绝对值做归一化
|
||||
const maxAbs = Math.max(
|
||||
Math.abs(item.mainNetInflow),
|
||||
Math.abs(item.superLargeInflow ?? 0),
|
||||
Math.abs(item.largeInflow ?? 0),
|
||||
Math.abs(item.mediumInflow ?? 0),
|
||||
Math.abs(item.smallInflow ?? 0),
|
||||
1,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{FLOW_LABELS.map((f) => {
|
||||
const val = item[f.key];
|
||||
if (val == null) return null;
|
||||
const pct = maxAbs > 0 ? (Math.abs(val) / maxAbs) * 100 : 0;
|
||||
const isPos = val >= 0;
|
||||
return (
|
||||
<div key={f.key} className="flex items-center gap-1.5">
|
||||
<span className="text-[9px] text-muted-foreground w-6 shrink-0 text-right">
|
||||
{f.label}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
isPos ? "bg-red-500/60 ml-1/2" : "bg-green-500/60"
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min(pct, 100)}%`,
|
||||
marginLeft: isPos ? "50%" : undefined,
|
||||
marginRight: isPos ? undefined : `${100 - Math.min(pct, 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-[9px] font-medium tabular-nums w-14 text-right shrink-0 ${
|
||||
isPos ? "text-red-500" : "text-green-500"
|
||||
}`}
|
||||
>
|
||||
{formatMoney(val)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-221
@@ -1,7 +1,8 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState, useEffect, useMemo, Fragment } from "react";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { collectionsApi } from "@/lib/api-client";
|
||||
import { fetchStockQuote, fetchStockHistory, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
|
||||
import { fetchStockQuote, fetchStockFundFlow, fetchCompanyProfile, fetchBusinessSegments, fetchFinancialData, getStockBoard, type StockQuote, type KLineData, type FundFlowData, type FundFlowSummary, type CompanyProfile, type BusinessSegmentsResponse, type FinancialDataResponse } from "@/lib/stock-api";
|
||||
import { fetchStockHistoryV2 } from "@/lib/fuyao-api";
|
||||
import StockProfileTabs from "@/components/stock-profile-tabs";
|
||||
import { getUserId } from "@/lib/user-id";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
@@ -10,6 +11,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, TrendingUp, TrendingDown, Calendar, ExternalLink, Newspaper } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import KLineCard from "@/components/kline-card";
|
||||
import {
|
||||
ComposedChart,
|
||||
Line,
|
||||
@@ -20,8 +22,6 @@ import {
|
||||
ResponsiveContainer,
|
||||
Bar,
|
||||
Cell,
|
||||
Scatter,
|
||||
Brush,
|
||||
ReferenceLine,
|
||||
PieChart,
|
||||
Pie,
|
||||
@@ -40,6 +40,7 @@ export const Route = createFileRoute("/stock/$code")({
|
||||
interface StockData {
|
||||
date: string;
|
||||
dateObj: Date;
|
||||
dateMs: number; // Unix seconds UTC (lightweight-charts numeric time)
|
||||
open: number;
|
||||
close: number;
|
||||
high: number;
|
||||
@@ -172,21 +173,15 @@ function StockDetail() {
|
||||
// 基础信息已就绪,结束主loading,先渲染页面框架
|
||||
setLoading(false);
|
||||
|
||||
// 阶段2:并行加载历史K线 + 资金流向(后置加载,不阻塞首屏)
|
||||
// 阶段2:并行拉取日K(供每日行情明细表格)+ 资金流向(非必需,失败不影响页面)
|
||||
const addedDate = new Date(addedAt);
|
||||
const now = new Date();
|
||||
const daysSinceAdded = Math.floor((now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24));
|
||||
const chartDays = Math.min(Math.max(90, daysSinceAdded), 120);
|
||||
|
||||
setChartLoading(true);
|
||||
setFundFlowLoading(true);
|
||||
|
||||
const [historyResult, fundFlowResult] = await Promise.allSettled([
|
||||
fetchStockHistory(code, chartDays),
|
||||
fetchStockHistoryV2(code, 30), // 明细表默认只展示近7/21日,30天足够
|
||||
fetchStockFundFlow(code, quote.name, 21),
|
||||
]);
|
||||
|
||||
// 处理历史K线(必需)
|
||||
if (historyResult.status === "fulfilled" && historyResult.value && historyResult.value.length > 0) {
|
||||
const historyData = convertToStockData(historyResult.value, addedDate);
|
||||
// 如果添加日不是交易日(周末/节假日),标记最接近的K线日期
|
||||
@@ -197,22 +192,15 @@ function StockDetail() {
|
||||
}
|
||||
}
|
||||
setChartData(historyData);
|
||||
if (historyResult.value.length < 2) {
|
||||
console.warn("历史K线数据过少(新股或上市首日),仅显示有限数据");
|
||||
}
|
||||
} else {
|
||||
console.error("无法获取历史K线数据");
|
||||
setError("获取历史K线数据失败,请稍后重试");
|
||||
}
|
||||
|
||||
// 处理资金流向(非必需,失败不影响主流程)
|
||||
if (fundFlowResult.status === "fulfilled" && fundFlowResult.value) {
|
||||
console.log("[stock-detail] 资金流向数据:", fundFlowResult.value);
|
||||
setFundFlowData(fundFlowResult.value.data);
|
||||
setFundFlowSummary(fundFlowResult.value.summary || null);
|
||||
} else {
|
||||
console.error("获取资金流向数据失败:", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未知错误");
|
||||
console.error("获取资金流向数据失败(非致命):", fundFlowResult.status === "rejected" ? fundFlowResult.reason : "未获取到数据");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("加载失败:", err);
|
||||
const msg = err instanceof Error ? err.message : "加载股票数据失败";
|
||||
@@ -224,7 +212,7 @@ function StockDetail() {
|
||||
}
|
||||
};
|
||||
|
||||
// 将K线数据转换为图表格式
|
||||
// 将K线数据转换为表格格式(每日行情明细用)
|
||||
const convertToStockData = (klines: KLineData[], addedDate: Date): StockData[] => {
|
||||
return klines.map(kline => {
|
||||
const dateObj = new Date(kline.date);
|
||||
@@ -233,6 +221,7 @@ function StockDetail() {
|
||||
return {
|
||||
date: dateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
|
||||
dateObj,
|
||||
dateMs: Math.floor(dateObj.getTime() / 1000), // Unix seconds UTC
|
||||
open: kline.open,
|
||||
close: kline.close,
|
||||
high: kline.high,
|
||||
@@ -287,6 +276,7 @@ function StockDetail() {
|
||||
const roe = latest.indicators["加权净资产收益率"];
|
||||
return typeof roe === "number" ? roe : null;
|
||||
}, [financialData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@@ -330,31 +320,6 @@ function StockDetail() {
|
||||
: null;
|
||||
const cumulativeIsPositive = cumulativeChange !== null && cumulativeChange >= 0;
|
||||
|
||||
// 找到添加日期在图表中的位置
|
||||
const addedDateObj = stockInfo ? new Date(stockInfo.addedAt) : null;
|
||||
const addedDateStr = addedDateObj ? addedDateObj.toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }) : "";
|
||||
const addedDataPoint = inCollection ? chartData.find(d => d.isAddedDate) : undefined;
|
||||
|
||||
// 计算X轴刻度:保证添加日期始终有标签
|
||||
const tickDates = (() => {
|
||||
if (chartData.length === 0) return [];
|
||||
const first = chartData[0].date;
|
||||
const last = chartData[chartData.length - 1].date;
|
||||
const ticks = [first];
|
||||
const added = addedDataPoint?.date;
|
||||
const targetCount = Math.min(7, chartData.length);
|
||||
const step = Math.max(1, Math.floor((chartData.length - 1) / (targetCount - 2)));
|
||||
for (let i = step; i < chartData.length - 1; i += step) {
|
||||
ticks.push(chartData[i].date);
|
||||
}
|
||||
if (added && !ticks.includes(added)) {
|
||||
ticks.push(added);
|
||||
}
|
||||
ticks.push(last);
|
||||
// 保持chartData的原始顺序(已按日期升序排列),不做二次排序
|
||||
return [...new Set(ticks)];
|
||||
})();
|
||||
|
||||
// 东方财富市场标识:0=深圳(000/002/300), 1=上海(60), 6=科创板(688)
|
||||
const getEastMoneyMarket = (code: string): string => {
|
||||
if (code.startsWith('688')) return '6';
|
||||
@@ -508,180 +473,8 @@ function StockDetail() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Chart */}
|
||||
<Card className="shadow-lg">
|
||||
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<CardTitle className="text-base md:text-lg">
|
||||
K线图
|
||||
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
|
||||
{chartLoading ? "(加载中...)" : `(${chartData.length}个交易日)`}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<span className="text-xs text-muted-foreground hidden sm:inline">
|
||||
双指缩放 · 拖动选区查看
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
|
||||
{chartLoading ? (
|
||||
<div className="h-[320px] sm:h-[380px] md:h-[440px] w-full flex items-center justify-center">
|
||||
<div className="flex items-center text-muted-foreground text-sm">
|
||||
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
|
||||
K线数据加载中...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="h-[320px] sm:h-[380px] md:h-[440px] w-full touch-none select-none"
|
||||
style={{ touchAction: "none", overscrollBehavior: "none", WebkitOverflowScrolling: "auto" }}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 8, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" opacity={0.5} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
ticks={tickDates}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={56}
|
||||
tick={(props: any) => {
|
||||
const { x, y, payload } = props;
|
||||
const dataPoint = chartData.find(d => d.date === payload.value);
|
||||
const isAddedDate = inCollection && dataPoint?.isAddedDate;
|
||||
|
||||
return (
|
||||
<text
|
||||
x={x}
|
||||
y={y}
|
||||
dy={16}
|
||||
textAnchor="end"
|
||||
fill={isAddedDate ? '#f59e0b' : 'var(--muted-foreground)'}
|
||||
fontWeight={isAddedDate ? 'bold' : 'normal'}
|
||||
fontSize={isAddedDate ? 11 : 10}
|
||||
>
|
||||
{payload.value}
|
||||
</text>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="left"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
domain={["auto", "auto"]}
|
||||
tickCount={5}
|
||||
width={42}
|
||||
/>
|
||||
<YAxis
|
||||
yAxisId="right"
|
||||
orientation="right"
|
||||
stroke="var(--muted-foreground)"
|
||||
fontSize={10}
|
||||
tickCount={5}
|
||||
width={42}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "8px",
|
||||
fontSize: "12px",
|
||||
padding: "8px 12px",
|
||||
}}
|
||||
labelFormatter={(label) => `日期: ${label}`}
|
||||
formatter={(value: any, name: string) => {
|
||||
if (name === 'close') return [`¥${Number(value).toFixed(2)}`, '收盘价'];
|
||||
if (name === 'volume') return [Number(value).toLocaleString(), '成交量'];
|
||||
return [value, name];
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
yAxisId="right"
|
||||
dataKey="volume"
|
||||
fill="var(--primary)"
|
||||
opacity={0.2}
|
||||
/>
|
||||
<Line
|
||||
yAxisId="left"
|
||||
type="monotone"
|
||||
dataKey="close"
|
||||
stroke="var(--primary)"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
{/* 高亮添加日期的K线点 */}
|
||||
<Scatter
|
||||
yAxisId="left"
|
||||
dataKey="close"
|
||||
fill="#fbbf24"
|
||||
shape={(props: any) => {
|
||||
const { cx, cy, payload } = props;
|
||||
if (!payload.isAddedDate) {
|
||||
return <circle cx={cx} cy={cy} r={0} fill="transparent" />;
|
||||
}
|
||||
return (
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={7}
|
||||
fill="#fbbf24"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{/* 添加日期参考线 */}
|
||||
{addedDataPoint && (
|
||||
<ReferenceLine
|
||||
yAxisId="left"
|
||||
x={addedDataPoint.date}
|
||||
stroke="#f59e0b"
|
||||
strokeDasharray="4 4"
|
||||
opacity={0.7}
|
||||
/>
|
||||
)}
|
||||
{/* 缩放/滑动控件:移动端可双指缩放,拖动选区查看局部 */}
|
||||
<Brush
|
||||
dataKey="date"
|
||||
height={24}
|
||||
stroke="var(--primary)"
|
||||
fill="var(--muted)"
|
||||
travellerWidth={8}
|
||||
gap={8}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap justify-center gap-3 md:gap-6 mt-2 md:mt-4 text-xs md:text-sm">
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary"></div>
|
||||
<span>收盘价</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full bg-primary opacity-20"></div>
|
||||
<span>成交量</span>
|
||||
</div>
|
||||
{inCollection && (
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<div className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-full" style={{ backgroundColor: '#fbbf24' }}></div>
|
||||
<span>自选日期</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Chart - 独立K线卡片组件(周期/显示方式/指标状态与数据加载均自包含) */}
|
||||
<KLineCard code={code} addedAt={stockInfo.addedAt} ready={true} />
|
||||
|
||||
{/* Price Details */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 mt-4 md:mt-6">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchActiveThemes } from "@/lib/theme-api";
|
||||
import { ArrowLeft, RefreshCw, Flame } from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/theme-history")({
|
||||
component: ThemeHistoryPage,
|
||||
});
|
||||
|
||||
/** 格式化涨幅,红涨绿跌 */
|
||||
function formatGain(v: number | null | undefined): string {
|
||||
if (v == null) return "·";
|
||||
const s = v >= 0 ? `+${v.toFixed(2)}%` : `${v.toFixed(2)}%`;
|
||||
return s;
|
||||
}
|
||||
|
||||
function ThemeHistoryPage() {
|
||||
const { data, isLoading, isFetching, refetch } = useQuery({
|
||||
queryKey: ["themes", "active"],
|
||||
queryFn: fetchActiveThemes,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const dates = data?.dates ?? [];
|
||||
const themes = data?.themes ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* 顶栏 */}
|
||||
<header className="sticky top-0 z-10 bg-background/95 backdrop-blur border-b">
|
||||
<div className="max-w-5xl mx-auto px-4 h-12 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/themes" className="hover:opacity-70 transition-opacity" aria-label="返回">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-base font-semibold">题材热点历史</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="max-w-5xl mx-auto px-4 mt-3 pb-8">
|
||||
<p className="text-[10px] text-muted-foreground mb-2">
|
||||
活跃题材(最近 10 个交易日内上榜涨幅前20)· 按上榜次数排序 · 共 {themes.length} 个
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="animate-pulse rounded-xl bg-muted h-32" />
|
||||
) : dates.length === 0 || themes.length === 0 ? (
|
||||
<div className="text-center text-sm text-muted-foreground py-16">
|
||||
暂无数据,数据将在每日收盘后自动采集
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th scope="col" className="px-3 py-2 text-left font-medium whitespace-nowrap">题材</th>
|
||||
{dates.map((d) => (
|
||||
<th key={d} scope="col" title={d} className="px-2 py-2 text-right font-medium tabular-nums whitespace-nowrap">
|
||||
{d.slice(5)}
|
||||
</th>
|
||||
))}
|
||||
<th scope="col" className="px-2 py-2 text-right font-medium">上榜</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{themes.map((t) => (
|
||||
<tr key={t.themeCode} className="border-b last:border-0 hover:bg-muted/30">
|
||||
<td className="px-3 py-1.5 whitespace-nowrap">
|
||||
<Link
|
||||
to="/theme/$code"
|
||||
params={{ code: t.themeCode }}
|
||||
className="font-medium hover:text-primary hover:underline"
|
||||
>
|
||||
{t.themeName}
|
||||
</Link>
|
||||
</td>
|
||||
{dates.map((d) => {
|
||||
const g = t.dailyGains[d];
|
||||
const cls = g == null ? "text-muted-foreground/40" : g >= 0 ? "text-red-500" : "text-green-500";
|
||||
return (
|
||||
<td key={d} className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${cls}`}>
|
||||
{formatGain(g)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5 text-right tabular-nums whitespace-nowrap">
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-500">
|
||||
<Flame className="h-3 w-3" />
|
||||
{t.appearCount}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+104
-29
@@ -1,7 +1,14 @@
|
||||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchThemeDetail, fetchThemeStocks, type ThemeStock } from "@/lib/theme-api";
|
||||
import {
|
||||
fetchThemeDetail,
|
||||
fetchThemeNews,
|
||||
fetchThemeQuote,
|
||||
fetchThemeStocks,
|
||||
type ThemeStock,
|
||||
type ThemeNewsItem,
|
||||
} from "@/lib/theme-api";
|
||||
import { getStockBoard } from "@/lib/stock-api";
|
||||
import { formatMoney } from "@/lib/utils";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -36,6 +43,27 @@ function ThemeDetailPage() {
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
// 相关新闻:pageNum 偏移分页(东财接口 maxEuTime 是增量游标,翻页靠 pageNum 递增)
|
||||
const [newsPage, setNewsPage] = useState(1);
|
||||
const [newsItems, setNewsItems] = useState<ThemeNewsItem[]>([]);
|
||||
const newsQ = useQuery({
|
||||
queryKey: ["themeNews", code, newsPage],
|
||||
queryFn: () => fetchThemeNews(code, newsPage, 10),
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const quoteQ = useQuery({
|
||||
queryKey: ["themeQuote", code],
|
||||
queryFn: () => fetchThemeQuote(code),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// 分页追加:首页重置列表,翻页拼接
|
||||
useEffect(() => {
|
||||
if (!newsQ.data?.list) return;
|
||||
setNewsItems((prev) => (newsPage === 1 ? newsQ.data!.list : [...prev, ...newsQ.data!.list]));
|
||||
}, [newsQ.data, newsPage]);
|
||||
|
||||
const isLoading = detailQ.isLoading || stocksQ.isLoading;
|
||||
const isError = detailQ.isError || stocksQ.isError;
|
||||
@@ -49,11 +77,13 @@ function ThemeDetailPage() {
|
||||
const refresh = () => {
|
||||
detailQ.refetch();
|
||||
stocksQ.refetch();
|
||||
quoteQ.refetch();
|
||||
setNewsPage(1);
|
||||
newsQ.refetch();
|
||||
};
|
||||
|
||||
const baseInfo = detail?.baseInfo;
|
||||
const hotEvent = detail?.hotEvent;
|
||||
const eventHistory = detail?.eventHistory ?? [];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -139,10 +169,20 @@ function ThemeDetailPage() {
|
||||
flat={statistic?.f106}
|
||||
fex5={statistic?.fex5}
|
||||
total={total}
|
||||
strength={quoteQ.data?.strengthValue ?? null}
|
||||
hotValue={quoteQ.data?.hotValue ?? 0}
|
||||
hotValueUpLimit={quoteQ.data?.hotValueUpLimit ?? 0}
|
||||
/>
|
||||
|
||||
{/* ── 相关新闻(可折叠) ── */}
|
||||
{eventHistory.length > 0 && <NewsList items={eventHistory} />}
|
||||
{/* ── 相关新闻(分页加载) ── */}
|
||||
{newsItems.length > 0 && (
|
||||
<NewsList
|
||||
items={newsItems}
|
||||
total={newsQ.data?.total ?? 0}
|
||||
loadingMore={newsQ.isFetching && newsPage > 1}
|
||||
onLoadMore={() => setNewsPage((p) => p + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── 相关股票 ── */}
|
||||
<div>
|
||||
@@ -181,6 +221,9 @@ function StatBar({
|
||||
flat,
|
||||
fex5,
|
||||
total,
|
||||
strength,
|
||||
hotValue,
|
||||
hotValueUpLimit,
|
||||
}: {
|
||||
f3: number | null | undefined;
|
||||
up: number | null | undefined;
|
||||
@@ -188,8 +231,13 @@ function StatBar({
|
||||
flat: number | null | undefined;
|
||||
fex5: number | null | undefined;
|
||||
total: number;
|
||||
strength: number | null;
|
||||
hotValue: number;
|
||||
hotValueUpLimit: number;
|
||||
}) {
|
||||
const isPos = (f3 ?? 0) >= 0;
|
||||
const hotPct = hotValueUpLimit > 0 ? Math.min((hotValue / hotValueUpLimit) * 100, 100) : 0;
|
||||
const showQuote = strength != null || hotValueUpLimit > 0;
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
@@ -218,24 +266,49 @@ function StatBar({
|
||||
平盘 {flat} 只
|
||||
</p>
|
||||
)}
|
||||
{/* 强度 + 热度(来自单题材实时行情接口) */}
|
||||
{showQuote && (
|
||||
<div className="mt-2 pt-2 border-t border-border/40 flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-muted-foreground">
|
||||
{strength != null && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
强度
|
||||
<b className="font-bold text-foreground tabular-nums">{strength}</b>
|
||||
</span>
|
||||
)}
|
||||
{hotValueUpLimit > 0 && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Flame className="h-3 w-3 text-orange-500" />
|
||||
<span className="w-16 h-1 rounded-full bg-muted overflow-hidden">
|
||||
<span
|
||||
className="block h-full rounded-full bg-gradient-to-r from-orange-400 to-red-500"
|
||||
style={{ width: `${Math.max(hotPct, 2)}%` }}
|
||||
/>
|
||||
</span>
|
||||
热度 {hotValue}/{hotValueUpLimit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
相关新闻(可折叠)
|
||||
相关新闻(分页加载)
|
||||
============================================================ */
|
||||
function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string; newsPublishTime: number | null }[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const shown = expanded ? items : items.slice(0, 2);
|
||||
|
||||
const fmtTime = (ts: number | null) => {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
function NewsList({
|
||||
items,
|
||||
total,
|
||||
loadingMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
items: ThemeNewsItem[];
|
||||
total: number;
|
||||
loadingMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const hasMore = items.length < total;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
@@ -243,26 +316,28 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Newspaper className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">相关新闻</h2>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">{items.length} 条</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">共 {total} 条</span>
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
{shown.map((n, idx) => (
|
||||
{items.map((n, idx) => (
|
||||
<div key={idx} className="space-y-0.5">
|
||||
<p className="text-sm leading-snug line-clamp-2">{n.newsTitle}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{n.newsMediaName}
|
||||
{n.newsPublishTime ? ` · ${fmtTime(n.newsPublishTime)}` : ""}
|
||||
{n.showDateTimeFormat ? ` · ${n.showDateTimeFormat}` : ""}
|
||||
{n.commentCount > 0 && <span className="ml-1">· {n.commentCount} 评论</span>}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{items.length > 2 && (
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="mt-2 text-xs text-primary hover:underline inline-flex items-center gap-0.5 disabled:opacity-50"
|
||||
>
|
||||
{expanded ? "收起" : `展开全部 ${items.length} 条`}
|
||||
{expanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{loadingMore ? "加载中…" : "加载更多"}
|
||||
{!loadingMore && <ChevronDown className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -276,11 +351,11 @@ function NewsList({ items }: { items: { newsTitle: string; newsMediaName: string
|
||||
function ThemeStockRow({ stock }: { stock: ThemeStock }) {
|
||||
const [showReason, setShowReason] = useState(true); // 入选理由默认展开
|
||||
const board = getStockBoard(stock.securityCode);
|
||||
const isPos = stock.f3 >= 0;
|
||||
const isPos = (stock.f3 ?? 0) >= 0;
|
||||
const reasons = stock.keywordList ?? [];
|
||||
|
||||
// 换手率:接口返回放大 100 倍的值(如 3733 = 37.33%)
|
||||
const turnoverRate = stock.f8 > 100 ? stock.f8 / 100 : stock.f8;
|
||||
const turnoverRate = stock.f8 != null ? (stock.f8 > 100 ? stock.f8 / 100 : stock.f8) : null;
|
||||
|
||||
return (
|
||||
<Link to="/stock/$code" params={{ code: stock.securityCode }} className="block">
|
||||
@@ -306,13 +381,13 @@ function ThemeStockRow({ stock }: { stock: ThemeStock }) {
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] text-muted-foreground">现价</p>
|
||||
<p className="text-sm font-semibold tabular-nums">{stock.f2.toFixed(2)}</p>
|
||||
<p className="text-sm font-semibold tabular-nums">{stock.f2 != null ? stock.f2.toFixed(2) : "--"}</p>
|
||||
</div>
|
||||
<div className="text-right min-w-[56px]">
|
||||
<p className="text-[10px] text-muted-foreground">涨跌</p>
|
||||
<p className={`text-sm font-bold tabular-nums ${isPos ? "text-red-500" : "text-green-500"}`}>
|
||||
{isPos ? "+" : ""}
|
||||
{stock.f3.toFixed(2)}%
|
||||
{stock.f3 != null ? stock.f3.toFixed(2) : "--"}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,7 +398,7 @@ function ThemeStockRow({ stock }: { stock: ThemeStock }) {
|
||||
{stock.f100 && (
|
||||
<span className="truncate bg-muted rounded px-1.5 py-0.5 text-[10px]">{stock.f100}</span>
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums">换手 {turnoverRate.toFixed(2)}%</span>
|
||||
<span className="shrink-0 tabular-nums">换手 {turnoverRate != null ? turnoverRate.toFixed(2) : "--"}%</span>
|
||||
<span className="shrink-0 tabular-nums">主力 {formatMoney(stock.f62)}</span>
|
||||
<span className="shrink-0 tabular-nums ml-auto">成交 {formatMoney(stock.f6)}</span>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TrendingDown,
|
||||
Flame,
|
||||
Network,
|
||||
History,
|
||||
} from "lucide-react";
|
||||
|
||||
export const Route = createFileRoute("/themes")({
|
||||
@@ -71,6 +72,20 @@ function ThemesPage() {
|
||||
<Network className="h-3.5 w-3.5" />
|
||||
热点穿透
|
||||
</Link>
|
||||
<Link
|
||||
to="/core-stocks"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<Flame className="h-3.5 w-3.5" />
|
||||
热点股
|
||||
</Link>
|
||||
<Link
|
||||
to="/theme-history"
|
||||
className="text-xs text-primary flex items-center gap-1 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
历史
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
|
||||
+125
@@ -146,3 +146,128 @@
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Report Content Styles */
|
||||
.ai-report-content {
|
||||
color: var(--card-foreground);
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.ai-report-content {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-report-content h1,
|
||||
.ai-report-content h2,
|
||||
.ai-report-content h3,
|
||||
.ai-report-content h4 {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.ai-report-content h1 { font-size: 1.4em; }
|
||||
.ai-report-content h2 { font-size: 1.2em; }
|
||||
.ai-report-content h3 { font-size: 1.05em; }
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.ai-report-content h1 { font-size: 1.5em; }
|
||||
.ai-report-content h2 { font-size: 1.3em; }
|
||||
.ai-report-content h3 { font-size: 1.15em; }
|
||||
}
|
||||
|
||||
.ai-report-content p {
|
||||
margin-bottom: 0.7em;
|
||||
}
|
||||
|
||||
.ai-report-content strong {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-report-content ul,
|
||||
.ai-report-content ol {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.ai-report-content li {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.ai-report-content blockquote {
|
||||
border-left: 3px solid var(--border);
|
||||
padding-left: 0.8em;
|
||||
margin: 0.7em 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.ai-report-content code {
|
||||
background: var(--muted);
|
||||
padding: 0.15em 0.35em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.ai-report-content pre {
|
||||
background: var(--muted);
|
||||
padding: 0.8em;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 0.7em 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.ai-report-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--card-foreground);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.ai-report-content table-wrapper {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
margin: 0.7em 0;
|
||||
}
|
||||
|
||||
.ai-report-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.ai-report-content th,
|
||||
.ai-report-content td {
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.4em 0.6em;
|
||||
text-align: left;
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.ai-report-content th,
|
||||
.ai-report-content td {
|
||||
padding: 0.5em 0.8em;
|
||||
font-size: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-report-content th {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-report-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.2em 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// stub:getStockBoard 测试用,仅用于解析 @/lib/api-client 别名(CommonJS)
|
||||
function getApiBaseUrl() {
|
||||
return "http://localhost:8000";
|
||||
}
|
||||
module.exports = { getApiBaseUrl };
|
||||
@@ -0,0 +1,63 @@
|
||||
// 最小复现/验证脚本:直接编译 src/lib/stock-api.ts 真实源码,
|
||||
// 调用 getStockBoard(null),复现「Cannot read properties of null (reading 'startsWith')」。
|
||||
// 无测试框架,node 直接运行:node tests/get-stock-board-repro.mjs
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Module from "node:module";
|
||||
import ts from "typescript";
|
||||
|
||||
const srcPath = path.resolve("src/lib/stock-api.ts");
|
||||
const source = fs.readFileSync(srcPath, "utf8");
|
||||
|
||||
const js = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
|
||||
}).outputText;
|
||||
|
||||
const mod = new Module(srcPath);
|
||||
mod.filename = srcPath;
|
||||
mod.paths = Module._nodeModulePaths(path.dirname(srcPath));
|
||||
|
||||
// 拦截 @/lib/api-client 别名导入(getStockBoard 本身不依赖它)
|
||||
const origResolve = Module._resolveFilename;
|
||||
Module._resolveFilename = function (request, ...args) {
|
||||
if (request === "@/lib/api-client") return path.resolve("tests/_stub-api-client.cjs");
|
||||
return origResolve.call(this, request, ...args);
|
||||
};
|
||||
|
||||
try {
|
||||
mod._compile(js, srcPath);
|
||||
} finally {
|
||||
Module._resolveFilename = origResolve;
|
||||
}
|
||||
|
||||
const { getStockBoard } = mod.exports;
|
||||
|
||||
// ---- 断言 ----
|
||||
function assertThrows(fn, label) {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✗ ${label}: 未抛错`);
|
||||
process.exitCode = 1;
|
||||
} catch (e) {
|
||||
console.log(`✓ ${label}: 抛错 -> ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoThrow(fn, label) {
|
||||
try {
|
||||
const r = fn();
|
||||
console.log(`✓ ${label}: 未抛错 -> ${JSON.stringify(r)}`);
|
||||
return r;
|
||||
} catch (e) {
|
||||
console.log(`✗ ${label}: 抛错 -> ${e.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 正常代码
|
||||
assertNoThrow(() => getStockBoard("600000"), "正常代码 getStockBoard('600000')");
|
||||
// 崩盘场景:securityCode 为 null(East Money 题材列表实测存在),修复后应兜底返回主板
|
||||
assertNoThrow(() => getStockBoard(null), "null securityCode");
|
||||
assertNoThrow(() => getStockBoard(undefined), "undefined securityCode");
|
||||
// 空串不崩
|
||||
assertNoThrow(() => getStockBoard(""), "空串 getStockBoard('')");
|
||||
@@ -52,6 +52,12 @@ export default defineConfig({
|
||||
entryFileNames: "assets/[name]-[hash].js",
|
||||
chunkFileNames: "assets/[name]-[hash].js",
|
||||
assetFileNames: "assets/[name]-[hash][extname]",
|
||||
manualChunks(id) {
|
||||
// 把常用大库拆到独立 chunk,减少主 chunk 体积
|
||||
if (id.includes("node_modules/recharts")) return "recharts";
|
||||
if (id.includes("node_modules/lightweight-charts")) return "lightweight-charts";
|
||||
if (id.includes("node_modules/@tanstack/react-router")) return "router";
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user