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

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],
}