commit 8de37d5c2dfcd46e4399c2ca84f60b67dd2b811f Author: Lukas Date: Sat Jun 13 02:26:22 2026 +0800 Initial commit: stock analysis backend and prototype UI. Co-authored-by: Cursor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53117ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# 密钥与本地配置 +backend/.env + +# Python +backend/.venv/ +__pycache__/ +*.pyc + +# 临时/日志 +*.log diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..a477e19 --- /dev/null +++ b/backend/.env.example @@ -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酱 Turbo:https://sct.ftqq.com 获取 SendKey +SERVERCHAN_KEY= +# 企业微信群机器人 webhook 完整地址 +WECOM_WEBHOOK= +# PushPlus:https://www.pushplus.plus 获取 token +PUSHPLUS_TOKEN= diff --git a/backend/ai.py b/backend/ai.py new file mode 100644 index 0000000..92b9239 --- /dev/null +++ b/backend/ai.py @@ -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} diff --git a/backend/akshare_service.py b/backend/akshare_service.py new file mode 100644 index 0000000..ca01c9f --- /dev/null +++ b/backend/akshare_service.py @@ -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]} diff --git a/backend/alerts.py b/backend/alerts.py new file mode 100644 index 0000000..f6e9c76 --- /dev/null +++ b/backend/alerts.py @@ -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} diff --git a/backend/backtest.py b/backend/backtest.py new file mode 100644 index 0000000..99a79c1 --- /dev/null +++ b/backend/backtest.py @@ -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), + }, + } diff --git a/backend/cli.py b/backend/cli.py new file mode 100644 index 0000000..0cbe277 --- /dev/null +++ b/backend/cli.py @@ -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() diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..2d744e3 --- /dev/null +++ b/backend/config.py @@ -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", "") diff --git a/backend/db.py b/backend/db.py new file mode 100644 index 0000000..56a715d --- /dev/null +++ b/backend/db.py @@ -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() diff --git a/backend/ingest.py b/backend/ingest.py new file mode 100644 index 0000000..fdb05ac --- /dev/null +++ b/backend/ingest.py @@ -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} diff --git a/backend/llm.py b/backend/llm.py new file mode 100644 index 0000000..717d30e --- /dev/null +++ b/backend/llm.py @@ -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) diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..b30882c --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..7990f50 --- /dev/null +++ b/backend/models.py @@ -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~1,60日价格分位 + 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="") diff --git a/backend/notifier.py b/backend/notifier.py new file mode 100644 index 0000000..05b512d --- /dev/null +++ b/backend/notifier.py @@ -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 diff --git a/backend/portfolio.py b/backend/portfolio.py new file mode 100644 index 0000000..d613a50 --- /dev/null +++ b/backend/portfolio.py @@ -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)} diff --git a/backend/rag.py b/backend/rag.py new file mode 100644 index 0000000..5b35d71 --- /dev/null +++ b/backend/rag.py @@ -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} diff --git a/backend/report.py b/backend/report.py new file mode 100644 index 0000000..ac00d93 --- /dev/null +++ b/backend/report.py @@ -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]} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..1d495d3 --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scheduler.py b/backend/scheduler.py new file mode 100644 index 0000000..5cf9ed6 --- /dev/null +++ b/backend/scheduler.py @@ -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]) diff --git a/backend/signals.py b/backend/signals.py new file mode 100644 index 0000000..03a7090 --- /dev/null +++ b/backend/signals.py @@ -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], + } diff --git a/prototype/app.js b/prototype/app.js new file mode 100644 index 0000000..d0a27b8 --- /dev/null +++ b/prototype/app.js @@ -0,0 +1,1017 @@ +/* ===================== 基础 ===================== */ +const UP = '#f6465d', DOWN = '#2ebd85', AXIS = '#7d8796', GRID = '#232c3a'; +const fmt = (n, d = 2) => Number(n).toLocaleString('zh-CN', { minimumFractionDigits: d, maximumFractionDigits: d }); +const rnd = (a, b) => a + Math.random() * (b - a); +const sign = n => (n >= 0 ? '+' : ''); +const cls = n => (n >= 0 ? 'up' : 'down'); +const charts = []; +function newChart(el) { const c = echarts.init(el); charts.push(c); return c; } +function disposeCharts() { while (charts.length) charts.pop().dispose(); } + +/* ===================== API 层(带降级) ===================== */ +const API_BASE = location.port === '8000' ? '' : 'http://localhost:8000'; +let LAST_SOURCE = '-'; +async function apiGet(path) { + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), 8000); + try { + const res = await fetch(API_BASE + path, { signal: ctl.signal }); + if (!res.ok) throw new Error(res.status); + const json = await res.json(); + LAST_SOURCE = json.source || 'akshare'; + updateSource(); + return json; + } catch (e) { + LAST_SOURCE = 'mock(本地)'; + updateSource(); + throw e; + } finally { + clearTimeout(t); + } +} +function updateSource() { + const el = document.getElementById('dsource'); + if (el) el.textContent = '数据源: ' + LAST_SOURCE; +} +async function apiPost(path) { + const res = await fetch(API_BASE + path, { method: 'POST' }); + return res.json(); +} + +/* ===================== 菜单配置(一级 / 二级) ===================== */ +const MENU = [ + { icon: '▤', name: '行情中心', children: [ + { id: 'overview', name: '大盘总览' }, + { id: 'cloud', name: '大盘云图' }, + { id: 'kline', name: 'K线分析' }, + { id: 'fund', name: '资金流向' }, + ]}, + { icon: '✦', name: '热榜异动', children: [ + { id: 'hot-stock', name: '热门股票' }, + { id: 'hot-sector', name: '热门板块' }, + { id: 'dragon', name: '龙虎榜' }, + { id: 'radar', name: '异动雷达', soon: true }, + ]}, + { icon: '⌖', name: '选股引擎', children: [ + { id: 'screen-strat', name: '策略选股' }, + { id: 'screen-cond', name: '条件选股' }, + { id: 'screen-sector', name: '板块选股', soon: true }, + ]}, + { icon: '★', name: '自选股', children: [ + { id: 'watch-list', name: '自选列表' }, + { id: 'watch-detail', name: '个股详情', soon: true }, + { id: 'watch-new', name: '新股次新', soon: true }, + ]}, + { icon: '↺', name: '复盘中心', children: [ + { id: 'review-daily', name: '每日复盘' }, + { id: 'review-report', name: 'AI日报' }, + { id: 'review-stock', name: '个股复盘' }, + { id: 'review-trade', name: '交易复盘', soon: true }, + ]}, + { icon: '⚙', name: '策略与中台', children: [ + { id: 'backtest', name: '策略回测' }, + { id: 'admin', name: '数据中台' }, + ]}, + { icon: '◈', name: 'AI 分析', children: [ + { id: 'ai-today', name: '今日策略' }, + { id: 'ai-diag', name: '个股诊断' }, + { id: 'ai-review', name: 'AI复盘点评' }, + { id: 'ai-accuracy', name: 'AI准确率' }, + ]}, + { icon: '◧', name: '组合交易', children: [ + { id: 'pf-holdings', name: '组合持仓' }, + { id: 'pf-equity', name: '资金曲线' }, + { id: 'pf-trades', name: '交易日志' }, + { id: 'pf-attr', name: '盈亏归因' }, + ]}, + { icon: '✉', name: '资讯中心', children: [ + { id: 'news-main', name: '要闻快讯' }, + { id: 'news-watch', name: '自选相关' }, + ]}, + { icon: '◔', name: '智能预警', children: [ + { id: 'alert-list', name: '预警规则' }, + { id: 'alert-events', name: '触发记录' }, + ]}, +]; +const VIEW_INDEX = {}; +MENU.forEach(g => g.children.forEach(c => { VIEW_INDEX[c.id] = { group: g.name, name: c.name, soon: c.soon }; })); + +/* ===================== 渲染菜单 ===================== */ +function renderMenu() { + const nav = document.getElementById('menu'); + nav.innerHTML = MENU.map((g, gi) => ` + `).join(''); + nav.querySelectorAll('.g-head').forEach(h => h.addEventListener('click', () => h.parentElement.classList.toggle('open'))); + nav.querySelectorAll('.submenu a').forEach(a => a.addEventListener('click', e => { e.preventDefault(); navigate(a.dataset.id); })); +} + +/* ===================== 路由 ===================== */ +function navigate(id) { + document.querySelectorAll('.submenu a').forEach(a => a.classList.toggle('active', a.dataset.id === id)); + document.querySelectorAll('.menu-group').forEach((g, gi) => { if (MENU[gi].children.some(c => c.id === id)) g.classList.add('open'); }); + const info = VIEW_INDEX[id]; + document.getElementById('crumb').innerHTML = `${info.group}/${info.name}`; + disposeCharts(); + const view = document.getElementById('view'); + view.innerHTML = '
加载中…
'; + (VIEWS[id] || renderSoon)(view, info); + location.hash = id; +} + +/* ===================== 顶部:时钟 + 指数滚动条 ===================== */ +function tick() { + const d = new Date(); + document.getElementById('clock').textContent = d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour12: false }); +} +setInterval(tick, 1000); tick(); + +async function renderTicker() { + let list; + try { list = (await apiGet('/api/indices')).list; } + catch { list = MAJOR.map(t => ({ name: t.n, price: t.b * (1 + rnd(-2.5, 2.5) / 100), pct: rnd(-2.5, 2.5) })); } + document.getElementById('ticker').innerHTML = list.map(t => + `${t.name} ${fmt(t.price)} ${sign(t.pct)}${fmt(t.pct)}%`).join(''); +} +const MAJOR = [{n:'上证指数',b:3210},{n:'深证成指',b:10180},{n:'创业板指',b:2105},{n:'沪深300',b:3760},{n:'北证50',b:1080}]; +renderTicker(); setInterval(renderTicker, 8000); + +/* ===================== 图表构建(接收数据) ===================== */ +const MA = (n, close) => close.map((_, i) => { if (i < n-1) return '-'; let s=0; for (let j=0;j{const k=2/(n+1);let e=arr[0];return arr.map(v=>(e=v*k+e*(1-k)));}; + const e12=ema(close,12),e26=ema(close,26); const dif=close.map((_,i)=>+(e12[i]-e26[i]).toFixed(2)); + const dea=ema(dif,9).map(v=>+v.toFixed(2)); const macd=dif.map((v,i)=>+((v-dea[i])*2).toFixed(2)); return {dif,dea,macd}; } + +function klineOption(d) { + const dates = d.dates, data = d.ohlc, close = data.map(x => x[1]); + const vols = d.vols.map((v, i) => ({ value: v, itemStyle: { color: data[i][1] >= data[i][0] ? UP : DOWN } })); + const { dif, dea, macd } = calcMACD(close); + return { + backgroundColor: 'transparent', animation: false, + tooltip: { trigger: 'axis', axisPointer: { type: 'cross' }, backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' } }, + legend: { data: ['K线','MA5','MA10','MA20','MA60'], textStyle: { color: AXIS }, top: 2, left: 0 }, + axisPointer: { link: [{ xAxisIndex: 'all' }], label: { backgroundColor: '#2f6fed' } }, + grid: [ { left: 56, right: 16, top: 28, height: '52%' }, { left: 56, right: 16, top: '64%', height: '14%' }, { left: 56, right: 16, top: '82%', height: '14%' } ], + xAxis: [ + { type: 'category', data: dates, axisLine: { lineStyle: { color: GRID } }, axisLabel: { color: AXIS } }, + { type: 'category', gridIndex: 1, data: dates, axisLabel: { show: false }, axisLine: { lineStyle: { color: GRID } } }, + { type: 'category', gridIndex: 2, data: dates, axisLabel: { show: false }, axisLine: { lineStyle: { color: GRID } } }, + ], + yAxis: [ + { scale: true, splitLine: { lineStyle: { color: GRID } }, axisLabel: { color: AXIS } }, + { gridIndex: 1, splitNumber: 2, axisLabel: { color: AXIS }, splitLine: { show: false } }, + { gridIndex: 2, splitNumber: 2, axisLabel: { color: AXIS }, splitLine: { show: false } }, + ], + dataZoom: [ { type: 'inside', xAxisIndex: [0,1,2], start: 40, end: 100 }, { type: 'slider', xAxisIndex: [0,1,2], bottom: 2, height: 14, start: 40, end: 100, textStyle: { color: AXIS }, borderColor: GRID } ], + series: [ + { name: 'K线', type: 'candlestick', data, itemStyle: { color: UP, color0: DOWN, borderColor: UP, borderColor0: DOWN } }, + { name: 'MA5', type: 'line', data: MA(5,close), smooth: true, symbol: 'none', lineStyle: { width: 1, color: '#e8a13a' } }, + { name: 'MA10', type: 'line', data: MA(10,close), smooth: true, symbol: 'none', lineStyle: { width: 1, color: '#2f6fed' } }, + { name: 'MA20', type: 'line', data: MA(20,close), smooth: true, symbol: 'none', lineStyle: { width: 1, color: '#a855f7' } }, + { name: 'MA60', type: 'line', data: MA(60,close), smooth: true, symbol: 'none', lineStyle: { width: 1, color: '#06b6d4' } }, + { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: vols }, + { name: 'MACD', type: 'bar', xAxisIndex: 2, yAxisIndex: 2, data: macd.map(v => ({ value: v, itemStyle: { color: v >= 0 ? UP : DOWN } })) }, + { name: 'DIF', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: dif, symbol: 'none', lineStyle: { width: 1, color: '#e8a13a' } }, + { name: 'DEA', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: dea, symbol: 'none', lineStyle: { width: 1, color: '#2f6fed' } }, + ], + }; +} +function mockKline(days = 120) { + const dates = [], ohlc = [], vols = []; let p = 1680; const t = new Date(); + for (let i = days; i > 0; i--) { const d = new Date(t); d.setDate(t.getDate()-i); dates.push(`${d.getMonth()+1}/${d.getDate()}`); + const o=p, c=+(o+rnd(-o*0.03,o*0.03)).toFixed(2), h=+(Math.max(o,c)+rnd(0,o*0.02)).toFixed(2), l=+(Math.min(o,c)-rnd(0,o*0.02)).toFixed(2); + ohlc.push([o,c,l,h]); vols.push(Math.round(rnd(2,9)*1e6)); p=c; } + return { dates, ohlc, vols }; +} + +function reviewKlineOption(r) { + const dates = r.dates, data = r.ohlc; + const vols = r.vols.map((v,i)=>({ value: v, itemStyle:{ color: data[i][1]>=data[i][0]?UP:DOWN } })); + const markData = r.signals.map(s => ({ + coord: [s.idx, s.price], value: s.type==='buy'?'B':'S', + symbol: s.type==='buy' ? 'triangle' : 'pin', + symbolRotate: s.type==='buy' ? 0 : 180, symbolSize: s.type==='buy'?16:14, + itemStyle: { color: s.type==='buy' ? '#f6465d' : '#2ebd85' }, + label: { show: true, color: '#fff', fontSize: 10, formatter: s.type==='buy'?'B':'S' }, + })); + return { + backgroundColor:'transparent', animation:false, + tooltip:{trigger:'axis',axisPointer:{type:'cross'},backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + legend:{data:['K线',`MA${r.fast}`,`MA${r.slow}`],textStyle:{color:AXIS},top:2,left:0}, + axisPointer:{link:[{xAxisIndex:'all'}]}, + grid:[{left:56,right:16,top:28,height:'62%'},{left:56,right:16,top:'76%',height:'18%'}], + xAxis:[{type:'category',data:dates,axisLine:{lineStyle:{color:GRID}},axisLabel:{color:AXIS}}, + {type:'category',gridIndex:1,data:dates,axisLabel:{show:false},axisLine:{lineStyle:{color:GRID}}}], + yAxis:[{scale:true,splitLine:{lineStyle:{color:GRID}},axisLabel:{color:AXIS}}, + {gridIndex:1,splitNumber:2,axisLabel:{color:AXIS},splitLine:{show:false}}], + dataZoom:[{type:'inside',xAxisIndex:[0,1],start:0,end:100},{type:'slider',xAxisIndex:[0,1],bottom:2,height:14,start:0,end:100,textStyle:{color:AXIS},borderColor:GRID}], + series:[ + {name:'K线',type:'candlestick',data,itemStyle:{color:UP,color0:DOWN,borderColor:UP,borderColor0:DOWN}, + markPoint:{data:markData,symbolKeepAspect:true}}, + {name:`MA${r.fast}`,type:'line',data:r.ma_fast,smooth:true,symbol:'none',lineStyle:{width:1,color:'#e8a13a'}}, + {name:`MA${r.slow}`,type:'line',data:r.ma_slow,smooth:true,symbol:'none',lineStyle:{width:1,color:'#2f6fed'}}, + {name:'成交量',type:'bar',xAxisIndex:1,yAxisIndex:1,data:vols}, + ], + }; +} + +function colorByPct(p){ const a=Math.min(Math.abs(p)/10,1); return p>=0?`rgba(246,70,93,${0.22+a*0.66})`:`rgba(46,189,133,${0.22+a*0.66})`; } +function treemapOption(items){ + const data = items.map(it => ({ name: it.name, value: it.value, pct: it.pct, itemStyle: { color: colorByPct(it.pct) }, + label: { formatter: `{name|${it.name}}\n{pct|${sign(it.pct)}${fmt(it.pct)}%}` } })); + return { backgroundColor: 'transparent', + tooltip: { backgroundColor: '#161d29', borderColor: GRID, textStyle: { color: '#d7dee8' }, + formatter: p => `${p.name}
规模 ${fmt(p.value,1)}
涨跌 ${sign(p.data.pct)}${fmt(p.data.pct)}%` }, + series: [{ type: 'treemap', roam: false, nodeClick: false, breadcrumb: { show: false }, + width: '100%', height: '100%', top: 4, left: 0, right: 0, bottom: 4, + label: { show: true, color: '#fff', overflow: 'truncate', rich: { name:{fontSize:12,fontWeight:600,color:'#fff'}, pct:{fontSize:11,color:'#fff',padding:[3,0,0,0]} } }, + itemStyle: { borderColor: '#0a0e15', borderWidth: 2, gapWidth: 2 }, data }] }; +} + +/* ===================== 视图组件 ===================== */ +async function loadIndices() { try { return (await apiGet('/api/indices')).list; } catch { return MAJOR.slice(0,4).map(t => { const pct=rnd(-2.5,2.5); return { name:t.n, price:t.b*(1+pct/100), change:t.b*pct/100, pct }; }); } } +function idxCardsHTML(list) { + return `
${list.slice(0,4).map((idx,i)=>{ + const series = []; let v = idx.price * 0.99; for (let k=0;k<40;k++){ v += rnd(-idx.price*0.004, idx.price*0.004); series.push(+v.toFixed(2)); } + return `
${idx.name}
+
${fmt(idx.price)}
+
${sign(idx.change)}${fmt(idx.change)} ${sign(idx.pct)}${fmt(idx.pct)}%
+
`; + }).join('')}
`; +} +function initSparks() { + document.querySelectorAll('.sp').forEach(el => { + const s = el.dataset.series.split(',').map(Number); const up = el.dataset.up === '1'; const c = newChart(el); + c.setOption({ grid:{left:0,right:0,top:2,bottom:2}, xAxis:{type:'category',show:false,data:s.map((_,k)=>k)}, yAxis:{type:'value',show:false,scale:true}, + series:[{type:'line',data:s,smooth:true,symbol:'none',lineStyle:{width:1.4,color:up?UP:DOWN},areaStyle:{color:(up?UP:DOWN)+'22'}}] }); + }); +} +async function sentimentHTML() { + let s; try { s = await apiGet('/api/sentiment'); } catch { s = { up:int(rnd(1800,3200)), down:int(rnd(1200,2600)), flat:int(rnd(80,260)), limit_up:int(rnd(20,90)), limit_down:int(rnd(2,30)), height:int(rnd(4,9)) }; } + const t = s.up + s.down + s.flat; + return `
市场情绪 实时统计
+
+
涨家数${s.up}
+
跌家数${s.down}
+
涨停${s.limit_up}
+
跌停${s.limit_down}
+
连板高度${s.height} 板
+
+
`; +} +const int = n => Math.round(n); +function rankTable(headers, rows) { + return `${headers.map(h=>``).join('')}${rows.map(r=>`${r.join('')}`).join('')}
${h}
`; +} +function condFilter(r) { + const g = id => { const v = document.getElementById(id); return v && v.value !== '' ? parseFloat(v.value) : null; }; + const ck = id => { const v = document.getElementById(id); return v && v.checked; }; + const ret5=g('c-ret5'), ret20=g('c-ret20'), vol=g('c-vol'), rsi=g('c-rsi'), pos=g('c-pos'), amt=g('c-amt'); + if (ret5!=null && r.ret5 < ret5) return false; + if (ret20!=null && r.ret20 < ret20) return false; + if (vol!=null && r.vol_ratio < vol) return false; + if (rsi!=null && r.rsi14 > rsi) return false; + if (pos!=null && r.pos60 > pos) return false; + if (amt!=null && r.amount < amt) return false; + return true; +} + +async function renderScreen(container, url, filterFn) { + container.innerHTML = '
筛选中…
'; + let data; try { data = await apiGet(url); } catch { container.innerHTML = '
后端未连接
'; return; } + let list = data.list || []; + if (filterFn) list = list.filter(filterFn); + const head = `
命中 ${list.length} 只 | 股票池 ${data.pool_size||0} 只 ${data.pool_size<200?'(提示:股票池较小,建议到「数据中台」执行全市场回填)':''}
`; + if (!list.length) { container.innerHTML = head + '
无符合条件的股票
'; return; } + const rows = list.map((r,i)=>` + ${i+1} ${r.name} ${r.code} + ${fmt(r.close)} + ${sign(r.pct)}${fmt(r.pct)}% + ${sign(r.ret5)}${fmt(r.ret5)}% + ${sign(r.ret20)}${fmt(r.ret20)}% + ${fmt(r.vol_ratio,2)} + ${fmt(r.rsi14,1)} + ${fmt(r.pos60,1)}% + ${fmt(r.amount,1)}亿`).join(''); + container.innerHTML = head + ` + + ${rows}
名称/代码现价涨跌幅5日20日量比RSI60日分位成交额
`; + container.querySelectorAll('.clickrow').forEach(tr => tr.addEventListener('click', () => { REVIEW_SYMBOL = tr.dataset.code; navigate('review-stock'); })); +} +let REVIEW_SYMBOL = '600519'; + +async function loadTreemap(mode){ try { return (await apiGet('/api/treemap?mode='+mode)).items; } catch { + const secs=['半导体','新能源','医药','白酒','军工','证券','银行','地产','AI','光伏','汽车','有色','煤炭','传媒','钢铁']; + return secs.map(s=>({name:s,value:rnd(2000,30000),pct:rnd(-6,6)})); } } +async function loadKline(days){ try { return await apiGet('/api/kline?symbol=600519&days='+days); } catch { return mockKline(days); } } + +/* ===================== 各视图 ===================== */ +const VIEWS = { + async overview(view) { + const indices = await loadIndices(); + const senti = await sentimentHTML(); + view.innerHTML = idxCardsHTML(indices) + + `
${senti}
` + + `
+
K线分析 贵州茅台 600519 +
+
+
大盘云图 红涨绿跌·面积=规模 +
+
+
`; + initSparks(); + const k = newChart(document.getElementById('kline')); k.setOption(klineOption(await loadKline(60))); + const t = newChart(document.getElementById('treemap')); t.setOption(treemapOption(await loadTreemap('sector'))); + document.getElementById('kseg').onclick = async e => { if(e.target.tagName!=='BUTTON')return; [...e.currentTarget.children].forEach(b=>b.classList.remove('active')); e.target.classList.add('active'); k.setOption(klineOption(await loadKline(+e.target.dataset.d)), true); }; + document.getElementById('tseg').onclick = async e => { if(e.target.tagName!=='BUTTON')return; [...e.currentTarget.children].forEach(b=>b.classList.remove('active')); e.target.classList.add('active'); t.setOption(treemapOption(await loadTreemap(e.target.dataset.m)), true); }; + }, + + async cloud(view) { + view.innerHTML = `
大盘云图 红涨绿跌·面积=规模 +
+
`; + const t = newChart(document.getElementById('treemap')); t.setOption(treemapOption(await loadTreemap('sector'))); + document.getElementById('tseg').onclick = async e => { if(e.target.tagName!=='BUTTON')return; [...e.currentTarget.children].forEach(b=>b.classList.remove('active')); e.target.classList.add('active'); t.setOption(treemapOption(await loadTreemap(e.target.dataset.m)), true); }; + }, + + async kline(view) { + view.innerHTML = `
K线分析 贵州茅台 600519 +
+
`; + const k = newChart(document.getElementById('kline')); k.setOption(klineOption(await loadKline(120))); + document.getElementById('kseg').onclick = async e => { if(e.target.tagName!=='BUTTON')return; [...e.currentTarget.children].forEach(b=>b.classList.remove('active')); e.target.classList.add('active'); k.setOption(klineOption(await loadKline(+e.target.dataset.d)), true); }; + }, + + async fund(view) { + let list; try { list = (await apiGet('/api/fundflow')).list; } catch { list = []; } + if (!list.length) { const s=['半导体','新能源','医药','白酒','军工','证券','银行','地产','AI','光伏','汽车','有色','煤炭','传媒']; list = s.map(n=>({name:n,net:rnd(-40,60)})).sort((a,b)=>a.net-b.net); } + view.innerHTML = `
板块资金流向 主力净流入/流出(亿元)
+
`; + const c = newChart(document.getElementById('fundflow')); + c.setOption({ backgroundColor:'transparent', grid:{left:90,right:40,top:10,bottom:20}, + tooltip:{trigger:'axis',axisPointer:{type:'shadow'},backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + xAxis:{type:'value',axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}}, + yAxis:{type:'category',data:list.map(d=>d.name),axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}}, + series:[{type:'bar',data:list.map(d=>({value:d.net,itemStyle:{color:d.net>=0?UP:DOWN}})),barWidth:'62%',label:{show:true,position:'right',color:AXIS,formatter:p=>fmt(p.value,1)}}] }); + }, + + async 'hot-stock'(view) { + let list; try { list = (await apiGet('/api/hot/stocks')).list; } catch { list = []; } + const rows = list.map((r,i)=>[`${r.rank||i+1} ${r.name} ${r.code}`, + `${fmt(r.price)}`, `${sign(r.pct)}${fmt(r.pct)}%`, + `${90-i}`]); + view.innerHTML = `
热门股票排行 人气榜
+
${rankTable(['名称/代码','现价','涨跌幅','热度'],rows)}
`; + }, + + async 'hot-sector'(view) { + let list; try { list = (await apiGet('/api/hot/sectors')).list; } catch { list = []; } + const rows = list.slice(0,20).map((b,i)=>[`${i+1} ${b.name}`,`${sign(b.pct)}${fmt(b.pct)}%`, + `${fmt(b.amount||0,1)}亿`,`${b.count||'-'}`,`${b.leader||'-'}`]); + view.innerHTML = `
热门板块排行 新浪行业
+
${rankTable(['排名/板块','涨跌幅','成交额','公司数','代表股'],rows)}
`; + }, + + async dragon(view) { + let res; try { res = await apiGet('/api/dragon'); } catch { res = { list: [], date: '' }; } + const rows = res.list.map(r=>[`${r.name} ${r.code}`, + `${sign(r.pct)}${fmt(r.pct)}%`,`${sign(r.net)}${fmt(r.net)}亿`,`${r.reason}`]); + view.innerHTML = `
龙虎榜 ${res.date||''}
+
${rankTable(['名称/代码','涨跌幅','龙虎榜净买额','上榜原因'],rows)}
`; + }, + + async 'watch-list'(view) { + let list; try { list = (await apiGet('/api/watchlist')).list; } catch { list = []; } + const rows = list.map(r=>[`${r.name} ${r.code}`, + `${fmt(r.price)}`,`${sign(r.pct)}${fmt(r.pct)}%`, + `${sign(r.change)}${fmt(r.change)}`,`${fmt(r.amount,1)}亿`]); + view.innerHTML = `
自选列表 ${list.length} 只
+
${rankTable(['名称/代码','现价','涨跌幅','涨跌额','成交额'],rows)}
`; + }, + + async 'review-daily'(view) { + let r; try { r = await apiGet('/api/review/daily'); } catch { r = { ok:false, msg:'后端未连接' }; } + if (!r.ok) { view.innerHTML = `

每日复盘

${r.msg||'暂无数据'}。请先到「数据中台」执行入库。

`; return; } + const s = r.sentiment || {}; + const secBars = (arr, color) => arr.map(x=>`
${x.name}${sign(x.pct)}${fmt(x.pct)}%
`).join(''); + const flowRows = (arr)=>arr.map(x=>`${x.name}${sign(x.net)}${fmt(x.net)}亿`).join(''); + view.innerHTML = ` +
每日复盘 ${r.date}
+
${r.summary}
+
+
上涨家数
${s.up??'-'}
+
下跌家数
${s.down??'-'}
+
涨停
${s.limit_up??'-'}
+
跌停
${s.limit_down??'-'}
+
+
+
领涨板块
${secBars(r.top_sectors)}
+
领跌板块
${secBars(r.weak_sectors)}
+
+
+
主力净流入 Top
${flowRows(r.inflow)}
+
主力净流出 Top
${flowRows(r.outflow)}
+
+
龙虎榜净买额 Top
+
${rankTable(['名称/代码','涨跌幅','净买额'], r.dragon.map(x=>[`${x.name} ${x.code}`,`${sign(x.pct)}${fmt(x.pct)}%`,`${sign(x.net)}${fmt(x.net)}亿`]))}
`; + }, + + async 'review-report'(view) { + view.innerHTML = `
AI 复盘日报 收盘后自动生成 · 可推送微信/邮件 + + + + +
+
加载中…
`; + const bodyEl = document.getElementById('rp-body'), subEl = document.getElementById('rp-sub'), histEl = document.getElementById('rp-hist'); + const show = (r) => { + if (!r || !r.ok) { bodyEl.innerHTML = `

暂无日报

${(r&&r.msg)||'后端未连接'}。请先在「数据中台」入库,再点「立即生成」。

`; subEl.textContent='尚未生成'; return; } + subEl.innerHTML = `${r.date} · ${r.source==='llm'?'大模型生成':'规则生成'}${r.pushed?' · 已推送':''}`; + bodyEl.innerHTML = `
${mdToHtml(r.content)}
`; + }; + const loadHist = async () => { + try { const h = await apiGet('/api/report/history?limit=60'); histEl.innerHTML = (h.list||[]).map(x=>``).join('') || ''; } catch {} + }; + const loadDate = async (date) => { bodyEl.innerHTML='
加载中…
'; try { show(await apiGet('/api/report/daily'+(date?`?date=${date}`:''))); } catch { show(null); } }; + histEl.onchange = () => loadDate(histEl.value); + const gen = async (push) => { + bodyEl.innerHTML = `
${push?'生成并推送':'生成'}中…
`; + try { const r = await apiPost('/api/report/generate?push='+(push?'true':'false')); show(r); await loadHist(); if(r.ok) histEl.value=r.date; + if (push && r.push) subEl.innerHTML += ' · 推送结果:'+JSON.stringify(r.push); } + catch { bodyEl.innerHTML = '

生成失败

后端未连接

'; } + }; + document.getElementById('rp-gen').onclick = () => gen(false); + document.getElementById('rp-push').onclick = () => gen(true); + await loadHist(); + await loadDate(null); + }, + + async 'review-stock'(view) { + view.innerHTML = `
个股复盘 K线·均线交叉买卖点·走势回放 + + + 快线 + 慢线 + + +
+
`; + let chart, playTimer = null; + const load = async () => { + const sym = document.getElementById('rs-sym').value.trim(); + const fast = document.getElementById('rs-fast').value, slow = document.getElementById('rs-slow').value; + document.getElementById('rs-msg').textContent = '加载中…'; + let r; try { r = await apiGet(`/api/review/stock?symbol=${sym}&days=250&fast=${fast}&slow=${slow}`); } catch { document.getElementById('rs-msg').textContent='后端未连接'; return; } + if (!r.ok) { document.getElementById('rs-msg').textContent = r.msg||'加载失败'; document.getElementById('rs-stats').innerHTML=''; return; } + REVIEW_SYMBOL = sym; + const buys = r.signals.filter(s=>s.type==='buy').length, sells = r.signals.filter(s=>s.type==='sell').length; + document.getElementById('rs-msg').textContent = `${r.name} ${r.symbol} ${r.stats.start}~${r.stats.end} 买入信号 ${buys} · 卖出信号 ${sells}`; + const st = r.stats; + const card=(l,v,c)=>`
${l}
${v}
`; + document.getElementById('rs-stats').innerHTML = + card('区间涨跌', sign(st.period_return)+st.period_return+'%', cls(st.period_return)) + + card('区间最高', fmt(st.high), 'up') + card('区间最低', fmt(st.low), 'down') + card('交易日数', st.bars); + disposeCharts(); + chart = newChart(document.getElementById('rs-chart')); + chart.setOption(reviewKlineOption(r), true); + window._rsBars = r.dates.length; + }; + const play = () => { + if (!chart) return; + if (playTimer) { clearInterval(playTimer); playTimer = null; document.getElementById('rs-play').textContent='▶ 回放'; chart.setOption({dataZoom:[{start:0,end:100},{start:0,end:100}]}); return; } + document.getElementById('rs-play').textContent='⏸ 停止'; + const total = window._rsBars || 100; let k = Math.min(40, total); + chart.setOption({dataZoom:[{startValue:0,endValue:k},{startValue:0,endValue:k}]}); + playTimer = setInterval(() => { + k += 2; + chart.setOption({dataZoom:[{startValue:0,endValue:k},{startValue:0,endValue:k}]}); + if (k >= total) { clearInterval(playTimer); playTimer=null; document.getElementById('rs-play').textContent='▶ 回放'; } + }, 120); + }; + document.getElementById('rs-load').onclick = load; + document.getElementById('rs-play').onclick = play; + await load(); + }, + + async 'screen-strat'(view) { + let strats; try { strats = (await apiGet('/api/screen/strategies')).list; } catch { strats = []; } + view.innerHTML = `
策略选股 全市场因子筛选 + ${strats.map((s,i)=>``).join('')}
+
`; + const seg = document.getElementById('strat-seg'); + const run = async (id) => { await renderScreen(document.getElementById('screen-result'), `/api/screen?strategy=${id}&limit=80`); }; + seg.onclick = e => { if(e.target.tagName!=='BUTTON')return; [...seg.children].forEach(b=>b.classList.remove('active')); e.target.classList.add('active'); run(e.target.dataset.id); }; + if (strats[0]) run(strats[0].id); + }, + + async 'screen-cond'(view) { + view.innerHTML = `
条件选股 自定义因子阈值
+
+
+ + + + + + + + + +
+
+
说明:条件选股在客户端对策略候选集叠加阈值过滤(演示)。生产环境将下推为后端多因子 SQL。
+
`; + const run = async () => { + // 取较宽候选(均线多头作为基集)再前端按阈值过滤 + const r = await renderScreen(document.getElementById('screen-result'), `/api/screen?strategy=ma_bull&limit=300`, condFilter); + }; + document.getElementById('c-run').onclick = run; + run(); + }, + + async backtest(view) { + view.innerHTML = `
策略回测 均线交叉 · 基于中台日线 + + + 快线 + 慢线 + +
+
`; + const run = async () => { + const sym = document.getElementById('bt-sym').value.trim(); + const fast = document.getElementById('bt-fast').value, slow = document.getElementById('bt-slow').value; + document.getElementById('bt-msg').textContent = '回测中…'; + let r; try { r = await apiGet(`/api/backtest?symbol=${sym}&fast=${fast}&slow=${slow}`); } catch { document.getElementById('bt-msg').textContent='后端未连接'; return; } + if (!r.ok) { document.getElementById('bt-msg').textContent = r.msg || '回测失败'; document.getElementById('bt-metrics').innerHTML=''; return; } + document.getElementById('bt-msg').textContent = ''; + const m = r.metrics; + const card = (l,v,c)=>`
${l}
${v}
`; + document.getElementById('bt-metrics').innerHTML = + card('策略收益', sign(m.total_return)+m.total_return+'%', cls(m.total_return)) + + card('基准(持有)', sign(m.bench_return)+m.bench_return+'%', cls(m.bench_return)) + + card('超额收益', sign(m.excess)+m.excess+'%', cls(m.excess)) + + card('最大回撤', '-'+m.max_drawdown+'%', 'down') + + card('交易次数', m.trades) + card('胜率', m.win_rate+'%'); + disposeCharts(); + const c = newChart(document.getElementById('bt-chart')); + c.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + legend:{data:['策略净值','买入持有'],textStyle:{color:AXIS},top:0}, grid:{left:50,right:20,top:30,bottom:40}, + xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}}, + yAxis:{type:'value',scale:true,axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}}, + dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}], + series:[{name:'策略净值',type:'line',data:r.equity,symbol:'none',lineStyle:{width:1.6,color:'#e8a13a'}}, + {name:'买入持有',type:'line',data:r.bench,symbol:'none',lineStyle:{width:1.2,color:'#2f6fed'}}] }); + }; + document.getElementById('bt-run').onclick = run; + run(); + }, + + async admin(view) { + const render = async () => { + let st; try { st = await apiGet('/api/admin/status'); } catch { view.innerHTML = '

数据中台

后端未连接。

'; return; } + const c = st.counts, ld = st.last_dates; + const countCard = (l,v,d)=>`
${l}
${(v||0).toLocaleString()}
${d?('至 '+d):''}
`; + const jobRows = st.jobs.map(j=>[`${j.id}`,`${j.job}`, + `${j.status}`, + `${j.started}`,`${j.finished}`,`${j.message}`]); + view.innerHTML = ` +
数据中台 入库计划:${st.schedule} + + ${st.running?'● 入库中…':'○ 空闲'} + +
+
+ ${countCard('证券', c.securities, ld.securities)} + ${countCard('个股日线', c.quotes_daily, ld.quotes_daily)} + ${countCard('指数日线', c.index_daily, ld.index_daily)} + ${countCard('板块快照', c.sector_daily, ld.sector_daily)} + ${countCard('资金流快照', c.fund_flow_daily, ld.fund_flow_daily)} + ${countCard('情绪快照', c.sentiment_daily, ld.sentiment_daily)} + ${countCard('龙虎榜', c.dragon_tiger, ld.dragon_tiger)} +
+
当前股票池:${st.universe.length} 只(${st.universe.slice(0,8).join(' ')} …)
+
任务日志
+
${rankTable(['#','任务','状态','开始','结束','详情'], jobRows)}
`; + const poll = (max) => { let n=0; const timer=setInterval(async()=>{ n++; let s2; try{s2=await apiGet('/api/admin/status');}catch{return;} if(!s2.running||n>max){clearInterval(timer);render();} },3000); }; + const btn = document.getElementById('ingest-btn'); + if (btn) btn.onclick = async () => { + btn.disabled = true; btn.textContent = '入库中…'; + try { await apiPost('/api/admin/ingest'); } catch {} + poll(40); + }; + const btnAll = document.getElementById('ingestall-btn'); + if (btnAll) btnAll.onclick = async () => { + if (!confirm('全市场回填约 5000+ 只股票,预计 15~25 分钟,期间可继续使用其他功能。确定开始?')) return; + btnAll.disabled = true; btnAll.textContent = '回填中…'; + try { await apiPost('/api/admin/ingest_all'); } catch {} + poll(600); + }; + }; + await render(); + }, + + async 'ai-today'(view) { + const badge = await aiBadge(); + view.innerHTML = `
今日策略 ${badge} +
+
生成中…
`; + const run = async () => { + document.getElementById('ai-body').innerHTML = '
生成中…
'; + let r; try { r = await apiGet('/api/ai/today'); } catch { document.getElementById('ai-body').innerHTML='
后端未连接
'; return; } + document.getElementById('ai-body').innerHTML = aiText(r); + }; + document.getElementById('ai-refresh').onclick = run; run(); + }, + + async 'ai-review'(view) { + const badge = await aiBadge(); + view.innerHTML = `
AI 复盘点评 ${badge} +
+
生成中…
`; + const run = async () => { + document.getElementById('ai-body').innerHTML = '
生成中…
'; + let r; try { r = await apiGet('/api/ai/review_daily'); } catch { document.getElementById('ai-body').innerHTML='
后端未连接
'; return; } + if (!r.ok) { document.getElementById('ai-body').innerHTML = `
${r.msg}
`; return; } + document.getElementById('ai-body').innerHTML = `
复盘日期:${r.date}
` + aiText(r); + }; + document.getElementById('ai-refresh').onclick = run; run(); + }, + + async 'ai-diag'(view) { + const badge = await aiBadge(); + view.innerHTML = `
个股 AI 诊断 ${badge} + + +
+
输入代码后点击诊断
`; + const run = async () => { + const sym = document.getElementById('ai-sym').value.trim(); + document.getElementById('ai-body').innerHTML = '
诊断中…
'; + document.getElementById('ai-score').innerHTML = ''; + let r; try { r = await apiGet('/api/ai/diagnose?symbol='+sym); } catch { document.getElementById('ai-body').innerHTML='
后端未连接
'; return; } + if (!r.ok) { document.getElementById('ai-body').innerHTML = `
${r.msg}
`; return; } + const scoreColor = v => v>=60?'up':(v<45?'down':''); + const card=(l,v)=>`
${l}
${v}
`; + document.getElementById('ai-score').innerHTML = + card('综合 '+r.total, r.total) + Object.entries(r.scores).map(([k,v])=>card(k,v)).join(''); + const dirMap = {up:['看多','up'],down:['看空','down'],flat:['中性','']}; + const [dirCn,dirCls] = dirMap[r.direction]||['—','']; + const sigTag = s => s==='bull'?'看多':(s==='bear'?'看空':'中性'); + const evRows = (r.evidence||[]).map(e=>[ + `${e.dim}`, + `${e.fact}`, + `${sigTag(e.signal)}`, + `${e.win_rate!=null?e.win_rate+'%':''}`, + `${e.avg_ret!=null?sign(e.avg_ret)+fmt(e.avg_ret)+'%':'—'}`, + `${e.samples!=null?e.samples:'—'}`, + ]); + const newsHTML = (r.news||[]).length ? (r.news||[]).map(n=>`
${n.time||''} · ${sentiTag(n.sentiment)}
${n.title||''}
`).join('') : '
暂无相关资讯
'; + document.getElementById('ai-body').innerHTML = ` +
+
${r.name} ${r.symbol}
+
方向 ${dirCn}
+
置信度 ${r.confidence}% +
+
消息面 ${sentiTag(r.news_tone)}
+
+
${aiText(r)}
+
证据链 · 历史命中率(5日)
+ ${rankTable(['维度','数据依据','信号','历史上涨概率','平均收益','样本'], evRows)} +
命中率为全市场历史回测统计,如显示「—」请到「AI准确率」页点『计算历史胜率』。
+
相关资讯(RAG 上下文)
+
${newsHTML}
`; + }; + document.getElementById('ai-run').onclick = run; run(); + }, + + async 'ai-accuracy'(view) { + view.innerHTML = `
信号历史胜率 全市场历史回测 · 5日 +
+
+
AI 实测准确率 诊断留痕 · N日后核验真实涨跌 +
+
`; + const loadSig = async () => { + let r; try { r = await apiGet('/api/ai/signal_stats?horizon=5'); } catch { return; } + const st = r.stats||{}; + const keys = Object.keys(st); + if (!keys.length) { document.getElementById('acc-sig').innerHTML = '
暂无数据,点右上「计算历史胜率」(约需数秒~数十秒)
'; return; } + const rows = keys.map(k=>[`${st[k].label||k}`,`${st[k].win_rate}%`,`${sign(st[k].avg_ret)}${fmt(st[k].avg_ret)}%`,`${st[k].samples}`]); + document.getElementById('acc-sig').innerHTML = rankTable(['信号','5日上涨概率','平均收益','样本数'], rows); + }; + const loadAcc = async () => { + let a; try { a = await apiGet('/api/ai/accuracy'); } catch { return; } + const card=(l,v,c)=>`
${l}
${v}
`; + document.getElementById('acc-sum').innerHTML = + card('已核验', a.closed) + card('待核验', a.open) + + card('命中率', a.hit_rate!=null?a.hit_rate+'%':'—', a.hit_rate!=null?(a.hit_rate>=50?'up':'down'):'') + + card('平均收益', a.avg_ret!=null?sign(a.avg_ret)+fmt(a.avg_ret)+'%':'—', a.avg_ret!=null?cls(a.avg_ret):''); + const dirCn = {up:'看多',down:'看空',flat:'中性'}; + const rows = (a.recent||[]).map(r=>[`${r.date}`,`${r.name} ${r.code}`, + `${dirCn[r.direction]||r.direction}`,`${r.confidence}%`, + `${sign(r.actual_ret)}${fmt(r.actual_ret)}%`, + `${r.hit?'命中':'未中'}`]); + document.getElementById('acc-recent').innerHTML = rows.length?rankTable(['诊断日','名称/代码','方向','置信度',`实际收益(${(a.recent[0]||{}).horizon||5}日)`,'结果'], rows):'
暂无已核验记录。先在「个股诊断」生成诊断(自动留痕),到期后核验。
'; + }; + document.getElementById('acc-compute').onclick = async () => { + document.getElementById('acc-msg').textContent = '已启动历史胜率回测(后台运行),约数秒~数十秒后刷新本页查看…'; + try { await apiPost('/api/ai/signal_stats/compute?sample=500&horizon=5'); } catch {} + setTimeout(loadSig, 8000); + }; + document.getElementById('acc-verify').onclick = async () => { try { await apiPost('/api/ai/accuracy/verify'); } catch {} loadAcc(); }; + loadSig(); loadAcc(); + }, + + async 'pf-holdings'(view) { + let p; try { p = await apiGet('/api/portfolio'); } catch { view.innerHTML='

组合持仓

后端未连接

'; return; } + const sm = p.summary; + const card=(l,v,c)=>`
${l}
${v}
`; + const rows = p.holdings.map(h=>[`${h.name} ${h.code}`, + `${h.qty}`,`${fmt(h.avg_cost)}`,`${fmt(h.cur)}`, + `${fmt(h.market_value)}`,`${sign(h.unrealized)}${fmt(h.unrealized)}`, + `${sign(h.unrealized_pct)}${fmt(h.unrealized_pct)}%`]); + view.innerHTML = `
+ ${card('持仓市值', fmt(sm.market_value))} + ${card('浮动盈亏', sign(sm.unrealized)+fmt(sm.unrealized), cls(sm.unrealized))} + ${card('已实现盈亏', sign(sm.realized)+fmt(sm.realized), cls(sm.realized))} + ${card('总盈亏', sign(sm.total_pnl)+fmt(sm.total_pnl), cls(sm.total_pnl))}
+
+ ${card('持仓数', sm.positions)} ${card('已平仓次数', sm.closed_trades)} ${card('胜率', sm.win_rate+'%')} ${card('—','')}
+
当前持仓
+
${p.holdings.length?rankTable(['名称/代码','数量','成本','现价','市值','浮动盈亏','盈亏%'],rows):'
暂无持仓,请在「交易日志」录入买卖记录
'}
`; + }, + + async 'pf-trades'(view) { + const td = new Date().toISOString().slice(0,10); + view.innerHTML = `
录入交易
+
+ + + + + + + + + +
+
交易记录
`; + const reload = async () => { + let r; try { r = await apiGet('/api/trades'); } catch { document.getElementById('t-list').innerHTML='
后端未连接
'; return; } + if (!r.list.length) { document.getElementById('t-list').innerHTML='
暂无交易记录
'; return; } + const rows = r.list.map(t=>`${t.date}${t.name} ${t.code} + ${t.side==='buy'?'买入':'卖出'}${fmt(t.price)}${t.qty} + ${t.reason||'-'}${t.emotion||'-'}删除`).join(''); + document.getElementById('t-list').innerHTML = `${rows}
日期名称/代码方向价格数量理由情绪
`; + document.querySelectorAll('.del-t').forEach(e=>e.onclick=async()=>{ await fetch(API_BASE+'/api/trades/'+e.dataset.id,{method:'DELETE'}); reload(); }); + }; + document.getElementById('t-add').onclick = async () => { + const body = { code:val('t-code'), side:document.getElementById('t-side').value, price:+val('t-price'), + qty:+val('t-qty'), fee:+val('t-fee')||0, date:val('t-date'), reason:val('t-reason'), emotion:document.getElementById('t-emotion').value }; + if (!body.code || !body.price || !body.qty) { document.getElementById('t-msg').textContent='请填写代码/价格/数量'; return; } + document.getElementById('t-msg').textContent='提交中…'; + try { await fetch(API_BASE+'/api/trades',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); } catch {} + document.getElementById('t-msg').textContent='已添加'; ['t-code','t-price','t-qty','t-reason'].forEach(id=>document.getElementById(id).value=''); reload(); + }; + reload(); + }, + + async 'pf-attr'(view) { + let p; try { p = await apiGet('/api/portfolio'); } catch { view.innerHTML='

盈亏归因

后端未连接

'; return; } + const a = p.attribution; + if (!a.by_stock.length) { view.innerHTML='

盈亏归因

暂无交易数据,请先在「交易日志」录入。

'; return; } + view.innerHTML = `
个股盈亏贡献 已实现+浮动
+
+
+
按交易理由归因
+
按交易情绪归因
+
`; + const c = newChart(document.getElementById('attr-stock')); + const ds = a.by_stock.slice(0,15); + c.setOption({ backgroundColor:'transparent', grid:{left:90,right:30,top:10,bottom:20}, + tooltip:{trigger:'axis',axisPointer:{type:'shadow'},backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + xAxis:{type:'value',axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}}, + yAxis:{type:'category',data:ds.map(x=>x.name).reverse(),axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}}, + series:[{type:'bar',data:ds.map(x=>({value:x.pnl,itemStyle:{color:x.pnl>=0?UP:DOWN}})).reverse(),barWidth:'60%',label:{show:true,position:'right',color:AXIS,formatter:p=>fmt(p.value)}}] }); + const tagTable = (arr) => `${arr.map(x=>``).join('')}
${x.key}${sign(x.pnl)}${fmt(x.pnl)}
`; + document.getElementById('attr-reason').innerHTML = tagTable(a.by_reason); + document.getElementById('attr-emotion').innerHTML = tagTable(a.by_emotion); + }, + + async 'pf-equity'(view) { + let r; try { r = await apiGet('/api/portfolio/equity'); } catch { view.innerHTML='

资金曲线

后端未连接

'; return; } + if (!r.ok) { view.innerHTML = `

资金曲线

${r.msg},请先到「交易日志」录入交易(且相关个股需已入库日线)。

`; return; } + const card=(l,v,c)=>`
${l}
${v}
`; + const navEnd = r.nav.length?r.nav[r.nav.length-1]:1; + view.innerHTML = `
+ ${card('累计盈亏', sign(r.final_pnl)+fmt(r.final_pnl), cls(r.final_pnl))} + ${card('当前净值', fmt(navEnd,3), cls(navEnd-1))} + ${card('最大投入', fmt(r.max_invested))} + ${card('交易日数', r.dates.length)}
+
组合净值 vs 沪深300
+
+
累计盈亏(元)
+
`; + const c1 = newChart(document.getElementById('eq-nav')); + c1.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + legend:{data:['组合净值','沪深300'],textStyle:{color:AXIS},top:0}, grid:{left:55,right:20,top:30,bottom:40}, + xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}}, + yAxis:{type:'value',scale:true,axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}}, + dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}], + series:[{name:'组合净值',type:'line',data:r.nav,symbol:'none',lineStyle:{width:1.8,color:'#e8a13a'}}, + {name:'沪深300',type:'line',data:r.bench,symbol:'none',lineStyle:{width:1.2,color:'#2f6fed'}}] }); + const c2 = newChart(document.getElementById('eq-pnl')); + c2.setOption({ backgroundColor:'transparent', tooltip:{trigger:'axis',backgroundColor:'#161d29',borderColor:GRID,textStyle:{color:'#d7dee8'}}, + grid:{left:60,right:20,top:14,bottom:40}, xAxis:{type:'category',data:r.dates,axisLabel:{color:AXIS},axisLine:{lineStyle:{color:GRID}}}, + yAxis:{type:'value',scale:true,axisLabel:{color:AXIS},splitLine:{lineStyle:{color:GRID}}}, + dataZoom:[{type:'inside'},{type:'slider',bottom:5,height:14,textStyle:{color:AXIS},borderColor:GRID}], + series:[{type:'line',data:r.equity,symbol:'none',areaStyle:{color:'#e8a13a22'},lineStyle:{width:1.5,color:'#e8a13a'}}] }); + }, + + async 'alert-list'(view) { + const KINDS = {price_above:'价格突破≥',price_below:'价格跌破≤',pct_above:'涨幅≥',pct_below:'跌幅≥'}; + view.innerHTML = `
新建预警 每60秒自动检查(实时价) +
+
+ + + + + + +
+
推送渠道 检测中… +
+
+
预警规则
`; + (async () => { + let st; try { st = await apiGet('/api/notify/status'); } catch { return; } + const names = {email:'邮件',serverchan:'Server酱',wecom:'企业微信',pushplus:'PushPlus'}; + const on = Object.entries(st.channels).filter(([k,v])=>v).map(([k])=>names[k]); + document.getElementById('push-sub').textContent = st.enabled ? ('已启用:'+on.join('、')) : '未配置(站内提醒仍可用)'; + document.getElementById('push-status').innerHTML = Object.entries(st.channels).map(([k,v])=>`${names[k]} ${v?'已配置':'未配置'}`).join(' | ') + + (st.enabled?'':' — 在 backend/.env 配置邮箱或微信推送 token 后重启即可'); + document.getElementById('push-test').onclick = async () => { + document.getElementById('push-msg').textContent='发送中…'; + try { const r = await apiPost('/api/notify/test'); document.getElementById('push-msg').textContent = r.ok?('结果:'+JSON.stringify(r.result)):r.msg; } catch { document.getElementById('push-msg').textContent='发送失败'; } + }; + })(); + const reload = async () => { + let r; try { r = await apiGet('/api/alerts'); } catch { document.getElementById('al-list').innerHTML='
后端未连接
'; return; } + if (!r.list.length) { document.getElementById('al-list').innerHTML='
暂无预警规则
'; return; } + const rows = r.list.map(a=>`${a.name} ${a.code} + ${KINDS[a.kind]||a.kind} ${a.threshold}${a.kind.startsWith('price')?'':'%'} + ${a.last_value?fmt(a.last_value):'-'} + ${a.status==='triggered'?'已触发':'监控中'} ${a.triggered_at||''} + ${a.note||'-'} + ${a.status==='triggered'?`重新启用 `:''}删除`).join(''); + document.getElementById('al-list').innerHTML = `${rows}
名称/代码条件最新价状态备注
`; + document.querySelectorAll('.del-a').forEach(e=>e.onclick=async()=>{ await fetch(API_BASE+'/api/alerts/'+e.dataset.id,{method:'DELETE'}); reload(); }); + document.querySelectorAll('.re-a').forEach(e=>e.onclick=async()=>{ await fetch(API_BASE+'/api/alerts/'+e.dataset.id+'/reactivate',{method:'POST'}); reload(); }); + }; + document.getElementById('al-add').onclick = async () => { + const body={ code:val('al-code'), kind:document.getElementById('al-kind').value, threshold:+val('al-th'), note:val('al-note') }; + if (!body.code || isNaN(body.threshold)) { document.getElementById('al-msg').textContent='请填写代码与阈值'; return; } + try { await fetch(API_BASE+'/api/alerts',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); } catch {} + document.getElementById('al-msg').textContent='已添加'; ['al-code','al-th','al-note'].forEach(id=>document.getElementById(id).value=''); reload(); + }; + document.getElementById('al-check').onclick = async () => { document.getElementById('al-msg').textContent='检查中…'; try{const r=await apiPost('/api/alerts/check'); document.getElementById('al-msg').textContent=`已检查 ${r.checked} 条,触发 ${r.triggered} 条`;}catch{} reload(); refreshAlertBell(); }; + reload(); + }, + + async 'alert-events'(view) { + let r; try { r = await apiGet('/api/alerts/events?limit=60'); } catch { view.innerHTML='

触发记录

后端未连接

'; return; } + try { await apiPost('/api/alerts/events/read'); } catch {} refreshAlertBell(); + const rows = r.list.map(e=>[`${e.time}`,`${e.name} ${e.code}`,`${e.message}`]); + view.innerHTML = `
预警触发记录
+
${r.list.length?rankTable(['时间','名称/代码','内容'],rows):'
暂无触发记录
'}
`; + }, + + async 'news-main'(view) { + view.innerHTML = `
要闻快讯 实时财经 · 利好/利空自动判定
+
加载中…
`; + let r; try { r = await apiGet('/api/news?limit=50'); } catch { document.getElementById('news-body').innerHTML='
后端未连接
'; return; } + _NEWS_ITEMS = r.list; + document.getElementById('news-body').innerHTML = newsListHTML(r.list); + bindNews(); + }, + + async 'news-watch'(view) { + view.innerHTML = `
自选股资讯 基于自选列表
+
加载中…
`; + let r; try { r = await apiGet('/api/news/watch'); } catch { document.getElementById('news-body').innerHTML='
后端未连接
'; return; } + _NEWS_ITEMS = r.list; + document.getElementById('news-body').innerHTML = r.list.length ? newsListHTML(r.list, true) : '
暂无自选股资讯
'; + bindNews(); + }, +}; + +function sentiTag(s) { + const c = s==='利好'?'background:#2a1418;color:var(--up);border-color:#5a2630':(s==='利空'?'background:#0e2018;color:var(--down);border-color:#1d4636':''); + return `${s}`; +} +function newsListHTML(list, withCode) { + return `
${list.map((n,i)=>`
+
${sentiTag(n.sentiment)} ${n.time}${withCode&&n.code?` ${n.code}`:''}${n.keywords&&n.keywords.length?` · ${n.keywords.join(' ')}`:''}
+
${n.title}
+ ${n.summary?`
${n.summary}
`:''} +
AI 分析${n.url?` 原文`:''}
+
`).join('')}
`; +} +let _NEWS_ITEMS = []; +function bindNews() { + document.querySelectorAll('.ai-news').forEach(el => el.onclick = async () => { + const i = el.dataset.i; const item = _NEWS_ITEMS[i]; + const out = document.getElementById('aiout-'+i); + out.innerHTML = '分析中…'; + try { const r = await fetch(API_BASE+'/api/news/ai',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:item.title,content:item.summary||''})}).then(x=>x.json()); + out.innerHTML = `
${sentiTag(r.sentiment)} ${(r.source==='rule'?'[规则] ':'')}${(r.text||'').replace(/\n/g,'
')}
`; + } catch { out.innerHTML='分析失败'; } + }); +} + +async function aiBadge() { + let st; try { st = await apiGet('/api/ai/status'); } catch { st = { enabled:false }; } + return st.enabled ? `● 大模型 ${st.model}` + : `规则降级(未配置大模型)`; +} +function aiText(r) { + const tag = r.source === 'llm' ? '' : '[规则生成] '; + const facts = r.facts ? `
查看依据数据
${r.facts}
` : ''; + return `
${tag}${(r.text||'').replace(/\n/g,'
')}
${facts}`; +} +const val = id => document.getElementById(id).value.trim(); + +// 极简 markdown 渲染(# 标题 / **加粗** / - 列表 / 段落) +function mdToHtml(md) { + const esc = s => s.replace(/&/g,'&').replace(//g,'>'); + const inline = s => esc(s).replace(/\*\*(.+?)\*\*/g, '$1'); + const lines = (md||'').split('\n'); let html = '', inList = false; + const closeList = () => { if (inList) { html += ''; inList = false; } }; + for (let ln of lines) { + ln = ln.replace(/\r$/, ''); + if (/^#\s+/.test(ln)) { closeList(); html += `

${inline(ln.replace(/^#\s+/,''))}

`; } + else if (/^##\s+/.test(ln)) { closeList(); html += `

${inline(ln.replace(/^##\s+/,''))}

`; } + else if (/^-\s+/.test(ln)) { if (!inList) { html += '
    '; inList = true; } html += `
  • ${inline(ln.replace(/^-\s+/,''))}
  • `; } + else if (ln.trim() === '') { closeList(); } + else { closeList(); html += `

    ${inline(ln)}

    `; } + } + closeList(); + return html; +} + +/* ===================== 占位(建设中) ===================== */ +const SOON_FEATURES = { + 'radar': ['快速拉升监测','放量突破','涨停打开提醒','大单扫货追踪'], + 'screen-cond': ['技术+资金+基本面多因子拖拽','结果一键回测','一键存为预警'], + 'screen-sector': ['按板块/概念/产业链筛选','板块强度排序'], + 'screen-strat': ['内置策略库','MACD金叉/底部放量/筹码集中'], + 'watch-detail': ['行情/技术/资金/财务/AI诊断聚合页'], + 'watch-new': ['次新股池','打新日历'], + 'review-daily': ['大盘总结','涨停梯队','资金流向','明日关注','AI自动日报'], + 'review-stock': ['走势回放','买卖点标注','形态识别'], + 'review-trade': ['交易记录归因','赚/亏在哪','是否追高'], + 'ai-today': ['今天炒什么+依据','题材脉络图','相似历史'], + 'ai-diag': ['技术/资金/基本面/消息面打分卡'], + 'ai-predict': ['概率区间预测','模型历史准确率'], + 'news-main': ['要闻/快讯','AI摘要','利好利空判定'], + 'news-watch': ['自选股关联资讯','事件日历'], + 'alert-list': ['价格/量能/技术/资金/新闻触发','多渠道推送'], +}; +function renderSoon(view, info) { + const id = location.hash.slice(1); + const feats = SOON_FEATURES[id] || ['模块规划中']; + view.innerHTML = `
    +

    ${info.name}规划中

    +

    该模块属于「${info.group}」,下方为规划的核心功能点。当前已接入真实行情、K线、云图、资金、榜单数据,后续按路线图迭代。

    +
    ${feats.map(f=>`
    ${f}
    接入数据后可用
    `).join('')}
    +
    `; +} + +/* ===================== 折叠 + 初始化 ===================== */ +document.getElementById('collapseBtn').onclick = () => { + const sb = document.getElementById('sidebar'); sb.classList.toggle('collapsed'); + document.getElementById('collapseBtn').textContent = sb.classList.contains('collapsed') ? '»' : '«'; + setTimeout(() => charts.forEach(c => c.resize()), 160); +}; +window.addEventListener('resize', () => charts.forEach(c => c.resize())); + +async function refreshAlertBell() { + let r; try { r = await apiGet('/api/alerts/events?unread_only=1&limit=1'); } catch { return; } + const el = document.getElementById('alert-count'); + if (r.unread > 0) { el.style.display = 'inline-block'; el.textContent = r.unread > 99 ? '99+' : r.unread; } + else el.style.display = 'none'; +} +document.getElementById('alert-bell').onclick = () => navigate('alert-events'); +refreshAlertBell(); +setInterval(refreshAlertBell, 30000); + +renderMenu(); +navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview'); diff --git a/prototype/index.html b/prototype/index.html new file mode 100644 index 0000000..d3b55ec --- /dev/null +++ b/prototype/index.html @@ -0,0 +1,43 @@ + + + + + + 智策 · 股票分析复盘终端 + + + + + +
    +
    智策 StockTerminal
    + +
    +
    + 🔔 + 数据源: - + + ● 交易中 + 游客 +
    +
    + +
    + + + + +
    +
    +
    +
    +
    + + + + diff --git a/prototype/style.css b/prototype/style.css new file mode 100644 index 0000000..c6a1516 --- /dev/null +++ b/prototype/style.css @@ -0,0 +1,194 @@ +:root { + --bg: #0a0e15; + --topbar: #0f141d; + --sidebar: #0d121a; + --sidebar-2: #131a25; + --panel: #121822; + --panel-head: #161d29; + --border: #232c3a; + --border-soft: #1b222e; + --text: #d7dee8; + --text-dim: #7d8796; + --text-mute: #586273; + --up: #f6465d; /* 红涨 */ + --down: #2ebd85; /* 绿跌 */ + --accent: #2f6fed; + --gold: #e8a13a; + --radius: 2px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; + font-size: 13px; + overflow: hidden; +} +.up { color: var(--up); } +.down { color: var(--down); } +.num { font-variant-numeric: tabular-nums; } + +/* ============ 顶部栏 ============ */ +.topbar { + height: 48px; display: flex; align-items: center; gap: 18px; + background: var(--topbar); border-bottom: 1px solid var(--border); + padding: 0 16px; +} +.brand { font-size: 16px; font-weight: 700; letter-spacing: .5px; white-space: nowrap; } +.brand em { color: var(--gold); font-style: normal; font-weight: 500; font-size: 12px; margin-left: 4px; } +.search { width: 280px; } +.search input { + width: 100%; height: 30px; background: #0a0e15; border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text); padding: 0 10px; font-size: 12px; outline: none; +} +.search input:focus { border-color: var(--accent); } +.ticker { flex: 1; overflow: hidden; display: flex; gap: 22px; white-space: nowrap; color: var(--text-dim); font-size: 12px; } +.ticker .ti { display: inline-flex; gap: 6px; align-items: center; } +.ticker .ti b { color: var(--text); font-weight: 500; } +.top-right { display: flex; align-items: center; gap: 16px; white-space: nowrap; font-size: 12px; color: var(--text-dim); } +.clock { font-variant-numeric: tabular-nums; } +.market-state { color: var(--down); } +.user { padding: 3px 10px; border: 1px solid var(--border); border-radius: var(--radius); } + +/* ============ 布局 ============ */ +.layout { display: flex; height: calc(100vh - 48px); } + +/* ============ 侧边栏 ============ */ +.sidebar { + width: 208px; background: var(--sidebar); border-right: 1px solid var(--border); + display: flex; flex-direction: column; transition: width .15s; flex-shrink: 0; +} +.sidebar.collapsed { width: 52px; } +.sidebar nav { flex: 1; overflow-y: auto; padding: 6px 0; } +.sidebar nav::-webkit-scrollbar { width: 6px; } +.sidebar nav::-webkit-scrollbar-thumb { background: #222b39; } + +.menu-group > .g-head { + display: flex; align-items: center; gap: 10px; height: 38px; padding: 0 14px; + cursor: pointer; color: var(--text-dim); user-select: none; font-size: 13px; + border-left: 2px solid transparent; +} +.menu-group > .g-head:hover { background: var(--sidebar-2); color: var(--text); } +.menu-group.open > .g-head { color: var(--text); } +.g-head .ico { width: 16px; text-align: center; font-size: 14px; flex-shrink: 0; } +.g-head .g-name { flex: 1; white-space: nowrap; overflow: hidden; } +.g-head .arrow { font-size: 10px; color: var(--text-mute); transition: transform .15s; } +.menu-group.open > .g-head .arrow { transform: rotate(90deg); } + +.submenu { display: none; background: #0a0e15; } +.menu-group.open .submenu { display: block; } +.submenu a { + display: block; height: 32px; line-height: 32px; padding: 0 14px 0 40px; + color: var(--text-dim); text-decoration: none; font-size: 12.5px; white-space: nowrap; + border-left: 2px solid transparent; +} +.submenu a:hover { color: var(--text); background: var(--sidebar-2); } +.submenu a.active { color: #fff; background: #16243f; border-left-color: var(--accent); } + +.sidebar.collapsed .g-name, .sidebar.collapsed .arrow, .sidebar.collapsed .submenu { display: none; } +.sidebar.collapsed .g-head { justify-content: center; padding: 0; } + +.collapse { + height: 34px; border-top: 1px solid var(--border); display: flex; align-items: center; + justify-content: center; cursor: pointer; color: var(--text-mute); font-size: 14px; +} +.collapse:hover { color: var(--text); background: var(--sidebar-2); } + +/* ============ 内容区 ============ */ +.content { flex: 1; overflow-y: auto; padding: 12px 14px 24px; } +.content::-webkit-scrollbar { width: 8px; } +.content::-webkit-scrollbar-thumb { background: #222b39; } +.crumb { color: var(--text-mute); font-size: 12px; margin-bottom: 10px; } +.crumb b { color: var(--text); font-weight: 500; } +.crumb .sep { margin: 0 6px; } + +/* ============ 通用面板 ============ */ +.panel { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); } +.panel-head { + height: 36px; display: flex; align-items: center; gap: 10px; padding: 0 12px; + border-bottom: 1px solid var(--border-soft); background: var(--panel-head); font-size: 13px; font-weight: 600; +} +.panel-head .bar { width: 3px; height: 13px; background: var(--accent); } +.panel-head .sub { font-weight: 400; color: var(--text-dim); font-size: 11.5px; } +.panel-head .seg { margin-left: auto; display: flex; } +.seg button { + background: #0d121a; color: var(--text-dim); border: 1px solid var(--border); + border-right: none; padding: 3px 11px; cursor: pointer; font-size: 11.5px; +} +.seg button:last-child { border-right: 1px solid var(--border); } +.seg button.active { color: #fff; background: var(--accent); border-color: var(--accent); } +.panel-body { padding: 10px 12px; } + +.row { display: grid; gap: 10px; margin-bottom: 10px; } +.row.c4 { grid-template-columns: repeat(4, 1fr); } +.row.c2 { grid-template-columns: 1fr 1fr; } +.row.c32 { grid-template-columns: 3fr 2fr; } +@media (max-width: 1200px){ .row.c4 { grid-template-columns: repeat(2,1fr);} .row.c2,.row.c32{grid-template-columns:1fr;} } + +/* 指数卡片 */ +.idx-card { background: var(--panel); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 10px 12px; position: relative; overflow: hidden; } +.idx-card .nm { font-size: 12px; color: var(--text-dim); } +.idx-card .pr { font-size: 22px; font-weight: 700; margin: 3px 0; } +.idx-card .cg { font-size: 12px; } +.idx-card .sp { position: absolute; right: 6px; bottom: 6px; width: 88px; height: 32px; opacity: .85; } + +/* 情绪条 */ +.senti { display: flex; align-items: center; gap: 26px; flex-wrap: wrap; } +.senti .it { display: flex; flex-direction: column; gap: 2px; } +.senti .it .l { font-size: 11px; color: var(--text-dim); } +.senti .it .v { font-size: 17px; font-weight: 600; } +.rbar { flex: 1; min-width: 220px; height: 20px; display: flex; border-radius: var(--radius); overflow: hidden; } +.rbar .ru { background: var(--up); } .rbar .rf { background: #4b5563; } .rbar .rd { background: var(--down); } + +/* 表格 */ +table.grid-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; } +.grid-tbl th { text-align: right; color: var(--text-dim); font-weight: 500; padding: 7px 10px; border-bottom: 1px solid var(--border); background: var(--panel-head); position: sticky; top: 0; } +.grid-tbl th:first-child, .grid-tbl td:first-child { text-align: left; } +.grid-tbl td { text-align: right; padding: 7px 10px; border-bottom: 1px solid var(--border-soft); } +.grid-tbl tr:hover td { background: var(--sidebar-2); } +.grid-tbl tbody tr:hover td { background: #131a25; } +.tag { display: inline-block; padding: 1px 7px; border-radius: var(--radius); font-size: 11px; border: 1px solid var(--border); color: var(--text-dim); } +.tag.hot { color: var(--up); border-color: #5a2630; background: #2a1418; } + +/* 占位(建设中) */ +.placeholder { padding: 26px; } +.placeholder h2 { font-size: 16px; margin-bottom: 6px; } +.placeholder p { color: var(--text-dim); margin-bottom: 16px; } +.feat-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px,1fr)); gap: 10px; } +.feat-card { border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; background: var(--panel); } +.feat-card .ft { font-weight: 600; margin-bottom: 4px; } +.feat-card .fd { color: var(--text-dim); font-size: 12px; line-height: 1.6; } +.badge { font-size: 11px; color: var(--gold); border: 1px solid #4a3a18; background: #241c0d; padding: 1px 6px; border-radius: var(--radius); margin-left: 6px; } + +.loading { padding: 40px; text-align: center; color: var(--text-mute); } +.btn-run { background: var(--accent); color: #fff; border: 1px solid var(--accent); height: 26px; padding: 0 14px; cursor: pointer; border-radius: var(--radius); font-size: 12px; } +.btn-run:hover { filter: brightness(1.1); } +.btn-run:disabled { background: #2a3140; border-color: #2a3140; color: var(--text-mute); cursor: not-allowed; } +.bt-form input:focus { outline: none; border-color: var(--accent); } +.cond { display: flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12.5px; } +.cond input[type=number] { width: 70px; height: 26px; background: #0a0e15; border: 1px solid var(--border); color: var(--text); padding: 0 8px; } +.cond input:focus { outline: none; border-color: var(--accent); } +.clickrow { cursor: pointer; } +#alert-bell { cursor: pointer; position: relative; font-size: 15px; } +.bell-count { position: absolute; top: -7px; right: -10px; background: var(--up); color: #fff; font-size: 10px; min-width: 15px; height: 15px; line-height: 15px; text-align: center; border-radius: 8px; padding: 0 3px; } +.news-list { display: flex; flex-direction: column; } +.news-item { padding: 10px 4px; border-bottom: 1px solid var(--border-soft); } +.news-meta { font-size: 12px; color: var(--text-dim); margin-bottom: 4px; } +.news-time { color: var(--text-mute); } +.news-title { font-size: 14px; font-weight: 600; color: var(--text); line-height: 1.5; } +.news-sum { font-size: 12.5px; color: var(--text-dim); margin-top: 4px; line-height: 1.6; } +.news-act { margin-top: 6px; } +.ai-box { margin-top: 8px; background: #0e1420; border: 1px solid var(--border-soft); border-left: 2px solid var(--accent); padding: 8px 10px; font-size: 12.5px; line-height: 1.7; color: var(--text); } +.md-doc { line-height: 1.85; color: var(--text); max-width: 920px; } +.md-doc .md-h1 { font-size: 18px; font-weight: 700; color: var(--text); margin: 2px 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); } +.md-doc .md-h2 { font-size: 14px; font-weight: 600; color: var(--text); margin: 16px 0 6px; display: flex; align-items: center; gap: 8px; } +.md-doc .md-h2 .bar { display: inline-block; width: 3px; height: 13px; background: var(--accent); } +.md-doc .md-ul { margin: 4px 0 4px 2px; padding-left: 16px; } +.md-doc .md-ul li { margin: 3px 0; color: var(--text-dim); } +.md-doc .md-p { margin: 6px 0; color: var(--text-dim); } +.md-doc b { color: var(--text); } +#dsource { color: var(--text-mute); font-size: 11.5px; } +#kline { width: 100%; height: 480px; } +#treemap { width: 100%; height: 480px; } +#fundflow { width: 100%; height: 360px; } diff --git a/功能架构.md b/功能架构.md new file mode 100644 index 0000000..9daaa68 --- /dev/null +++ b/功能架构.md @@ -0,0 +1,160 @@ +# 股票分析 · 建议 · 复盘系统 — 功能架构设计 + +> 版本:v2.0(优化版) +> 定位:一套覆盖「行情监控 → 选股 → 复盘归因 → AI 辅助决策 → 策略回测」的个人/小团队量化辅助系统 +> 核心理念:**数据先行、AI 结论可回溯、复盘形成闭环** + +--- + +## 一、设计目标与核心壁垒 + +| 目标 | 说明 | +|------|------| +| 数据中台化 | 行情/资金/财务/新闻/龙虎榜统一清洗入库,可历史回溯 | +| 复盘闭环 | 每日复盘 + 个股复盘 + 交易复盘(盈亏归因),训练盘感 | +| 可信 AI | 所有 AI 建议附「依据 + 置信度 + 风险点 + 历史命中率」 | +| 选股→回测→盯盘打通 | 一个策略可一键回测、一键存为预警 | +| 全局预警 | 价格/量能/技术/资金/新闻触发,多渠道推送 | + +--- + +## 二、整体分层架构 + +```mermaid +graph TD + A[数据采集层
    行情/资金/财务/新闻/龙虎榜] --> B[数据中台
    清洗·存储·因子计算·快照] + B --> C1[行情中心] + B --> C2[选股引擎] + B --> C3[复盘中心] + B --> C4[策略与回测] + B --> C5[组合与交易] + B --> C6[资讯中心] + B --> D[AI 分析层
    RAG+因子+LLM] + D --> C1 & C2 & C3 + C2 & C3 & C4 & C5 --> E[智能预警/通知] + C1 & C2 & C3 & C4 & C5 & C6 & D --> F[Web/App 展示层] +``` + +--- + +## 三、功能模块清单 + +### 1. 行情中心 +- **大盘总览** + - 大盘云图(涨跌热力图,可按市值/成交额加权,参考 dapanyuntu.com) + - 三大指数 + 北向资金 + 涨跌停家数 + 封板率 + - A股 / 美股 / 港股 大盘与板块 +- **资金流向** + - 主力 / 超大单 / 大单 / 散户 资金分布 + - 板块资金轮动(桑基图),支持 日 / 周 / 月 / 年 +- **市场情绪温度计**(新增) + - 赚钱效应、连板高度、炸板率、量能对比、涨跌比 + +### 2. 热榜与异动 +- 热股 / 热板块 / 热 ETF / 龙虎榜 +- 强势股:连续增长(三日 >20% / 两周连涨 / 两月连涨) +- **盘中异动雷达**(新增):快速拉升、放量突破、涨停打开、大单扫货 +- **龙虎榜深挖**(新增):游资席位画像、机构净买、一线游资跟踪 + +### 3. 选股引擎 +- **内置策略**:暴涨 / 暴跌 / 抄底 / 突破 / 底部放量 / MACD金叉 / 筹码集中 +- **自定义条件组合器**(新增):技术面 + 资金面 + 基本面 多因子拖拽组合 +- **板块选股**:按板块、概念、产业链筛选 +- 选股结果可:一键回测 / 一键存为预警 / 加入自选 + +### 4. 自选股 +- 自选列表 + 分组 +- 个股详情(行情、技术、资金、财务、消息、AI 诊断聚合页) +- 上新股(次新股池、打新日历) + +### 5. 复盘中心 ⭐(核心模块) +- **每日复盘**:大盘总结、领涨/领跌板块、涨停梯队、资金流向、明日关注 +- **个股复盘**:走势回放、关键买卖点标注、形态识别 +- **交易复盘**:基于真实交易记录的盈亏归因(赚在哪/亏在哪/是否追高) +- **AI 自动复盘日报**(新增):收盘后自动生成图文报告 +- **历史情景回放**(新增):时间轴重放某天盘面,训练盘感 + +### 6. 组合与交易日志(新增模块) +- 多组合 / 模拟盘管理 +- 持仓盈亏、仓位分布、成本分析 +- 交易记录(买卖点 + 理由 + 情绪标签) +- 风险指标:最大回撤、夏普、胜率、盈亏比 + +### 7. 策略与回测引擎 +- 向量化回测、参数寻优、滑点 / 手续费建模 +- 资金曲线、回撤曲线、月度收益热力图 +- 技术指标库(MA/MACD/KDJ/BOLL/RSI…) +- 策略库 + 实盘跟踪对比 + +### 8. AI 分析层 +- **今天炒什么 / 接下来炒什么**:附资金、题材、政策、相似历史依据 +- **买卖建议**:理由 + 置信度 + 风险点 + 止损位 + 历史命中率 +- **AI 预测**:概率区间 + 模型历史准确率(不给死点位) +- **个股 AI 诊断**:技术/资金/基本面/消息面 打分卡 +- **题材脉络图**:政策/事件 → 受益板块 → 龙头股 传导链 +- RAG 接入资讯 + 财报,降低幻觉 + +### 9. 资讯中心 +- 热点 / 要闻 / 快讯 / 自选股相关 +- **AI 摘要 + 利好利空判定 + 关联个股**(新增) +- 事件日历:财报、解禁、分红、政策 + +### 10. 智能预警(贯穿全局) +- 触发类型:价格 / 涨跌幅 / 量能 / 技术信号 / 资金 / 新闻 +- 推送渠道:站内 / 微信 / 邮件 / App + +--- + +## 四、数据模型(核心表草案) + +| 表名 | 用途 | 关键字段 | +|------|------|----------| +| `securities` | 证券基础信息 | code, name, market, industry, list_date | +| `quotes_daily` | 日线行情 | code, date, ohlcv, amount, turnover | +| `quotes_minute` | 分时行情 | code, datetime, price, volume | +| `fund_flow` | 资金流向 | code/sector, date, main_net, super_big… | +| `dragon_tiger` | 龙虎榜 | code, date, seat, buy, sell, type | +| `financials` | 财务数据 | code, report_date, revenue, profit, roe… | +| `news` | 资讯 | id, time, title, content, sentiment, related_codes | +| `factors` | 因子库 | code, date, factor_name, value | +| `watchlist` | 自选 | user_id, code, group | +| `portfolios` / `trades` | 组合与交易 | user_id, code, side, price, qty, reason, time | +| `strategies` / `backtests` | 策略与回测 | id, params, metrics, equity_curve | +| `alerts` | 预警规则 | user_id, code, condition, channel, status | +| `reviews` | 复盘记录 | date/code, type, content(AI生成), tags | + +--- + +## 五、技术选型 + +| 层 | 选型 | 说明 | +|----|------|------| +| 数据源 | AkShare / Tushare(A股)、yfinance(美股)、东方财富/同花顺接口、新闻爬虫 | 多源互补 | +| 后端 | Python + FastAPI | 金融生态最佳,pandas/TA-Lib/backtrader/vectorbt | +| 时序存储 | TimescaleDB 或 ClickHouse | 海量行情读写 | +| 业务存储 | PostgreSQL | 用户/组合/策略 | +| 缓存/实时 | Redis | 行情缓存、实时推送 | +| AI | LLM(DeepSeek/通义/OpenAI)+ 向量库(RAG)+ 因子模型 | 可回溯建议 | +| 前端 | Next.js / Vue + ECharts | K线、云图、资金桑基图 | +| 任务调度 | APScheduler / Celery | 收盘自动复盘、盘中预警 | + +--- + +## 六、实施路线(分阶段) + +1. **第一阶段(地基)**:数据中台 + 行情中心 + 自选股 + K线/技术指标 +2. **第二阶段(选股+复盘)**:选股引擎 + 每日复盘 + 资讯中心 +3. **第三阶段(闭环)**:组合交易日志 + 回测引擎 + 智能预警 +4. **第四阶段(智能)**:AI 分析层(可回溯)+ AI 自动复盘日报 + +--- + +## 七、相比原架构的主要增强 + +- 新增 **数据中台**:所有上层能力的统一、可回溯数据基础 +- 强化 **复盘中心**:从"系统名"变为真正的核心闭环(每日/个股/交易复盘 + AI 日报 + 情景回放) +- 新增 **组合与交易日志**:支撑交易复盘与盈亏归因 +- 重构 **AI 分析层**:从"直接给结论"升级为"结论 + 证据链 + 历史胜率" +- 收敛 **回测/指标/资金曲线**:从散落页面变为服务选股与复盘的引擎 +- 新增 **智能预警**:贯穿全局,减少盯盘成本 +- 新增 **市场情绪指标 / 盘中异动雷达 / 龙虎榜深挖 / 事件日历** diff --git a/架构总结.md b/架构总结.md new file mode 100644 index 0000000..46a8919 --- /dev/null +++ b/架构总结.md @@ -0,0 +1,223 @@ +# 智策 StockTerminal — 架构总结 + +> 版本:当前实现态(2026-06) +> 定位:个人/小团队 A 股分析·复盘·智能辅助系统 + +--- + +## 一、整体分层 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 展示层(前端) │ +│ HTML + ECharts 5 + 原生 JS │ +│ prototype/index.html · style.css · app.js │ +└────────────────────────┬────────────────────────────────────┘ + │ HTTP / REST +┌────────────────────────▼────────────────────────────────────┐ +│ 服务层(后端 FastAPI) │ +│ main.py —— 50+ API 端点,静态文件托管 │ +│ │ +│ 数据服务 akshare_service.py (行情·情绪·资讯·实时报价) │ +│ AI 分析 ai.py (证据链·置信度·LLM/规则) │ +│ 信号统计 signals.py (胜率回测·预测留痕·核验) │ +│ RAG 检索 rag.py (资讯检索·情绪标注) │ +│ 复盘日报 report.py (结构化日报·推送) │ +│ 组合计算 portfolio.py (持仓·P&L·资金曲线) │ +│ 回测引擎 backtest.py (MA交叉·净值曲线) │ +│ 预警引擎 alerts.py (实时报价判断·触发事件) │ +│ 推送通知 notifier.py (SMTP·Server酱·企微·PP) │ +│ 大模型客户端 llm.py (OpenAI 兼容接口) │ +│ 定时调度 scheduler.py (APScheduler 5 个任务) │ +│ ETL 入库 ingest.py (AkShare→PostgreSQL) │ +│ CLI 工具 cli.py (init/ingest/ingest_all) │ +│ 配置 config.py + .env (DB·LLM·推送密钥) │ +└────────────────────────┬────────────────────────────────────┘ + │ SQLAlchemy ORM / psycopg2 +┌────────────────────────▼────────────────────────────────────┐ +│ 数据层(PostgreSQL) │ +│ 13 张业务表,见下方数据模型 │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 二、后端模块职责 + +| 文件 | 职责 | 关键依赖 | +|---|---|---| +| `main.py` | FastAPI 入口,50+ REST 端点,静态托管 | 所有模块 | +| `config.py` | 环境变量读取,支持 `.env` 文件 | python-dotenv | +| `db.py` | 引擎/Session,自动建库建表 | SQLAlchemy, psycopg2 | +| `models.py` | 13 个 ORM 表(见下节) | SQLAlchemy | +| `akshare_service.py` | AkShare 数据抓取,带 TTL 缓存和 mock 降级 | akshare, cachetools | +| `ingest.py` | ETL:AkShare → PostgreSQL 增量 upsert | akshare, models | +| `scheduler.py` | APScheduler 后台任务(5 个定时任务) | apscheduler | +| `backtest.py` | MA 交叉策略回测,读 DB 日线,输出净值曲线 | sqlalchemy, pandas | +| `ai.py` | AI 分析:证据链构造、置信度计算、LLM 调用/规则降级 | llm, signals, rag | +| `signals.py` | 6 类信号历史胜率回测;预测留痕 + 到期核验 | sqlalchemy, pandas, numpy | +| `rag.py` | 资讯检索 + 利好/利空情绪标注,作为 LLM 上下文 | akshare_service | +| `report.py` | 七段式 AI 复盘日报生成、落库、推送精简版 | ai, notifier | +| `portfolio.py` | 持仓计算(移动加权均价)、P&L 归因、逐日资金曲线 | sqlalchemy | +| `alerts.py` | 实时报价轮询 → 规则命中 → 写事件 + 推送 | akshare_service, notifier | +| `notifier.py` | 四渠道推送:SMTP 邮件、Server酱、企业微信 Webhook、PushPlus | requests, smtplib | +| `llm.py` | OpenAI 兼容客户端(DeepSeek/通义/Kimi 均适用) | requests | +| `cli.py` | 命令行工具:`init` / `ingest` / `ingest_all` | ingest, db | + +--- + +## 三、数据模型(PostgreSQL) + +| 表名 | 说明 | 主键 / 唯一约束 | +|---|---|---| +| `securities` | 证券基础信息(代码·名称·市场) | `code` | +| `quotes_daily` | 个股日线(OHLCV,前复权) | `(code, date)` | +| `index_daily` | 指数日线(上证/深证/沪深300) | `(code, date)` | +| `sector_daily` | 板块每日快照(涨跌·成交额·龙头) | `(date, name)` | +| `fund_flow_daily` | 行业主力资金流每日快照 | `(date, name)` | +| `sentiment_daily` | 全市场情绪(涨跌家数/涨跌停) | `date` | +| `dragon_tiger` | 龙虎榜明细(代码·席位·净买额) | `(date, code, reason)` | +| `stock_metrics` | 个股最新因子快照(MA/RSI/MACD/量比/分位/连涨 等 15 个因子) | `code` | +| `signal_stats` | 6 类技术信号历史胜率(回测样本·N日上涨概率·平均收益) | `(signal, horizon)` | +| `predictions` | AI 诊断留痕(方向·置信度·N日后核验·命中与否) | `(code, date, kind)` | +| `daily_reports` | AI 复盘日报(markdown 正文·来源·是否已推送) | `date` | +| `trades` | 交易记录(买卖·价格·数量·手续费·理由·情绪标签) | `id` | +| `alert_rules` | 预警规则(价格上穿/下穿/涨跌幅条件·状态) | `id` | +| `alert_events` | 预警触发事件(站内通知·已读状态) | `id` | +| `job_runs` | 定时任务执行日志 | `id` | + +--- + +## 四、定时任务(APScheduler,周一至周五) + +| 任务 ID | 触发时间 | 功能 | +|---|---|---| +| `daily_ingest` | 15:35(收盘后,可配) | 抓取当日板块/资金流/情绪/龙虎榜/自选股日线入库 | +| `alert_check` | 每 60 秒 | 实时报价核查所有 active 预警规则,触发则写事件并推送 | +| `daily_report` | 15:45(入库+10分) | 生成 AI 七段式复盘日报并推送微信/邮件 | +| `verify_pred` | 15:50 | 核验到期 AI 预测,计算命中率 | +| `signal_stats` | 每周六 09:00 | 对全市场样本股回测 6 类信号历史胜率(可手动触发) | + +--- + +## 五、AI 分析层设计(可回溯) + +``` +用户请求 /api/ai/diagnose + │ + ▼ + _stock_context() 从 stock_metrics + DailyQuote 取最新因子 + │ + ├─ signals.get_stats() 读 signal_stats 表,取各信号历史胜率 + │ + ├─ rag.stock_context() 拉近期资讯 → 情绪标注(利好/利空/中性) + │ + ▼ + _build_evidence() 生成 6 维证据链(趋势/技术/动量/资金/位置/消息) + 每条附:事实描述 · 方向(bull/bear/neutral) · 历史胜率 · 样本数 + │ + ▼ + _confidence_direction() 加权多空净值 → 置信度(%) + 预测方向(up/down/flat) + │ + ├─ signals.record_prediction() 写入 predictions 表留痕 + │ + ├─ llm.ask() (有 key) 将证据链+胜率+RAG资讯构造 prompt → 大模型输出 + │ 或 + └─ 规则文本降级 格式化证据链为可读文本 +``` + +实测准确率闭环: +``` +预测留痕(open) → N 个交易日后 verify_predictions() 拉 DailyQuote 核验 +→ 填写 actual_ret + hit(True/False) → status=closed +→ /api/ai/accuracy 按方向汇总命中率 +``` + +--- + +## 六、推送通知渠道 + +| 渠道 | 触发条件 | 配置项(`.env`) | +|---|---|---| +| SMTP 邮件 | 预警触发 / 复盘日报 / 测试 | `SMTP_HOST/PORT/USER/PASSWORD/TO` | +| Server酱(微信) | 同上 | `SERVERCHAN_KEY` | +| 企业微信群机器人 | 同上 | `WECOM_WEBHOOK` | +| PushPlus(微信) | 同上 | `PUSHPLUS_TOKEN` | + +任意渠道配置即自动启用,互不依赖。 + +--- + +## 七、前端菜单结构(app.js MENU) + +``` +大盘行情 + ├─ 市场总览 行情/三大指数/情绪温度计/资金流向 + ├─ 大盘云图 行业/板块涨跌热力图(ECharts treemap) + ├─ 热股榜 实时热股+板块 + └─ 龙虎榜 当日龙虎榜净买额排名 + +自选股 + ├─ 自选列表 持仓快照 + K线 + ├─ 全市场选股 8 个内置策略 + SQL 直查 stock_metrics + └─ 条件选股 客户端多因子过滤 + +复盘中心 + ├─ 每日复盘 板块/情绪/资金流统计 + ├─ AI日报 七段式 markdown 日报,历史翻阅,一键推送 + └─ 个股复盘 K线回放 + MA交叉买卖点标注 + +策略与中台 + ├─ 策略回测 MA交叉净值曲线 + 最大回撤 + └─ 数据中台 入库状态 / 手动触发 / 任务日志 + +AI 分析 + ├─ 今日策略 大模型/规则 生成当日操作方向 + ├─ 个股诊断 6维证据链 + 历史胜率 + 置信度 + RAG资讯 + ├─ AI复盘点评 当日大模型复盘摘要 + └─ AI准确率 信号历史胜率表 + 实测命中率 + 近期核验记录 + +组合交易 + ├─ 组合持仓 实时市值/浮亏/胜率 + ├─ 资金曲线 逐日净值 vs 沪深300基准(ECharts折线) + ├─ 交易日志 录入买卖 / 理由 / 情绪标签 + └─ 盈亏归因 按个股/理由/情绪三维归因 + +智能预警 + ├─ 预警规则 创建/删除/重激活规则,推送渠道状态检测 + └─ 触发记录 预警事件列表,标记已读,🔔铃铛角标 + +资讯中心 + ├─ 要闻快讯 全球财经资讯 + 情绪标注 + AI摘要 + └─ 自选相关 自选股关联资讯聚合 +``` + +--- + +## 八、技术栈 + +| 层 | 技术选型 | +|---|---| +| 前端 | 纯 HTML + CSS + 原生 JS,ECharts 5(CDN) | +| 后端 | Python 3.12 + FastAPI 0.115 + uvicorn | +| 数据库 | PostgreSQL(psycopg2-binary + SQLAlchemy 2.0) | +| 数据源 | AkShare(新浪/同花顺/乐估备选源),Sina hq 实时报价 | +| 调度 | APScheduler 3.x BackgroundScheduler | +| AI | OpenAI 兼容 REST(默认 DeepSeek),规则降级无缝切换 | +| 缓存 | cachetools TTL(内存),无 Redis 依赖 | +| 推送 | SMTP(SSL) + Server酱 + 企业微信 Webhook + PushPlus | +| 部署 | WSL2 / Linux,`nohup python main.py`,可 Docker 化 | + +--- + +## 九、待完善方向 + +| 优先级 | 模块 | 建议 | +|---|---|---| +| 高 | 回测引擎 | 手续费/滑点建模,参数寻优,月度收益热力图 | +| 高 | 数据稳定性 | 东财源封锁问题,增加 Tushare 备用源,分钟线入库 | +| 中 | RAG 升级 | Embedding + 向量库(Chroma/Qdrant),支持语义检索财报 | +| 中 | 多组合 | 目前单一组合,扩展为模拟盘/真实盘多组合管理 | +| 中 | 盘中异动雷达 | 快速拉升/放量突破/涨停打开实时监测 | +| 低 | 用户系统 | 登录/鉴权,多用户自选股独立管理 | +| 低 | 部署文档 | Docker Compose(FastAPI + PostgreSQL),Nginx 反代 |