Initial commit: stock analysis backend and prototype UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-13 02:26:22 +08:00
commit 8de37d5c2d
25 changed files with 4624 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# 密钥与本地配置
backend/.env
# Python
backend/.venv/
__pycache__/
*.pyc
# 临时/日志
*.log

29
backend/.env.example Normal file
View File

@@ -0,0 +1,29 @@
# 复制本文件为 .env 并填入你的密钥然后重启后端python main.py生效。
# ===== 大模型OpenAI 兼容;任选其一)=====
# DeepSeek
LLM_API_KEY=
LLM_BASE_URL=https://api.deepseek.com/v1
LLM_MODEL=deepseek-chat
# 通义千问(示例)
# LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# LLM_MODEL=qwen-plus
# Kimi示例
# LLM_BASE_URL=https://api.moonshot.cn/v1
# LLM_MODEL=moonshot-v1-8k
# ===== 邮件推送SMTP=====
# 以QQ邮箱为例SMTP_HOST=smtp.qq.com SMTP_PORT=465 SMTP_PASSWORD填“授权码”而非登录密码
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
SMTP_TO=
# ===== 微信推送(任选其一)=====
# Server酱 Turbohttps://sct.ftqq.com 获取 SendKey
SERVERCHAN_KEY=
# 企业微信群机器人 webhook 完整地址
WECOM_WEBHOOK=
# PushPlushttps://www.pushplus.plus 获取 token
PUSHPLUS_TOKEN=

232
backend/ai.py Normal file
View File

@@ -0,0 +1,232 @@
"""AI 分析层:基于中台数据构造上下文,调用大模型生成点评;无 key 时规则降级。"""
from __future__ import annotations
import datetime as dt
from sqlalchemy import select, func, desc
import llm
import signals
import rag
from db import get_session
from models import (SectorDaily, FundFlowDaily, SentimentDaily, DragonTiger,
StockMetric, DailyQuote, Security)
DISCLAIMER = "(注:以上为基于数据的程序化分析,不构成投资建议,市场有风险。)"
# ============ 每日复盘点评 ============
def _daily_context(date=None):
with get_session() as s:
d = dt.date.fromisoformat(date) if date else s.execute(select(func.max(SectorDaily.date))).scalar()
if not d:
return None
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all()
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all()
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(6)).scalars().all()
return {
"date": d, "senti": senti,
"top": [(x.name, x.pct) for x in secs[:6]],
"bot": [(x.name, x.pct) for x in secs[-4:]],
"inflow": [(x.name, x.net) for x in flows[:6]],
"outflow": [(x.name, x.net) for x in flows[-4:][::-1]],
"lhb": [(x.name, x.net) for x in lhb],
}
def review_daily_comment(date=None):
ctx = _daily_context(date)
if not ctx:
return {"ok": False, "msg": "暂无入库数据,请先到数据中台入库"}
s = ctx["senti"]
senti_line = (f"上涨{s.up}家/下跌{s.down}家,涨停{s.limit_up}家/跌停{s.limit_down}" if s else "无情绪数据")
facts = (
f"日期:{ctx['date']}\n"
f"市场情绪:{senti_line}\n"
f"领涨板块:{', '.join(f'{n}({p:+.2f}%)' for n,p in ctx['top'])}\n"
f"领跌板块:{', '.join(f'{n}({p:+.2f}%)' for n,p in ctx['bot'])}\n"
f"主力净流入前列:{', '.join(f'{n}({v:+.1f}亿)' for n,v in ctx['inflow'])}\n"
f"主力净流出前列:{', '.join(f'{n}({v:+.1f}亿)' for n,v in ctx['outflow'])}\n"
f"龙虎榜净买额居前:{', '.join(f'{n}({v:+.2f}亿)' for n,v in ctx['lhb'])}\n"
)
if llm.enabled():
try:
prompt = ("请根据以下当日A股盘面数据撰写一篇 250 字以内的收盘复盘点评,"
"包含:① 今日市场情绪与赚钱效应判断;② 资金与题材主线;③ 明日需关注的方向与风险。"
"分段清晰。\n\n" + facts)
text = llm.ask(prompt, temperature=0.6)
return {"ok": True, "source": "llm", "date": ctx["date"].isoformat(), "facts": facts, "text": text}
except Exception as e:
pass
# 规则降级
tone = "情绪偏暖、赚钱效应尚可" if (s and s.up > s.down) else "情绪偏弱、需控制仓位"
text = (f"今日{tone}{senti_line}\n"
f"领涨主线集中在 {', '.join(n for n,_ in ctx['top'][:3])}"
f"主力资金净流入居前的是 {', '.join(n for n,_ in ctx['inflow'][:3])}"
f"{', '.join(n for n,_ in ctx['outflow'][:2])} 遭资金流出,需回避。\n"
f"明日关注领涨板块的持续性与量能配合,注意高位股退潮风险。\n{DISCLAIMER}")
return {"ok": True, "source": "rule", "date": ctx["date"].isoformat(), "facts": facts, "text": text}
# ============ 个股诊断 ============
def _stock_context(symbol):
with get_session() as s:
m = s.get(StockMetric, symbol)
sec = s.get(Security, symbol)
rows = s.execute(select(DailyQuote).where(DailyQuote.code == symbol)
.order_by(DailyQuote.date.desc()).limit(60)).scalars().all()
return m, sec, list(reversed(rows))
def _build_evidence(m, stats, news_tone):
"""构造证据链:每条含 维度/事实/方向/历史命中率。"""
def hr(key):
s = stats.get(key)
return (s["win_rate"], s["avg_ret"], s["samples"]) if s else (None, None, None)
ev = []
# 趋势
wr, ar, ns = hr("ma_bull")
ev.append({"dim": "趋势", "fact": f"均线{'多头排列(MA5>MA10>MA20)' if m.ma_bull else '未呈多头'}20日{m.ret20:+.1f}%",
"signal": "bull" if m.ma_bull else ("bear" if m.ret20 < -3 else "neutral"),
"win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 1.2})
# 技术(MACD)
wr, ar, ns = hr("macd_gold")
ev.append({"dim": "技术", "fact": f"{'MACD金叉' if m.macd_gold else 'MACD未金叉'}RSI14={m.rsi14:.0f}"
+ ("(超买)" if m.rsi14 >= 80 else ("(超卖)" if m.rsi14 < 30 else "")),
"signal": "bull" if m.macd_gold else ("bear" if m.rsi14 >= 80 else "neutral"),
"win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 1.0})
# 动量
wr, ar, ns = hr("up_streak3")
streak_sig = "bull" if m.up_streak >= 3 else ("bear" if m.ret5 < -5 else "neutral")
ev.append({"dim": "动量", "fact": f"连涨{m.up_streak}5日{m.ret5:+.1f}%",
"signal": streak_sig, "win_rate": wr if m.up_streak >= 3 else None,
"avg_ret": ar if m.up_streak >= 3 else None, "samples": ns if m.up_streak >= 3 else None, "weight": 1.0})
# 资金
wr, ar, ns = hr("vol_breakout")
vol_sig = "bull" if (m.vol_ratio > 2 and m.pct > 0) else ("bear" if (m.vol_ratio > 2 and m.pct < 0) else "neutral")
ev.append({"dim": "资金", "fact": f"量比{m.vol_ratio},当日{m.pct:+.2f}%,成交额{m.amount}亿",
"signal": vol_sig, "win_rate": wr if vol_sig != "neutral" else None,
"avg_ret": ar if vol_sig != "neutral" else None, "samples": ns if vol_sig != "neutral" else None, "weight": 1.1})
# 位置
key = "new_high60" if m.pos60 >= 0.99 else ("rsi_oversold" if m.rsi14 < 30 else None)
wr, ar, ns = hr(key) if key else (None, None, None)
pos_sig = "bear" if m.pos60 > 0.92 else ("bull" if m.pos60 < 0.2 else "neutral")
ev.append({"dim": "位置", "fact": f"处于60日 {m.pos60*100:.0f}% 分位" + ("(创新高)" if m.pos60 >= 0.99 else ("(低位)" if m.pos60 < 0.2 else "")),
"signal": pos_sig, "win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 0.8})
# 消息面RAG
ev.append({"dim": "消息", "fact": f"近期资讯情绪:{news_tone}",
"signal": {"利好": "bull", "利空": "bear"}.get(news_tone, "neutral"),
"win_rate": None, "avg_ret": None, "samples": None, "weight": 0.9})
return ev
def _confidence_direction(ev):
sval = {"bull": 1, "bear": -1, "neutral": 0}
net = sum(sval[e["signal"]] * e["weight"] for e in ev)
tw = sum(e["weight"] for e in ev if e["signal"] != "neutral")
agreement = abs(net) / tw if tw else 0.0
conf = int(max(5, min(96, 35 + agreement * 55 + (8 if tw >= 3 else 0))))
thr = 0.15 * sum(e["weight"] for e in ev)
direction = "up" if net > thr else ("down" if net < -thr else "flat")
return conf, direction, round(net, 2)
def diagnose(symbol):
m, sec, rows = _stock_context(symbol)
if not m:
return {"ok": False, "msg": "该股票无因子数据,请先入库(数据中台→全市场回填或指定入库)"}
name = (sec.name if sec else m.name) or symbol
# 规则打分0~100
trend = 50 + (15 if m.ma_bull else -10) + min(20, max(-20, m.ret20))
momentum = 50 + min(25, max(-25, m.ret5)) + (10 if 50 < m.rsi14 < 75 else (-10 if m.rsi14 >= 80 else 0))
capital = 50 + min(30, (m.vol_ratio - 1) * 20) * (1 if m.pct > 0 else -1)
position = 100 - abs(m.pos60 * 100 - 45) # 价格分位适中得分高
clamp = lambda x: int(max(5, min(98, x)))
scores = {"趋势": clamp(trend), "动量": clamp(momentum), "资金": clamp(capital), "位置": clamp(position)}
total = clamp(sum(scores.values()) / 4)
# 证据链 + 历史命中率 + RAG 资讯
stats = signals.get_stats(horizon=5)
rctx = rag.stock_context(symbol, limit=5)
evidence = _build_evidence(m, stats, rctx["tone"])
confidence, direction, net = _confidence_direction(evidence)
# 留痕本次诊断写入预测N 日后核验
try:
signals.record_prediction(symbol, name, m.date, total, confidence, direction, m.close, horizon=5)
except Exception:
pass
facts = (
f"股票:{name}({symbol})\n"
f"最新价:{m.close},当日{m.pct:+.2f}%\n"
f"均线MA5={m.ma5} MA10={m.ma10} MA20={m.ma20} MA60={m.ma60}{'多头排列' if m.ma_bull else '非多头'}\n"
f"涨幅5日{m.ret5:+.2f}% / 20日{m.ret20:+.2f}% / 60日{m.ret60:+.2f}%\n"
f"量比:{m.vol_ratio}RSI14{m.rsi14}60日价格分位{m.pos60*100:.0f}%"
f"{'MACD金叉' if m.macd_gold else 'MACD未金叉'},连涨{m.up_streak}日,成交额{m.amount}亿\n"
)
hit_lines = "\n".join(
f"- {e['dim']}信号「{e['fact']}」历史5日上涨概率 {e['win_rate']}%、平均{e['avg_ret']:+.2f}%(样本{e['samples']}"
for e in evidence if e.get("win_rate") is not None)
base = {"ok": True, "symbol": symbol, "name": name, "scores": scores, "total": total,
"confidence": confidence, "direction": direction, "evidence": evidence,
"news": rctx["items"], "news_tone": rctx["tone"], "facts": facts}
if llm.enabled():
try:
prompt = ("请基于以下个股量化数据、信号历史命中率与相关资讯,输出结构化诊断:\n"
"1) 一句话结论含方向与置信判断2) 技术面/资金面/趋势面分别点评(引用历史命中率);"
"3) 操作建议含参考性关注位与止损思路4) 主要风险。200~320字。\n\n"
f"{facts}\n历史命中率:\n{hit_lines or '暂无回测数据请先在AI准确率页计算'}\n\n"
f"{rctx['block'] or '近期无相关资讯。'}\n")
text = llm.ask(prompt, temperature=0.4)
base.update({"source": "llm", "text": text})
return base
except Exception:
pass
judge = "偏强" if total >= 60 else ("偏弱" if total < 45 else "中性")
dir_cn = {"up": "看多", "down": "看空", "flat": "中性观望"}[direction]
text = (f"综合评分 {total}{judge}),方向{dir_cn},置信度 {confidence}%。"
f"趋势{'多头向上' if m.ma_bull else '尚未走强'}20日{m.ret20:+.1f}%"
f"动量 RSI {m.rsi14:.0f}{',超买防回调' if m.rsi14>=80 else ''}"
f"资金{'放量' if m.vol_ratio>=1.5 else '量能一般'}(量比{m.vol_ratio}"
f"价格处于60日 {m.pos60*100:.0f}% 分位;消息面{rctx['tone']}\n"
+ (f"依据信号历史表现:\n{hit_lines}\n" if hit_lines else "")
+ f"操作:{'回踩不破MA20可关注' if m.ma_bull else '等待站稳均线再观察'}跌破MA20或前低则减仓。"
f"风险:{'高位放量谨防见顶' if m.pos60>0.9 else '大盘系统性波动'}\n{DISCLAIMER}")
base.update({"source": "rule", "text": text})
return base
# ============ 今日策略 ============
def today_strategy():
with get_session() as s:
d = s.execute(select(func.max(SectorDaily.date))).scalar()
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc()).limit(5)).scalars().all() if d else []
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc()).limit(5)).scalars().all() if d else []
strong = s.execute(select(StockMetric).where(StockMetric.up_streak >= 3).order_by(StockMetric.up_streak.desc()).limit(8)).scalars().all()
macd = s.execute(select(StockMetric).where(StockMetric.macd_gold.is_(True)).order_by(StockMetric.ret5.desc()).limit(8)).scalars().all()
facts = (
f"领涨板块:{', '.join(f'{x.name}({x.pct:+.2f}%)' for x in secs)}\n"
f"主力净流入板块:{', '.join(f'{x.name}({x.net:+.1f}亿)' for x in flows)}\n"
f"强势连涨个股:{', '.join(f'{x.name}({x.up_streak}连阳)' for x in strong)}\n"
f"MACD金叉个股{', '.join(x.name for x in macd)}\n"
)
if llm.enabled():
try:
prompt = ("请基于以下盘面数据,给出『今天/接下来怎么做』的策略观点:"
"① 当前资金与题材主线;② 值得关注的方向(给理由);③ 需要回避的风险。"
"220字以内。\n\n" + facts)
text = llm.ask(prompt, temperature=0.6)
return {"ok": True, "source": "llm", "facts": facts, "text": text}
except Exception:
pass
main = "".join(x.name for x in secs[:3]) or "暂无数据"
text = (f"当前资金主线集中在 {main}"
f"可重点跟踪净流入居前的 {', '.join(x.name for x in flows[:3])} 板块内的强势个股;"
f"强势连涨股需注意分歧与退潮节奏,避免追高。\n{DISCLAIMER}")
return {"ok": True, "source": "rule", "facts": facts, "text": text}

444
backend/akshare_service.py Normal file
View File

@@ -0,0 +1,444 @@
"""AkShare 数据服务层。
每个函数都做了 try/except 降级:真实数据拿不到时返回 Python 端生成的模拟数据,
并通过 `source` 字段标注来源akshare / mock保证前端任何情况下都有数据可渲染。
"""
from __future__ import annotations
import random
import datetime as dt
from functools import wraps
from cachetools import TTLCache
import requests
try:
import akshare as ak
AK_OK = True
except Exception: # akshare 未安装也能跑(全部走 mock
ak = None
AK_OK = False
# ---- 简单 TTL 缓存(按函数+参数) ----
_cache = TTLCache(maxsize=256, ttl=30)
def cached(ttl: int):
def deco(fn):
local = TTLCache(maxsize=64, ttl=ttl)
@wraps(fn)
def wrapper(*args, **kwargs):
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
if key in local:
return local[key]
val = fn(*args, **kwargs)
local[key] = val
return val
return wrapper
return deco
def _rnd(a, b):
return round(random.uniform(a, b), 2)
# ============================================================
# 指数
# ============================================================
MAJOR_INDEX = {
"sh000001": ("上证指数", 3210),
"sz399001": ("深证成指", 10180),
"sz399006": ("创业板指", 2105),
"sh000300": ("沪深300", 3760),
"bj899050": ("北证50", 1080),
}
@cached(10)
def get_indices():
if AK_OK:
try:
df = ak.stock_zh_index_spot_sina()
rows = []
for code, (name, _base) in MAJOR_INDEX.items():
r = df[df["代码"] == code]
if r.empty:
continue
r = r.iloc[0]
rows.append({
"code": code, "name": name,
"price": float(r["最新价"]),
"change": float(r["涨跌额"]),
"pct": float(r["涨跌幅"]),
})
if rows:
return {"source": "akshare", "list": rows}
except Exception as e: # noqa
pass
# mock
rows = []
for code, (name, base) in MAJOR_INDEX.items():
pct = _rnd(-2.5, 2.5)
price = round(base * (1 + pct / 100), 2)
rows.append({"code": code, "name": name, "price": price,
"change": round(price - base, 2), "pct": pct})
return {"source": "mock", "list": rows}
# ============================================================
# K线
# ============================================================
# ============================================================
# 实时报价(新浪 hq速度快且稳定用于盯盘预警
# ============================================================
def realtime_quotes(codes):
"""返回 {code: {name, price, prev_close, pct, open, high, low}}。失败返回 {}"""
if not codes:
return {}
syms = ",".join(_sina_symbol(c) for c in codes)
try:
r = requests.get("https://hq.sinajs.cn/list=" + syms,
headers={"Referer": "https://finance.sina.com.cn"}, timeout=6)
out = {}
for line in r.text.split(";\n"):
if "hq_str_" not in line or '="' not in line:
continue
head, body = line.split('="', 1)
sym = head.split("hq_str_")[1].strip()
code = sym[2:]
f = body.strip('"').split(",")
if len(f) < 6 or not f[3]:
continue
price = float(f[3]); prev = float(f[2]) if f[2] else 0.0
out[code] = {"name": f[0], "open": float(f[1] or 0), "prev_close": prev,
"price": price, "high": float(f[4] or 0), "low": float(f[5] or 0),
"pct": round((price - prev) / prev * 100, 2) if prev else 0.0}
return out
except Exception:
return {}
# ============================================================
# 资讯新闻
# ============================================================
_BULL = ["涨停", "利好", "增长", "大涨", "突破", "中标", "签约", "回购", "增持", "扭亏",
"超预期", "新高", "提价", "涨价", "订单", "合作", "获批", "盈利", "分红", "重组",
"并购", "补贴", "减税", "降准", "降息", "刺激", "国产替代", "放量", "净流入"]
_BEAR = ["跌停", "利空", "下滑", "大跌", "亏损", "减持", "处罚", "退市", "违规", "下调",
"不及预期", "新低", "停牌", "质押", "爆雷", "诉讼", "解禁", "商誉", "预亏", "降价",
"裁员", "债务", "暴跌", "净流出", "风险警示"]
def judge_sentiment(text: str):
t = text or ""
pos = [w for w in _BULL if w in t]
neg = [w for w in _BEAR if w in t]
if len(pos) > len(neg):
return "利好", pos[:4]
if len(neg) > len(pos):
return "利空", neg[:4]
return "中性", (pos or neg)[:4]
@cached(120)
def get_news(limit: int = 40):
if AK_OK:
try:
df = ak.stock_info_global_em()
rows = []
for _, r in df.head(limit).iterrows():
title = str(r["标题"]); summary = str(r.get("摘要", ""))
senti, kw = judge_sentiment(title + summary)
rows.append({"time": str(r["发布时间"]), "title": title, "summary": summary,
"url": str(r.get("链接", "")), "sentiment": senti, "keywords": kw})
if rows:
return {"source": "akshare", "list": rows}
except Exception:
pass
return {"source": "mock", "list": [
{"time": "", "title": "示例资讯:市场情绪回暖,多板块走强", "summary": "(演示数据)",
"sentiment": "利好", "keywords": ["利好"], "url": ""}]}
@cached(180)
def get_stock_news(code: str, limit: int = 12):
if AK_OK:
try:
df = ak.stock_news_em(symbol=code)
rows = []
for _, r in df.head(limit).iterrows():
title = str(r["新闻标题"]); content = str(r.get("新闻内容", ""))
senti, kw = judge_sentiment(title + content)
rows.append({"time": str(r["发布时间"]), "title": title,
"summary": content[:120], "source": str(r.get("文章来源", "")),
"url": str(r.get("新闻链接", "")), "sentiment": senti, "keywords": kw})
if rows:
return {"source": "akshare", "list": rows}
except Exception:
pass
return {"source": "mock", "list": []}
def _sina_symbol(code: str) -> str:
if code.startswith("6"):
return "sh" + code
if code.startswith(("0", "3")):
return "sz" + code
if code.startswith(("8", "4")):
return "bj" + code
return "sh" + code
@cached(60)
def get_kline(symbol: str = "600519", days: int = 120):
if AK_OK:
# 主源:新浪日线(更稳定);备源:腾讯
for src in ("sina", "tx"):
try:
sym = _sina_symbol(symbol)
if src == "sina":
df = ak.stock_zh_a_daily(symbol=sym, adjust="qfq")
else:
df = ak.stock_zh_a_hist_tx(symbol=sym)
if df is not None and not df.empty:
df = df.tail(days)
dates = [str(d)[5:].replace("-", "/") for d in df["date"]]
ohlc = [[float(o), float(c), float(l), float(h)] for o, c, l, h in
zip(df["open"], df["close"], df["low"], df["high"])]
vols = [int(v) for v in (df["volume"] if "volume" in df.columns else df["amount"])]
return {"source": "akshare", "symbol": symbol, "dates": dates, "ohlc": ohlc, "vols": vols}
except Exception:
continue
# mock
dates, ohlc, vols = [], [], []
price = 1680.0
today = dt.date.today()
for i in range(days, 0, -1):
d = today - dt.timedelta(days=i)
dates.append(f"{d.month}/{d.day}")
o = price
c = round(o + _rnd(-o * 0.03, o * 0.03), 2)
h = round(max(o, c) + _rnd(0, o * 0.02), 2)
l = round(min(o, c) - _rnd(0, o * 0.02), 2)
ohlc.append([o, c, l, h])
vols.append(int(_rnd(2, 9) * 1e6))
price = c
return {"source": "mock", "symbol": symbol, "dates": dates, "ohlc": ohlc, "vols": vols}
# ============================================================
# 行业板块(云图 / 热门板块复用)—— 新浪行业东财push2在部分网络被封
# ============================================================
@cached(60)
def get_industry_boards():
if AK_OK:
try:
df = ak.stock_sector_spot(indicator="新浪行业")
rows = []
for _, r in df.iterrows():
rows.append({
"name": str(r["板块"]),
"pct": float(r["涨跌幅"]),
"amount": round(float(r["总成交额"]) / 1e8, 1), # 亿
"count": int(r.get("公司家数", 0) or 0),
"leader": str(r.get("股票名称", "")),
})
if rows:
rows.sort(key=lambda x: x["pct"], reverse=True)
return {"source": "akshare", "list": rows}
except Exception:
pass
sectors = ["半导体", "新能源", "医药生物", "白酒食品", "军工", "证券", "银行",
"房地产", "人工智能", "消费电子", "光伏", "汽车", "有色金属", "煤炭", "传媒"]
return {"source": "mock", "list": [
{"name": s, "pct": _rnd(-3, 6), "amount": _rnd(50, 500),
"count": int(_rnd(10, 80)), "leader": "龙头股"} for s in sectors]}
# ============================================================
# 全市场快照(情绪 / 全市场云图)
# ============================================================
@cached(60)
def _spot():
if AK_OK:
try:
df = ak.stock_zh_a_spot_em()
if df is not None and not df.empty:
return df
except Exception:
pass
return None
@cached(30)
def get_sentiment():
if AK_OK:
try:
df = ak.stock_market_activity_legu()
m = {}
for _, r in df.iterrows():
m[str(r["item"]).strip()] = r["value"]
def num(k):
try:
return int(float(m.get(k, 0)))
except Exception:
return 0
up, down, flat = num("上涨"), num("下跌"), num("平盘")
if up or down:
return {"source": "akshare", "up": up, "down": down, "flat": flat,
"limit_up": num("涨停"), "limit_down": num("跌停"),
"height": min(9, max(3, num("涨停") // 8))}
except Exception:
pass
up, down, flat = int(_rnd(1800, 3200)), int(_rnd(1200, 2600)), int(_rnd(80, 260))
return {"source": "mock", "up": up, "down": down, "flat": flat,
"limit_up": int(_rnd(20, 90)), "limit_down": int(_rnd(2, 30)), "height": int(_rnd(4, 9))}
@cached(60)
def get_treemap(mode: str = "sector"):
if mode == "all":
df = _spot()
if df is not None:
try:
top = df.sort_values("成交额", ascending=False).head(150)
items = [{"name": str(r["名称"]), "value": round(float(r["成交额"]) / 1e8, 2),
"pct": float(r["涨跌幅"])} for _, r in top.iterrows()]
return {"source": "akshare", "mode": "all", "items": items}
except Exception:
pass
# mock flat
items = [{"name": f"个股{i}", "value": _rnd(2, 50), "pct": _rnd(-9, 9)} for i in range(60)]
return {"source": "mock", "mode": "all", "items": items}
# sector
boards = get_industry_boards()
items = [{"name": b["name"], "value": b.get("amount", 1), "pct": b["pct"]} for b in boards["list"]]
return {"source": boards["source"], "mode": "sector", "items": items}
# ============================================================
# 资金流向(行业)
# ============================================================
@cached(60)
def get_fund_flow():
if AK_OK:
try:
df = ak.stock_fund_flow_industry(symbol="即时")
rows = []
for _, r in df.iterrows():
rows.append({"name": str(r["行业"]),
"net": round(float(r["净额"]), 2), # 同花顺已是亿元
"pct": float(r["行业-涨跌幅"])})
if rows:
rows.sort(key=lambda x: x["net"])
# 取首尾各15条突出流入流出两端
show = rows[:15] + rows[-15:] if len(rows) > 30 else rows
show.sort(key=lambda x: x["net"])
return {"source": "akshare", "list": show}
except Exception:
pass
sectors = ["半导体", "新能源", "医药生物", "白酒食品", "军工", "证券", "银行",
"房地产", "人工智能", "消费电子", "光伏", "汽车", "有色金属", "煤炭", "传媒"]
rows = [{"name": s, "net": _rnd(-40, 60), "pct": _rnd(-3, 6)} for s in sectors]
rows.sort(key=lambda x: x["net"])
return {"source": "mock", "list": rows}
# ============================================================
# 热门股票(人气榜)
# ============================================================
@cached(60)
def get_hot_stocks():
if AK_OK:
try:
df = ak.stock_hot_rank_em()
rows = []
for _, r in df.head(20).iterrows():
rows.append({"rank": int(r["当前排名"]), "code": str(r["代码"]),
"name": str(r["股票名称"]), "price": float(r["最新价"]),
"pct": float(r["涨跌幅"])})
if rows:
return {"source": "akshare", "list": rows}
except Exception:
pass
pool = ["龙头A", "龙头B", "中军C", "黑马D", "次新E", "蓝筹F", "题材G", "妖股H"]
return {"source": "mock", "list": [
{"rank": i + 1, "code": f"60{1000+i}", "name": f"{pool[i % len(pool)]}{i}",
"price": _rnd(5, 200), "pct": _rnd(-5, 11)} for i in range(15)]}
# ============================================================
# 龙虎榜
# ============================================================
@cached(300)
def get_dragon_tiger():
if AK_OK:
try:
for back in range(0, 7):
d = (dt.date.today() - dt.timedelta(days=back)).strftime("%Y%m%d")
try:
df = ak.stock_lhb_detail_em(start_date=d, end_date=d)
except Exception:
df = None
if df is not None and not df.empty:
rows = []
for _, r in df.head(20).iterrows():
rows.append({
"code": str(r.get("代码", "")), "name": str(r.get("名称", "")),
"pct": float(r.get("涨跌幅", 0) or 0),
"net": round(float(r.get("龙虎榜净买额", 0) or 0) / 1e8, 2),
"reason": str(r.get("上榜原因", "")),
})
return {"source": "akshare", "date": d, "list": rows}
except Exception:
pass
pool = ["龙头A", "龙头B", "中军C", "黑马D"]
return {"source": "mock", "date": "", "list": [
{"code": f"60{1000+i}", "name": f"{pool[i % len(pool)]}{i}", "pct": _rnd(-3, 10),
"net": _rnd(-3, 5), "reason": ["日涨幅偏离", "换手率达20%", "连续三日涨停"][i % 3]} for i in range(12)]}
# ============================================================
# 自选股 —— 代码名称表 + 个股日线push2 被封时的稳妥方案)
# ============================================================
@cached(3600)
def _code_name_map():
if AK_OK:
try:
cn = ak.stock_info_a_code_name()
return {str(r["code"]): str(r["name"]) for _, r in cn.iterrows()}
except Exception:
pass
return {}
def get_watchlist(symbols: list[str]):
names = {"600519": "贵州茅台", "300750": "宁德时代", "002594": "比亚迪",
"688981": "中芯国际", "300059": "东方财富", "601012": "隆基绿能"}
if AK_OK:
cmap = _code_name_map()
rows = []
for s in symbols:
try:
k = get_kline(s, 30)
if k["source"] != "akshare" or len(k["ohlc"]) < 2:
continue
last, prev = k["ohlc"][-1], k["ohlc"][-2]
price, prev_close = last[1], prev[1]
change = round(price - prev_close, 2)
pct = round(change / prev_close * 100, 2) if prev_close else 0.0
rows.append({"code": s, "name": cmap.get(s, names.get(s, s)), "price": price,
"pct": pct, "change": change,
"amount": round(k["vols"][-1] * price / 1e8, 2)})
except Exception:
continue
if rows:
return {"source": "akshare", "list": rows}
return {"source": "mock", "list": [
{"code": c, "name": names.get(c, c), "price": _rnd(20, 1800), "pct": _rnd(-4, 5),
"change": _rnd(-30, 30), "amount": _rnd(3, 60)} for c in symbols]}

67
backend/alerts.py Normal file
View File

@@ -0,0 +1,67 @@
"""预警引擎:拉取实时价格,评估规则,触发即生成站内事件。"""
from __future__ import annotations
import datetime as dt
from sqlalchemy import select
import akshare_service as svc
import notifier
from db import get_session
from models import AlertRule, AlertEvent
KIND_LABEL = {
"price_above": "价格突破",
"price_below": "价格跌破",
"pct_above": "涨幅达到",
"pct_below": "跌幅达到",
}
def _hit(kind, threshold, q):
price, pct = q["price"], q["pct"]
if kind == "price_above":
return price >= threshold, price
if kind == "price_below":
return price <= threshold, price
if kind == "pct_above":
return pct >= threshold, pct
if kind == "pct_below":
return pct <= -abs(threshold), pct
return False, price
def check_alerts():
with get_session() as s:
rules = s.execute(select(AlertRule).where(AlertRule.status == "active")).scalars().all()
if not rules:
return {"checked": 0, "triggered": 0}
codes = list({r.code for r in rules})
quotes = svc.realtime_quotes(codes)
triggered = 0
push_msgs = []
for r in rules:
q = quotes.get(r.code)
if not q:
continue
hit, val = _hit(r.kind, r.threshold, q)
r.last_value = q["price"]
if hit:
unit = "" if r.kind.startswith("price") else "%"
msg = (f"{q['name']} {KIND_LABEL.get(r.kind, r.kind)} {r.threshold}{unit}"
f"(现价 {q['price']}{q['pct']:+.2f}%")
s.add(AlertEvent(rule_id=r.id, code=r.code, name=q["name"], message=msg, value=val))
r.status = "triggered"
r.triggered_at = dt.datetime.now()
triggered += 1
push_msgs.append(msg)
s.commit()
# 触发后向已配置渠道推送(站外)
if push_msgs and notifier.any_enabled():
try:
notifier.notify("【智策预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
"\n".join(push_msgs))
except Exception:
pass
return {"checked": len(rules), "triggered": triggered}

78
backend/backtest.py Normal file
View File

@@ -0,0 +1,78 @@
"""基于中台日线的简易回测引擎(均线交叉策略)。
读取 quotes_daily金叉满仓 / 死叉空仓,输出资金曲线与核心指标。
"""
from __future__ import annotations
from sqlalchemy import select
from db import get_session
from models import DailyQuote
def _ma(arr, n):
out = [None] * len(arr)
s = 0.0
for i, v in enumerate(arr):
s += v
if i >= n:
s -= arr[i - n]
if i >= n - 1:
out[i] = s / n
return out
def run_backtest(symbol: str, fast: int = 5, slow: int = 20, fee: float = 0.0005):
with get_session() as s:
rows = s.execute(
select(DailyQuote.date, DailyQuote.close)
.where(DailyQuote.code == symbol)
.order_by(DailyQuote.date)
).all()
if len(rows) < slow + 5:
return {"ok": False, "msg": "该股票库内日线不足,请先在数据中台入库", "have": len(rows)}
dates = [r[0].isoformat() for r in rows]
close = [float(r[1]) for r in rows]
maf, mas = _ma(close, fast), _ma(close, slow)
equity, bench = [], []
cash, pos, shares = 1.0, 0, 0.0
trades, wins, entry = 0, 0, 0.0
peak, max_dd = 1.0, 0.0
base = close[0]
for i in range(len(close)):
if maf[i] is not None and mas[i] is not None:
if pos == 0 and maf[i] > mas[i]:
shares = cash * (1 - fee) / close[i]
cash, pos, entry = 0.0, 1, close[i]
trades += 1
elif pos == 1 and maf[i] < mas[i]:
cash = shares * close[i] * (1 - fee)
shares, pos = 0.0, 0
if close[i] > entry:
wins += 1
nav = cash + shares * close[i]
equity.append(round(nav, 4))
bench.append(round(close[i] / base, 4))
peak = max(peak, nav)
max_dd = max(max_dd, (peak - nav) / peak)
total_ret = equity[-1] - 1
bench_ret = bench[-1] - 1
closed = trades - pos
win_rate = (wins / closed) if closed > 0 else 0.0
return {
"ok": True, "symbol": symbol, "fast": fast, "slow": slow,
"dates": dates, "equity": equity, "bench": bench,
"metrics": {
"total_return": round(total_ret * 100, 2),
"bench_return": round(bench_ret * 100, 2),
"excess": round((total_ret - bench_ret) * 100, 2),
"max_drawdown": round(max_dd * 100, 2),
"trades": trades,
"win_rate": round(win_rate * 100, 1),
},
}

33
backend/cli.py Normal file
View File

@@ -0,0 +1,33 @@
"""命令行入库工具。
用法:
python cli.py init # 仅建库建表
python cli.py ingest # 全量入库(默认股票池)
python cli.py ingest 600519 000001 # 指定股票入库(含快照)
"""
import sys
from db import init_db
import ingest
def main():
init_db()
args = sys.argv[1:]
if not args or args[0] == "init":
print("init done")
return
if args[0] == "ingest":
codes = args[1:] or None
res = ingest.run_daily_ingest(universe=codes)
print(res)
elif args[0] == "ingest_all":
days = int(args[1]) if len(args) > 1 else 250
# 先抓快照类数据,再全市场日线
ingest.run_daily_ingest(with_quotes=False)
res = ingest.ingest_quotes_all(days=days)
print(res)
if __name__ == "__main__":
main()

53
backend/config.py Normal file
View File

@@ -0,0 +1,53 @@
"""中台配置。优先读环境变量,便于以后切换部署环境。
可在 backend/.env 写入密钥(大模型 key、邮箱、推送 token无需改代码。
"""
import os
try:
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
except Exception:
pass
PG_USER = os.getenv("PG_USER", "postgres")
PG_PASSWORD = os.getenv("PG_PASSWORD", "13142324")
PG_HOST = os.getenv("PG_HOST", "localhost")
PG_PORT = os.getenv("PG_PORT", "5432")
PG_DB = os.getenv("PG_DB", "stock_cs")
DB_URL = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
DB_URL_DEFAULT = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/postgres"
# 默认入库股票池(沪深龙头 + 自选)。可在「数据中台」页或环境变量扩展。
DEFAULT_UNIVERSE = [
"600519", "300750", "002594", "688981", "300059", "601012",
"600036", "601318", "000858", "002415", "600276", "002230",
"601899", "600030", "000333", "002475", "300760", "601166",
"688111", "600887",
]
# 收盘后定时任务时间24h制本地时区
INGEST_HOUR = int(os.getenv("INGEST_HOUR", "15"))
INGEST_MINUTE = int(os.getenv("INGEST_MINUTE", "35"))
# ---- 大模型OpenAI 兼容DeepSeek/通义/Kimi 等均可)----
# 配置环境变量 LLM_API_KEY 后即启用真实大模型,否则走内置规则点评。
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1")
LLM_MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "40"))
# ---- 推送通知 ----
# 邮件SMTP
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") # 邮箱授权码
SMTP_TO = os.getenv("SMTP_TO", "") # 收件人,多个用逗号
# Server酱 Turbo微信推送最简单
SERVERCHAN_KEY = os.getenv("SERVERCHAN_KEY", "")
# 企业微信群机器人 webhook
WECOM_WEBHOOK = os.getenv("WECOM_WEBHOOK", "")
# PushPlus微信推送
PUSHPLUS_TOKEN = os.getenv("PUSHPLUS_TOKEN", "")

43
backend/db.py Normal file
View File

@@ -0,0 +1,43 @@
"""数据库引擎与初始化。
init_db():
- 若目标库不存在则自动创建(连到默认 postgres 库执行 CREATE DATABASE
- 建表
"""
from __future__ import annotations
import psycopg2
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
import config
from models import Base
engine = create_engine(config.DB_URL, pool_pre_ping=True, future=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
def _ensure_database():
try:
conn = psycopg2.connect(
host=config.PG_HOST, port=config.PG_PORT, user=config.PG_USER,
password=config.PG_PASSWORD, dbname="postgres", connect_timeout=5)
conn.autocommit = True
cur = conn.cursor()
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.PG_DB,))
if not cur.fetchone():
cur.execute(f'CREATE DATABASE "{config.PG_DB}"')
cur.close()
conn.close()
except Exception as e: # 数据库已存在或无权限时不阻断
print("[db] ensure_database:", repr(e)[:120])
def init_db():
_ensure_database()
Base.metadata.create_all(engine)
print("[db] tables ready")
def get_session():
return SessionLocal()

298
backend/ingest.py Normal file
View File

@@ -0,0 +1,298 @@
"""ETL从数据源抽取并增量入库。
每个 ingest_* 负责一类数据run_daily_ingest 编排全部并写任务日志。
"""
from __future__ import annotations
import datetime as dt
from sqlalchemy.dialects.postgresql import insert as pg_insert
import akshare_service as svc
import config
from db import get_session
from models import (DailyQuote, DragonTiger, FundFlowDaily, IndexDaily, JobRun,
SectorDaily, Security, SentimentDaily, StockMetric)
try:
import akshare as ak
except Exception:
ak = None
def _today():
return dt.date.today()
def _upsert(session, model, rows, index_elements, update_cols):
if not rows:
return 0
stmt = pg_insert(model).values(rows)
stmt = stmt.on_conflict_do_update(
index_elements=index_elements,
set_={c: getattr(stmt.excluded, c) for c in update_cols},
)
session.execute(stmt)
return len(rows)
# ---------------- 个股日线(保留真实日期) ----------------
def fetch_daily(code: str, days: int = 400):
if ak is None:
return []
sym = svc._sina_symbol(code)
for src in ("sina", "tx"):
try:
df = ak.stock_zh_a_daily(symbol=sym, adjust="qfq") if src == "sina" else ak.stock_zh_a_hist_tx(symbol=sym)
if df is None or df.empty:
continue
df = df.tail(days)
out = []
for _, r in df.iterrows():
d = r["date"]
d = d.date() if hasattr(d, "date") else dt.date.fromisoformat(str(d)[:10])
out.append({
"code": code, "date": d,
"open": float(r["open"]), "high": float(r["high"]),
"low": float(r["low"]), "close": float(r["close"]),
"volume": int(r["volume"]) if "volume" in df.columns else 0,
"amount": float(r["amount"]) if "amount" in df.columns else 0.0,
})
return out
except Exception:
continue
return []
# ---------------- 因子计算 ----------------
def _mean(a):
return sum(a) / len(a) if a else 0.0
def _ema(arr, n):
k = 2 / (n + 1)
e = arr[0]
out = []
for v in arr:
e = v * k + e * (1 - k)
out.append(e)
return out
def compute_metrics(code, name, rows):
"""rows: 按日期升序的日线 dict 列表。返回 StockMetric 字段 dict 或 None。"""
if len(rows) < 25:
return None
closes = [r["close"] for r in rows]
highs = [r["high"] for r in rows]
lows = [r["low"] for r in rows]
vols = [r["volume"] for r in rows]
n = len(closes)
last = closes[-1]
prev = closes[-2]
def ma(k):
return round(_mean(closes[-k:]), 3) if n >= k else 0.0
def ret(k):
return round((last / closes[-k - 1] - 1) * 100, 2) if n > k else 0.0
ma5, ma10, ma20, ma60 = ma(5), ma(10), ma(20), ma(60)
vol_ratio = round(vols[-1] / _mean(vols[-6:-1]), 2) if n >= 6 and _mean(vols[-6:-1]) else 0.0
win = min(60, n)
high60, low60 = max(highs[-win:]), min(lows[-win:])
pos60 = round((last - low60) / (high60 - low60), 3) if high60 > low60 else 0.5
# RSI14
gains, losses = [], []
for i in range(max(1, n - 14), n):
d = closes[i] - closes[i - 1]
gains.append(max(d, 0)); losses.append(max(-d, 0))
ag, al = _mean(gains), _mean(losses)
rsi14 = round(100 - 100 / (1 + ag / al), 1) if al else (100.0 if ag else 50.0)
# MACD 金叉
e12, e26 = _ema(closes, 12), _ema(closes, 26)
dif = [a - b for a, b in zip(e12, e26)]
dea = _ema(dif, 9)
macd_gold = len(dif) >= 2 and dif[-2] < dea[-2] and dif[-1] >= dea[-1]
ma_bull = ma5 > ma10 > ma20 > 0
streak = 0
for i in range(n - 1, 0, -1):
if closes[i] > closes[i - 1]:
streak += 1
else:
break
return {
"code": code, "name": name, "date": rows[-1]["date"], "close": last,
"pct": round((last / prev - 1) * 100, 2) if prev else 0.0,
"ma5": ma5, "ma10": ma10, "ma20": ma20, "ma60": ma60,
"vol_ratio": vol_ratio, "ret5": ret(5), "ret20": ret(20), "ret60": ret(60),
"pos60": pos60, "rsi14": rsi14, "macd_gold": bool(macd_gold),
"ma_bull": bool(ma_bull), "up_streak": streak,
"amount": round(rows[-1]["amount"] / 1e8, 3),
}
def ingest_quotes(codes, days=400, with_metrics=True, cmap=None):
if with_metrics and cmap is None:
cmap = svc._code_name_map()
n = 0
with get_session() as s:
for code in codes:
rows = fetch_daily(code, days)
if not rows:
continue
n += _upsert(s, DailyQuote, rows, ["code", "date"],
["open", "high", "low", "close", "volume", "amount"])
if with_metrics:
m = compute_metrics(code, (cmap or {}).get(code, code), rows)
if m:
_upsert(s, StockMetric, [m], ["code"],
["name", "date", "close", "pct", "ma5", "ma10", "ma20", "ma60",
"vol_ratio", "ret5", "ret20", "ret60", "pos60", "rsi14",
"macd_gold", "ma_bull", "up_streak", "amount"])
s.commit()
return n
def ingest_quotes_all(days=250, progress_every=300):
"""全市场回填:对所有证券抓取日线并计算因子。耗时较长。"""
cmap = svc._code_name_map()
codes = [c for c in cmap.keys() if c[:1] in ("0", "3", "6")]
total = len(codes)
done = 0
with get_session() as s:
job = JobRun(job="ingest_all", status="running", message=f"0/{total}")
s.add(job); s.commit(); job_id = job.id
try:
for i in range(0, total, 50):
batch = codes[i:i + 50]
ingest_quotes(batch, days=days, with_metrics=True, cmap=cmap)
done += len(batch)
if done % progress_every < 50:
with get_session() as s:
j = s.get(JobRun, job_id); j.message = f"{done}/{total}"; s.commit()
status, msg = "success", f"{done}/{total}"
except Exception as e:
status, msg = "error", f"{done}/{total} | {repr(e)[:160]}"
with get_session() as s:
j = s.get(JobRun, job_id); j.status = status
j.finished_at = dt.datetime.now(); j.message = msg; s.commit()
return {"status": status, "done": done, "total": total}
def ingest_securities():
cmap = svc._code_name_map()
rows = [{"code": c, "name": n, "market": "A"} for c, n in cmap.items()]
with get_session() as s:
cnt = _upsert(s, Security, rows, ["code"], ["name", "market"])
s.commit()
return cnt
def ingest_indices():
if ak is None:
return 0
n = 0
with get_session() as s:
for code, (name, _b) in svc.MAJOR_INDEX.items():
try:
df = ak.stock_zh_index_daily(symbol=code).tail(400)
rows = []
for _, r in df.iterrows():
d = r["date"]
d = d.date() if hasattr(d, "date") else dt.date.fromisoformat(str(d)[:10])
rows.append({"code": code, "name": name, "date": d,
"open": float(r["open"]), "high": float(r["high"]),
"low": float(r["low"]), "close": float(r["close"]),
"volume": int(r.get("volume", 0) or 0)})
n += _upsert(s, IndexDaily, rows, ["code", "date"],
["name", "open", "high", "low", "close", "volume"])
s.commit()
except Exception:
continue
return n
def ingest_sectors():
data = svc.get_industry_boards()
d = _today()
rows = [{"date": d, "name": b["name"], "pct": b["pct"], "amount": b.get("amount", 0),
"count": b.get("count", 0), "leader": b.get("leader", "")} for b in data["list"]]
with get_session() as s:
n = _upsert(s, SectorDaily, rows, ["date", "name"], ["pct", "amount", "count", "leader"])
s.commit()
return n
def ingest_fund_flow():
data = svc.get_fund_flow()
d = _today()
rows = [{"date": d, "name": x["name"], "net": x["net"], "pct": x.get("pct", 0)} for x in data["list"]]
with get_session() as s:
n = _upsert(s, FundFlowDaily, rows, ["date", "name"], ["net", "pct"])
s.commit()
return n
def ingest_sentiment():
x = svc.get_sentiment()
d = _today()
row = [{"date": d, "up": x["up"], "down": x["down"], "flat": x["flat"],
"limit_up": x["limit_up"], "limit_down": x["limit_down"]}]
with get_session() as s:
n = _upsert(s, SentimentDaily, row, ["date"], ["up", "down", "flat", "limit_up", "limit_down"])
s.commit()
return n
def ingest_dragon():
data = svc.get_dragon_tiger()
if not data["list"]:
return 0
try:
d = dt.date.fromisoformat(f"{data['date'][:4]}-{data['date'][4:6]}-{data['date'][6:8]}") if data.get("date") else _today()
except Exception:
d = _today()
rows = [{"date": d, "code": x["code"], "name": x["name"], "pct": x["pct"],
"net": x["net"], "reason": x["reason"][:120]} for x in data["list"]]
with get_session() as s:
n = _upsert(s, DragonTiger, rows, ["date", "code", "reason"], ["name", "pct", "net"])
s.commit()
return n
# ---------------- 编排 ----------------
def run_daily_ingest(universe=None, with_quotes=True):
universe = universe or config.DEFAULT_UNIVERSE
with get_session() as s:
job = JobRun(job="daily_ingest", status="running", message="")
s.add(job)
s.commit()
job_id = job.id
summary = {}
try:
summary["securities"] = ingest_securities()
summary["indices"] = ingest_indices()
summary["sectors"] = ingest_sectors()
summary["fund_flow"] = ingest_fund_flow()
summary["sentiment"] = ingest_sentiment()
summary["dragon"] = ingest_dragon()
if with_quotes:
summary["quotes"] = ingest_quotes(universe)
status, msg = "success", str(summary)
except Exception as e:
status, msg = "error", f"{summary} | EXC {repr(e)[:200]}"
with get_session() as s:
job = s.get(JobRun, job_id)
job.status = status
job.finished_at = dt.datetime.now()
job.message = msg
s.commit()
return {"status": status, "summary": summary}

42
backend/llm.py Normal file
View File

@@ -0,0 +1,42 @@
"""大模型客户端OpenAI 兼容接口)。
未配置 LLM_API_KEY 时 enabled() 返回 False调用方应走规则降级。
"""
from __future__ import annotations
import requests
import config
def enabled() -> bool:
return bool(config.LLM_API_KEY)
def chat(messages, temperature: float = 0.5, max_tokens: int = 900) -> str:
"""调用 chat/completions。失败抛异常由上层捕获降级。"""
if not enabled():
raise RuntimeError("LLM 未配置")
url = config.LLM_BASE_URL.rstrip("/") + "/chat/completions"
headers = {"Authorization": f"Bearer {config.LLM_API_KEY}", "Content-Type": "application/json"}
payload = {"model": config.LLM_MODEL, "messages": messages,
"temperature": temperature, "max_tokens": max_tokens, "stream": False}
r = requests.post(url, json=payload, headers=headers, timeout=config.LLM_TIMEOUT)
r.raise_for_status()
data = r.json()
return data["choices"][0]["message"]["content"].strip()
SYSTEM_PROMPT = (
"你是一名专业的A股市场分析师擅长复盘、个股诊断与题材分析。"
"你的结论必须严格基于用户提供的结构化数据,不臆造未提供的数字。"
"语言简洁专业,使用中文。涉及操作建议时必须同时给出风险提示,"
"并说明这不构成投资建议。"
)
def ask(user_content: str, temperature: float = 0.5, max_tokens: int = 900) -> str:
return chat([
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_content},
], temperature=temperature, max_tokens=max_tokens)

624
backend/main.py Normal file
View File

@@ -0,0 +1,624 @@
"""智策股票终端 — FastAPI 后端入口。
- /api/* : 数据接口(基于 AkShare带缓存与降级
- / : 托管前端原型prototype 目录)
"""
import os
import json
import datetime as dt
from contextlib import asynccontextmanager
from fastapi import FastAPI, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, func, desc
from pydantic import BaseModel
import akshare_service as svc
import config
import scheduler
import backtest as bt
import ai
import signals as sig
import report as rpt
import portfolio as pf
import llm
import alerts as al
import notifier
from db import init_db, get_session
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
AlertRule, AlertEvent)
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
init_db()
scheduler.start_scheduler()
print("[startup] db + scheduler ready")
except Exception as e:
print("[startup] WARN:", repr(e)[:160])
yield
app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# 自选股本地存储
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
WATCH_FILE = os.path.join(BASE_DIR, "watchlist.json")
DEFAULT_WATCH = ["600519", "300750", "002594", "688981", "300059", "601012"]
def load_watch():
if os.path.exists(WATCH_FILE):
try:
with open(WATCH_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
pass
return DEFAULT_WATCH
def save_watch(symbols):
with open(WATCH_FILE, "w", encoding="utf-8") as f:
json.dump(symbols, f, ensure_ascii=False)
# ============ API ============
@app.get("/api/health")
def health():
return {"ok": True, "akshare": svc.AK_OK}
@app.get("/api/indices")
def indices():
return svc.get_indices()
@app.get("/api/kline")
def kline(symbol: str = Query("600519"), days: int = Query(120, ge=20, le=500)):
return svc.get_kline(symbol, days)
@app.get("/api/sentiment")
def sentiment():
return svc.get_sentiment()
@app.get("/api/treemap")
def treemap(mode: str = Query("sector")):
return svc.get_treemap(mode)
@app.get("/api/fundflow")
def fundflow():
return svc.get_fund_flow()
@app.get("/api/hot/stocks")
def hot_stocks():
return svc.get_hot_stocks()
@app.get("/api/hot/sectors")
def hot_sectors():
return svc.get_industry_boards()
@app.get("/api/dragon")
def dragon():
return svc.get_dragon_tiger()
@app.get("/api/watchlist")
def watchlist():
return svc.get_watchlist(load_watch())
@app.post("/api/watchlist/{code}")
def watch_add(code: str):
w = load_watch()
if code not in w:
w.append(code)
save_watch(w)
return {"ok": True, "list": w}
@app.delete("/api/watchlist/{code}")
def watch_del(code: str):
w = [c for c in load_watch() if c != code]
save_watch(w)
return {"ok": True, "list": w}
# ============ 数据中台 ============
@app.get("/api/admin/status")
def admin_status():
counts, last_dates = {}, {}
with get_session() as s:
for label, model in [("securities", Security), ("quotes_daily", DailyQuote),
("index_daily", IndexDaily), ("sector_daily", SectorDaily),
("fund_flow_daily", FundFlowDaily), ("sentiment_daily", SentimentDaily),
("dragon_tiger", DragonTiger)]:
counts[label] = s.execute(select(func.count()).select_from(model)).scalar() or 0
if hasattr(model, "date"):
d = s.execute(select(func.max(model.date))).scalar()
last_dates[label] = d.isoformat() if d else None
jobs = s.execute(select(JobRun).order_by(desc(JobRun.id)).limit(8)).scalars().all()
job_list = [{"id": j.id, "job": j.job, "status": j.status,
"started": j.started_at.strftime("%m-%d %H:%M:%S") if j.started_at else "",
"finished": j.finished_at.strftime("%H:%M:%S") if j.finished_at else "",
"message": j.message[:200]} for j in jobs]
return {"counts": counts, "last_dates": last_dates, "jobs": job_list,
"running": scheduler.is_running(), "universe": config.DEFAULT_UNIVERSE,
"schedule": f"周一至周五 {config.INGEST_HOUR:02d}:{config.INGEST_MINUTE:02d}"}
@app.post("/api/admin/ingest")
def admin_ingest():
if scheduler.is_running():
return {"started": False, "msg": "已有入库任务在执行"}
return scheduler.trigger_async()
@app.post("/api/admin/ingest_all")
def admin_ingest_all():
return scheduler.trigger_all_async()
@app.get("/api/db/kline")
def db_kline(symbol: str = Query("600519"), days: int = Query(250, ge=20, le=1000)):
with get_session() as s:
rows = s.execute(
select(DailyQuote).where(DailyQuote.code == symbol)
.order_by(DailyQuote.date.desc()).limit(days)
).scalars().all()
rows = list(reversed(rows))
if not rows:
return {"source": "db", "empty": True, "symbol": symbol, "dates": [], "ohlc": [], "vols": []}
return {"source": "db", "symbol": symbol,
"dates": [r.date.strftime("%m/%d") for r in rows],
"ohlc": [[r.open, r.close, r.low, r.high] for r in rows],
"vols": [r.volume for r in rows]}
@app.get("/api/db/sentiment_history")
def db_sentiment_history(days: int = Query(60, ge=5, le=365)):
with get_session() as s:
rows = s.execute(select(SentimentDaily).order_by(SentimentDaily.date.desc()).limit(days)).scalars().all()
rows = list(reversed(rows))
return {"dates": [r.date.isoformat() for r in rows],
"up": [r.up for r in rows], "down": [r.down for r in rows],
"limit_up": [r.limit_up for r in rows]}
@app.get("/api/review/daily")
def review_daily(date: str = Query(None)):
with get_session() as s:
if date:
d = dt.date.fromisoformat(date)
else:
d = s.execute(select(func.max(SectorDaily.date))).scalar()
if not d:
return {"ok": False, "msg": "暂无入库数据,请先在数据中台执行入库"}
sectors = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all()
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all()
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(10)).scalars().all()
top_sec = [{"name": x.name, "pct": x.pct} for x in sectors[:8]]
bot_sec = [{"name": x.name, "pct": x.pct} for x in sectors[-5:]]
inflow = [{"name": x.name, "net": x.net} for x in flows[:8]]
outflow = [{"name": x.name, "net": x.net} for x in flows[-5:][::-1]]
senti_d = ({"up": senti.up, "down": senti.down, "limit_up": senti.limit_up,
"limit_down": senti.limit_down} if senti else None)
summary = _gen_review_text(d, senti_d, top_sec, inflow)
return {"ok": True, "date": d.isoformat(), "sentiment": senti_d,
"top_sectors": top_sec, "weak_sectors": bot_sec,
"inflow": inflow, "outflow": outflow,
"dragon": [{"name": x.name, "code": x.code, "net": x.net, "pct": x.pct} for x in lhb],
"summary": summary}
def _gen_review_text(d, senti, top_sec, inflow):
parts = [f"{d.isoformat()} 复盘】"]
if senti:
tone = "情绪偏暖" if senti["up"] > senti["down"] else "情绪偏弱"
parts.append(f"全市场上涨 {senti['up']} 家、下跌 {senti['down']} 家,涨停 {senti['limit_up']} 家、跌停 {senti['limit_down']} 家,{tone}")
if top_sec:
names = "".join(x["name"] for x in top_sec[:3])
parts.append(f"领涨板块:{names}")
if inflow:
names = "".join(x["name"] for x in inflow[:3])
parts.append(f"主力净流入居前:{names}")
parts.append("以上为基于入库数据的自动统计AI 智能点评将在 AI 分析模块接入大模型后生成。")
return " ".join(parts)
@app.get("/api/backtest")
def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60),
slow: int = Query(20, ge=5, le=250)):
if fast >= slow:
return {"ok": False, "msg": "快线周期需小于慢线周期"}
return bt.run_backtest(symbol, fast, slow)
# ============ 全市场选股 ============
STRATEGIES = {
"surge": "最近暴涨5日涨幅≥20%",
"plunge": "最近暴跌5日跌幅≥15%",
"dip": "超跌抄底60日分位≤20%且当日企稳)",
"breakout": "突破走强逼近60日新高",
"ma_bull": "均线多头MA5>10>20",
"volume": "放量上攻量比≥2且上涨",
"macd_gold": "MACD金叉",
"strong": "强势连涨≥3日连阳",
}
@app.get("/api/screen/strategies")
def screen_strategies():
return {"list": [{"id": k, "name": v} for k, v in STRATEGIES.items()]}
@app.get("/api/screen")
def screen(strategy: str = Query("surge"), limit: int = Query(60, ge=10, le=300),
min_amount: float = Query(0.0)):
M = StockMetric
q = select(M)
order = M.ret5.desc()
if strategy == "surge":
q = q.where(M.ret5 >= 20)
elif strategy == "plunge":
q = q.where(M.ret5 <= -15); order = M.ret5.asc()
elif strategy == "dip":
q = q.where(M.pos60 <= 0.2, M.pct > 0); order = M.pos60.asc()
elif strategy == "breakout":
q = q.where(M.pos60 >= 0.95, M.pct > 0); order = M.ret20.desc()
elif strategy == "ma_bull":
q = q.where(M.ma_bull.is_(True)); order = M.ret20.desc()
elif strategy == "volume":
q = q.where(M.vol_ratio >= 2, M.pct > 0); order = M.vol_ratio.desc()
elif strategy == "macd_gold":
q = q.where(M.macd_gold.is_(True)); order = M.ret5.desc()
elif strategy == "strong":
q = q.where(M.up_streak >= 3); order = M.up_streak.desc()
if min_amount > 0:
q = q.where(M.amount >= min_amount)
q = q.order_by(order).limit(limit)
with get_session() as s:
rows = s.execute(q).scalars().all()
total = s.execute(select(func.count()).select_from(M)).scalar() or 0
return {"strategy": strategy, "name": STRATEGIES.get(strategy, strategy), "pool_size": total,
"count": len(rows), "list": [{
"code": r.code, "name": r.name, "close": r.close, "pct": r.pct,
"ret5": r.ret5, "ret20": r.ret20, "vol_ratio": r.vol_ratio,
"rsi14": r.rsi14, "pos60": round(r.pos60 * 100, 1), "amount": r.amount,
"up_streak": r.up_streak} for r in rows]}
@app.get("/api/securities/search")
def securities_search(q: str = Query("", min_length=0), limit: int = Query(15, le=50)):
with get_session() as s:
stmt = select(Security)
if q:
stmt = stmt.where((Security.code.like(f"{q}%")) | (Security.name.like(f"%{q}%")))
rows = s.execute(stmt.limit(limit)).scalars().all()
return {"list": [{"code": r.code, "name": r.name} for r in rows]}
# ============ 个股复盘K线 + 买卖点 + 回放) ============
def _ma_list(close, n):
out = [None] * len(close)
for i in range(len(close)):
if i >= n - 1:
out[i] = round(sum(close[i - n + 1:i + 1]) / n, 3)
return out
@app.get("/api/review/stock")
def review_stock(symbol: str = Query("600519"), days: int = Query(250, ge=40, le=1000),
fast: int = Query(5), slow: int = Query(20)):
with get_session() as s:
rows = s.execute(
select(DailyQuote).where(DailyQuote.code == symbol)
.order_by(DailyQuote.date.desc()).limit(days)
).scalars().all()
sec = s.get(Security, symbol)
rows = list(reversed(rows))
if not rows:
return {"ok": False, "msg": "该股票库内无日线,请先在数据中台入库该股或执行全市场回填", "symbol": symbol}
dates = [r.date.strftime("%y/%m/%d") for r in rows]
ohlc = [[r.open, r.close, r.low, r.high] for r in rows]
vols = [r.volume for r in rows]
close = [r.close for r in rows]
maf, mas = _ma_list(close, fast), _ma_list(close, slow)
signals = []
for i in range(1, len(close)):
if maf[i] is None or mas[i] is None or maf[i - 1] is None or mas[i - 1] is None:
continue
if maf[i - 1] <= mas[i - 1] and maf[i] > mas[i]:
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "buy"})
elif maf[i - 1] >= mas[i - 1] and maf[i] < mas[i]:
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "sell"})
# 区间统计
hi = max(r.high for r in rows); lo = min(r.low for r in rows)
period_ret = round((close[-1] / close[0] - 1) * 100, 2)
return {"ok": True, "symbol": symbol, "name": sec.name if sec else symbol,
"dates": dates, "ohlc": ohlc, "vols": vols,
"ma_fast": maf, "ma_slow": mas, "fast": fast, "slow": slow,
"signals": signals,
"stats": {"period_return": period_ret, "high": hi, "low": lo,
"start": dates[0], "end": dates[-1], "bars": len(rows)}}
# ============ AI 分析 ============
@app.get("/api/ai/status")
def ai_status():
return {"enabled": llm.enabled(), "model": config.LLM_MODEL if llm.enabled() else None}
@app.get("/api/ai/review_daily")
def ai_review_daily(date: str = Query(None)):
return ai.review_daily_comment(date)
@app.get("/api/ai/diagnose")
def ai_diagnose(symbol: str = Query("600519")):
return ai.diagnose(symbol)
@app.get("/api/ai/today")
def ai_today():
return ai.today_strategy()
# ============ 可回溯:信号历史胜率 + 实测准确率 ============
@app.get("/api/ai/signal_stats")
def ai_signal_stats(horizon: int = Query(5, ge=1, le=20)):
return {"horizon": horizon, "stats": sig.get_stats(horizon)}
@app.post("/api/ai/signal_stats/compute")
def ai_signal_stats_compute(sample: int = Query(500, ge=50, le=4000), horizon: int = Query(5, ge=1, le=20)):
return scheduler.trigger_signal_stats_async(sample, horizon)
@app.get("/api/ai/accuracy")
def ai_accuracy():
return sig.accuracy()
@app.post("/api/ai/accuracy/verify")
def ai_accuracy_verify():
return sig.verify_predictions()
# ============ AI 自动复盘日报 ============
@app.get("/api/report/daily")
def report_daily(date: str = Query(None)):
return rpt.get_by_date(date) if date else rpt.latest()
@app.get("/api/report/history")
def report_history(limit: int = Query(30, ge=1, le=120)):
return rpt.history(limit)
@app.post("/api/report/generate")
def report_generate(date: str = Query(None), push: bool = Query(False)):
return rpt.generate(date, push=push)
# ============ 交易日志 & 组合 ============
class TradeIn(BaseModel):
code: str
name: str = ""
side: str = "buy"
price: float
qty: int
fee: float = 0.0
date: str = ""
reason: str = ""
emotion: str = ""
@app.get("/api/trades")
def list_trades():
with get_session() as s:
rows = s.execute(select(Trade).order_by(Trade.date.desc(), Trade.id.desc())).scalars().all()
names = {}
return {"list": [{"id": t.id, "date": t.date.isoformat(), "code": t.code, "name": t.name,
"side": t.side, "price": t.price, "qty": t.qty, "fee": t.fee,
"reason": t.reason, "emotion": t.emotion} for t in rows]}
@app.post("/api/trades")
def add_trade(t: TradeIn):
d = dt.date.fromisoformat(t.date) if t.date else dt.date.today()
name = t.name
if not name:
with get_session() as s:
sec = s.get(Security, t.code)
name = sec.name if sec else t.code
with get_session() as s:
row = Trade(date=d, code=t.code, name=name, side=t.side, price=t.price,
qty=t.qty, fee=t.fee, reason=t.reason, emotion=t.emotion)
s.add(row); s.commit()
return {"ok": True, "id": row.id}
@app.delete("/api/trades/{tid}")
def del_trade(tid: int):
with get_session() as s:
row = s.get(Trade, tid)
if row:
s.delete(row); s.commit()
return {"ok": True}
@app.get("/api/portfolio")
def get_portfolio():
return pf.compute()
@app.get("/api/portfolio/equity")
def portfolio_equity():
return pf.equity_curve()
# ============ 推送通知 ============
@app.get("/api/notify/status")
def notify_status():
return {"channels": notifier.channels_status(), "enabled": notifier.any_enabled()}
@app.post("/api/notify/test")
def notify_test():
if not notifier.any_enabled():
return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"}
res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。")
return {"ok": True, "result": res}
# ============ 智能预警 ============
class AlertIn(BaseModel):
code: str
kind: str = "price_above"
threshold: float
note: str = ""
@app.get("/api/alerts")
def list_alerts():
with get_session() as s:
rows = s.execute(select(AlertRule).order_by(AlertRule.id.desc())).scalars().all()
return {"list": [{"id": r.id, "code": r.code, "name": r.name, "kind": r.kind,
"threshold": r.threshold, "status": r.status, "note": r.note,
"last_value": r.last_value,
"triggered_at": r.triggered_at.strftime("%m-%d %H:%M") if r.triggered_at else ""}
for r in rows]}
@app.post("/api/alerts")
def add_alert(a: AlertIn):
with get_session() as s:
sec = s.get(Security, a.code)
name = sec.name if sec else a.code
row = AlertRule(code=a.code, name=name, kind=a.kind, threshold=a.threshold, note=a.note)
s.add(row); s.commit()
return {"ok": True, "id": row.id}
@app.delete("/api/alerts/{aid}")
def del_alert(aid: int):
with get_session() as s:
row = s.get(AlertRule, aid)
if row:
s.delete(row); s.commit()
return {"ok": True}
@app.post("/api/alerts/{aid}/reactivate")
def reactivate_alert(aid: int):
with get_session() as s:
row = s.get(AlertRule, aid)
if row:
row.status = "active"; row.triggered_at = None; s.commit()
return {"ok": True}
@app.post("/api/alerts/check")
def manual_check():
return al.check_alerts()
@app.get("/api/alerts/events")
def alert_events(unread_only: bool = Query(False), limit: int = Query(30, le=100)):
with get_session() as s:
stmt = select(AlertEvent).order_by(AlertEvent.id.desc())
if unread_only:
stmt = stmt.where(AlertEvent.read.is_(False))
rows = s.execute(stmt.limit(limit)).scalars().all()
unread = s.execute(select(func.count()).select_from(AlertEvent).where(AlertEvent.read.is_(False))).scalar() or 0
return {"unread": unread, "list": [{"id": e.id, "code": e.code, "name": e.name,
"message": e.message, "time": e.created_at.strftime("%m-%d %H:%M:%S") if e.created_at else ""}
for e in rows]}
@app.post("/api/alerts/events/read")
def mark_events_read():
with get_session() as s:
for e in s.execute(select(AlertEvent).where(AlertEvent.read.is_(False))).scalars():
e.read = True
s.commit()
return {"ok": True}
# ============ 资讯中心 ============
@app.get("/api/news")
def news(limit: int = Query(40, le=100)):
return svc.get_news(limit)
@app.get("/api/news/stock")
def news_stock(code: str = Query(...)):
return svc.get_stock_news(code)
@app.get("/api/news/watch")
def news_watch():
codes = load_watch()[:6]
out = []
for c in codes:
r = svc.get_stock_news(c, limit=4)
for x in r["list"]:
x["code"] = c
out.append(x)
out.sort(key=lambda x: x["time"], reverse=True)
return {"list": out[:40]}
class NewsAI(BaseModel):
title: str
content: str = ""
@app.post("/api/news/ai")
def news_ai(n: NewsAI):
text_in = (n.title + "" + n.content).strip()
senti, kw = svc.judge_sentiment(text_in)
if llm.enabled():
try:
prompt = ("请分析下面这条财经资讯:\n"
"1) 一句话摘要2) 利好/利空/中性判断及理由3) 可能受影响的板块或个股方向。120字内。\n\n"
+ text_in[:1200])
text = llm.ask(prompt, temperature=0.3, max_tokens=400)
return {"ok": True, "source": "llm", "sentiment": senti, "text": text}
except Exception:
pass
return {"ok": True, "source": "rule", "sentiment": senti,
"text": f"判断:{senti}(关键词:{''.join(kw) or ''})。摘要:{text_in[:80]}\n(配置大模型后可获得更深入的关联分析)"}
# ============ 静态前端 ============
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype")
if os.path.isdir(FRONTEND_DIR):
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)

223
backend/models.py Normal file
View File

@@ -0,0 +1,223 @@
"""数据中台 ORM 模型SQLAlchemy 2.0)。"""
from __future__ import annotations
import datetime as dt
from sqlalchemy import (BigInteger, Date, DateTime, Float, Integer, String,
Text, UniqueConstraint, func)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class Security(Base):
"""证券基础信息。"""
__tablename__ = "securities"
code: Mapped[str] = mapped_column(String(12), primary_key=True)
name: Mapped[str] = mapped_column(String(40))
market: Mapped[str] = mapped_column(String(8), default="A")
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class DailyQuote(Base):
"""个股日线(前复权)。"""
__tablename__ = "quotes_daily"
__table_args__ = (UniqueConstraint("code", "date", name="uq_quote_code_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
open: Mapped[float] = mapped_column(Float)
high: Mapped[float] = mapped_column(Float)
low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
volume: Mapped[int] = mapped_column(BigInteger, default=0)
amount: Mapped[float] = mapped_column(Float, default=0.0)
class IndexDaily(Base):
"""指数日线。"""
__tablename__ = "index_daily"
__table_args__ = (UniqueConstraint("code", "date", name="uq_index_code_date"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
date: Mapped[dt.date] = mapped_column(Date, index=True)
open: Mapped[float] = mapped_column(Float)
high: Mapped[float] = mapped_column(Float)
low: Mapped[float] = mapped_column(Float)
close: Mapped[float] = mapped_column(Float)
volume: Mapped[int] = mapped_column(BigInteger, default=0)
class SectorDaily(Base):
"""板块每日快照。"""
__tablename__ = "sector_daily"
__table_args__ = (UniqueConstraint("date", "name", name="uq_sector_date_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
name: Mapped[str] = mapped_column(String(40))
pct: Mapped[float] = mapped_column(Float, default=0.0)
amount: Mapped[float] = mapped_column(Float, default=0.0)
count: Mapped[int] = mapped_column(Integer, default=0)
leader: Mapped[str] = mapped_column(String(40), default="")
class FundFlowDaily(Base):
"""行业资金流每日快照。"""
__tablename__ = "fund_flow_daily"
__table_args__ = (UniqueConstraint("date", "name", name="uq_fund_date_name"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
name: Mapped[str] = mapped_column(String(40))
net: Mapped[float] = mapped_column(Float, default=0.0)
pct: Mapped[float] = mapped_column(Float, default=0.0)
class SentimentDaily(Base):
"""市场情绪每日快照。"""
__tablename__ = "sentiment_daily"
date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
up: Mapped[int] = mapped_column(Integer, default=0)
down: Mapped[int] = mapped_column(Integer, default=0)
flat: Mapped[int] = mapped_column(Integer, default=0)
limit_up: Mapped[int] = mapped_column(Integer, default=0)
limit_down: Mapped[int] = mapped_column(Integer, default=0)
class DragonTiger(Base):
"""龙虎榜明细。"""
__tablename__ = "dragon_tiger"
__table_args__ = (UniqueConstraint("date", "code", "reason", name="uq_lhb"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
code: Mapped[str] = mapped_column(String(12))
name: Mapped[str] = mapped_column(String(40), default="")
pct: Mapped[float] = mapped_column(Float, default=0.0)
net: Mapped[float] = mapped_column(Float, default=0.0)
reason: Mapped[str] = mapped_column(String(120), default="")
class StockMetric(Base):
"""个股最新因子快照(供全市场选股快速查询)。"""
__tablename__ = "stock_metrics"
code: Mapped[str] = mapped_column(String(12), primary_key=True)
name: Mapped[str] = mapped_column(String(40), default="")
date: Mapped[dt.date] = mapped_column(Date, index=True)
close: Mapped[float] = mapped_column(Float, default=0.0)
pct: Mapped[float] = mapped_column(Float, default=0.0, index=True)
ma5: Mapped[float] = mapped_column(Float, default=0.0)
ma10: Mapped[float] = mapped_column(Float, default=0.0)
ma20: Mapped[float] = mapped_column(Float, default=0.0)
ma60: Mapped[float] = mapped_column(Float, default=0.0)
vol_ratio: Mapped[float] = mapped_column(Float, default=0.0)
ret5: Mapped[float] = mapped_column(Float, default=0.0, index=True)
ret20: Mapped[float] = mapped_column(Float, default=0.0)
ret60: Mapped[float] = mapped_column(Float, default=0.0)
pos60: Mapped[float] = mapped_column(Float, default=0.0) # 0~160日价格分位
rsi14: Mapped[float] = mapped_column(Float, default=0.0)
macd_gold: Mapped[bool] = mapped_column(default=False)
ma_bull: Mapped[bool] = mapped_column(default=False)
up_streak: Mapped[int] = mapped_column(Integer, default=0)
amount: Mapped[float] = mapped_column(Float, default=0.0)
class Trade(Base):
"""交易记录(用于持仓盈亏与归因)。"""
__tablename__ = "trades"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
side: Mapped[str] = mapped_column(String(4)) # buy / sell
price: Mapped[float] = mapped_column(Float)
qty: Mapped[int] = mapped_column(Integer)
fee: Mapped[float] = mapped_column(Float, default=0.0)
reason: Mapped[str] = mapped_column(String(60), default="")
emotion: Mapped[str] = mapped_column(String(20), default="")
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class AlertRule(Base):
"""预警规则。"""
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
kind: Mapped[str] = mapped_column(String(20)) # price_above/price_below/pct_above/pct_below
threshold: Mapped[float] = mapped_column(Float)
channel: Mapped[str] = mapped_column(String(20), default="站内")
note: Mapped[str] = mapped_column(String(80), default="")
status: Mapped[str] = mapped_column(String(12), default="active") # active/triggered
last_value: Mapped[float] = mapped_column(Float, default=0.0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
triggered_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
class AlertEvent(Base):
"""预警触发事件(站内通知)。"""
__tablename__ = "alert_events"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
rule_id: Mapped[int] = mapped_column(Integer, index=True)
code: Mapped[str] = mapped_column(String(12))
name: Mapped[str] = mapped_column(String(40), default="")
message: Mapped[str] = mapped_column(String(160))
value: Mapped[float] = mapped_column(Float, default=0.0)
read: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class DailyReport(Base):
"""AI 自动复盘日报(收盘后生成,可推送)。"""
__tablename__ = "daily_reports"
date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
source: Mapped[str] = mapped_column(String(8), default="rule") # llm / rule
title: Mapped[str] = mapped_column(String(80), default="")
content: Mapped[str] = mapped_column(Text, default="") # markdown 正文
pushed: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class SignalStat(Base):
"""信号历史胜率(基于全市场历史日线回测的统计,支撑 AI 证据链的『历史命中率』)。"""
__tablename__ = "signal_stats"
__table_args__ = (UniqueConstraint("signal", "horizon", name="uq_signal_horizon"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
signal: Mapped[str] = mapped_column(String(24), index=True)
horizon: Mapped[int] = mapped_column(Integer, default=5) # 向后 N 个交易日
samples: Mapped[int] = mapped_column(Integer, default=0)
win_rate: Mapped[float] = mapped_column(Float, default=0.0) # 上涨占比 %
avg_ret: Mapped[float] = mapped_column(Float, default=0.0) # 平均收益 %
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class Prediction(Base):
"""AI 诊断/预测留痕N 日后核验真实涨跌,形成可回溯的『实测准确率』。"""
__tablename__ = "predictions"
__table_args__ = (UniqueConstraint("code", "date", "kind", name="uq_pred"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
date: Mapped[dt.date] = mapped_column(Date, index=True)
code: Mapped[str] = mapped_column(String(12), index=True)
name: Mapped[str] = mapped_column(String(40), default="")
kind: Mapped[str] = mapped_column(String(16), default="diagnose")
score: Mapped[float] = mapped_column(Float, default=0.0)
confidence: Mapped[float] = mapped_column(Float, default=0.0)
direction: Mapped[str] = mapped_column(String(6), default="flat") # up/down/flat
horizon: Mapped[int] = mapped_column(Integer, default=5)
base_close: Mapped[float] = mapped_column(Float, default=0.0)
actual_ret: Mapped[float] = mapped_column(Float, default=0.0)
status: Mapped[str] = mapped_column(String(8), default="open") # open/closed
hit: Mapped[bool | None] = mapped_column(nullable=True)
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class JobRun(Base):
"""定时/手动任务执行日志。"""
__tablename__ = "job_runs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
job: Mapped[str] = mapped_column(String(40))
status: Mapped[str] = mapped_column(String(16)) # running/success/error
started_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
finished_at: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True)
message: Mapped[str] = mapped_column(Text, default="")

84
backend/notifier.py Normal file
View File

@@ -0,0 +1,84 @@
"""推送通知:邮件(SMTP) + Server酱 + 企业微信机器人 + PushPlus。
任意渠道配置了凭证即启用notify() 会向所有已配置渠道发送,返回各渠道结果。
"""
from __future__ import annotations
import smtplib
import ssl
from email.mime.text import MIMEText
from email.header import Header
import requests
import config
def channels_status():
return {
"email": bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_TO),
"serverchan": bool(config.SERVERCHAN_KEY),
"wecom": bool(config.WECOM_WEBHOOK),
"pushplus": bool(config.PUSHPLUS_TOKEN),
}
def any_enabled():
return any(channels_status().values())
def _send_email(title, content):
msg = MIMEText(content, "plain", "utf-8")
msg["Subject"] = Header(title, "utf-8")
msg["From"] = config.SMTP_USER
tos = [x.strip() for x in config.SMTP_TO.split(",") if x.strip()]
msg["To"] = ",".join(tos)
ctx = ssl.create_default_context()
if config.SMTP_PORT == 465:
with smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT, context=ctx, timeout=15) as srv:
srv.login(config.SMTP_USER, config.SMTP_PASSWORD)
srv.sendmail(config.SMTP_USER, tos, msg.as_string())
else:
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as srv:
srv.starttls(context=ctx)
srv.login(config.SMTP_USER, config.SMTP_PASSWORD)
srv.sendmail(config.SMTP_USER, tos, msg.as_string())
return True
def _send_serverchan(title, content):
url = f"https://sctapi.ftqq.com/{config.SERVERCHAN_KEY}.send"
r = requests.post(url, data={"title": title, "desp": content}, timeout=12)
r.raise_for_status()
return r.json().get("code", 0) == 0
def _send_wecom(title, content):
r = requests.post(config.WECOM_WEBHOOK,
json={"msgtype": "text", "text": {"content": f"{title}\n{content}"}}, timeout=12)
r.raise_for_status()
return r.json().get("errcode", -1) == 0
def _send_pushplus(title, content):
r = requests.post("https://www.pushplus.plus/send",
json={"token": config.PUSHPLUS_TOKEN, "title": title, "content": content}, timeout=12)
r.raise_for_status()
return r.json().get("code", 0) == 200
def notify(title, content):
"""向所有已配置渠道推送,返回 {渠道: 'ok'|错误信息}。"""
st = channels_status()
senders = {"email": _send_email, "serverchan": _send_serverchan,
"wecom": _send_wecom, "pushplus": _send_pushplus}
result = {}
for ch, on in st.items():
if not on:
continue
try:
senders[ch](title, content)
result[ch] = "ok"
except Exception as e:
result[ch] = repr(e)[:120]
return result

176
backend/portfolio.py Normal file
View File

@@ -0,0 +1,176 @@
"""持仓与盈亏归因(移动加权平均成本法)。"""
from __future__ import annotations
from collections import defaultdict
from sqlalchemy import select
from db import get_session
from models import Trade, StockMetric, DailyQuote, Security, IndexDaily
def _current_prices(codes):
px = {}
if not codes:
return px
with get_session() as s:
for m in s.execute(select(StockMetric).where(StockMetric.code.in_(codes))).scalars():
px[m.code] = m.close
missing = [c for c in codes if c not in px]
for c in missing:
row = s.execute(select(DailyQuote.close).where(DailyQuote.code == c)
.order_by(DailyQuote.date.desc()).limit(1)).scalar()
if row:
px[c] = float(row)
return px
def compute():
with get_session() as s:
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
pos = defaultdict(lambda: {"qty": 0, "cost": 0.0, "name": ""}) # 持仓
realized = defaultdict(float) # 各股已实现盈亏
by_reason = defaultdict(float) # 按理由归因(已实现)
by_emotion = defaultdict(float)
closed_sells = [] # 每笔卖出的盈亏(胜率用)
for t in trades:
p = pos[t.code]
p["name"] = t.name or p["name"]
if t.side == "buy":
p["cost"] += t.price * t.qty + t.fee
p["qty"] += t.qty
else: # sell
if p["qty"] <= 0:
continue
avg = p["cost"] / p["qty"] if p["qty"] else 0.0
qty = min(t.qty, p["qty"])
pnl = (t.price - avg) * qty - t.fee
realized[t.code] += pnl
by_reason[t.reason or "未标注"] += pnl
by_emotion[t.emotion or "未标注"] += pnl
closed_sells.append(pnl)
p["cost"] -= avg * qty
p["qty"] -= qty
codes = [c for c, v in pos.items() if v["qty"] > 0]
px = _current_prices(codes)
holdings, mkt_val, hold_cost, unreal = [], 0.0, 0.0, 0.0
for c in codes:
v = pos[c]
avg = v["cost"] / v["qty"] if v["qty"] else 0.0
cur = px.get(c, avg)
mv = cur * v["qty"]
u = (cur - avg) * v["qty"]
mkt_val += mv; hold_cost += v["cost"]; unreal += u
holdings.append({"code": c, "name": v["name"], "qty": v["qty"],
"avg_cost": round(avg, 3), "cur": round(cur, 3),
"market_value": round(mv, 2), "unrealized": round(u, 2),
"unrealized_pct": round((cur / avg - 1) * 100, 2) if avg else 0.0})
holdings.sort(key=lambda x: x["unrealized"], reverse=True)
total_realized = sum(realized.values())
wins = sum(1 for x in closed_sells if x > 0)
win_rate = round(wins / len(closed_sells) * 100, 1) if closed_sells else 0.0
# 盈亏归因:按个股(已实现+浮动)
attr_codes = defaultdict(float)
for c, r in realized.items():
attr_codes[c] += r
for h in holdings:
attr_codes[h["code"]] += h["unrealized"]
name_map = {h["code"]: h["name"] for h in holdings}
name_map.update({c: pos[c]["name"] for c in realized})
by_stock = sorted([{"code": c, "name": name_map.get(c, c), "pnl": round(v, 2)}
for c, v in attr_codes.items()], key=lambda x: x["pnl"], reverse=True)
return {
"summary": {
"market_value": round(mkt_val, 2), "hold_cost": round(hold_cost, 2),
"unrealized": round(unreal, 2), "realized": round(total_realized, 2),
"total_pnl": round(unreal + total_realized, 2),
"positions": len(holdings), "closed_trades": len(closed_sells), "win_rate": win_rate,
},
"holdings": holdings,
"attribution": {
"by_stock": by_stock,
"by_reason": sorted([{"key": k, "pnl": round(v, 2)} for k, v in by_reason.items()], key=lambda x: x["pnl"], reverse=True),
"by_emotion": sorted([{"key": k, "pnl": round(v, 2)} for k, v in by_emotion.items()], key=lambda x: x["pnl"], reverse=True),
},
}
def equity_curve():
"""重建每日资金曲线:累计盈亏(已实现+浮动) + 投入本金净值并对比沪深300。"""
with get_session() as s:
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
if not trades:
return {"ok": False, "msg": "暂无交易记录"}
codes = list({t.code for t in trades})
# 各股日线 {code: {date: close}}
px = defaultdict(dict)
start = min(t.date for t in trades)
for c in codes:
for d, close in s.execute(select(DailyQuote.date, DailyQuote.close)
.where(DailyQuote.code == c, DailyQuote.date >= start)
.order_by(DailyQuote.date)).all():
px[c][d] = float(close)
# 交易日序列用沪深300的日期轴
idx = s.execute(select(IndexDaily.date, IndexDaily.close)
.where(IndexDaily.code == "sh000300", IndexDaily.date >= start)
.order_by(IndexDaily.date)).all()
days = [d for d, _ in idx] or sorted({d for c in px for d in px[c]})
idx_map = {d: float(c) for d, c in idx}
# 按日推进
from collections import defaultdict as dd
pos = dd(lambda: {"qty": 0, "cost": 0.0})
realized = 0.0
ti = 0
last_px = {}
dates, equity, invested_curve, bench = [], [], [], []
base_idx = None
max_invested = 0.0
for d in days:
# 应用当天及之前所有交易
while ti < len(trades) and trades[ti].date <= d:
t = trades[ti]; p = pos[t.code]
if t.side == "buy":
p["cost"] += t.price * t.qty + t.fee; p["qty"] += t.qty
else:
if p["qty"] > 0:
avg = p["cost"] / p["qty"]; qty = min(t.qty, p["qty"])
realized += (t.price - avg) * qty - t.fee
p["cost"] -= avg * qty; p["qty"] -= qty
ti += 1
unreal = 0.0
invested = 0.0
for c, p in pos.items():
if p["qty"] <= 0:
continue
close = px[c].get(d, last_px.get(c))
if close is None:
continue
last_px[c] = close
avg = p["cost"] / p["qty"]
unreal += (close - avg) * p["qty"]
invested += p["cost"]
max_invested = max(max_invested, invested, 1.0)
dates.append(d.isoformat())
equity.append(round(realized + unreal, 2))
invested_curve.append(round(invested, 2))
if d in idx_map:
base_idx = base_idx or idx_map[d]
bench.append(round(idx_map[d] / base_idx, 4))
else:
bench.append(bench[-1] if bench else 1.0)
# 净值:以累计最大投入为基准
nav = [round(1 + e / max_invested, 4) for e in equity]
return {"ok": True, "dates": dates, "equity": equity, "invested": invested_curve,
"nav": nav, "bench": bench,
"final_pnl": equity[-1] if equity else 0.0,
"max_invested": round(max_invested, 2)}

37
backend/rag.py Normal file
View File

@@ -0,0 +1,37 @@
"""轻量 RAG检索与个股/大盘相关的资讯,做情绪标注,作为 LLM 上下文降低幻觉。
当前为基于来源接口 + 关键词情绪的检索式上下文后续可平滑升级为向量检索embedding + 向量库)。
"""
from __future__ import annotations
import akshare_service as svc
def stock_news(symbol: str, limit: int = 5):
"""返回个股相关资讯(已带利好/利空标注)。"""
try:
data = svc.get_stock_news(symbol, limit=limit)
return data.get("list", [])[:limit]
except Exception:
return []
def _senti_score(items):
pos = sum(1 for x in items if x.get("sentiment") == "利好")
neg = sum(1 for x in items if x.get("sentiment") == "利空")
if pos > neg:
return "利好", pos, neg
if neg > pos:
return "利空", pos, neg
return "中性", pos, neg
def stock_context(symbol: str, limit: int = 5):
"""供 AI 诊断使用:检索资讯 + 汇总情绪。"""
items = stock_news(symbol, limit)
tone, pos, neg = _senti_score(items)
block = ""
if items:
block = "近期相关资讯(检索):\n" + "\n".join(
f"- [{x.get('sentiment','中性')}] {x.get('title','')}{x.get('time','')}" for x in items)
return {"items": items, "tone": tone, "pos": pos, "neg": neg, "block": block}

168
backend/report.py Normal file
View File

@@ -0,0 +1,168 @@
"""AI 自动复盘日报汇总当日盘面生成图文日报markdown落库并可推送。
数据全部来自中台sector_daily / fund_flow_daily / sentiment_daily / dragon_tiger / stock_metrics
AI 点评与明日策略复用 ai.py有 key 走大模型,无 key 规则降级)。
"""
from __future__ import annotations
import datetime as dt
from sqlalchemy import select, func
import ai
import llm
import notifier
from db import get_session
from models import (SectorDaily, FundFlowDaily, SentimentDaily, DragonTiger,
StockMetric, DailyReport)
def _gather(date=None):
with get_session() as s:
d = (dt.date.fromisoformat(date) if date
else s.execute(select(func.max(SectorDaily.date))).scalar())
if not d:
return None
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d)
.order_by(SectorDaily.pct.desc())).scalars().all()
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d)
.order_by(FundFlowDaily.net.desc())).scalars().all()
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d)
.order_by(DragonTiger.net.desc()).limit(6)).scalars().all()
mdate = s.execute(select(func.max(StockMetric.date))).scalar()
gainers = streak = []
if mdate:
gainers = s.execute(select(StockMetric).where(StockMetric.date == mdate)
.order_by(StockMetric.pct.desc()).limit(8)).scalars().all()
streak = s.execute(select(StockMetric).where(StockMetric.date == mdate, StockMetric.up_streak >= 3)
.order_by(StockMetric.up_streak.desc(), StockMetric.ret5.desc()).limit(8)).scalars().all()
return {
"date": d, "senti": senti, "secs": secs, "flows": flows, "lhb": lhb,
"gainers": [(x.name, x.code, x.pct) for x in gainers],
"streak": [(x.name, x.up_streak, x.ret5) for x in streak],
}
def _build_markdown(g):
d = g["date"]
s = g["senti"]
secs, flows, lhb = g["secs"], g["flows"], g["lhb"]
parts = [f"# {d} 收盘复盘日报\n"]
# 一、市场温度
if s:
ratio = (s.up / max(1, s.up + s.down)) * 100
mood = "赚钱效应较好" if s.up > s.down * 1.5 else ("情绪偏弱" if s.down > s.up else "多空分歧")
parts.append("## 一、市场温度")
parts.append(f"- 涨跌家数:上涨 **{s.up}** / 下跌 **{s.down}** / 平盘 {s.flat}(上涨占比 {ratio:.0f}%")
parts.append(f"- 涨停 **{s.limit_up}** 家 / 跌停 **{s.limit_down}** 家")
parts.append(f"- 综合判断:{mood}\n")
# 二、板块表现
if secs:
top = secs[:6]
bot = secs[-4:]
parts.append("## 二、板块表现")
parts.append("- 领涨:" + "".join(f"{x.name}({x.pct:+.2f}%)" for x in top))
parts.append("- 领跌:" + "".join(f"{x.name}({x.pct:+.2f}%)" for x in bot) + "\n")
# 三、资金流向
if flows:
parts.append("## 三、资金流向(主力净额)")
parts.append("- 净流入前列:" + "".join(f"{x.name}({x.net:+.1f}亿)" for x in flows[:6]))
parts.append("- 净流出前列:" + "".join(f"{x.name}({x.net:+.1f}亿)" for x in flows[-4:][::-1]) + "\n")
# 四、强势梯队
if g["streak"]:
parts.append("## 四、强势梯队(连涨)")
parts.append("".join(f"{n}{k}连阳, 5日{r:+.1f}%" for n, k, r in g["streak"]) + "\n")
# 五、龙虎榜焦点
if lhb:
parts.append("## 五、龙虎榜焦点(净买额)")
parts.append("".join(f"{n}({v:+.2f}亿)" for n, v in [(x.name, x.net) for x in lhb]) + "\n")
return "\n".join(parts)
def generate(date=None, push=False):
"""生成(或重生成)某日复盘日报。默认最新交易日。"""
g = _gather(date)
if not g:
return {"ok": False, "msg": "暂无入库数据,请先到数据中台入库"}
d = g["date"]
body = _build_markdown(g)
# AI 综合点评 + 明日策略(复用 ai.py
rv = ai.review_daily_comment(d.isoformat())
st = ai.today_strategy()
source = "llm" if (rv.get("source") == "llm" or st.get("source") == "llm") else "rule"
body += "\n## 六、AI 综合点评\n" + (rv.get("text", "") if rv.get("ok") else "")
body += "\n\n## 七、明日策略\n" + (st.get("text", "") if st.get("ok") else "")
title = f"{d} 收盘复盘日报"
with get_session() as s:
row = s.get(DailyReport, d)
if row:
row.source, row.title, row.content = source, title, body
else:
row = DailyReport(date=d, source=source, title=title, content=body)
s.add(row)
s.commit()
result = {"ok": True, "date": d.isoformat(), "source": source, "title": title, "content": body}
if push and notifier.any_enabled():
try:
# 推送精简版(情绪 + 领涨 + AI 点评首段)
brief = _push_brief(g, rv)
res = notifier.notify("【智策】" + title, brief)
with get_session() as s:
r2 = s.get(DailyReport, d)
if r2:
r2.pushed = True
s.commit()
result["push"] = res
except Exception as e:
result["push"] = {"error": repr(e)[:120]}
return result
def _push_brief(g, rv):
s = g["senti"]
lines = [f"{g['date']} 收盘复盘"]
if s:
lines.append(f"{s.up}/跌{s.down},涨停{s.limit_up}/跌停{s.limit_down}")
if g["secs"]:
lines.append("领涨:" + "".join(f"{x.name}({x.pct:+.1f}%)" for x in g["secs"][:4]))
if g["flows"]:
lines.append("吸金:" + "".join(x.name for x in g["flows"][:4]))
txt = (rv.get("text") or "").strip().split("\n")[0]
if txt:
lines.append("点评:" + txt[:80])
return "\n".join(lines)
def latest():
with get_session() as s:
row = s.execute(select(DailyReport).order_by(DailyReport.date.desc())).scalars().first()
if not row:
return {"ok": False, "msg": "暂无日报,请先生成"}
return {"ok": True, "date": row.date.isoformat(), "source": row.source,
"title": row.title, "content": row.content, "pushed": row.pushed}
def get_by_date(date):
with get_session() as s:
row = s.get(DailyReport, dt.date.fromisoformat(date))
if not row:
return {"ok": False, "msg": "该日无日报"}
return {"ok": True, "date": row.date.isoformat(), "source": row.source,
"title": row.title, "content": row.content, "pushed": row.pushed}
def history(limit=30):
with get_session() as s:
rows = s.execute(select(DailyReport).order_by(DailyReport.date.desc()).limit(limit)).scalars().all()
return {"list": [{"date": r.date.isoformat(), "source": r.source,
"title": r.title, "pushed": r.pushed} for r in rows]}

8
backend/requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
akshare>=1.18.60
pandas>=2.2.2
cachetools==5.5.0
SQLAlchemy>=2.0.30
APScheduler>=3.10.4
psycopg2-binary>=2.9.9

144
backend/scheduler.py Normal file
View File

@@ -0,0 +1,144 @@
"""定时任务:收盘后自动入库。"""
from __future__ import annotations
import threading
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger
import config
import ingest
import alerts
import report
import signals
_scheduler: BackgroundScheduler | None = None
_lock = threading.Lock()
_running = {"flag": False}
def _job():
with _lock:
if _running["flag"]:
return
_running["flag"] = True
try:
ingest.run_daily_ingest()
finally:
_running["flag"] = False
def trigger_async():
"""手动触发一次入库(后台线程,不阻塞请求)。"""
t = threading.Thread(target=_job, daemon=True)
t.start()
return {"started": True}
def _job_all():
with _lock:
if _running["flag"]:
return
_running["flag"] = True
try:
ingest.ingest_quotes_all()
finally:
_running["flag"] = False
def trigger_all_async():
"""手动触发全市场回填(后台线程,耗时较长)。"""
if _running["flag"]:
return {"started": False, "msg": "已有任务在执行"}
t = threading.Thread(target=_job_all, daemon=True)
t.start()
return {"started": True}
def is_running():
return _running["flag"]
def _job_report():
try:
report.generate(push=True)
except Exception as e:
print("[report] generate error:", repr(e)[:160])
def trigger_report_async(push=True):
"""手动触发生成日报(后台线程)。"""
t = threading.Thread(target=lambda: report.generate(push=push), daemon=True)
t.start()
return {"started": True}
_sig_running = {"flag": False}
def _job_signal_stats(sample=500, horizon=5):
if _sig_running["flag"]:
return
_sig_running["flag"] = True
try:
signals.compute_signal_stats(sample_limit=sample, horizon=horizon)
except Exception as e:
print("[signals] compute error:", repr(e)[:160])
finally:
_sig_running["flag"] = False
def trigger_signal_stats_async(sample=500, horizon=5):
if _sig_running["flag"]:
return {"started": False, "msg": "胜率回测进行中"}
t = threading.Thread(target=lambda: _job_signal_stats(sample, horizon), daemon=True)
t.start()
return {"started": True}
def _job_verify():
try:
signals.verify_predictions()
except Exception as e:
print("[predict] verify error:", repr(e)[:160])
def start_scheduler():
global _scheduler
if _scheduler is not None:
return _scheduler
_scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
_scheduler.add_job(
_job, CronTrigger(day_of_week="mon-fri", hour=config.INGEST_HOUR, minute=config.INGEST_MINUTE),
id="daily_ingest", replace_existing=True, misfire_grace_time=3600,
)
_scheduler.add_job(
_safe_check_alerts, IntervalTrigger(seconds=60),
id="alert_check", replace_existing=True, max_instances=1,
)
# 收盘入库之后 10 分钟生成 AI 复盘日报并推送
_rep_total = config.INGEST_HOUR * 60 + config.INGEST_MINUTE + 10
_scheduler.add_job(
_job_report, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=_rep_total % 60),
id="daily_report", replace_existing=True, misfire_grace_time=3600,
)
# 收盘后核验到期预测(实测准确率)
_scheduler.add_job(
_job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60),
id="verify_pred", replace_existing=True, misfire_grace_time=3600,
)
# 每周六重算信号历史胜率
_scheduler.add_job(
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
id="signal_stats", replace_existing=True, misfire_grace_time=7200,
)
_scheduler.start()
return _scheduler
def _safe_check_alerts():
try:
alerts.check_alerts()
except Exception as e:
print("[alert] check error:", repr(e)[:120])

194
backend/signals.py Normal file
View File

@@ -0,0 +1,194 @@
"""信号历史胜率回测 + AI 预测留痕核验。
- compute_signal_stats: 对全市场历史日线回测各技术信号「N 日后上涨概率/平均收益」,
作为 AI 证据链『历史命中率』的客观依据。
- record_prediction / verify_predictions / accuracy: 记录每次 AI 诊断N 日后核验真实涨跌,
形成可回溯的『实测准确率』。
"""
from __future__ import annotations
import datetime as dt
import numpy as np
import pandas as pd
from sqlalchemy import select, func, distinct
from db import get_session
from models import DailyQuote, StockMetric, SignalStat, Prediction
SIGNAL_DEFS = {
"ma_bull": "均线多头排列(MA5>MA10>MA20)",
"macd_gold": "MACD 金叉",
"up_streak3": "三连阳",
"vol_breakout": "放量上涨(量比>2)",
"rsi_oversold": "RSI 超卖(<30)抄底",
"new_high60": "创60日新高",
}
def _indicators(df: pd.DataFrame) -> pd.DataFrame:
c = df["close"]
df["ma5"] = c.rolling(5).mean()
df["ma10"] = c.rolling(10).mean()
df["ma20"] = c.rolling(20).mean()
e12 = c.ewm(span=12, adjust=False).mean()
e26 = c.ewm(span=26, adjust=False).mean()
df["dif"] = e12 - e26
df["dea"] = df["dif"].ewm(span=9, adjust=False).mean()
delta = c.diff()
gain = delta.clip(lower=0)
loss = -delta.clip(upper=0)
ag = gain.rolling(14).mean()
al = loss.rolling(14).mean()
df["rsi"] = 100 - 100 / (1 + ag / al.replace(0, np.nan))
df["vavg5"] = df["volume"].rolling(5).mean().shift(1)
df["high60"] = c.rolling(60).max()
return df
def _masks(df: pd.DataFrame) -> dict:
c = df["close"]
c1 = c.shift(1)
return {
"ma_bull": (df.ma5 > df.ma10) & (df.ma10 > df.ma20),
"macd_gold": (df.dif.shift(1) < df.dea.shift(1)) & (df.dif >= df.dea),
"up_streak3": (c > c1) & (c1 > c.shift(2)) & (c.shift(2) > c.shift(3)),
"vol_breakout": (df.volume / df.vavg5 > 2) & (c > c1),
"rsi_oversold": df.rsi < 30,
"new_high60": c >= df.high60,
}
def compute_signal_stats(sample_limit: int = 500, horizon: int = 5):
"""对样本股历史回测各信号 N 日后表现并落库。"""
with get_session() as s:
codes = [r[0] for r in s.execute(select(StockMetric.code).limit(sample_limit)).all()]
if not codes:
codes = [r[0] for r in s.execute(select(distinct(DailyQuote.code)).limit(sample_limit)).all()]
agg = {k: {"n": 0, "win": 0, "sum": 0.0} for k in SIGNAL_DEFS}
used = 0
with get_session() as s:
for code in codes:
rows = s.execute(select(DailyQuote.date, DailyQuote.close, DailyQuote.volume)
.where(DailyQuote.code == code).order_by(DailyQuote.date)).all()
if len(rows) < 80:
continue
used += 1
df = pd.DataFrame(rows, columns=["date", "close", "volume"])
df["close"] = df["close"].astype(float)
df["volume"] = df["volume"].astype(float)
df = _indicators(df)
fwd = df["close"].shift(-horizon) / df["close"] - 1
for k, m in _masks(df).items():
m = m & fwd.notna()
r = fwd[m]
if len(r):
agg[k]["n"] += int(len(r))
agg[k]["win"] += int((r > 0).sum())
agg[k]["sum"] += float(r.sum())
with get_session() as s:
for k, a in agg.items():
if a["n"] == 0:
continue
wr = round(a["win"] / a["n"] * 100, 1)
ar = round(a["sum"] / a["n"] * 100, 2)
row = s.execute(select(SignalStat).where(SignalStat.signal == k, SignalStat.horizon == horizon)).scalar_one_or_none()
if row:
row.samples, row.win_rate, row.avg_ret, row.updated_at = a["n"], wr, ar, dt.datetime.now()
else:
s.add(SignalStat(signal=k, horizon=horizon, samples=a["n"], win_rate=wr, avg_ret=ar))
s.commit()
return {"ok": True, "horizon": horizon, "sampled": used,
"result": {k: {"samples": a["n"], "win_rate": round(a["win"] / a["n"] * 100, 1) if a["n"] else None}
for k, a in agg.items()}}
def get_stats(horizon: int = 5) -> dict:
with get_session() as s:
rows = s.execute(select(SignalStat).where(SignalStat.horizon == horizon)).scalars().all()
return {r.signal: {"label": SIGNAL_DEFS.get(r.signal, r.signal), "win_rate": r.win_rate,
"avg_ret": r.avg_ret, "samples": r.samples} for r in rows}
def active_signals(m: StockMetric) -> list[str]:
"""根据最新因子快照判断当前激活的信号。"""
out = []
if m.ma_bull:
out.append("ma_bull")
if m.macd_gold:
out.append("macd_gold")
if m.up_streak >= 3:
out.append("up_streak3")
if m.vol_ratio > 2 and m.pct > 0:
out.append("vol_breakout")
if m.rsi14 < 30:
out.append("rsi_oversold")
if m.pos60 >= 0.99:
out.append("new_high60")
return out
# ---------------- 预测留痕与核验 ----------------
def record_prediction(code, name, date, score, confidence, direction, base_close, horizon=5, kind="diagnose"):
with get_session() as s:
exist = s.execute(select(Prediction).where(
Prediction.code == code, Prediction.date == date, Prediction.kind == kind)).scalar_one_or_none()
if exist:
return False
s.add(Prediction(date=date, code=code, name=name, kind=kind, score=score,
confidence=confidence, direction=direction, horizon=horizon, base_close=base_close))
s.commit()
return True
def verify_predictions():
"""对到期(已有 horizon 个交易日)的 open 预测核验真实涨跌。"""
with get_session() as s:
opens = s.execute(select(Prediction).where(Prediction.status == "open")).scalars().all()
closed = 0
for p in opens:
future = s.execute(select(DailyQuote.close).where(
DailyQuote.code == p.code, DailyQuote.date > p.date)
.order_by(DailyQuote.date).limit(p.horizon)).all()
if len(future) < p.horizon:
continue
end = float(future[-1][0])
ret = (end / p.base_close - 1) * 100 if p.base_close else 0.0
if p.direction == "up":
hit = ret > 0
elif p.direction == "down":
hit = ret < 0
else:
hit = abs(ret) < 2
p.actual_ret = round(ret, 2)
p.hit = bool(hit)
p.status = "closed"
closed += 1
s.commit()
return {"ok": True, "closed": closed}
def accuracy():
with get_session() as s:
rows = s.execute(select(Prediction).where(Prediction.status == "closed")).scalars().all()
opens = s.execute(select(func.count()).select_from(Prediction).where(Prediction.status == "open")).scalar()
n = len(rows)
hits = sum(1 for r in rows if r.hit)
by_dir = {}
for r in rows:
d = by_dir.setdefault(r.direction, {"n": 0, "hit": 0})
d["n"] += 1
d["hit"] += 1 if r.hit else 0
recent = sorted(rows, key=lambda r: (r.date, r.id), reverse=True)[:25]
return {
"closed": n, "open": opens or 0,
"hit_rate": round(hits / n * 100, 1) if n else None,
"avg_ret": round(sum(r.actual_ret for r in rows) / n, 2) if n else None,
"by_direction": {k: {"n": v["n"], "hit_rate": round(v["hit"] / v["n"] * 100, 1)}
for k, v in by_dir.items()},
"recent": [{"date": r.date.isoformat(), "code": r.code, "name": r.name,
"direction": r.direction, "score": r.score, "confidence": r.confidence,
"actual_ret": r.actual_ret, "hit": r.hit, "horizon": r.horizon} for r in recent],
}

1017
prototype/app.js vendored Normal file

File diff suppressed because it is too large Load Diff

43
prototype/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>智策 · 股票分析复盘终端</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<!-- 顶部栏 -->
<header class="topbar">
<div class="brand">智策 <em>StockTerminal</em></div>
<div class="search">
<input type="text" placeholder="搜索股票 / 代码 / 板块 (Ctrl+K)" />
</div>
<div class="ticker" id="ticker"></div>
<div class="top-right">
<span id="alert-bell" title="预警提醒">🔔<span id="alert-count" class="bell-count" style="display:none">0</span></span>
<span id="dsource" title="当前数据来源">数据源: -</span>
<span class="clock" id="clock"></span>
<span class="market-state" id="mstate">● 交易中</span>
<span class="user">游客</span>
</div>
</header>
<div class="layout">
<!-- 左侧菜单 -->
<aside class="sidebar" id="sidebar">
<nav id="menu"></nav>
<div class="collapse" id="collapseBtn" title="折叠菜单">«</div>
</aside>
<!-- 内容区 -->
<main class="content">
<div class="crumb" id="crumb"></div>
<div id="view"></div>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

194
prototype/style.css Normal file
View File

@@ -0,0 +1,194 @@
:root {
--bg: #0a0e15;
--topbar: #0f141d;
--sidebar: #0d121a;
--sidebar-2: #131a25;
--panel: #121822;
--panel-head: #161d29;
--border: #232c3a;
--border-soft: #1b222e;
--text: #d7dee8;
--text-dim: #7d8796;
--text-mute: #586273;
--up: #f6465d; /* 红涨 */
--down: #2ebd85; /* 绿跌 */
--accent: #2f6fed;
--gold: #e8a13a;
--radius: 2px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
font-size: 13px;
overflow: hidden;
}
.up { color: var(--up); }
.down { color: var(--down); }
.num { font-variant-numeric: tabular-nums; }
/* ============ 顶部栏 ============ */
.topbar {
height: 48px; display: flex; align-items: center; gap: 18px;
background: var(--topbar); border-bottom: 1px solid var(--border);
padding: 0 16px;
}
.brand { font-size: 16px; font-weight: 700; letter-spacing: .5px; white-space: nowrap; }
.brand em { color: var(--gold); font-style: normal; font-weight: 500; font-size: 12px; margin-left: 4px; }
.search { width: 280px; }
.search input {
width: 100%; height: 30px; background: #0a0e15; border: 1px solid var(--border);
border-radius: var(--radius); color: var(--text); padding: 0 10px; font-size: 12px; outline: none;
}
.search input:focus { border-color: var(--accent); }
.ticker { flex: 1; overflow: hidden; display: flex; gap: 22px; white-space: nowrap; color: var(--text-dim); font-size: 12px; }
.ticker .ti { display: inline-flex; gap: 6px; align-items: center; }
.ticker .ti b { color: var(--text); font-weight: 500; }
.top-right { display: flex; align-items: center; gap: 16px; white-space: nowrap; font-size: 12px; color: var(--text-dim); }
.clock { font-variant-numeric: tabular-nums; }
.market-state { color: var(--down); }
.user { padding: 3px 10px; border: 1px solid var(--border); border-radius: var(--radius); }
/* ============ 布局 ============ */
.layout { display: flex; height: calc(100vh - 48px); }
/* ============ 侧边栏 ============ */
.sidebar {
width: 208px; background: var(--sidebar); border-right: 1px solid var(--border);
display: flex; flex-direction: column; transition: width .15s; flex-shrink: 0;
}
.sidebar.collapsed { width: 52px; }
.sidebar nav { flex: 1; overflow-y: auto; padding: 6px 0; }
.sidebar nav::-webkit-scrollbar { width: 6px; }
.sidebar nav::-webkit-scrollbar-thumb { background: #222b39; }
.menu-group > .g-head {
display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 14px;
cursor: pointer; color: var(--text-dim); user-select: none; font-size: 13px;
border-left: 2px solid transparent;
}
.menu-group > .g-head:hover { background: var(--sidebar-2); color: var(--text); }
.menu-group.open > .g-head { color: var(--text); }
.g-head .ico { width: 16px; text-align: center; font-size: 14px; flex-shrink: 0; }
.g-head .g-name { flex: 1; white-space: nowrap; overflow: hidden; }
.g-head .arrow { font-size: 10px; color: var(--text-mute); transition: transform .15s; }
.menu-group.open > .g-head .arrow { transform: rotate(90deg); }
.submenu { display: none; background: #0a0e15; }
.menu-group.open .submenu { display: block; }
.submenu a {
display: block; height: 32px; line-height: 32px; padding: 0 14px 0 40px;
color: var(--text-dim); text-decoration: none; font-size: 12.5px; white-space: nowrap;
border-left: 2px solid transparent;
}
.submenu a:hover { color: var(--text); background: var(--sidebar-2); }
.submenu a.active { color: #fff; background: #16243f; border-left-color: var(--accent); }
.sidebar.collapsed .g-name, .sidebar.collapsed .arrow, .sidebar.collapsed .submenu { display: none; }
.sidebar.collapsed .g-head { justify-content: center; padding: 0; }
.collapse {
height: 34px; border-top: 1px solid var(--border); display: flex; align-items: center;
justify-content: center; cursor: pointer; color: var(--text-mute); font-size: 14px;
}
.collapse:hover { color: var(--text); background: var(--sidebar-2); }
/* ============ 内容区 ============ */
.content { flex: 1; overflow-y: auto; padding: 12px 14px 24px; }
.content::-webkit-scrollbar { width: 8px; }
.content::-webkit-scrollbar-thumb { background: #222b39; }
.crumb { color: var(--text-mute); font-size: 12px; margin-bottom: 10px; }
.crumb b { color: var(--text); font-weight: 500; }
.crumb .sep { margin: 0 6px; }
/* ============ 通用面板 ============ */
.panel { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); }
.panel-head {
height: 36px; display: flex; align-items: center; gap: 10px; padding: 0 12px;
border-bottom: 1px solid var(--border-soft); background: var(--panel-head); font-size: 13px; font-weight: 600;
}
.panel-head .bar { width: 3px; height: 13px; background: var(--accent); }
.panel-head .sub { font-weight: 400; color: var(--text-dim); font-size: 11.5px; }
.panel-head .seg { margin-left: auto; display: flex; }
.seg button {
background: #0d121a; color: var(--text-dim); border: 1px solid var(--border);
border-right: none; padding: 3px 11px; cursor: pointer; font-size: 11.5px;
}
.seg button:last-child { border-right: 1px solid var(--border); }
.seg button.active { color: #fff; background: var(--accent); border-color: var(--accent); }
.panel-body { padding: 10px 12px; }
.row { display: grid; gap: 10px; margin-bottom: 10px; }
.row.c4 { grid-template-columns: repeat(4, 1fr); }
.row.c2 { grid-template-columns: 1fr 1fr; }
.row.c32 { grid-template-columns: 3fr 2fr; }
@media (max-width: 1200px){ .row.c4 { grid-template-columns: repeat(2,1fr);} .row.c2,.row.c32{grid-template-columns:1fr;} }
/* 指数卡片 */
.idx-card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 10px 12px; position: relative; overflow: hidden; }
.idx-card .nm { font-size: 12px; color: var(--text-dim); }
.idx-card .pr { font-size: 22px; font-weight: 700; margin: 3px 0; }
.idx-card .cg { font-size: 12px; }
.idx-card .sp { position: absolute; right: 6px; bottom: 6px; width: 88px; height: 32px; opacity: .85; }
/* 情绪条 */
.senti { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; }
.senti .it { display: flex; flex-direction: column; gap: 2px; }
.senti .it .l { font-size: 11px; color: var(--text-dim); }
.senti .it .v { font-size: 17px; font-weight: 600; }
.rbar { flex: 1; min-width: 220px; height: 20px; display: flex; border-radius: var(--radius); overflow: hidden; }
.rbar .ru { background: var(--up); } .rbar .rf { background: #4b5563; } .rbar .rd { background: var(--down); }
/* 表格 */
table.grid-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.grid-tbl th { text-align: right; color: var(--text-dim); font-weight: 500; padding: 7px 10px; border-bottom: 1px solid var(--border); background: var(--panel-head); position: sticky; top: 0; }
.grid-tbl th:first-child, .grid-tbl td:first-child { text-align: left; }
.grid-tbl td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--border-soft); }
.grid-tbl tr:hover td { background: var(--sidebar-2); }
.grid-tbl tbody tr:hover td { background: #131a25; }
.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius); font-size: 11px; border: 1px solid var(--border); color: var(--text-dim); }
.tag.hot { color: var(--up); border-color: #5a2630; background: #2a1418; }
/* 占位(建设中) */
.placeholder { padding: 26px; }
.placeholder h2 { font-size: 16px; margin-bottom: 6px; }
.placeholder p { color: var(--text-dim); margin-bottom: 16px; }
.feat-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px,1fr)); gap: 10px; }
.feat-card { border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; background: var(--panel); }
.feat-card .ft { font-weight: 600; margin-bottom: 4px; }
.feat-card .fd { color: var(--text-dim); font-size: 12px; line-height: 1.6; }
.badge { font-size: 11px; color: var(--gold); border: 1px solid #4a3a18; background: #241c0d; padding: 1px 6px; border-radius: var(--radius); margin-left: 6px; }
.loading { padding: 40px; text-align: center; color: var(--text-mute); }
.btn-run { background: var(--accent); color: #fff; border: 1px solid var(--accent); height: 26px; padding: 0 14px; cursor: pointer; border-radius: var(--radius); font-size: 12px; }
.btn-run:hover { filter: brightness(1.1); }
.btn-run:disabled { background: #2a3140; border-color: #2a3140; color: var(--text-mute); cursor: not-allowed; }
.bt-form input:focus { outline: none; border-color: var(--accent); }
.cond { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12.5px; }
.cond input[type=number] { width: 70px; height: 26px; background: #0a0e15; border: 1px solid var(--border); color: var(--text); padding: 0 8px; }
.cond input:focus { outline: none; border-color: var(--accent); }
.clickrow { cursor: pointer; }
#alert-bell { cursor: pointer; position: relative; font-size: 15px; }
.bell-count { position: absolute; top: -7px; right: -10px; background: var(--up); color: #fff; font-size: 10px; min-width: 15px; height: 15px; line-height: 15px; text-align: center; border-radius: 8px; padding: 0 3px; }
.news-list { display: flex; flex-direction: column; }
.news-item { padding: 10px 4px; border-bottom: 1px solid var(--border-soft); }
.news-meta { font-size: 12px; color: var(--text-dim); margin-bottom: 4px; }
.news-time { color: var(--text-mute); }
.news-title { font-size: 14px; font-weight: 600; color: var(--text); line-height: 1.5; }
.news-sum { font-size: 12.5px; color: var(--text-dim); margin-top: 4px; line-height: 1.6; }
.news-act { margin-top: 6px; }
.ai-box { margin-top: 8px; background: #0e1420; border: 1px solid var(--border-soft); border-left: 2px solid var(--accent); padding: 8px 10px; font-size: 12.5px; line-height: 1.7; color: var(--text); }
.md-doc { line-height: 1.85; color: var(--text); max-width: 920px; }
.md-doc .md-h1 { font-size: 18px; font-weight: 700; color: var(--text); margin: 2px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
.md-doc .md-h2 { font-size: 14px; font-weight: 600; color: var(--text); margin: 16px 0 6px; display: flex; align-items: center; gap: 8px; }
.md-doc .md-h2 .bar { display: inline-block; width: 3px; height: 13px; background: var(--accent); }
.md-doc .md-ul { margin: 4px 0 4px 2px; padding-left: 16px; }
.md-doc .md-ul li { margin: 3px 0; color: var(--text-dim); }
.md-doc .md-p { margin: 6px 0; color: var(--text-dim); }
.md-doc b { color: var(--text); }
#dsource { color: var(--text-mute); font-size: 11.5px; }
#kline { width: 100%; height: 480px; }
#treemap { width: 100%; height: 480px; }
#fundflow { width: 100%; height: 360px; }

160
功能架构.md Normal file
View File

@@ -0,0 +1,160 @@
# 股票分析 · 建议 · 复盘系统 — 功能架构设计
> 版本v2.0(优化版)
> 定位:一套覆盖「行情监控 → 选股 → 复盘归因 → AI 辅助决策 → 策略回测」的个人/小团队量化辅助系统
> 核心理念:**数据先行、AI 结论可回溯、复盘形成闭环**
---
## 一、设计目标与核心壁垒
| 目标 | 说明 |
|------|------|
| 数据中台化 | 行情/资金/财务/新闻/龙虎榜统一清洗入库,可历史回溯 |
| 复盘闭环 | 每日复盘 + 个股复盘 + 交易复盘(盈亏归因),训练盘感 |
| 可信 AI | 所有 AI 建议附「依据 + 置信度 + 风险点 + 历史命中率」 |
| 选股→回测→盯盘打通 | 一个策略可一键回测、一键存为预警 |
| 全局预警 | 价格/量能/技术/资金/新闻触发,多渠道推送 |
---
## 二、整体分层架构
```mermaid
graph TD
A[数据采集层<br/>行情/资金/财务/新闻/龙虎榜] --> B[数据中台<br/>清洗·存储·因子计算·快照]
B --> C1[行情中心]
B --> C2[选股引擎]
B --> C3[复盘中心]
B --> C4[策略与回测]
B --> C5[组合与交易]
B --> C6[资讯中心]
B --> D[AI 分析层<br/>RAG+因子+LLM]
D --> C1 & C2 & C3
C2 & C3 & C4 & C5 --> E[智能预警/通知]
C1 & C2 & C3 & C4 & C5 & C6 & D --> F[Web/App 展示层]
```
---
## 三、功能模块清单
### 1. 行情中心
- **大盘总览**
- 大盘云图(涨跌热力图,可按市值/成交额加权,参考 dapanyuntu.com
- 三大指数 + 北向资金 + 涨跌停家数 + 封板率
- A股 / 美股 / 港股 大盘与板块
- **资金流向**
- 主力 / 超大单 / 大单 / 散户 资金分布
- 板块资金轮动(桑基图),支持 日 / 周 / 月 / 年
- **市场情绪温度计**(新增)
- 赚钱效应、连板高度、炸板率、量能对比、涨跌比
### 2. 热榜与异动
- 热股 / 热板块 / 热 ETF / 龙虎榜
- 强势股:连续增长(三日 >20% / 两周连涨 / 两月连涨)
- **盘中异动雷达**(新增):快速拉升、放量突破、涨停打开、大单扫货
- **龙虎榜深挖**(新增):游资席位画像、机构净买、一线游资跟踪
### 3. 选股引擎
- **内置策略**:暴涨 / 暴跌 / 抄底 / 突破 / 底部放量 / MACD金叉 / 筹码集中
- **自定义条件组合器**(新增):技术面 + 资金面 + 基本面 多因子拖拽组合
- **板块选股**:按板块、概念、产业链筛选
- 选股结果可:一键回测 / 一键存为预警 / 加入自选
### 4. 自选股
- 自选列表 + 分组
- 个股详情行情、技术、资金、财务、消息、AI 诊断聚合页)
- 上新股(次新股池、打新日历)
### 5. 复盘中心 ⭐(核心模块)
- **每日复盘**:大盘总结、领涨/领跌板块、涨停梯队、资金流向、明日关注
- **个股复盘**:走势回放、关键买卖点标注、形态识别
- **交易复盘**:基于真实交易记录的盈亏归因(赚在哪/亏在哪/是否追高)
- **AI 自动复盘日报**(新增):收盘后自动生成图文报告
- **历史情景回放**(新增):时间轴重放某天盘面,训练盘感
### 6. 组合与交易日志(新增模块)
- 多组合 / 模拟盘管理
- 持仓盈亏、仓位分布、成本分析
- 交易记录(买卖点 + 理由 + 情绪标签)
- 风险指标:最大回撤、夏普、胜率、盈亏比
### 7. 策略与回测引擎
- 向量化回测、参数寻优、滑点 / 手续费建模
- 资金曲线、回撤曲线、月度收益热力图
- 技术指标库MA/MACD/KDJ/BOLL/RSI…
- 策略库 + 实盘跟踪对比
### 8. AI 分析层
- **今天炒什么 / 接下来炒什么**:附资金、题材、政策、相似历史依据
- **买卖建议**:理由 + 置信度 + 风险点 + 止损位 + 历史命中率
- **AI 预测**:概率区间 + 模型历史准确率(不给死点位)
- **个股 AI 诊断**:技术/资金/基本面/消息面 打分卡
- **题材脉络图**:政策/事件 → 受益板块 → 龙头股 传导链
- RAG 接入资讯 + 财报,降低幻觉
### 9. 资讯中心
- 热点 / 要闻 / 快讯 / 自选股相关
- **AI 摘要 + 利好利空判定 + 关联个股**(新增)
- 事件日历:财报、解禁、分红、政策
### 10. 智能预警(贯穿全局)
- 触发类型:价格 / 涨跌幅 / 量能 / 技术信号 / 资金 / 新闻
- 推送渠道:站内 / 微信 / 邮件 / App
---
## 四、数据模型(核心表草案)
| 表名 | 用途 | 关键字段 |
|------|------|----------|
| `securities` | 证券基础信息 | code, name, market, industry, list_date |
| `quotes_daily` | 日线行情 | code, date, ohlcv, amount, turnover |
| `quotes_minute` | 分时行情 | code, datetime, price, volume |
| `fund_flow` | 资金流向 | code/sector, date, main_net, super_big… |
| `dragon_tiger` | 龙虎榜 | code, date, seat, buy, sell, type |
| `financials` | 财务数据 | code, report_date, revenue, profit, roe… |
| `news` | 资讯 | id, time, title, content, sentiment, related_codes |
| `factors` | 因子库 | code, date, factor_name, value |
| `watchlist` | 自选 | user_id, code, group |
| `portfolios` / `trades` | 组合与交易 | user_id, code, side, price, qty, reason, time |
| `strategies` / `backtests` | 策略与回测 | id, params, metrics, equity_curve |
| `alerts` | 预警规则 | user_id, code, condition, channel, status |
| `reviews` | 复盘记录 | date/code, type, content(AI生成), tags |
---
## 五、技术选型
| 层 | 选型 | 说明 |
|----|------|------|
| 数据源 | AkShare / TushareA股、yfinance美股、东方财富/同花顺接口、新闻爬虫 | 多源互补 |
| 后端 | Python + FastAPI | 金融生态最佳pandas/TA-Lib/backtrader/vectorbt |
| 时序存储 | TimescaleDB 或 ClickHouse | 海量行情读写 |
| 业务存储 | PostgreSQL | 用户/组合/策略 |
| 缓存/实时 | Redis | 行情缓存、实时推送 |
| AI | LLMDeepSeek/通义/OpenAI+ 向量库RAG+ 因子模型 | 可回溯建议 |
| 前端 | Next.js / Vue + ECharts | K线、云图、资金桑基图 |
| 任务调度 | APScheduler / Celery | 收盘自动复盘、盘中预警 |
---
## 六、实施路线(分阶段)
1. **第一阶段(地基)**:数据中台 + 行情中心 + 自选股 + K线/技术指标
2. **第二阶段(选股+复盘)**:选股引擎 + 每日复盘 + 资讯中心
3. **第三阶段(闭环)**:组合交易日志 + 回测引擎 + 智能预警
4. **第四阶段(智能)**AI 分析层(可回溯)+ AI 自动复盘日报
---
## 七、相比原架构的主要增强
- 新增 **数据中台**:所有上层能力的统一、可回溯数据基础
- 强化 **复盘中心**:从"系统名"变为真正的核心闭环(每日/个股/交易复盘 + AI 日报 + 情景回放)
- 新增 **组合与交易日志**:支撑交易复盘与盈亏归因
- 重构 **AI 分析层**:从"直接给结论"升级为"结论 + 证据链 + 历史胜率"
- 收敛 **回测/指标/资金曲线**:从散落页面变为服务选股与复盘的引擎
- 新增 **智能预警**:贯穿全局,减少盯盘成本
- 新增 **市场情绪指标 / 盘中异动雷达 / 龙虎榜深挖 / 事件日历**

223
架构总结.md Normal file
View File

@@ -0,0 +1,223 @@
# 智策 StockTerminal — 架构总结
> 版本当前实现态2026-06
> 定位:个人/小团队 A 股分析·复盘·智能辅助系统
---
## 一、整体分层
```
┌─────────────────────────────────────────────────────────────┐
│ 展示层(前端) │
│ HTML + ECharts 5 + 原生 JS │
│ prototype/index.html · style.css · app.js │
└────────────────────────┬────────────────────────────────────┘
│ HTTP / REST
┌────────────────────────▼────────────────────────────────────┐
│ 服务层(后端 FastAPI
│ main.py —— 50+ API 端点,静态文件托管 │
│ │
│ 数据服务 akshare_service.py (行情·情绪·资讯·实时报价) │
│ AI 分析 ai.py (证据链·置信度·LLM/规则) │
│ 信号统计 signals.py (胜率回测·预测留痕·核验) │
│ RAG 检索 rag.py (资讯检索·情绪标注) │
│ 复盘日报 report.py (结构化日报·推送) │
│ 组合计算 portfolio.py (持仓·P&L·资金曲线) │
│ 回测引擎 backtest.py (MA交叉·净值曲线) │
│ 预警引擎 alerts.py (实时报价判断·触发事件) │
│ 推送通知 notifier.py (SMTP·Server酱·企微·PP) │
│ 大模型客户端 llm.py (OpenAI 兼容接口) │
│ 定时调度 scheduler.py (APScheduler 5 个任务) │
│ ETL 入库 ingest.py (AkShare→PostgreSQL) │
│ CLI 工具 cli.py (init/ingest/ingest_all) │
│ 配置 config.py + .env (DB·LLM·推送密钥) │
└────────────────────────┬────────────────────────────────────┘
│ SQLAlchemy ORM / psycopg2
┌────────────────────────▼────────────────────────────────────┐
│ 数据层PostgreSQL
│ 13 张业务表,见下方数据模型 │
└─────────────────────────────────────────────────────────────┘
```
---
## 二、后端模块职责
| 文件 | 职责 | 关键依赖 |
|---|---|---|
| `main.py` | FastAPI 入口50+ REST 端点,静态托管 | 所有模块 |
| `config.py` | 环境变量读取,支持 `.env` 文件 | python-dotenv |
| `db.py` | 引擎/Session自动建库建表 | SQLAlchemy, psycopg2 |
| `models.py` | 13 个 ORM 表(见下节) | SQLAlchemy |
| `akshare_service.py` | AkShare 数据抓取,带 TTL 缓存和 mock 降级 | akshare, cachetools |
| `ingest.py` | ETLAkShare → PostgreSQL 增量 upsert | akshare, models |
| `scheduler.py` | APScheduler 后台任务5 个定时任务) | apscheduler |
| `backtest.py` | MA 交叉策略回测,读 DB 日线,输出净值曲线 | sqlalchemy, pandas |
| `ai.py` | AI 分析证据链构造、置信度计算、LLM 调用/规则降级 | llm, signals, rag |
| `signals.py` | 6 类信号历史胜率回测;预测留痕 + 到期核验 | sqlalchemy, pandas, numpy |
| `rag.py` | 资讯检索 + 利好/利空情绪标注,作为 LLM 上下文 | akshare_service |
| `report.py` | 七段式 AI 复盘日报生成、落库、推送精简版 | ai, notifier |
| `portfolio.py` | 持仓计算移动加权均价、P&L 归因、逐日资金曲线 | sqlalchemy |
| `alerts.py` | 实时报价轮询 → 规则命中 → 写事件 + 推送 | akshare_service, notifier |
| `notifier.py` | 四渠道推送SMTP 邮件、Server酱、企业微信 Webhook、PushPlus | requests, smtplib |
| `llm.py` | OpenAI 兼容客户端DeepSeek/通义/Kimi 均适用) | requests |
| `cli.py` | 命令行工具:`init` / `ingest` / `ingest_all` | ingest, db |
---
## 三、数据模型PostgreSQL
| 表名 | 说明 | 主键 / 唯一约束 |
|---|---|---|
| `securities` | 证券基础信息(代码·名称·市场) | `code` |
| `quotes_daily` | 个股日线OHLCV前复权 | `(code, date)` |
| `index_daily` | 指数日线(上证/深证/沪深300 | `(code, date)` |
| `sector_daily` | 板块每日快照(涨跌·成交额·龙头) | `(date, name)` |
| `fund_flow_daily` | 行业主力资金流每日快照 | `(date, name)` |
| `sentiment_daily` | 全市场情绪(涨跌家数/涨跌停) | `date` |
| `dragon_tiger` | 龙虎榜明细(代码·席位·净买额) | `(date, code, reason)` |
| `stock_metrics` | 个股最新因子快照MA/RSI/MACD/量比/分位/连涨 等 15 个因子) | `code` |
| `signal_stats` | 6 类技术信号历史胜率回测样本·N日上涨概率·平均收益 | `(signal, horizon)` |
| `predictions` | AI 诊断留痕方向·置信度·N日后核验·命中与否 | `(code, date, kind)` |
| `daily_reports` | AI 复盘日报markdown 正文·来源·是否已推送) | `date` |
| `trades` | 交易记录(买卖·价格·数量·手续费·理由·情绪标签) | `id` |
| `alert_rules` | 预警规则(价格上穿/下穿/涨跌幅条件·状态) | `id` |
| `alert_events` | 预警触发事件(站内通知·已读状态) | `id` |
| `job_runs` | 定时任务执行日志 | `id` |
---
## 四、定时任务APScheduler周一至周五
| 任务 ID | 触发时间 | 功能 |
|---|---|---|
| `daily_ingest` | 15:35收盘后可配 | 抓取当日板块/资金流/情绪/龙虎榜/自选股日线入库 |
| `alert_check` | 每 60 秒 | 实时报价核查所有 active 预警规则,触发则写事件并推送 |
| `daily_report` | 15:45入库+10分 | 生成 AI 七段式复盘日报并推送微信/邮件 |
| `verify_pred` | 15:50 | 核验到期 AI 预测,计算命中率 |
| `signal_stats` | 每周六 09:00 | 对全市场样本股回测 6 类信号历史胜率(可手动触发) |
---
## 五、AI 分析层设计(可回溯)
```
用户请求 /api/ai/diagnose
_stock_context() 从 stock_metrics + DailyQuote 取最新因子
├─ signals.get_stats() 读 signal_stats 表,取各信号历史胜率
├─ rag.stock_context() 拉近期资讯 → 情绪标注(利好/利空/中性)
_build_evidence() 生成 6 维证据链(趋势/技术/动量/资金/位置/消息)
每条附:事实描述 · 方向(bull/bear/neutral) · 历史胜率 · 样本数
_confidence_direction() 加权多空净值 → 置信度(%) + 预测方向(up/down/flat)
├─ signals.record_prediction() 写入 predictions 表留痕
├─ llm.ask() (有 key) 将证据链+胜率+RAG资讯构造 prompt → 大模型输出
│ 或
└─ 规则文本降级 格式化证据链为可读文本
```
实测准确率闭环:
```
预测留痕(open) → N 个交易日后 verify_predictions() 拉 DailyQuote 核验
→ 填写 actual_ret + hit(True/False) → status=closed
→ /api/ai/accuracy 按方向汇总命中率
```
---
## 六、推送通知渠道
| 渠道 | 触发条件 | 配置项(`.env` |
|---|---|---|
| SMTP 邮件 | 预警触发 / 复盘日报 / 测试 | `SMTP_HOST/PORT/USER/PASSWORD/TO` |
| Server酱微信 | 同上 | `SERVERCHAN_KEY` |
| 企业微信群机器人 | 同上 | `WECOM_WEBHOOK` |
| PushPlus微信 | 同上 | `PUSHPLUS_TOKEN` |
任意渠道配置即自动启用,互不依赖。
---
## 七、前端菜单结构app.js MENU
```
大盘行情
├─ 市场总览 行情/三大指数/情绪温度计/资金流向
├─ 大盘云图 行业/板块涨跌热力图ECharts treemap
├─ 热股榜 实时热股+板块
└─ 龙虎榜 当日龙虎榜净买额排名
自选股
├─ 自选列表 持仓快照 + K线
├─ 全市场选股 8 个内置策略 + SQL 直查 stock_metrics
└─ 条件选股 客户端多因子过滤
复盘中心
├─ 每日复盘 板块/情绪/资金流统计
├─ AI日报 七段式 markdown 日报,历史翻阅,一键推送
└─ 个股复盘 K线回放 + MA交叉买卖点标注
策略与中台
├─ 策略回测 MA交叉净值曲线 + 最大回撤
└─ 数据中台 入库状态 / 手动触发 / 任务日志
AI 分析
├─ 今日策略 大模型/规则 生成当日操作方向
├─ 个股诊断 6维证据链 + 历史胜率 + 置信度 + RAG资讯
├─ AI复盘点评 当日大模型复盘摘要
└─ AI准确率 信号历史胜率表 + 实测命中率 + 近期核验记录
组合交易
├─ 组合持仓 实时市值/浮亏/胜率
├─ 资金曲线 逐日净值 vs 沪深300基准ECharts折线
├─ 交易日志 录入买卖 / 理由 / 情绪标签
└─ 盈亏归因 按个股/理由/情绪三维归因
智能预警
├─ 预警规则 创建/删除/重激活规则,推送渠道状态检测
└─ 触发记录 预警事件列表,标记已读,🔔铃铛角标
资讯中心
├─ 要闻快讯 全球财经资讯 + 情绪标注 + AI摘要
└─ 自选相关 自选股关联资讯聚合
```
---
## 八、技术栈
| 层 | 技术选型 |
|---|---|
| 前端 | 纯 HTML + CSS + 原生 JSECharts 5CDN |
| 后端 | Python 3.12 + FastAPI 0.115 + uvicorn |
| 数据库 | PostgreSQLpsycopg2-binary + SQLAlchemy 2.0 |
| 数据源 | AkShare新浪/同花顺/乐估备选源Sina hq 实时报价 |
| 调度 | APScheduler 3.x BackgroundScheduler |
| AI | OpenAI 兼容 REST默认 DeepSeek规则降级无缝切换 |
| 缓存 | cachetools TTL内存无 Redis 依赖 |
| 推送 | SMTP(SSL) + Server酱 + 企业微信 Webhook + PushPlus |
| 部署 | WSL2 / Linux`nohup python main.py`,可 Docker 化 |
---
## 九、待完善方向
| 优先级 | 模块 | 建议 |
|---|---|---|
| 高 | 回测引擎 | 手续费/滑点建模,参数寻优,月度收益热力图 |
| 高 | 数据稳定性 | 东财源封锁问题,增加 Tushare 备用源,分钟线入库 |
| 中 | RAG 升级 | Embedding + 向量库Chroma/Qdrant支持语义检索财报 |
| 中 | 多组合 | 目前单一组合,扩展为模拟盘/真实盘多组合管理 |
| 中 | 盘中异动雷达 | 快速拉升/放量突破/涨停打开实时监测 |
| 低 | 用户系统 | 登录/鉴权,多用户自选股独立管理 |
| 低 | 部署文档 | Docker ComposeFastAPI + PostgreSQLNginx 反代 |