Initial commit: stock analysis backend and prototype UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
29
backend/.env.example
Normal file
29
backend/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# 复制本文件为 .env 并填入你的密钥,然后重启后端(python main.py)生效。
|
||||
|
||||
# ===== 大模型(OpenAI 兼容;任选其一)=====
|
||||
# DeepSeek
|
||||
LLM_API_KEY=
|
||||
LLM_BASE_URL=https://api.deepseek.com/v1
|
||||
LLM_MODEL=deepseek-chat
|
||||
# 通义千问(示例)
|
||||
# LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||
# LLM_MODEL=qwen-plus
|
||||
# Kimi(示例)
|
||||
# LLM_BASE_URL=https://api.moonshot.cn/v1
|
||||
# LLM_MODEL=moonshot-v1-8k
|
||||
|
||||
# ===== 邮件推送(SMTP)=====
|
||||
# 以QQ邮箱为例:SMTP_HOST=smtp.qq.com SMTP_PORT=465 SMTP_PASSWORD填“授权码”而非登录密码
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=465
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TO=
|
||||
|
||||
# ===== 微信推送(任选其一)=====
|
||||
# Server酱 Turbo:https://sct.ftqq.com 获取 SendKey
|
||||
SERVERCHAN_KEY=
|
||||
# 企业微信群机器人 webhook 完整地址
|
||||
WECOM_WEBHOOK=
|
||||
# PushPlus:https://www.pushplus.plus 获取 token
|
||||
PUSHPLUS_TOKEN=
|
||||
232
backend/ai.py
Normal file
232
backend/ai.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""AI 分析层:基于中台数据构造上下文,调用大模型生成点评;无 key 时规则降级。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
import llm
|
||||
import signals
|
||||
import rag
|
||||
from db import get_session
|
||||
from models import (SectorDaily, FundFlowDaily, SentimentDaily, DragonTiger,
|
||||
StockMetric, DailyQuote, Security)
|
||||
|
||||
DISCLAIMER = "(注:以上为基于数据的程序化分析,不构成投资建议,市场有风险。)"
|
||||
|
||||
|
||||
# ============ 每日复盘点评 ============
|
||||
def _daily_context(date=None):
|
||||
with get_session() as s:
|
||||
d = dt.date.fromisoformat(date) if date else s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not d:
|
||||
return None
|
||||
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all()
|
||||
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all()
|
||||
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
|
||||
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(6)).scalars().all()
|
||||
return {
|
||||
"date": d, "senti": senti,
|
||||
"top": [(x.name, x.pct) for x in secs[:6]],
|
||||
"bot": [(x.name, x.pct) for x in secs[-4:]],
|
||||
"inflow": [(x.name, x.net) for x in flows[:6]],
|
||||
"outflow": [(x.name, x.net) for x in flows[-4:][::-1]],
|
||||
"lhb": [(x.name, x.net) for x in lhb],
|
||||
}
|
||||
|
||||
|
||||
def review_daily_comment(date=None):
|
||||
ctx = _daily_context(date)
|
||||
if not ctx:
|
||||
return {"ok": False, "msg": "暂无入库数据,请先到数据中台入库"}
|
||||
s = ctx["senti"]
|
||||
senti_line = (f"上涨{s.up}家/下跌{s.down}家,涨停{s.limit_up}家/跌停{s.limit_down}家" if s else "无情绪数据")
|
||||
facts = (
|
||||
f"日期:{ctx['date']}\n"
|
||||
f"市场情绪:{senti_line}\n"
|
||||
f"领涨板块:{', '.join(f'{n}({p:+.2f}%)' for n,p in ctx['top'])}\n"
|
||||
f"领跌板块:{', '.join(f'{n}({p:+.2f}%)' for n,p in ctx['bot'])}\n"
|
||||
f"主力净流入前列:{', '.join(f'{n}({v:+.1f}亿)' for n,v in ctx['inflow'])}\n"
|
||||
f"主力净流出前列:{', '.join(f'{n}({v:+.1f}亿)' for n,v in ctx['outflow'])}\n"
|
||||
f"龙虎榜净买额居前:{', '.join(f'{n}({v:+.2f}亿)' for n,v in ctx['lhb'])}\n"
|
||||
)
|
||||
if llm.enabled():
|
||||
try:
|
||||
prompt = ("请根据以下当日A股盘面数据,撰写一篇 250 字以内的收盘复盘点评,"
|
||||
"包含:① 今日市场情绪与赚钱效应判断;② 资金与题材主线;③ 明日需关注的方向与风险。"
|
||||
"分段清晰。\n\n" + facts)
|
||||
text = llm.ask(prompt, temperature=0.6)
|
||||
return {"ok": True, "source": "llm", "date": ctx["date"].isoformat(), "facts": facts, "text": text}
|
||||
except Exception as e:
|
||||
pass
|
||||
# 规则降级
|
||||
tone = "情绪偏暖、赚钱效应尚可" if (s and s.up > s.down) else "情绪偏弱、需控制仓位"
|
||||
text = (f"今日{tone}。{senti_line}。\n"
|
||||
f"领涨主线集中在 {', '.join(n for n,_ in ctx['top'][:3])},"
|
||||
f"主力资金净流入居前的是 {', '.join(n for n,_ in ctx['inflow'][:3])};"
|
||||
f"而 {', '.join(n for n,_ in ctx['outflow'][:2])} 遭资金流出,需回避。\n"
|
||||
f"明日关注领涨板块的持续性与量能配合,注意高位股退潮风险。\n{DISCLAIMER}")
|
||||
return {"ok": True, "source": "rule", "date": ctx["date"].isoformat(), "facts": facts, "text": text}
|
||||
|
||||
|
||||
# ============ 个股诊断 ============
|
||||
def _stock_context(symbol):
|
||||
with get_session() as s:
|
||||
m = s.get(StockMetric, symbol)
|
||||
sec = s.get(Security, symbol)
|
||||
rows = s.execute(select(DailyQuote).where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date.desc()).limit(60)).scalars().all()
|
||||
return m, sec, list(reversed(rows))
|
||||
|
||||
|
||||
def _build_evidence(m, stats, news_tone):
|
||||
"""构造证据链:每条含 维度/事实/方向/历史命中率。"""
|
||||
def hr(key):
|
||||
s = stats.get(key)
|
||||
return (s["win_rate"], s["avg_ret"], s["samples"]) if s else (None, None, None)
|
||||
|
||||
ev = []
|
||||
# 趋势
|
||||
wr, ar, ns = hr("ma_bull")
|
||||
ev.append({"dim": "趋势", "fact": f"均线{'多头排列(MA5>MA10>MA20)' if m.ma_bull else '未呈多头'},20日{m.ret20:+.1f}%",
|
||||
"signal": "bull" if m.ma_bull else ("bear" if m.ret20 < -3 else "neutral"),
|
||||
"win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 1.2})
|
||||
# 技术(MACD)
|
||||
wr, ar, ns = hr("macd_gold")
|
||||
ev.append({"dim": "技术", "fact": f"{'MACD金叉' if m.macd_gold else 'MACD未金叉'},RSI14={m.rsi14:.0f}"
|
||||
+ ("(超买)" if m.rsi14 >= 80 else ("(超卖)" if m.rsi14 < 30 else "")),
|
||||
"signal": "bull" if m.macd_gold else ("bear" if m.rsi14 >= 80 else "neutral"),
|
||||
"win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 1.0})
|
||||
# 动量
|
||||
wr, ar, ns = hr("up_streak3")
|
||||
streak_sig = "bull" if m.up_streak >= 3 else ("bear" if m.ret5 < -5 else "neutral")
|
||||
ev.append({"dim": "动量", "fact": f"连涨{m.up_streak}日,5日{m.ret5:+.1f}%",
|
||||
"signal": streak_sig, "win_rate": wr if m.up_streak >= 3 else None,
|
||||
"avg_ret": ar if m.up_streak >= 3 else None, "samples": ns if m.up_streak >= 3 else None, "weight": 1.0})
|
||||
# 资金
|
||||
wr, ar, ns = hr("vol_breakout")
|
||||
vol_sig = "bull" if (m.vol_ratio > 2 and m.pct > 0) else ("bear" if (m.vol_ratio > 2 and m.pct < 0) else "neutral")
|
||||
ev.append({"dim": "资金", "fact": f"量比{m.vol_ratio},当日{m.pct:+.2f}%,成交额{m.amount}亿",
|
||||
"signal": vol_sig, "win_rate": wr if vol_sig != "neutral" else None,
|
||||
"avg_ret": ar if vol_sig != "neutral" else None, "samples": ns if vol_sig != "neutral" else None, "weight": 1.1})
|
||||
# 位置
|
||||
key = "new_high60" if m.pos60 >= 0.99 else ("rsi_oversold" if m.rsi14 < 30 else None)
|
||||
wr, ar, ns = hr(key) if key else (None, None, None)
|
||||
pos_sig = "bear" if m.pos60 > 0.92 else ("bull" if m.pos60 < 0.2 else "neutral")
|
||||
ev.append({"dim": "位置", "fact": f"处于60日 {m.pos60*100:.0f}% 分位" + ("(创新高)" if m.pos60 >= 0.99 else ("(低位)" if m.pos60 < 0.2 else "")),
|
||||
"signal": pos_sig, "win_rate": wr, "avg_ret": ar, "samples": ns, "weight": 0.8})
|
||||
# 消息面(RAG)
|
||||
ev.append({"dim": "消息", "fact": f"近期资讯情绪:{news_tone}",
|
||||
"signal": {"利好": "bull", "利空": "bear"}.get(news_tone, "neutral"),
|
||||
"win_rate": None, "avg_ret": None, "samples": None, "weight": 0.9})
|
||||
return ev
|
||||
|
||||
|
||||
def _confidence_direction(ev):
|
||||
sval = {"bull": 1, "bear": -1, "neutral": 0}
|
||||
net = sum(sval[e["signal"]] * e["weight"] for e in ev)
|
||||
tw = sum(e["weight"] for e in ev if e["signal"] != "neutral")
|
||||
agreement = abs(net) / tw if tw else 0.0
|
||||
conf = int(max(5, min(96, 35 + agreement * 55 + (8 if tw >= 3 else 0))))
|
||||
thr = 0.15 * sum(e["weight"] for e in ev)
|
||||
direction = "up" if net > thr else ("down" if net < -thr else "flat")
|
||||
return conf, direction, round(net, 2)
|
||||
|
||||
|
||||
def diagnose(symbol):
|
||||
m, sec, rows = _stock_context(symbol)
|
||||
if not m:
|
||||
return {"ok": False, "msg": "该股票无因子数据,请先入库(数据中台→全市场回填或指定入库)"}
|
||||
name = (sec.name if sec else m.name) or symbol
|
||||
|
||||
# 规则打分(0~100)
|
||||
trend = 50 + (15 if m.ma_bull else -10) + min(20, max(-20, m.ret20))
|
||||
momentum = 50 + min(25, max(-25, m.ret5)) + (10 if 50 < m.rsi14 < 75 else (-10 if m.rsi14 >= 80 else 0))
|
||||
capital = 50 + min(30, (m.vol_ratio - 1) * 20) * (1 if m.pct > 0 else -1)
|
||||
position = 100 - abs(m.pos60 * 100 - 45) # 价格分位适中得分高
|
||||
clamp = lambda x: int(max(5, min(98, x)))
|
||||
scores = {"趋势": clamp(trend), "动量": clamp(momentum), "资金": clamp(capital), "位置": clamp(position)}
|
||||
total = clamp(sum(scores.values()) / 4)
|
||||
|
||||
# 证据链 + 历史命中率 + RAG 资讯
|
||||
stats = signals.get_stats(horizon=5)
|
||||
rctx = rag.stock_context(symbol, limit=5)
|
||||
evidence = _build_evidence(m, stats, rctx["tone"])
|
||||
confidence, direction, net = _confidence_direction(evidence)
|
||||
|
||||
# 留痕:本次诊断写入预测,N 日后核验
|
||||
try:
|
||||
signals.record_prediction(symbol, name, m.date, total, confidence, direction, m.close, horizon=5)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
facts = (
|
||||
f"股票:{name}({symbol})\n"
|
||||
f"最新价:{m.close},当日{m.pct:+.2f}%\n"
|
||||
f"均线:MA5={m.ma5} MA10={m.ma10} MA20={m.ma20} MA60={m.ma60},{'多头排列' if m.ma_bull else '非多头'}\n"
|
||||
f"涨幅:5日{m.ret5:+.2f}% / 20日{m.ret20:+.2f}% / 60日{m.ret60:+.2f}%\n"
|
||||
f"量比:{m.vol_ratio},RSI14:{m.rsi14},60日价格分位:{m.pos60*100:.0f}%,"
|
||||
f"{'MACD金叉' if m.macd_gold else 'MACD未金叉'},连涨{m.up_streak}日,成交额{m.amount}亿\n"
|
||||
)
|
||||
hit_lines = "\n".join(
|
||||
f"- {e['dim']}信号「{e['fact']}」历史5日上涨概率 {e['win_rate']}%、平均{e['avg_ret']:+.2f}%(样本{e['samples']})"
|
||||
for e in evidence if e.get("win_rate") is not None)
|
||||
base = {"ok": True, "symbol": symbol, "name": name, "scores": scores, "total": total,
|
||||
"confidence": confidence, "direction": direction, "evidence": evidence,
|
||||
"news": rctx["items"], "news_tone": rctx["tone"], "facts": facts}
|
||||
|
||||
if llm.enabled():
|
||||
try:
|
||||
prompt = ("请基于以下个股量化数据、信号历史命中率与相关资讯,输出结构化诊断:\n"
|
||||
"1) 一句话结论(含方向与置信判断);2) 技术面/资金面/趋势面分别点评(引用历史命中率);"
|
||||
"3) 操作建议(含参考性关注位与止损思路);4) 主要风险。200~320字。\n\n"
|
||||
f"{facts}\n历史命中率:\n{hit_lines or '(暂无回测数据,请先在AI准确率页计算)'}\n\n"
|
||||
f"{rctx['block'] or '近期无相关资讯。'}\n")
|
||||
text = llm.ask(prompt, temperature=0.4)
|
||||
base.update({"source": "llm", "text": text})
|
||||
return base
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
judge = "偏强" if total >= 60 else ("偏弱" if total < 45 else "中性")
|
||||
dir_cn = {"up": "看多", "down": "看空", "flat": "中性观望"}[direction]
|
||||
text = (f"综合评分 {total}({judge}),方向{dir_cn},置信度 {confidence}%。"
|
||||
f"趋势{'多头向上' if m.ma_bull else '尚未走强'},20日{m.ret20:+.1f}%;"
|
||||
f"动量 RSI {m.rsi14:.0f}{',超买防回调' if m.rsi14>=80 else ''};"
|
||||
f"资金{'放量' if m.vol_ratio>=1.5 else '量能一般'}(量比{m.vol_ratio});"
|
||||
f"价格处于60日 {m.pos60*100:.0f}% 分位;消息面{rctx['tone']}。\n"
|
||||
+ (f"依据信号历史表现:\n{hit_lines}\n" if hit_lines else "")
|
||||
+ f"操作:{'回踩不破MA20可关注' if m.ma_bull else '等待站稳均线再观察'},跌破MA20或前低则减仓。"
|
||||
f"风险:{'高位放量谨防见顶' if m.pos60>0.9 else '大盘系统性波动'}。\n{DISCLAIMER}")
|
||||
base.update({"source": "rule", "text": text})
|
||||
return base
|
||||
|
||||
|
||||
# ============ 今日策略 ============
|
||||
def today_strategy():
|
||||
with get_session() as s:
|
||||
d = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc()).limit(5)).scalars().all() if d else []
|
||||
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc()).limit(5)).scalars().all() if d else []
|
||||
strong = s.execute(select(StockMetric).where(StockMetric.up_streak >= 3).order_by(StockMetric.up_streak.desc()).limit(8)).scalars().all()
|
||||
macd = s.execute(select(StockMetric).where(StockMetric.macd_gold.is_(True)).order_by(StockMetric.ret5.desc()).limit(8)).scalars().all()
|
||||
facts = (
|
||||
f"领涨板块:{', '.join(f'{x.name}({x.pct:+.2f}%)' for x in secs)}\n"
|
||||
f"主力净流入板块:{', '.join(f'{x.name}({x.net:+.1f}亿)' for x in flows)}\n"
|
||||
f"强势连涨个股:{', '.join(f'{x.name}({x.up_streak}连阳)' for x in strong)}\n"
|
||||
f"MACD金叉个股:{', '.join(x.name for x in macd)}\n"
|
||||
)
|
||||
if llm.enabled():
|
||||
try:
|
||||
prompt = ("请基于以下盘面数据,给出『今天/接下来怎么做』的策略观点:"
|
||||
"① 当前资金与题材主线;② 值得关注的方向(给理由);③ 需要回避的风险。"
|
||||
"220字以内。\n\n" + facts)
|
||||
text = llm.ask(prompt, temperature=0.6)
|
||||
return {"ok": True, "source": "llm", "facts": facts, "text": text}
|
||||
except Exception:
|
||||
pass
|
||||
main = "、".join(x.name for x in secs[:3]) or "暂无数据"
|
||||
text = (f"当前资金主线集中在 {main}。"
|
||||
f"可重点跟踪净流入居前的 {', '.join(x.name for x in flows[:3])} 板块内的强势个股;"
|
||||
f"强势连涨股需注意分歧与退潮节奏,避免追高。\n{DISCLAIMER}")
|
||||
return {"ok": True, "source": "rule", "facts": facts, "text": text}
|
||||
444
backend/akshare_service.py
Normal file
444
backend/akshare_service.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""AkShare 数据服务层。
|
||||
|
||||
每个函数都做了 try/except 降级:真实数据拿不到时返回 Python 端生成的模拟数据,
|
||||
并通过 `source` 字段标注来源(akshare / mock),保证前端任何情况下都有数据可渲染。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
import datetime as dt
|
||||
from functools import wraps
|
||||
|
||||
from cachetools import TTLCache
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
AK_OK = True
|
||||
except Exception: # akshare 未安装也能跑(全部走 mock)
|
||||
ak = None
|
||||
AK_OK = False
|
||||
|
||||
# ---- 简单 TTL 缓存(按函数+参数) ----
|
||||
_cache = TTLCache(maxsize=256, ttl=30)
|
||||
|
||||
|
||||
def cached(ttl: int):
|
||||
def deco(fn):
|
||||
local = TTLCache(maxsize=64, ttl=ttl)
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
key = (fn.__name__, args, tuple(sorted(kwargs.items())))
|
||||
if key in local:
|
||||
return local[key]
|
||||
val = fn(*args, **kwargs)
|
||||
local[key] = val
|
||||
return val
|
||||
|
||||
return wrapper
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
def _rnd(a, b):
|
||||
return round(random.uniform(a, b), 2)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 指数
|
||||
# ============================================================
|
||||
MAJOR_INDEX = {
|
||||
"sh000001": ("上证指数", 3210),
|
||||
"sz399001": ("深证成指", 10180),
|
||||
"sz399006": ("创业板指", 2105),
|
||||
"sh000300": ("沪深300", 3760),
|
||||
"bj899050": ("北证50", 1080),
|
||||
}
|
||||
|
||||
|
||||
@cached(10)
|
||||
def get_indices():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_zh_index_spot_sina()
|
||||
rows = []
|
||||
for code, (name, _base) in MAJOR_INDEX.items():
|
||||
r = df[df["代码"] == code]
|
||||
if r.empty:
|
||||
continue
|
||||
r = r.iloc[0]
|
||||
rows.append({
|
||||
"code": code, "name": name,
|
||||
"price": float(r["最新价"]),
|
||||
"change": float(r["涨跌额"]),
|
||||
"pct": float(r["涨跌幅"]),
|
||||
})
|
||||
if rows:
|
||||
return {"source": "akshare", "list": rows}
|
||||
except Exception as e: # noqa
|
||||
pass
|
||||
# mock
|
||||
rows = []
|
||||
for code, (name, base) in MAJOR_INDEX.items():
|
||||
pct = _rnd(-2.5, 2.5)
|
||||
price = round(base * (1 + pct / 100), 2)
|
||||
rows.append({"code": code, "name": name, "price": price,
|
||||
"change": round(price - base, 2), "pct": pct})
|
||||
return {"source": "mock", "list": rows}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# K线
|
||||
# ============================================================
|
||||
# ============================================================
|
||||
# 实时报价(新浪 hq,速度快且稳定,用于盯盘预警)
|
||||
# ============================================================
|
||||
def realtime_quotes(codes):
|
||||
"""返回 {code: {name, price, prev_close, pct, open, high, low}}。失败返回 {}。"""
|
||||
if not codes:
|
||||
return {}
|
||||
syms = ",".join(_sina_symbol(c) for c in codes)
|
||||
try:
|
||||
r = requests.get("https://hq.sinajs.cn/list=" + syms,
|
||||
headers={"Referer": "https://finance.sina.com.cn"}, timeout=6)
|
||||
out = {}
|
||||
for line in r.text.split(";\n"):
|
||||
if "hq_str_" not in line or '="' not in line:
|
||||
continue
|
||||
head, body = line.split('="', 1)
|
||||
sym = head.split("hq_str_")[1].strip()
|
||||
code = sym[2:]
|
||||
f = body.strip('"').split(",")
|
||||
if len(f) < 6 or not f[3]:
|
||||
continue
|
||||
price = float(f[3]); prev = float(f[2]) if f[2] else 0.0
|
||||
out[code] = {"name": f[0], "open": float(f[1] or 0), "prev_close": prev,
|
||||
"price": price, "high": float(f[4] or 0), "low": float(f[5] or 0),
|
||||
"pct": round((price - prev) / prev * 100, 2) if prev else 0.0}
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 资讯新闻
|
||||
# ============================================================
|
||||
_BULL = ["涨停", "利好", "增长", "大涨", "突破", "中标", "签约", "回购", "增持", "扭亏",
|
||||
"超预期", "新高", "提价", "涨价", "订单", "合作", "获批", "盈利", "分红", "重组",
|
||||
"并购", "补贴", "减税", "降准", "降息", "刺激", "国产替代", "放量", "净流入"]
|
||||
_BEAR = ["跌停", "利空", "下滑", "大跌", "亏损", "减持", "处罚", "退市", "违规", "下调",
|
||||
"不及预期", "新低", "停牌", "质押", "爆雷", "诉讼", "解禁", "商誉", "预亏", "降价",
|
||||
"裁员", "债务", "暴跌", "净流出", "风险警示"]
|
||||
|
||||
|
||||
def judge_sentiment(text: str):
|
||||
t = text or ""
|
||||
pos = [w for w in _BULL if w in t]
|
||||
neg = [w for w in _BEAR if w in t]
|
||||
if len(pos) > len(neg):
|
||||
return "利好", pos[:4]
|
||||
if len(neg) > len(pos):
|
||||
return "利空", neg[:4]
|
||||
return "中性", (pos or neg)[:4]
|
||||
|
||||
|
||||
@cached(120)
|
||||
def get_news(limit: int = 40):
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_info_global_em()
|
||||
rows = []
|
||||
for _, r in df.head(limit).iterrows():
|
||||
title = str(r["标题"]); summary = str(r.get("摘要", ""))
|
||||
senti, kw = judge_sentiment(title + summary)
|
||||
rows.append({"time": str(r["发布时间"]), "title": title, "summary": summary,
|
||||
"url": str(r.get("链接", "")), "sentiment": senti, "keywords": kw})
|
||||
if rows:
|
||||
return {"source": "akshare", "list": rows}
|
||||
except Exception:
|
||||
pass
|
||||
return {"source": "mock", "list": [
|
||||
{"time": "—", "title": "示例资讯:市场情绪回暖,多板块走强", "summary": "(演示数据)",
|
||||
"sentiment": "利好", "keywords": ["利好"], "url": ""}]}
|
||||
|
||||
|
||||
@cached(180)
|
||||
def get_stock_news(code: str, limit: int = 12):
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_news_em(symbol=code)
|
||||
rows = []
|
||||
for _, r in df.head(limit).iterrows():
|
||||
title = str(r["新闻标题"]); content = str(r.get("新闻内容", ""))
|
||||
senti, kw = judge_sentiment(title + content)
|
||||
rows.append({"time": str(r["发布时间"]), "title": title,
|
||||
"summary": content[:120], "source": str(r.get("文章来源", "")),
|
||||
"url": str(r.get("新闻链接", "")), "sentiment": senti, "keywords": kw})
|
||||
if rows:
|
||||
return {"source": "akshare", "list": rows}
|
||||
except Exception:
|
||||
pass
|
||||
return {"source": "mock", "list": []}
|
||||
|
||||
|
||||
def _sina_symbol(code: str) -> str:
|
||||
if code.startswith("6"):
|
||||
return "sh" + code
|
||||
if code.startswith(("0", "3")):
|
||||
return "sz" + code
|
||||
if code.startswith(("8", "4")):
|
||||
return "bj" + code
|
||||
return "sh" + code
|
||||
|
||||
|
||||
@cached(60)
|
||||
def get_kline(symbol: str = "600519", days: int = 120):
|
||||
if AK_OK:
|
||||
# 主源:新浪日线(更稳定);备源:腾讯
|
||||
for src in ("sina", "tx"):
|
||||
try:
|
||||
sym = _sina_symbol(symbol)
|
||||
if src == "sina":
|
||||
df = ak.stock_zh_a_daily(symbol=sym, adjust="qfq")
|
||||
else:
|
||||
df = ak.stock_zh_a_hist_tx(symbol=sym)
|
||||
if df is not None and not df.empty:
|
||||
df = df.tail(days)
|
||||
dates = [str(d)[5:].replace("-", "/") for d in df["date"]]
|
||||
ohlc = [[float(o), float(c), float(l), float(h)] for o, c, l, h in
|
||||
zip(df["open"], df["close"], df["low"], df["high"])]
|
||||
vols = [int(v) for v in (df["volume"] if "volume" in df.columns else df["amount"])]
|
||||
return {"source": "akshare", "symbol": symbol, "dates": dates, "ohlc": ohlc, "vols": vols}
|
||||
except Exception:
|
||||
continue
|
||||
# mock
|
||||
dates, ohlc, vols = [], [], []
|
||||
price = 1680.0
|
||||
today = dt.date.today()
|
||||
for i in range(days, 0, -1):
|
||||
d = today - dt.timedelta(days=i)
|
||||
dates.append(f"{d.month}/{d.day}")
|
||||
o = price
|
||||
c = round(o + _rnd(-o * 0.03, o * 0.03), 2)
|
||||
h = round(max(o, c) + _rnd(0, o * 0.02), 2)
|
||||
l = round(min(o, c) - _rnd(0, o * 0.02), 2)
|
||||
ohlc.append([o, c, l, h])
|
||||
vols.append(int(_rnd(2, 9) * 1e6))
|
||||
price = c
|
||||
return {"source": "mock", "symbol": symbol, "dates": dates, "ohlc": ohlc, "vols": vols}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 行业板块(云图 / 热门板块复用)—— 新浪行业(东财push2在部分网络被封)
|
||||
# ============================================================
|
||||
@cached(60)
|
||||
def get_industry_boards():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_sector_spot(indicator="新浪行业")
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
rows.append({
|
||||
"name": str(r["板块"]),
|
||||
"pct": float(r["涨跌幅"]),
|
||||
"amount": round(float(r["总成交额"]) / 1e8, 1), # 亿
|
||||
"count": int(r.get("公司家数", 0) or 0),
|
||||
"leader": str(r.get("股票名称", "")),
|
||||
})
|
||||
if rows:
|
||||
rows.sort(key=lambda x: x["pct"], reverse=True)
|
||||
return {"source": "akshare", "list": rows}
|
||||
except Exception:
|
||||
pass
|
||||
sectors = ["半导体", "新能源", "医药生物", "白酒食品", "军工", "证券", "银行",
|
||||
"房地产", "人工智能", "消费电子", "光伏", "汽车", "有色金属", "煤炭", "传媒"]
|
||||
return {"source": "mock", "list": [
|
||||
{"name": s, "pct": _rnd(-3, 6), "amount": _rnd(50, 500),
|
||||
"count": int(_rnd(10, 80)), "leader": "龙头股"} for s in sectors]}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 全市场快照(情绪 / 全市场云图)
|
||||
# ============================================================
|
||||
@cached(60)
|
||||
def _spot():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_zh_a_spot_em()
|
||||
if df is not None and not df.empty:
|
||||
return df
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
@cached(30)
|
||||
def get_sentiment():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_market_activity_legu()
|
||||
m = {}
|
||||
for _, r in df.iterrows():
|
||||
m[str(r["item"]).strip()] = r["value"]
|
||||
|
||||
def num(k):
|
||||
try:
|
||||
return int(float(m.get(k, 0)))
|
||||
except Exception:
|
||||
return 0
|
||||
up, down, flat = num("上涨"), num("下跌"), num("平盘")
|
||||
if up or down:
|
||||
return {"source": "akshare", "up": up, "down": down, "flat": flat,
|
||||
"limit_up": num("涨停"), "limit_down": num("跌停"),
|
||||
"height": min(9, max(3, num("涨停") // 8))}
|
||||
except Exception:
|
||||
pass
|
||||
up, down, flat = int(_rnd(1800, 3200)), int(_rnd(1200, 2600)), int(_rnd(80, 260))
|
||||
return {"source": "mock", "up": up, "down": down, "flat": flat,
|
||||
"limit_up": int(_rnd(20, 90)), "limit_down": int(_rnd(2, 30)), "height": int(_rnd(4, 9))}
|
||||
|
||||
|
||||
@cached(60)
|
||||
def get_treemap(mode: str = "sector"):
|
||||
if mode == "all":
|
||||
df = _spot()
|
||||
if df is not None:
|
||||
try:
|
||||
top = df.sort_values("成交额", ascending=False).head(150)
|
||||
items = [{"name": str(r["名称"]), "value": round(float(r["成交额"]) / 1e8, 2),
|
||||
"pct": float(r["涨跌幅"])} for _, r in top.iterrows()]
|
||||
return {"source": "akshare", "mode": "all", "items": items}
|
||||
except Exception:
|
||||
pass
|
||||
# mock flat
|
||||
items = [{"name": f"个股{i}", "value": _rnd(2, 50), "pct": _rnd(-9, 9)} for i in range(60)]
|
||||
return {"source": "mock", "mode": "all", "items": items}
|
||||
# sector
|
||||
boards = get_industry_boards()
|
||||
items = [{"name": b["name"], "value": b.get("amount", 1), "pct": b["pct"]} for b in boards["list"]]
|
||||
return {"source": boards["source"], "mode": "sector", "items": items}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 资金流向(行业)
|
||||
# ============================================================
|
||||
@cached(60)
|
||||
def get_fund_flow():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_fund_flow_industry(symbol="即时")
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
rows.append({"name": str(r["行业"]),
|
||||
"net": round(float(r["净额"]), 2), # 同花顺已是亿元
|
||||
"pct": float(r["行业-涨跌幅"])})
|
||||
if rows:
|
||||
rows.sort(key=lambda x: x["net"])
|
||||
# 取首尾各15条,突出流入流出两端
|
||||
show = rows[:15] + rows[-15:] if len(rows) > 30 else rows
|
||||
show.sort(key=lambda x: x["net"])
|
||||
return {"source": "akshare", "list": show}
|
||||
except Exception:
|
||||
pass
|
||||
sectors = ["半导体", "新能源", "医药生物", "白酒食品", "军工", "证券", "银行",
|
||||
"房地产", "人工智能", "消费电子", "光伏", "汽车", "有色金属", "煤炭", "传媒"]
|
||||
rows = [{"name": s, "net": _rnd(-40, 60), "pct": _rnd(-3, 6)} for s in sectors]
|
||||
rows.sort(key=lambda x: x["net"])
|
||||
return {"source": "mock", "list": rows}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 热门股票(人气榜)
|
||||
# ============================================================
|
||||
@cached(60)
|
||||
def get_hot_stocks():
|
||||
if AK_OK:
|
||||
try:
|
||||
df = ak.stock_hot_rank_em()
|
||||
rows = []
|
||||
for _, r in df.head(20).iterrows():
|
||||
rows.append({"rank": int(r["当前排名"]), "code": str(r["代码"]),
|
||||
"name": str(r["股票名称"]), "price": float(r["最新价"]),
|
||||
"pct": float(r["涨跌幅"])})
|
||||
if rows:
|
||||
return {"source": "akshare", "list": rows}
|
||||
except Exception:
|
||||
pass
|
||||
pool = ["龙头A", "龙头B", "中军C", "黑马D", "次新E", "蓝筹F", "题材G", "妖股H"]
|
||||
return {"source": "mock", "list": [
|
||||
{"rank": i + 1, "code": f"60{1000+i}", "name": f"{pool[i % len(pool)]}{i}",
|
||||
"price": _rnd(5, 200), "pct": _rnd(-5, 11)} for i in range(15)]}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 龙虎榜
|
||||
# ============================================================
|
||||
@cached(300)
|
||||
def get_dragon_tiger():
|
||||
if AK_OK:
|
||||
try:
|
||||
for back in range(0, 7):
|
||||
d = (dt.date.today() - dt.timedelta(days=back)).strftime("%Y%m%d")
|
||||
try:
|
||||
df = ak.stock_lhb_detail_em(start_date=d, end_date=d)
|
||||
except Exception:
|
||||
df = None
|
||||
if df is not None and not df.empty:
|
||||
rows = []
|
||||
for _, r in df.head(20).iterrows():
|
||||
rows.append({
|
||||
"code": str(r.get("代码", "")), "name": str(r.get("名称", "")),
|
||||
"pct": float(r.get("涨跌幅", 0) or 0),
|
||||
"net": round(float(r.get("龙虎榜净买额", 0) or 0) / 1e8, 2),
|
||||
"reason": str(r.get("上榜原因", "")),
|
||||
})
|
||||
return {"source": "akshare", "date": d, "list": rows}
|
||||
except Exception:
|
||||
pass
|
||||
pool = ["龙头A", "龙头B", "中军C", "黑马D"]
|
||||
return {"source": "mock", "date": "", "list": [
|
||||
{"code": f"60{1000+i}", "name": f"{pool[i % len(pool)]}{i}", "pct": _rnd(-3, 10),
|
||||
"net": _rnd(-3, 5), "reason": ["日涨幅偏离", "换手率达20%", "连续三日涨停"][i % 3]} for i in range(12)]}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 自选股 —— 代码名称表 + 个股日线(push2 被封时的稳妥方案)
|
||||
# ============================================================
|
||||
@cached(3600)
|
||||
def _code_name_map():
|
||||
if AK_OK:
|
||||
try:
|
||||
cn = ak.stock_info_a_code_name()
|
||||
return {str(r["code"]): str(r["name"]) for _, r in cn.iterrows()}
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def get_watchlist(symbols: list[str]):
|
||||
names = {"600519": "贵州茅台", "300750": "宁德时代", "002594": "比亚迪",
|
||||
"688981": "中芯国际", "300059": "东方财富", "601012": "隆基绿能"}
|
||||
if AK_OK:
|
||||
cmap = _code_name_map()
|
||||
rows = []
|
||||
for s in symbols:
|
||||
try:
|
||||
k = get_kline(s, 30)
|
||||
if k["source"] != "akshare" or len(k["ohlc"]) < 2:
|
||||
continue
|
||||
last, prev = k["ohlc"][-1], k["ohlc"][-2]
|
||||
price, prev_close = last[1], prev[1]
|
||||
change = round(price - prev_close, 2)
|
||||
pct = round(change / prev_close * 100, 2) if prev_close else 0.0
|
||||
rows.append({"code": s, "name": cmap.get(s, names.get(s, s)), "price": price,
|
||||
"pct": pct, "change": change,
|
||||
"amount": round(k["vols"][-1] * price / 1e8, 2)})
|
||||
except Exception:
|
||||
continue
|
||||
if rows:
|
||||
return {"source": "akshare", "list": rows}
|
||||
return {"source": "mock", "list": [
|
||||
{"code": c, "name": names.get(c, c), "price": _rnd(20, 1800), "pct": _rnd(-4, 5),
|
||||
"change": _rnd(-30, 30), "amount": _rnd(3, 60)} for c in symbols]}
|
||||
67
backend/alerts.py
Normal file
67
backend/alerts.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""预警引擎:拉取实时价格,评估规则,触发即生成站内事件。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
import akshare_service as svc
|
||||
import notifier
|
||||
from db import get_session
|
||||
from models import AlertRule, AlertEvent
|
||||
|
||||
KIND_LABEL = {
|
||||
"price_above": "价格突破",
|
||||
"price_below": "价格跌破",
|
||||
"pct_above": "涨幅达到",
|
||||
"pct_below": "跌幅达到",
|
||||
}
|
||||
|
||||
|
||||
def _hit(kind, threshold, q):
|
||||
price, pct = q["price"], q["pct"]
|
||||
if kind == "price_above":
|
||||
return price >= threshold, price
|
||||
if kind == "price_below":
|
||||
return price <= threshold, price
|
||||
if kind == "pct_above":
|
||||
return pct >= threshold, pct
|
||||
if kind == "pct_below":
|
||||
return pct <= -abs(threshold), pct
|
||||
return False, price
|
||||
|
||||
|
||||
def check_alerts():
|
||||
with get_session() as s:
|
||||
rules = s.execute(select(AlertRule).where(AlertRule.status == "active")).scalars().all()
|
||||
if not rules:
|
||||
return {"checked": 0, "triggered": 0}
|
||||
codes = list({r.code for r in rules})
|
||||
quotes = svc.realtime_quotes(codes)
|
||||
triggered = 0
|
||||
push_msgs = []
|
||||
for r in rules:
|
||||
q = quotes.get(r.code)
|
||||
if not q:
|
||||
continue
|
||||
hit, val = _hit(r.kind, r.threshold, q)
|
||||
r.last_value = q["price"]
|
||||
if hit:
|
||||
unit = "" if r.kind.startswith("price") else "%"
|
||||
msg = (f"{q['name']} {KIND_LABEL.get(r.kind, r.kind)} {r.threshold}{unit}"
|
||||
f"(现价 {q['price']},{q['pct']:+.2f}%)")
|
||||
s.add(AlertEvent(rule_id=r.id, code=r.code, name=q["name"], message=msg, value=val))
|
||||
r.status = "triggered"
|
||||
r.triggered_at = dt.datetime.now()
|
||||
triggered += 1
|
||||
push_msgs.append(msg)
|
||||
s.commit()
|
||||
|
||||
# 触发后向已配置渠道推送(站外)
|
||||
if push_msgs and notifier.any_enabled():
|
||||
try:
|
||||
notifier.notify("【智策预警】" + (push_msgs[0] if len(push_msgs) == 1 else f"{len(push_msgs)} 条预警触发"),
|
||||
"\n".join(push_msgs))
|
||||
except Exception:
|
||||
pass
|
||||
return {"checked": len(rules), "triggered": triggered}
|
||||
78
backend/backtest.py
Normal file
78
backend/backtest.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""基于中台日线的简易回测引擎(均线交叉策略)。
|
||||
|
||||
读取 quotes_daily,金叉满仓 / 死叉空仓,输出资金曲线与核心指标。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import get_session
|
||||
from models import DailyQuote
|
||||
|
||||
|
||||
def _ma(arr, n):
|
||||
out = [None] * len(arr)
|
||||
s = 0.0
|
||||
for i, v in enumerate(arr):
|
||||
s += v
|
||||
if i >= n:
|
||||
s -= arr[i - n]
|
||||
if i >= n - 1:
|
||||
out[i] = s / n
|
||||
return out
|
||||
|
||||
|
||||
def run_backtest(symbol: str, fast: int = 5, slow: int = 20, fee: float = 0.0005):
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(DailyQuote.date, DailyQuote.close)
|
||||
.where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date)
|
||||
).all()
|
||||
|
||||
if len(rows) < slow + 5:
|
||||
return {"ok": False, "msg": "该股票库内日线不足,请先在数据中台入库", "have": len(rows)}
|
||||
|
||||
dates = [r[0].isoformat() for r in rows]
|
||||
close = [float(r[1]) for r in rows]
|
||||
maf, mas = _ma(close, fast), _ma(close, slow)
|
||||
|
||||
equity, bench = [], []
|
||||
cash, pos, shares = 1.0, 0, 0.0
|
||||
trades, wins, entry = 0, 0, 0.0
|
||||
peak, max_dd = 1.0, 0.0
|
||||
base = close[0]
|
||||
|
||||
for i in range(len(close)):
|
||||
if maf[i] is not None and mas[i] is not None:
|
||||
if pos == 0 and maf[i] > mas[i]:
|
||||
shares = cash * (1 - fee) / close[i]
|
||||
cash, pos, entry = 0.0, 1, close[i]
|
||||
trades += 1
|
||||
elif pos == 1 and maf[i] < mas[i]:
|
||||
cash = shares * close[i] * (1 - fee)
|
||||
shares, pos = 0.0, 0
|
||||
if close[i] > entry:
|
||||
wins += 1
|
||||
nav = cash + shares * close[i]
|
||||
equity.append(round(nav, 4))
|
||||
bench.append(round(close[i] / base, 4))
|
||||
peak = max(peak, nav)
|
||||
max_dd = max(max_dd, (peak - nav) / peak)
|
||||
|
||||
total_ret = equity[-1] - 1
|
||||
bench_ret = bench[-1] - 1
|
||||
closed = trades - pos
|
||||
win_rate = (wins / closed) if closed > 0 else 0.0
|
||||
return {
|
||||
"ok": True, "symbol": symbol, "fast": fast, "slow": slow,
|
||||
"dates": dates, "equity": equity, "bench": bench,
|
||||
"metrics": {
|
||||
"total_return": round(total_ret * 100, 2),
|
||||
"bench_return": round(bench_ret * 100, 2),
|
||||
"excess": round((total_ret - bench_ret) * 100, 2),
|
||||
"max_drawdown": round(max_dd * 100, 2),
|
||||
"trades": trades,
|
||||
"win_rate": round(win_rate * 100, 1),
|
||||
},
|
||||
}
|
||||
33
backend/cli.py
Normal file
33
backend/cli.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""命令行入库工具。
|
||||
|
||||
用法:
|
||||
python cli.py init # 仅建库建表
|
||||
python cli.py ingest # 全量入库(默认股票池)
|
||||
python cli.py ingest 600519 000001 # 指定股票入库(含快照)
|
||||
"""
|
||||
import sys
|
||||
|
||||
from db import init_db
|
||||
import ingest
|
||||
|
||||
|
||||
def main():
|
||||
init_db()
|
||||
args = sys.argv[1:]
|
||||
if not args or args[0] == "init":
|
||||
print("init done")
|
||||
return
|
||||
if args[0] == "ingest":
|
||||
codes = args[1:] or None
|
||||
res = ingest.run_daily_ingest(universe=codes)
|
||||
print(res)
|
||||
elif args[0] == "ingest_all":
|
||||
days = int(args[1]) if len(args) > 1 else 250
|
||||
# 先抓快照类数据,再全市场日线
|
||||
ingest.run_daily_ingest(with_quotes=False)
|
||||
res = ingest.ingest_quotes_all(days=days)
|
||||
print(res)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
53
backend/config.py
Normal file
53
backend/config.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""中台配置。优先读环境变量,便于以后切换部署环境。
|
||||
|
||||
可在 backend/.env 写入密钥(大模型 key、邮箱、推送 token),无需改代码。
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
PG_USER = os.getenv("PG_USER", "postgres")
|
||||
PG_PASSWORD = os.getenv("PG_PASSWORD", "13142324")
|
||||
PG_HOST = os.getenv("PG_HOST", "localhost")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
PG_DB = os.getenv("PG_DB", "stock_cs")
|
||||
|
||||
DB_URL = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DB}"
|
||||
DB_URL_DEFAULT = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/postgres"
|
||||
|
||||
# 默认入库股票池(沪深龙头 + 自选)。可在「数据中台」页或环境变量扩展。
|
||||
DEFAULT_UNIVERSE = [
|
||||
"600519", "300750", "002594", "688981", "300059", "601012",
|
||||
"600036", "601318", "000858", "002415", "600276", "002230",
|
||||
"601899", "600030", "000333", "002475", "300760", "601166",
|
||||
"688111", "600887",
|
||||
]
|
||||
|
||||
# 收盘后定时任务时间(24h制,本地时区)
|
||||
INGEST_HOUR = int(os.getenv("INGEST_HOUR", "15"))
|
||||
INGEST_MINUTE = int(os.getenv("INGEST_MINUTE", "35"))
|
||||
|
||||
# ---- 大模型(OpenAI 兼容,DeepSeek/通义/Kimi 等均可)----
|
||||
# 配置环境变量 LLM_API_KEY 后即启用真实大模型,否则走内置规则点评。
|
||||
LLM_API_KEY = os.getenv("LLM_API_KEY", "")
|
||||
LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1")
|
||||
LLM_MODEL = os.getenv("LLM_MODEL", "deepseek-chat")
|
||||
LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "40"))
|
||||
|
||||
# ---- 推送通知 ----
|
||||
# 邮件(SMTP)
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT", "465"))
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") # 邮箱授权码
|
||||
SMTP_TO = os.getenv("SMTP_TO", "") # 收件人,多个用逗号
|
||||
# Server酱 Turbo(微信推送,最简单)
|
||||
SERVERCHAN_KEY = os.getenv("SERVERCHAN_KEY", "")
|
||||
# 企业微信群机器人 webhook
|
||||
WECOM_WEBHOOK = os.getenv("WECOM_WEBHOOK", "")
|
||||
# PushPlus(微信推送)
|
||||
PUSHPLUS_TOKEN = os.getenv("PUSHPLUS_TOKEN", "")
|
||||
43
backend/db.py
Normal file
43
backend/db.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""数据库引擎与初始化。
|
||||
|
||||
init_db():
|
||||
- 若目标库不存在则自动创建(连到默认 postgres 库执行 CREATE DATABASE)
|
||||
- 建表
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import psycopg2
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import config
|
||||
from models import Base
|
||||
|
||||
engine = create_engine(config.DB_URL, pool_pre_ping=True, future=True)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
|
||||
|
||||
|
||||
def _ensure_database():
|
||||
try:
|
||||
conn = psycopg2.connect(
|
||||
host=config.PG_HOST, port=config.PG_PORT, user=config.PG_USER,
|
||||
password=config.PG_PASSWORD, dbname="postgres", connect_timeout=5)
|
||||
conn.autocommit = True
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (config.PG_DB,))
|
||||
if not cur.fetchone():
|
||||
cur.execute(f'CREATE DATABASE "{config.PG_DB}"')
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e: # 数据库已存在或无权限时不阻断
|
||||
print("[db] ensure_database:", repr(e)[:120])
|
||||
|
||||
|
||||
def init_db():
|
||||
_ensure_database()
|
||||
Base.metadata.create_all(engine)
|
||||
print("[db] tables ready")
|
||||
|
||||
|
||||
def get_session():
|
||||
return SessionLocal()
|
||||
298
backend/ingest.py
Normal file
298
backend/ingest.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""ETL:从数据源抽取并增量入库。
|
||||
|
||||
每个 ingest_* 负责一类数据;run_daily_ingest 编排全部并写任务日志。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
import akshare_service as svc
|
||||
import config
|
||||
from db import get_session
|
||||
from models import (DailyQuote, DragonTiger, FundFlowDaily, IndexDaily, JobRun,
|
||||
SectorDaily, Security, SentimentDaily, StockMetric)
|
||||
|
||||
try:
|
||||
import akshare as ak
|
||||
except Exception:
|
||||
ak = None
|
||||
|
||||
|
||||
def _today():
|
||||
return dt.date.today()
|
||||
|
||||
|
||||
def _upsert(session, model, rows, index_elements, update_cols):
|
||||
if not rows:
|
||||
return 0
|
||||
stmt = pg_insert(model).values(rows)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=index_elements,
|
||||
set_={c: getattr(stmt.excluded, c) for c in update_cols},
|
||||
)
|
||||
session.execute(stmt)
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ---------------- 个股日线(保留真实日期) ----------------
|
||||
def fetch_daily(code: str, days: int = 400):
|
||||
if ak is None:
|
||||
return []
|
||||
sym = svc._sina_symbol(code)
|
||||
for src in ("sina", "tx"):
|
||||
try:
|
||||
df = ak.stock_zh_a_daily(symbol=sym, adjust="qfq") if src == "sina" else ak.stock_zh_a_hist_tx(symbol=sym)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
df = df.tail(days)
|
||||
out = []
|
||||
for _, r in df.iterrows():
|
||||
d = r["date"]
|
||||
d = d.date() if hasattr(d, "date") else dt.date.fromisoformat(str(d)[:10])
|
||||
out.append({
|
||||
"code": code, "date": d,
|
||||
"open": float(r["open"]), "high": float(r["high"]),
|
||||
"low": float(r["low"]), "close": float(r["close"]),
|
||||
"volume": int(r["volume"]) if "volume" in df.columns else 0,
|
||||
"amount": float(r["amount"]) if "amount" in df.columns else 0.0,
|
||||
})
|
||||
return out
|
||||
except Exception:
|
||||
continue
|
||||
return []
|
||||
|
||||
|
||||
# ---------------- 因子计算 ----------------
|
||||
def _mean(a):
|
||||
return sum(a) / len(a) if a else 0.0
|
||||
|
||||
|
||||
def _ema(arr, n):
|
||||
k = 2 / (n + 1)
|
||||
e = arr[0]
|
||||
out = []
|
||||
for v in arr:
|
||||
e = v * k + e * (1 - k)
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def compute_metrics(code, name, rows):
|
||||
"""rows: 按日期升序的日线 dict 列表。返回 StockMetric 字段 dict 或 None。"""
|
||||
if len(rows) < 25:
|
||||
return None
|
||||
closes = [r["close"] for r in rows]
|
||||
highs = [r["high"] for r in rows]
|
||||
lows = [r["low"] for r in rows]
|
||||
vols = [r["volume"] for r in rows]
|
||||
n = len(closes)
|
||||
last = closes[-1]
|
||||
prev = closes[-2]
|
||||
|
||||
def ma(k):
|
||||
return round(_mean(closes[-k:]), 3) if n >= k else 0.0
|
||||
|
||||
def ret(k):
|
||||
return round((last / closes[-k - 1] - 1) * 100, 2) if n > k else 0.0
|
||||
|
||||
ma5, ma10, ma20, ma60 = ma(5), ma(10), ma(20), ma(60)
|
||||
vol_ratio = round(vols[-1] / _mean(vols[-6:-1]), 2) if n >= 6 and _mean(vols[-6:-1]) else 0.0
|
||||
win = min(60, n)
|
||||
high60, low60 = max(highs[-win:]), min(lows[-win:])
|
||||
pos60 = round((last - low60) / (high60 - low60), 3) if high60 > low60 else 0.5
|
||||
|
||||
# RSI14
|
||||
gains, losses = [], []
|
||||
for i in range(max(1, n - 14), n):
|
||||
d = closes[i] - closes[i - 1]
|
||||
gains.append(max(d, 0)); losses.append(max(-d, 0))
|
||||
ag, al = _mean(gains), _mean(losses)
|
||||
rsi14 = round(100 - 100 / (1 + ag / al), 1) if al else (100.0 if ag else 50.0)
|
||||
|
||||
# MACD 金叉
|
||||
e12, e26 = _ema(closes, 12), _ema(closes, 26)
|
||||
dif = [a - b for a, b in zip(e12, e26)]
|
||||
dea = _ema(dif, 9)
|
||||
macd_gold = len(dif) >= 2 and dif[-2] < dea[-2] and dif[-1] >= dea[-1]
|
||||
|
||||
ma_bull = ma5 > ma10 > ma20 > 0
|
||||
|
||||
streak = 0
|
||||
for i in range(n - 1, 0, -1):
|
||||
if closes[i] > closes[i - 1]:
|
||||
streak += 1
|
||||
else:
|
||||
break
|
||||
|
||||
return {
|
||||
"code": code, "name": name, "date": rows[-1]["date"], "close": last,
|
||||
"pct": round((last / prev - 1) * 100, 2) if prev else 0.0,
|
||||
"ma5": ma5, "ma10": ma10, "ma20": ma20, "ma60": ma60,
|
||||
"vol_ratio": vol_ratio, "ret5": ret(5), "ret20": ret(20), "ret60": ret(60),
|
||||
"pos60": pos60, "rsi14": rsi14, "macd_gold": bool(macd_gold),
|
||||
"ma_bull": bool(ma_bull), "up_streak": streak,
|
||||
"amount": round(rows[-1]["amount"] / 1e8, 3),
|
||||
}
|
||||
|
||||
|
||||
def ingest_quotes(codes, days=400, with_metrics=True, cmap=None):
|
||||
if with_metrics and cmap is None:
|
||||
cmap = svc._code_name_map()
|
||||
n = 0
|
||||
with get_session() as s:
|
||||
for code in codes:
|
||||
rows = fetch_daily(code, days)
|
||||
if not rows:
|
||||
continue
|
||||
n += _upsert(s, DailyQuote, rows, ["code", "date"],
|
||||
["open", "high", "low", "close", "volume", "amount"])
|
||||
if with_metrics:
|
||||
m = compute_metrics(code, (cmap or {}).get(code, code), rows)
|
||||
if m:
|
||||
_upsert(s, StockMetric, [m], ["code"],
|
||||
["name", "date", "close", "pct", "ma5", "ma10", "ma20", "ma60",
|
||||
"vol_ratio", "ret5", "ret20", "ret60", "pos60", "rsi14",
|
||||
"macd_gold", "ma_bull", "up_streak", "amount"])
|
||||
s.commit()
|
||||
return n
|
||||
|
||||
|
||||
def ingest_quotes_all(days=250, progress_every=300):
|
||||
"""全市场回填:对所有证券抓取日线并计算因子。耗时较长。"""
|
||||
cmap = svc._code_name_map()
|
||||
codes = [c for c in cmap.keys() if c[:1] in ("0", "3", "6")]
|
||||
total = len(codes)
|
||||
done = 0
|
||||
with get_session() as s:
|
||||
job = JobRun(job="ingest_all", status="running", message=f"0/{total}")
|
||||
s.add(job); s.commit(); job_id = job.id
|
||||
try:
|
||||
for i in range(0, total, 50):
|
||||
batch = codes[i:i + 50]
|
||||
ingest_quotes(batch, days=days, with_metrics=True, cmap=cmap)
|
||||
done += len(batch)
|
||||
if done % progress_every < 50:
|
||||
with get_session() as s:
|
||||
j = s.get(JobRun, job_id); j.message = f"{done}/{total}"; s.commit()
|
||||
status, msg = "success", f"{done}/{total}"
|
||||
except Exception as e:
|
||||
status, msg = "error", f"{done}/{total} | {repr(e)[:160]}"
|
||||
with get_session() as s:
|
||||
j = s.get(JobRun, job_id); j.status = status
|
||||
j.finished_at = dt.datetime.now(); j.message = msg; s.commit()
|
||||
return {"status": status, "done": done, "total": total}
|
||||
|
||||
|
||||
def ingest_securities():
|
||||
cmap = svc._code_name_map()
|
||||
rows = [{"code": c, "name": n, "market": "A"} for c, n in cmap.items()]
|
||||
with get_session() as s:
|
||||
cnt = _upsert(s, Security, rows, ["code"], ["name", "market"])
|
||||
s.commit()
|
||||
return cnt
|
||||
|
||||
|
||||
def ingest_indices():
|
||||
if ak is None:
|
||||
return 0
|
||||
n = 0
|
||||
with get_session() as s:
|
||||
for code, (name, _b) in svc.MAJOR_INDEX.items():
|
||||
try:
|
||||
df = ak.stock_zh_index_daily(symbol=code).tail(400)
|
||||
rows = []
|
||||
for _, r in df.iterrows():
|
||||
d = r["date"]
|
||||
d = d.date() if hasattr(d, "date") else dt.date.fromisoformat(str(d)[:10])
|
||||
rows.append({"code": code, "name": name, "date": d,
|
||||
"open": float(r["open"]), "high": float(r["high"]),
|
||||
"low": float(r["low"]), "close": float(r["close"]),
|
||||
"volume": int(r.get("volume", 0) or 0)})
|
||||
n += _upsert(s, IndexDaily, rows, ["code", "date"],
|
||||
["name", "open", "high", "low", "close", "volume"])
|
||||
s.commit()
|
||||
except Exception:
|
||||
continue
|
||||
return n
|
||||
|
||||
|
||||
def ingest_sectors():
|
||||
data = svc.get_industry_boards()
|
||||
d = _today()
|
||||
rows = [{"date": d, "name": b["name"], "pct": b["pct"], "amount": b.get("amount", 0),
|
||||
"count": b.get("count", 0), "leader": b.get("leader", "")} for b in data["list"]]
|
||||
with get_session() as s:
|
||||
n = _upsert(s, SectorDaily, rows, ["date", "name"], ["pct", "amount", "count", "leader"])
|
||||
s.commit()
|
||||
return n
|
||||
|
||||
|
||||
def ingest_fund_flow():
|
||||
data = svc.get_fund_flow()
|
||||
d = _today()
|
||||
rows = [{"date": d, "name": x["name"], "net": x["net"], "pct": x.get("pct", 0)} for x in data["list"]]
|
||||
with get_session() as s:
|
||||
n = _upsert(s, FundFlowDaily, rows, ["date", "name"], ["net", "pct"])
|
||||
s.commit()
|
||||
return n
|
||||
|
||||
|
||||
def ingest_sentiment():
|
||||
x = svc.get_sentiment()
|
||||
d = _today()
|
||||
row = [{"date": d, "up": x["up"], "down": x["down"], "flat": x["flat"],
|
||||
"limit_up": x["limit_up"], "limit_down": x["limit_down"]}]
|
||||
with get_session() as s:
|
||||
n = _upsert(s, SentimentDaily, row, ["date"], ["up", "down", "flat", "limit_up", "limit_down"])
|
||||
s.commit()
|
||||
return n
|
||||
|
||||
|
||||
def ingest_dragon():
|
||||
data = svc.get_dragon_tiger()
|
||||
if not data["list"]:
|
||||
return 0
|
||||
try:
|
||||
d = dt.date.fromisoformat(f"{data['date'][:4]}-{data['date'][4:6]}-{data['date'][6:8]}") if data.get("date") else _today()
|
||||
except Exception:
|
||||
d = _today()
|
||||
rows = [{"date": d, "code": x["code"], "name": x["name"], "pct": x["pct"],
|
||||
"net": x["net"], "reason": x["reason"][:120]} for x in data["list"]]
|
||||
with get_session() as s:
|
||||
n = _upsert(s, DragonTiger, rows, ["date", "code", "reason"], ["name", "pct", "net"])
|
||||
s.commit()
|
||||
return n
|
||||
|
||||
|
||||
# ---------------- 编排 ----------------
|
||||
def run_daily_ingest(universe=None, with_quotes=True):
|
||||
universe = universe or config.DEFAULT_UNIVERSE
|
||||
with get_session() as s:
|
||||
job = JobRun(job="daily_ingest", status="running", message="")
|
||||
s.add(job)
|
||||
s.commit()
|
||||
job_id = job.id
|
||||
|
||||
summary = {}
|
||||
try:
|
||||
summary["securities"] = ingest_securities()
|
||||
summary["indices"] = ingest_indices()
|
||||
summary["sectors"] = ingest_sectors()
|
||||
summary["fund_flow"] = ingest_fund_flow()
|
||||
summary["sentiment"] = ingest_sentiment()
|
||||
summary["dragon"] = ingest_dragon()
|
||||
if with_quotes:
|
||||
summary["quotes"] = ingest_quotes(universe)
|
||||
status, msg = "success", str(summary)
|
||||
except Exception as e:
|
||||
status, msg = "error", f"{summary} | EXC {repr(e)[:200]}"
|
||||
|
||||
with get_session() as s:
|
||||
job = s.get(JobRun, job_id)
|
||||
job.status = status
|
||||
job.finished_at = dt.datetime.now()
|
||||
job.message = msg
|
||||
s.commit()
|
||||
return {"status": status, "summary": summary}
|
||||
42
backend/llm.py
Normal file
42
backend/llm.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""大模型客户端(OpenAI 兼容接口)。
|
||||
|
||||
未配置 LLM_API_KEY 时 enabled() 返回 False,调用方应走规则降级。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
return bool(config.LLM_API_KEY)
|
||||
|
||||
|
||||
def chat(messages, temperature: float = 0.5, max_tokens: int = 900) -> str:
|
||||
"""调用 chat/completions。失败抛异常,由上层捕获降级。"""
|
||||
if not enabled():
|
||||
raise RuntimeError("LLM 未配置")
|
||||
url = config.LLM_BASE_URL.rstrip("/") + "/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {config.LLM_API_KEY}", "Content-Type": "application/json"}
|
||||
payload = {"model": config.LLM_MODEL, "messages": messages,
|
||||
"temperature": temperature, "max_tokens": max_tokens, "stream": False}
|
||||
r = requests.post(url, json=payload, headers=headers, timeout=config.LLM_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
return data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"你是一名专业的A股市场分析师,擅长复盘、个股诊断与题材分析。"
|
||||
"你的结论必须严格基于用户提供的结构化数据,不臆造未提供的数字。"
|
||||
"语言简洁专业,使用中文。涉及操作建议时必须同时给出风险提示,"
|
||||
"并说明这不构成投资建议。"
|
||||
)
|
||||
|
||||
|
||||
def ask(user_content: str, temperature: float = 0.5, max_tokens: int = 900) -> str:
|
||||
return chat([
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_content},
|
||||
], temperature=temperature, max_tokens=max_tokens)
|
||||
624
backend/main.py
Normal file
624
backend/main.py
Normal file
@@ -0,0 +1,624 @@
|
||||
"""智策股票终端 — FastAPI 后端入口。
|
||||
|
||||
- /api/* : 数据接口(基于 AkShare,带缓存与降级)
|
||||
- / : 托管前端原型(prototype 目录)
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import datetime as dt
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select, func, desc
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
import akshare_service as svc
|
||||
import config
|
||||
import scheduler
|
||||
import backtest as bt
|
||||
import ai
|
||||
import signals as sig
|
||||
import report as rpt
|
||||
import portfolio as pf
|
||||
import llm
|
||||
import alerts as al
|
||||
import notifier
|
||||
from db import init_db, get_session
|
||||
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
|
||||
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
|
||||
AlertRule, AlertEvent)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
try:
|
||||
init_db()
|
||||
scheduler.start_scheduler()
|
||||
print("[startup] db + scheduler ready")
|
||||
except Exception as e:
|
||||
print("[startup] WARN:", repr(e)[:160])
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="智策股票终端 API", version="0.2.0", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 自选股本地存储
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
WATCH_FILE = os.path.join(BASE_DIR, "watchlist.json")
|
||||
DEFAULT_WATCH = ["600519", "300750", "002594", "688981", "300059", "601012"]
|
||||
|
||||
|
||||
def load_watch():
|
||||
if os.path.exists(WATCH_FILE):
|
||||
try:
|
||||
with open(WATCH_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return DEFAULT_WATCH
|
||||
|
||||
|
||||
def save_watch(symbols):
|
||||
with open(WATCH_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(symbols, f, ensure_ascii=False)
|
||||
|
||||
|
||||
# ============ API ============
|
||||
@app.get("/api/health")
|
||||
def health():
|
||||
return {"ok": True, "akshare": svc.AK_OK}
|
||||
|
||||
|
||||
@app.get("/api/indices")
|
||||
def indices():
|
||||
return svc.get_indices()
|
||||
|
||||
|
||||
@app.get("/api/kline")
|
||||
def kline(symbol: str = Query("600519"), days: int = Query(120, ge=20, le=500)):
|
||||
return svc.get_kline(symbol, days)
|
||||
|
||||
|
||||
@app.get("/api/sentiment")
|
||||
def sentiment():
|
||||
return svc.get_sentiment()
|
||||
|
||||
|
||||
@app.get("/api/treemap")
|
||||
def treemap(mode: str = Query("sector")):
|
||||
return svc.get_treemap(mode)
|
||||
|
||||
|
||||
@app.get("/api/fundflow")
|
||||
def fundflow():
|
||||
return svc.get_fund_flow()
|
||||
|
||||
|
||||
@app.get("/api/hot/stocks")
|
||||
def hot_stocks():
|
||||
return svc.get_hot_stocks()
|
||||
|
||||
|
||||
@app.get("/api/hot/sectors")
|
||||
def hot_sectors():
|
||||
return svc.get_industry_boards()
|
||||
|
||||
|
||||
@app.get("/api/dragon")
|
||||
def dragon():
|
||||
return svc.get_dragon_tiger()
|
||||
|
||||
|
||||
@app.get("/api/watchlist")
|
||||
def watchlist():
|
||||
return svc.get_watchlist(load_watch())
|
||||
|
||||
|
||||
@app.post("/api/watchlist/{code}")
|
||||
def watch_add(code: str):
|
||||
w = load_watch()
|
||||
if code not in w:
|
||||
w.append(code)
|
||||
save_watch(w)
|
||||
return {"ok": True, "list": w}
|
||||
|
||||
|
||||
@app.delete("/api/watchlist/{code}")
|
||||
def watch_del(code: str):
|
||||
w = [c for c in load_watch() if c != code]
|
||||
save_watch(w)
|
||||
return {"ok": True, "list": w}
|
||||
|
||||
|
||||
# ============ 数据中台 ============
|
||||
@app.get("/api/admin/status")
|
||||
def admin_status():
|
||||
counts, last_dates = {}, {}
|
||||
with get_session() as s:
|
||||
for label, model in [("securities", Security), ("quotes_daily", DailyQuote),
|
||||
("index_daily", IndexDaily), ("sector_daily", SectorDaily),
|
||||
("fund_flow_daily", FundFlowDaily), ("sentiment_daily", SentimentDaily),
|
||||
("dragon_tiger", DragonTiger)]:
|
||||
counts[label] = s.execute(select(func.count()).select_from(model)).scalar() or 0
|
||||
if hasattr(model, "date"):
|
||||
d = s.execute(select(func.max(model.date))).scalar()
|
||||
last_dates[label] = d.isoformat() if d else None
|
||||
jobs = s.execute(select(JobRun).order_by(desc(JobRun.id)).limit(8)).scalars().all()
|
||||
job_list = [{"id": j.id, "job": j.job, "status": j.status,
|
||||
"started": j.started_at.strftime("%m-%d %H:%M:%S") if j.started_at else "",
|
||||
"finished": j.finished_at.strftime("%H:%M:%S") if j.finished_at else "",
|
||||
"message": j.message[:200]} for j in jobs]
|
||||
return {"counts": counts, "last_dates": last_dates, "jobs": job_list,
|
||||
"running": scheduler.is_running(), "universe": config.DEFAULT_UNIVERSE,
|
||||
"schedule": f"周一至周五 {config.INGEST_HOUR:02d}:{config.INGEST_MINUTE:02d}"}
|
||||
|
||||
|
||||
@app.post("/api/admin/ingest")
|
||||
def admin_ingest():
|
||||
if scheduler.is_running():
|
||||
return {"started": False, "msg": "已有入库任务在执行"}
|
||||
return scheduler.trigger_async()
|
||||
|
||||
|
||||
@app.post("/api/admin/ingest_all")
|
||||
def admin_ingest_all():
|
||||
return scheduler.trigger_all_async()
|
||||
|
||||
|
||||
@app.get("/api/db/kline")
|
||||
def db_kline(symbol: str = Query("600519"), days: int = Query(250, ge=20, le=1000)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(DailyQuote).where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date.desc()).limit(days)
|
||||
).scalars().all()
|
||||
rows = list(reversed(rows))
|
||||
if not rows:
|
||||
return {"source": "db", "empty": True, "symbol": symbol, "dates": [], "ohlc": [], "vols": []}
|
||||
return {"source": "db", "symbol": symbol,
|
||||
"dates": [r.date.strftime("%m/%d") for r in rows],
|
||||
"ohlc": [[r.open, r.close, r.low, r.high] for r in rows],
|
||||
"vols": [r.volume for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/db/sentiment_history")
|
||||
def db_sentiment_history(days: int = Query(60, ge=5, le=365)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(SentimentDaily).order_by(SentimentDaily.date.desc()).limit(days)).scalars().all()
|
||||
rows = list(reversed(rows))
|
||||
return {"dates": [r.date.isoformat() for r in rows],
|
||||
"up": [r.up for r in rows], "down": [r.down for r in rows],
|
||||
"limit_up": [r.limit_up for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/review/daily")
|
||||
def review_daily(date: str = Query(None)):
|
||||
with get_session() as s:
|
||||
if date:
|
||||
d = dt.date.fromisoformat(date)
|
||||
else:
|
||||
d = s.execute(select(func.max(SectorDaily.date))).scalar()
|
||||
if not d:
|
||||
return {"ok": False, "msg": "暂无入库数据,请先在数据中台执行入库"}
|
||||
sectors = s.execute(select(SectorDaily).where(SectorDaily.date == d).order_by(SectorDaily.pct.desc())).scalars().all()
|
||||
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d).order_by(FundFlowDaily.net.desc())).scalars().all()
|
||||
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
|
||||
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d).order_by(DragonTiger.net.desc()).limit(10)).scalars().all()
|
||||
|
||||
top_sec = [{"name": x.name, "pct": x.pct} for x in sectors[:8]]
|
||||
bot_sec = [{"name": x.name, "pct": x.pct} for x in sectors[-5:]]
|
||||
inflow = [{"name": x.name, "net": x.net} for x in flows[:8]]
|
||||
outflow = [{"name": x.name, "net": x.net} for x in flows[-5:][::-1]]
|
||||
senti_d = ({"up": senti.up, "down": senti.down, "limit_up": senti.limit_up,
|
||||
"limit_down": senti.limit_down} if senti else None)
|
||||
|
||||
summary = _gen_review_text(d, senti_d, top_sec, inflow)
|
||||
return {"ok": True, "date": d.isoformat(), "sentiment": senti_d,
|
||||
"top_sectors": top_sec, "weak_sectors": bot_sec,
|
||||
"inflow": inflow, "outflow": outflow,
|
||||
"dragon": [{"name": x.name, "code": x.code, "net": x.net, "pct": x.pct} for x in lhb],
|
||||
"summary": summary}
|
||||
|
||||
|
||||
def _gen_review_text(d, senti, top_sec, inflow):
|
||||
parts = [f"【{d.isoformat()} 复盘】"]
|
||||
if senti:
|
||||
tone = "情绪偏暖" if senti["up"] > senti["down"] else "情绪偏弱"
|
||||
parts.append(f"全市场上涨 {senti['up']} 家、下跌 {senti['down']} 家,涨停 {senti['limit_up']} 家、跌停 {senti['limit_down']} 家,{tone}。")
|
||||
if top_sec:
|
||||
names = "、".join(x["name"] for x in top_sec[:3])
|
||||
parts.append(f"领涨板块:{names}。")
|
||||
if inflow:
|
||||
names = "、".join(x["name"] for x in inflow[:3])
|
||||
parts.append(f"主力净流入居前:{names}。")
|
||||
parts.append("注:以上为基于入库数据的自动统计,AI 智能点评将在 AI 分析模块接入大模型后生成。")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
@app.get("/api/backtest")
|
||||
def backtest_api(symbol: str = Query("600519"), fast: int = Query(5, ge=2, le=60),
|
||||
slow: int = Query(20, ge=5, le=250)):
|
||||
if fast >= slow:
|
||||
return {"ok": False, "msg": "快线周期需小于慢线周期"}
|
||||
return bt.run_backtest(symbol, fast, slow)
|
||||
|
||||
|
||||
# ============ 全市场选股 ============
|
||||
STRATEGIES = {
|
||||
"surge": "最近暴涨(5日涨幅≥20%)",
|
||||
"plunge": "最近暴跌(5日跌幅≥15%)",
|
||||
"dip": "超跌抄底(60日分位≤20%且当日企稳)",
|
||||
"breakout": "突破走强(逼近60日新高)",
|
||||
"ma_bull": "均线多头(MA5>10>20)",
|
||||
"volume": "放量上攻(量比≥2且上涨)",
|
||||
"macd_gold": "MACD金叉",
|
||||
"strong": "强势连涨(≥3日连阳)",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/screen/strategies")
|
||||
def screen_strategies():
|
||||
return {"list": [{"id": k, "name": v} for k, v in STRATEGIES.items()]}
|
||||
|
||||
|
||||
@app.get("/api/screen")
|
||||
def screen(strategy: str = Query("surge"), limit: int = Query(60, ge=10, le=300),
|
||||
min_amount: float = Query(0.0)):
|
||||
M = StockMetric
|
||||
q = select(M)
|
||||
order = M.ret5.desc()
|
||||
if strategy == "surge":
|
||||
q = q.where(M.ret5 >= 20)
|
||||
elif strategy == "plunge":
|
||||
q = q.where(M.ret5 <= -15); order = M.ret5.asc()
|
||||
elif strategy == "dip":
|
||||
q = q.where(M.pos60 <= 0.2, M.pct > 0); order = M.pos60.asc()
|
||||
elif strategy == "breakout":
|
||||
q = q.where(M.pos60 >= 0.95, M.pct > 0); order = M.ret20.desc()
|
||||
elif strategy == "ma_bull":
|
||||
q = q.where(M.ma_bull.is_(True)); order = M.ret20.desc()
|
||||
elif strategy == "volume":
|
||||
q = q.where(M.vol_ratio >= 2, M.pct > 0); order = M.vol_ratio.desc()
|
||||
elif strategy == "macd_gold":
|
||||
q = q.where(M.macd_gold.is_(True)); order = M.ret5.desc()
|
||||
elif strategy == "strong":
|
||||
q = q.where(M.up_streak >= 3); order = M.up_streak.desc()
|
||||
if min_amount > 0:
|
||||
q = q.where(M.amount >= min_amount)
|
||||
q = q.order_by(order).limit(limit)
|
||||
with get_session() as s:
|
||||
rows = s.execute(q).scalars().all()
|
||||
total = s.execute(select(func.count()).select_from(M)).scalar() or 0
|
||||
return {"strategy": strategy, "name": STRATEGIES.get(strategy, strategy), "pool_size": total,
|
||||
"count": len(rows), "list": [{
|
||||
"code": r.code, "name": r.name, "close": r.close, "pct": r.pct,
|
||||
"ret5": r.ret5, "ret20": r.ret20, "vol_ratio": r.vol_ratio,
|
||||
"rsi14": r.rsi14, "pos60": round(r.pos60 * 100, 1), "amount": r.amount,
|
||||
"up_streak": r.up_streak} for r in rows]}
|
||||
|
||||
|
||||
@app.get("/api/securities/search")
|
||||
def securities_search(q: str = Query("", min_length=0), limit: int = Query(15, le=50)):
|
||||
with get_session() as s:
|
||||
stmt = select(Security)
|
||||
if q:
|
||||
stmt = stmt.where((Security.code.like(f"{q}%")) | (Security.name.like(f"%{q}%")))
|
||||
rows = s.execute(stmt.limit(limit)).scalars().all()
|
||||
return {"list": [{"code": r.code, "name": r.name} for r in rows]}
|
||||
|
||||
|
||||
# ============ 个股复盘(K线 + 买卖点 + 回放) ============
|
||||
def _ma_list(close, n):
|
||||
out = [None] * len(close)
|
||||
for i in range(len(close)):
|
||||
if i >= n - 1:
|
||||
out[i] = round(sum(close[i - n + 1:i + 1]) / n, 3)
|
||||
return out
|
||||
|
||||
|
||||
@app.get("/api/review/stock")
|
||||
def review_stock(symbol: str = Query("600519"), days: int = Query(250, ge=40, le=1000),
|
||||
fast: int = Query(5), slow: int = Query(20)):
|
||||
with get_session() as s:
|
||||
rows = s.execute(
|
||||
select(DailyQuote).where(DailyQuote.code == symbol)
|
||||
.order_by(DailyQuote.date.desc()).limit(days)
|
||||
).scalars().all()
|
||||
sec = s.get(Security, symbol)
|
||||
rows = list(reversed(rows))
|
||||
if not rows:
|
||||
return {"ok": False, "msg": "该股票库内无日线,请先在数据中台入库该股或执行全市场回填", "symbol": symbol}
|
||||
dates = [r.date.strftime("%y/%m/%d") for r in rows]
|
||||
ohlc = [[r.open, r.close, r.low, r.high] for r in rows]
|
||||
vols = [r.volume for r in rows]
|
||||
close = [r.close for r in rows]
|
||||
maf, mas = _ma_list(close, fast), _ma_list(close, slow)
|
||||
|
||||
signals = []
|
||||
for i in range(1, len(close)):
|
||||
if maf[i] is None or mas[i] is None or maf[i - 1] is None or mas[i - 1] is None:
|
||||
continue
|
||||
if maf[i - 1] <= mas[i - 1] and maf[i] > mas[i]:
|
||||
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "buy"})
|
||||
elif maf[i - 1] >= mas[i - 1] and maf[i] < mas[i]:
|
||||
signals.append({"idx": i, "date": dates[i], "price": close[i], "type": "sell"})
|
||||
|
||||
# 区间统计
|
||||
hi = max(r.high for r in rows); lo = min(r.low for r in rows)
|
||||
period_ret = round((close[-1] / close[0] - 1) * 100, 2)
|
||||
return {"ok": True, "symbol": symbol, "name": sec.name if sec else symbol,
|
||||
"dates": dates, "ohlc": ohlc, "vols": vols,
|
||||
"ma_fast": maf, "ma_slow": mas, "fast": fast, "slow": slow,
|
||||
"signals": signals,
|
||||
"stats": {"period_return": period_ret, "high": hi, "low": lo,
|
||||
"start": dates[0], "end": dates[-1], "bars": len(rows)}}
|
||||
|
||||
|
||||
# ============ AI 分析 ============
|
||||
@app.get("/api/ai/status")
|
||||
def ai_status():
|
||||
return {"enabled": llm.enabled(), "model": config.LLM_MODEL if llm.enabled() else None}
|
||||
|
||||
|
||||
@app.get("/api/ai/review_daily")
|
||||
def ai_review_daily(date: str = Query(None)):
|
||||
return ai.review_daily_comment(date)
|
||||
|
||||
|
||||
@app.get("/api/ai/diagnose")
|
||||
def ai_diagnose(symbol: str = Query("600519")):
|
||||
return ai.diagnose(symbol)
|
||||
|
||||
|
||||
@app.get("/api/ai/today")
|
||||
def ai_today():
|
||||
return ai.today_strategy()
|
||||
|
||||
|
||||
# ============ 可回溯:信号历史胜率 + 实测准确率 ============
|
||||
@app.get("/api/ai/signal_stats")
|
||||
def ai_signal_stats(horizon: int = Query(5, ge=1, le=20)):
|
||||
return {"horizon": horizon, "stats": sig.get_stats(horizon)}
|
||||
|
||||
|
||||
@app.post("/api/ai/signal_stats/compute")
|
||||
def ai_signal_stats_compute(sample: int = Query(500, ge=50, le=4000), horizon: int = Query(5, ge=1, le=20)):
|
||||
return scheduler.trigger_signal_stats_async(sample, horizon)
|
||||
|
||||
|
||||
@app.get("/api/ai/accuracy")
|
||||
def ai_accuracy():
|
||||
return sig.accuracy()
|
||||
|
||||
|
||||
@app.post("/api/ai/accuracy/verify")
|
||||
def ai_accuracy_verify():
|
||||
return sig.verify_predictions()
|
||||
|
||||
|
||||
# ============ AI 自动复盘日报 ============
|
||||
@app.get("/api/report/daily")
|
||||
def report_daily(date: str = Query(None)):
|
||||
return rpt.get_by_date(date) if date else rpt.latest()
|
||||
|
||||
|
||||
@app.get("/api/report/history")
|
||||
def report_history(limit: int = Query(30, ge=1, le=120)):
|
||||
return rpt.history(limit)
|
||||
|
||||
|
||||
@app.post("/api/report/generate")
|
||||
def report_generate(date: str = Query(None), push: bool = Query(False)):
|
||||
return rpt.generate(date, push=push)
|
||||
|
||||
|
||||
# ============ 交易日志 & 组合 ============
|
||||
class TradeIn(BaseModel):
|
||||
code: str
|
||||
name: str = ""
|
||||
side: str = "buy"
|
||||
price: float
|
||||
qty: int
|
||||
fee: float = 0.0
|
||||
date: str = ""
|
||||
reason: str = ""
|
||||
emotion: str = ""
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
def list_trades():
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(Trade).order_by(Trade.date.desc(), Trade.id.desc())).scalars().all()
|
||||
names = {}
|
||||
return {"list": [{"id": t.id, "date": t.date.isoformat(), "code": t.code, "name": t.name,
|
||||
"side": t.side, "price": t.price, "qty": t.qty, "fee": t.fee,
|
||||
"reason": t.reason, "emotion": t.emotion} for t in rows]}
|
||||
|
||||
|
||||
@app.post("/api/trades")
|
||||
def add_trade(t: TradeIn):
|
||||
d = dt.date.fromisoformat(t.date) if t.date else dt.date.today()
|
||||
name = t.name
|
||||
if not name:
|
||||
with get_session() as s:
|
||||
sec = s.get(Security, t.code)
|
||||
name = sec.name if sec else t.code
|
||||
with get_session() as s:
|
||||
row = Trade(date=d, code=t.code, name=name, side=t.side, price=t.price,
|
||||
qty=t.qty, fee=t.fee, reason=t.reason, emotion=t.emotion)
|
||||
s.add(row); s.commit()
|
||||
return {"ok": True, "id": row.id}
|
||||
|
||||
|
||||
@app.delete("/api/trades/{tid}")
|
||||
def del_trade(tid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(Trade, tid)
|
||||
if row:
|
||||
s.delete(row); s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/portfolio")
|
||||
def get_portfolio():
|
||||
return pf.compute()
|
||||
|
||||
|
||||
@app.get("/api/portfolio/equity")
|
||||
def portfolio_equity():
|
||||
return pf.equity_curve()
|
||||
|
||||
|
||||
# ============ 推送通知 ============
|
||||
@app.get("/api/notify/status")
|
||||
def notify_status():
|
||||
return {"channels": notifier.channels_status(), "enabled": notifier.any_enabled()}
|
||||
|
||||
|
||||
@app.post("/api/notify/test")
|
||||
def notify_test():
|
||||
if not notifier.any_enabled():
|
||||
return {"ok": False, "msg": "未配置任何推送渠道,请在 backend/.env 配置后重启"}
|
||||
res = notifier.notify("【智策】推送测试", "这是一条来自智策股票终端的测试通知,收到即表示推送通道正常。")
|
||||
return {"ok": True, "result": res}
|
||||
|
||||
|
||||
# ============ 智能预警 ============
|
||||
class AlertIn(BaseModel):
|
||||
code: str
|
||||
kind: str = "price_above"
|
||||
threshold: float
|
||||
note: str = ""
|
||||
|
||||
|
||||
@app.get("/api/alerts")
|
||||
def list_alerts():
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(AlertRule).order_by(AlertRule.id.desc())).scalars().all()
|
||||
return {"list": [{"id": r.id, "code": r.code, "name": r.name, "kind": r.kind,
|
||||
"threshold": r.threshold, "status": r.status, "note": r.note,
|
||||
"last_value": r.last_value,
|
||||
"triggered_at": r.triggered_at.strftime("%m-%d %H:%M") if r.triggered_at else ""}
|
||||
for r in rows]}
|
||||
|
||||
|
||||
@app.post("/api/alerts")
|
||||
def add_alert(a: AlertIn):
|
||||
with get_session() as s:
|
||||
sec = s.get(Security, a.code)
|
||||
name = sec.name if sec else a.code
|
||||
row = AlertRule(code=a.code, name=name, kind=a.kind, threshold=a.threshold, note=a.note)
|
||||
s.add(row); s.commit()
|
||||
return {"ok": True, "id": row.id}
|
||||
|
||||
|
||||
@app.delete("/api/alerts/{aid}")
|
||||
def del_alert(aid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(AlertRule, aid)
|
||||
if row:
|
||||
s.delete(row); s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/alerts/{aid}/reactivate")
|
||||
def reactivate_alert(aid: int):
|
||||
with get_session() as s:
|
||||
row = s.get(AlertRule, aid)
|
||||
if row:
|
||||
row.status = "active"; row.triggered_at = None; s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/alerts/check")
|
||||
def manual_check():
|
||||
return al.check_alerts()
|
||||
|
||||
|
||||
@app.get("/api/alerts/events")
|
||||
def alert_events(unread_only: bool = Query(False), limit: int = Query(30, le=100)):
|
||||
with get_session() as s:
|
||||
stmt = select(AlertEvent).order_by(AlertEvent.id.desc())
|
||||
if unread_only:
|
||||
stmt = stmt.where(AlertEvent.read.is_(False))
|
||||
rows = s.execute(stmt.limit(limit)).scalars().all()
|
||||
unread = s.execute(select(func.count()).select_from(AlertEvent).where(AlertEvent.read.is_(False))).scalar() or 0
|
||||
return {"unread": unread, "list": [{"id": e.id, "code": e.code, "name": e.name,
|
||||
"message": e.message, "time": e.created_at.strftime("%m-%d %H:%M:%S") if e.created_at else ""}
|
||||
for e in rows]}
|
||||
|
||||
|
||||
@app.post("/api/alerts/events/read")
|
||||
def mark_events_read():
|
||||
with get_session() as s:
|
||||
for e in s.execute(select(AlertEvent).where(AlertEvent.read.is_(False))).scalars():
|
||||
e.read = True
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============ 资讯中心 ============
|
||||
@app.get("/api/news")
|
||||
def news(limit: int = Query(40, le=100)):
|
||||
return svc.get_news(limit)
|
||||
|
||||
|
||||
@app.get("/api/news/stock")
|
||||
def news_stock(code: str = Query(...)):
|
||||
return svc.get_stock_news(code)
|
||||
|
||||
|
||||
@app.get("/api/news/watch")
|
||||
def news_watch():
|
||||
codes = load_watch()[:6]
|
||||
out = []
|
||||
for c in codes:
|
||||
r = svc.get_stock_news(c, limit=4)
|
||||
for x in r["list"]:
|
||||
x["code"] = c
|
||||
out.append(x)
|
||||
out.sort(key=lambda x: x["time"], reverse=True)
|
||||
return {"list": out[:40]}
|
||||
|
||||
|
||||
class NewsAI(BaseModel):
|
||||
title: str
|
||||
content: str = ""
|
||||
|
||||
|
||||
@app.post("/api/news/ai")
|
||||
def news_ai(n: NewsAI):
|
||||
text_in = (n.title + "。" + n.content).strip()
|
||||
senti, kw = svc.judge_sentiment(text_in)
|
||||
if llm.enabled():
|
||||
try:
|
||||
prompt = ("请分析下面这条财经资讯:\n"
|
||||
"1) 一句话摘要;2) 利好/利空/中性判断及理由;3) 可能受影响的板块或个股方向。120字内。\n\n"
|
||||
+ text_in[:1200])
|
||||
text = llm.ask(prompt, temperature=0.3, max_tokens=400)
|
||||
return {"ok": True, "source": "llm", "sentiment": senti, "text": text}
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True, "source": "rule", "sentiment": senti,
|
||||
"text": f"判断:{senti}(关键词:{'、'.join(kw) or '无'})。摘要:{text_in[:80]}…\n(配置大模型后可获得更深入的关联分析)"}
|
||||
|
||||
|
||||
# ============ 静态前端 ============
|
||||
FRONTEND_DIR = os.path.join(os.path.dirname(BASE_DIR), "prototype")
|
||||
if os.path.isdir(FRONTEND_DIR):
|
||||
app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
||||
223
backend/models.py
Normal file
223
backend/models.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""数据中台 ORM 模型(SQLAlchemy 2.0)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import (BigInteger, Date, DateTime, Float, Integer, String,
|
||||
Text, UniqueConstraint, func)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class Security(Base):
|
||||
"""证券基础信息。"""
|
||||
__tablename__ = "securities"
|
||||
code: Mapped[str] = mapped_column(String(12), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(40))
|
||||
market: Mapped[str] = mapped_column(String(8), default="A")
|
||||
updated_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
|
||||
|
||||
|
||||
class DailyQuote(Base):
|
||||
"""个股日线(前复权)。"""
|
||||
__tablename__ = "quotes_daily"
|
||||
__table_args__ = (UniqueConstraint("code", "date", name="uq_quote_code_date"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
open: Mapped[float] = mapped_column(Float)
|
||||
high: Mapped[float] = mapped_column(Float)
|
||||
low: Mapped[float] = mapped_column(Float)
|
||||
close: Mapped[float] = mapped_column(Float)
|
||||
volume: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class IndexDaily(Base):
|
||||
"""指数日线。"""
|
||||
__tablename__ = "index_daily"
|
||||
__table_args__ = (UniqueConstraint("code", "date", name="uq_index_code_date"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
code: Mapped[str] = mapped_column(String(12), index=True)
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
open: Mapped[float] = mapped_column(Float)
|
||||
high: Mapped[float] = mapped_column(Float)
|
||||
low: Mapped[float] = mapped_column(Float)
|
||||
close: Mapped[float] = mapped_column(Float)
|
||||
volume: Mapped[int] = mapped_column(BigInteger, default=0)
|
||||
|
||||
|
||||
class SectorDaily(Base):
|
||||
"""板块每日快照。"""
|
||||
__tablename__ = "sector_daily"
|
||||
__table_args__ = (UniqueConstraint("date", "name", name="uq_sector_date_name"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
name: Mapped[str] = mapped_column(String(40))
|
||||
pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
amount: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
leader: Mapped[str] = mapped_column(String(40), default="")
|
||||
|
||||
|
||||
class FundFlowDaily(Base):
|
||||
"""行业资金流每日快照。"""
|
||||
__tablename__ = "fund_flow_daily"
|
||||
__table_args__ = (UniqueConstraint("date", "name", name="uq_fund_date_name"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
name: Mapped[str] = mapped_column(String(40))
|
||||
net: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
|
||||
|
||||
class SentimentDaily(Base):
|
||||
"""市场情绪每日快照。"""
|
||||
__tablename__ = "sentiment_daily"
|
||||
date: Mapped[dt.date] = mapped_column(Date, primary_key=True)
|
||||
up: Mapped[int] = mapped_column(Integer, default=0)
|
||||
down: Mapped[int] = mapped_column(Integer, default=0)
|
||||
flat: Mapped[int] = mapped_column(Integer, default=0)
|
||||
limit_up: Mapped[int] = mapped_column(Integer, default=0)
|
||||
limit_down: Mapped[int] = mapped_column(Integer, default=0)
|
||||
|
||||
|
||||
class DragonTiger(Base):
|
||||
"""龙虎榜明细。"""
|
||||
__tablename__ = "dragon_tiger"
|
||||
__table_args__ = (UniqueConstraint("date", "code", "reason", name="uq_lhb"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
code: Mapped[str] = mapped_column(String(12))
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
pct: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
net: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
reason: Mapped[str] = mapped_column(String(120), default="")
|
||||
|
||||
|
||||
class StockMetric(Base):
|
||||
"""个股最新因子快照(供全市场选股快速查询)。"""
|
||||
__tablename__ = "stock_metrics"
|
||||
code: Mapped[str] = mapped_column(String(12), primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(40), default="")
|
||||
date: Mapped[dt.date] = mapped_column(Date, index=True)
|
||||
close: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
pct: Mapped[float] = mapped_column(Float, default=0.0, index=True)
|
||||
ma5: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
ma10: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
ma20: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
ma60: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
vol_ratio: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
ret5: Mapped[float] = mapped_column(Float, default=0.0, index=True)
|
||||
ret20: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
ret60: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
pos60: Mapped[float] = mapped_column(Float, default=0.0) # 0~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="")
|
||||
84
backend/notifier.py
Normal file
84
backend/notifier.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""推送通知:邮件(SMTP) + Server酱 + 企业微信机器人 + PushPlus。
|
||||
|
||||
任意渠道配置了凭证即启用;notify() 会向所有已配置渠道发送,返回各渠道结果。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.mime.text import MIMEText
|
||||
from email.header import Header
|
||||
|
||||
import requests
|
||||
|
||||
import config
|
||||
|
||||
|
||||
def channels_status():
|
||||
return {
|
||||
"email": bool(config.SMTP_HOST and config.SMTP_USER and config.SMTP_TO),
|
||||
"serverchan": bool(config.SERVERCHAN_KEY),
|
||||
"wecom": bool(config.WECOM_WEBHOOK),
|
||||
"pushplus": bool(config.PUSHPLUS_TOKEN),
|
||||
}
|
||||
|
||||
|
||||
def any_enabled():
|
||||
return any(channels_status().values())
|
||||
|
||||
|
||||
def _send_email(title, content):
|
||||
msg = MIMEText(content, "plain", "utf-8")
|
||||
msg["Subject"] = Header(title, "utf-8")
|
||||
msg["From"] = config.SMTP_USER
|
||||
tos = [x.strip() for x in config.SMTP_TO.split(",") if x.strip()]
|
||||
msg["To"] = ",".join(tos)
|
||||
ctx = ssl.create_default_context()
|
||||
if config.SMTP_PORT == 465:
|
||||
with smtplib.SMTP_SSL(config.SMTP_HOST, config.SMTP_PORT, context=ctx, timeout=15) as srv:
|
||||
srv.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||
srv.sendmail(config.SMTP_USER, tos, msg.as_string())
|
||||
else:
|
||||
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as srv:
|
||||
srv.starttls(context=ctx)
|
||||
srv.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||
srv.sendmail(config.SMTP_USER, tos, msg.as_string())
|
||||
return True
|
||||
|
||||
|
||||
def _send_serverchan(title, content):
|
||||
url = f"https://sctapi.ftqq.com/{config.SERVERCHAN_KEY}.send"
|
||||
r = requests.post(url, data={"title": title, "desp": content}, timeout=12)
|
||||
r.raise_for_status()
|
||||
return r.json().get("code", 0) == 0
|
||||
|
||||
|
||||
def _send_wecom(title, content):
|
||||
r = requests.post(config.WECOM_WEBHOOK,
|
||||
json={"msgtype": "text", "text": {"content": f"{title}\n{content}"}}, timeout=12)
|
||||
r.raise_for_status()
|
||||
return r.json().get("errcode", -1) == 0
|
||||
|
||||
|
||||
def _send_pushplus(title, content):
|
||||
r = requests.post("https://www.pushplus.plus/send",
|
||||
json={"token": config.PUSHPLUS_TOKEN, "title": title, "content": content}, timeout=12)
|
||||
r.raise_for_status()
|
||||
return r.json().get("code", 0) == 200
|
||||
|
||||
|
||||
def notify(title, content):
|
||||
"""向所有已配置渠道推送,返回 {渠道: 'ok'|错误信息}。"""
|
||||
st = channels_status()
|
||||
senders = {"email": _send_email, "serverchan": _send_serverchan,
|
||||
"wecom": _send_wecom, "pushplus": _send_pushplus}
|
||||
result = {}
|
||||
for ch, on in st.items():
|
||||
if not on:
|
||||
continue
|
||||
try:
|
||||
senders[ch](title, content)
|
||||
result[ch] = "ok"
|
||||
except Exception as e:
|
||||
result[ch] = repr(e)[:120]
|
||||
return result
|
||||
176
backend/portfolio.py
Normal file
176
backend/portfolio.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""持仓与盈亏归因(移动加权平均成本法)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from db import get_session
|
||||
from models import Trade, StockMetric, DailyQuote, Security, IndexDaily
|
||||
|
||||
|
||||
def _current_prices(codes):
|
||||
px = {}
|
||||
if not codes:
|
||||
return px
|
||||
with get_session() as s:
|
||||
for m in s.execute(select(StockMetric).where(StockMetric.code.in_(codes))).scalars():
|
||||
px[m.code] = m.close
|
||||
missing = [c for c in codes if c not in px]
|
||||
for c in missing:
|
||||
row = s.execute(select(DailyQuote.close).where(DailyQuote.code == c)
|
||||
.order_by(DailyQuote.date.desc()).limit(1)).scalar()
|
||||
if row:
|
||||
px[c] = float(row)
|
||||
return px
|
||||
|
||||
|
||||
def compute():
|
||||
with get_session() as s:
|
||||
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
|
||||
|
||||
pos = defaultdict(lambda: {"qty": 0, "cost": 0.0, "name": ""}) # 持仓
|
||||
realized = defaultdict(float) # 各股已实现盈亏
|
||||
by_reason = defaultdict(float) # 按理由归因(已实现)
|
||||
by_emotion = defaultdict(float)
|
||||
closed_sells = [] # 每笔卖出的盈亏(胜率用)
|
||||
|
||||
for t in trades:
|
||||
p = pos[t.code]
|
||||
p["name"] = t.name or p["name"]
|
||||
if t.side == "buy":
|
||||
p["cost"] += t.price * t.qty + t.fee
|
||||
p["qty"] += t.qty
|
||||
else: # sell
|
||||
if p["qty"] <= 0:
|
||||
continue
|
||||
avg = p["cost"] / p["qty"] if p["qty"] else 0.0
|
||||
qty = min(t.qty, p["qty"])
|
||||
pnl = (t.price - avg) * qty - t.fee
|
||||
realized[t.code] += pnl
|
||||
by_reason[t.reason or "未标注"] += pnl
|
||||
by_emotion[t.emotion or "未标注"] += pnl
|
||||
closed_sells.append(pnl)
|
||||
p["cost"] -= avg * qty
|
||||
p["qty"] -= qty
|
||||
|
||||
codes = [c for c, v in pos.items() if v["qty"] > 0]
|
||||
px = _current_prices(codes)
|
||||
|
||||
holdings, mkt_val, hold_cost, unreal = [], 0.0, 0.0, 0.0
|
||||
for c in codes:
|
||||
v = pos[c]
|
||||
avg = v["cost"] / v["qty"] if v["qty"] else 0.0
|
||||
cur = px.get(c, avg)
|
||||
mv = cur * v["qty"]
|
||||
u = (cur - avg) * v["qty"]
|
||||
mkt_val += mv; hold_cost += v["cost"]; unreal += u
|
||||
holdings.append({"code": c, "name": v["name"], "qty": v["qty"],
|
||||
"avg_cost": round(avg, 3), "cur": round(cur, 3),
|
||||
"market_value": round(mv, 2), "unrealized": round(u, 2),
|
||||
"unrealized_pct": round((cur / avg - 1) * 100, 2) if avg else 0.0})
|
||||
holdings.sort(key=lambda x: x["unrealized"], reverse=True)
|
||||
|
||||
total_realized = sum(realized.values())
|
||||
wins = sum(1 for x in closed_sells if x > 0)
|
||||
win_rate = round(wins / len(closed_sells) * 100, 1) if closed_sells else 0.0
|
||||
|
||||
# 盈亏归因:按个股(已实现+浮动)
|
||||
attr_codes = defaultdict(float)
|
||||
for c, r in realized.items():
|
||||
attr_codes[c] += r
|
||||
for h in holdings:
|
||||
attr_codes[h["code"]] += h["unrealized"]
|
||||
name_map = {h["code"]: h["name"] for h in holdings}
|
||||
name_map.update({c: pos[c]["name"] for c in realized})
|
||||
by_stock = sorted([{"code": c, "name": name_map.get(c, c), "pnl": round(v, 2)}
|
||||
for c, v in attr_codes.items()], key=lambda x: x["pnl"], reverse=True)
|
||||
|
||||
return {
|
||||
"summary": {
|
||||
"market_value": round(mkt_val, 2), "hold_cost": round(hold_cost, 2),
|
||||
"unrealized": round(unreal, 2), "realized": round(total_realized, 2),
|
||||
"total_pnl": round(unreal + total_realized, 2),
|
||||
"positions": len(holdings), "closed_trades": len(closed_sells), "win_rate": win_rate,
|
||||
},
|
||||
"holdings": holdings,
|
||||
"attribution": {
|
||||
"by_stock": by_stock,
|
||||
"by_reason": sorted([{"key": k, "pnl": round(v, 2)} for k, v in by_reason.items()], key=lambda x: x["pnl"], reverse=True),
|
||||
"by_emotion": sorted([{"key": k, "pnl": round(v, 2)} for k, v in by_emotion.items()], key=lambda x: x["pnl"], reverse=True),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def equity_curve():
|
||||
"""重建每日资金曲线:累计盈亏(已实现+浮动) + 投入本金净值,并对比沪深300。"""
|
||||
with get_session() as s:
|
||||
trades = s.execute(select(Trade).order_by(Trade.date, Trade.id)).scalars().all()
|
||||
if not trades:
|
||||
return {"ok": False, "msg": "暂无交易记录"}
|
||||
codes = list({t.code for t in trades})
|
||||
# 各股日线 {code: {date: close}}
|
||||
px = defaultdict(dict)
|
||||
start = min(t.date for t in trades)
|
||||
for c in codes:
|
||||
for d, close in s.execute(select(DailyQuote.date, DailyQuote.close)
|
||||
.where(DailyQuote.code == c, DailyQuote.date >= start)
|
||||
.order_by(DailyQuote.date)).all():
|
||||
px[c][d] = float(close)
|
||||
# 交易日序列(用沪深300的日期轴)
|
||||
idx = s.execute(select(IndexDaily.date, IndexDaily.close)
|
||||
.where(IndexDaily.code == "sh000300", IndexDaily.date >= start)
|
||||
.order_by(IndexDaily.date)).all()
|
||||
|
||||
days = [d for d, _ in idx] or sorted({d for c in px for d in px[c]})
|
||||
idx_map = {d: float(c) for d, c in idx}
|
||||
|
||||
# 按日推进
|
||||
from collections import defaultdict as dd
|
||||
pos = dd(lambda: {"qty": 0, "cost": 0.0})
|
||||
realized = 0.0
|
||||
ti = 0
|
||||
last_px = {}
|
||||
dates, equity, invested_curve, bench = [], [], [], []
|
||||
base_idx = None
|
||||
max_invested = 0.0
|
||||
for d in days:
|
||||
# 应用当天及之前所有交易
|
||||
while ti < len(trades) and trades[ti].date <= d:
|
||||
t = trades[ti]; p = pos[t.code]
|
||||
if t.side == "buy":
|
||||
p["cost"] += t.price * t.qty + t.fee; p["qty"] += t.qty
|
||||
else:
|
||||
if p["qty"] > 0:
|
||||
avg = p["cost"] / p["qty"]; qty = min(t.qty, p["qty"])
|
||||
realized += (t.price - avg) * qty - t.fee
|
||||
p["cost"] -= avg * qty; p["qty"] -= qty
|
||||
ti += 1
|
||||
unreal = 0.0
|
||||
invested = 0.0
|
||||
for c, p in pos.items():
|
||||
if p["qty"] <= 0:
|
||||
continue
|
||||
close = px[c].get(d, last_px.get(c))
|
||||
if close is None:
|
||||
continue
|
||||
last_px[c] = close
|
||||
avg = p["cost"] / p["qty"]
|
||||
unreal += (close - avg) * p["qty"]
|
||||
invested += p["cost"]
|
||||
max_invested = max(max_invested, invested, 1.0)
|
||||
dates.append(d.isoformat())
|
||||
equity.append(round(realized + unreal, 2))
|
||||
invested_curve.append(round(invested, 2))
|
||||
if d in idx_map:
|
||||
base_idx = base_idx or idx_map[d]
|
||||
bench.append(round(idx_map[d] / base_idx, 4))
|
||||
else:
|
||||
bench.append(bench[-1] if bench else 1.0)
|
||||
|
||||
# 净值:以累计最大投入为基准
|
||||
nav = [round(1 + e / max_invested, 4) for e in equity]
|
||||
return {"ok": True, "dates": dates, "equity": equity, "invested": invested_curve,
|
||||
"nav": nav, "bench": bench,
|
||||
"final_pnl": equity[-1] if equity else 0.0,
|
||||
"max_invested": round(max_invested, 2)}
|
||||
37
backend/rag.py
Normal file
37
backend/rag.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""轻量 RAG:检索与个股/大盘相关的资讯,做情绪标注,作为 LLM 上下文降低幻觉。
|
||||
|
||||
当前为基于来源接口 + 关键词情绪的检索式上下文;后续可平滑升级为向量检索(embedding + 向量库)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import akshare_service as svc
|
||||
|
||||
|
||||
def stock_news(symbol: str, limit: int = 5):
|
||||
"""返回个股相关资讯(已带利好/利空标注)。"""
|
||||
try:
|
||||
data = svc.get_stock_news(symbol, limit=limit)
|
||||
return data.get("list", [])[:limit]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _senti_score(items):
|
||||
pos = sum(1 for x in items if x.get("sentiment") == "利好")
|
||||
neg = sum(1 for x in items if x.get("sentiment") == "利空")
|
||||
if pos > neg:
|
||||
return "利好", pos, neg
|
||||
if neg > pos:
|
||||
return "利空", pos, neg
|
||||
return "中性", pos, neg
|
||||
|
||||
|
||||
def stock_context(symbol: str, limit: int = 5):
|
||||
"""供 AI 诊断使用:检索资讯 + 汇总情绪。"""
|
||||
items = stock_news(symbol, limit)
|
||||
tone, pos, neg = _senti_score(items)
|
||||
block = ""
|
||||
if items:
|
||||
block = "近期相关资讯(检索):\n" + "\n".join(
|
||||
f"- [{x.get('sentiment','中性')}] {x.get('title','')}({x.get('time','')})" for x in items)
|
||||
return {"items": items, "tone": tone, "pos": pos, "neg": neg, "block": block}
|
||||
168
backend/report.py
Normal file
168
backend/report.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""AI 自动复盘日报:汇总当日盘面,生成图文日报(markdown),落库并可推送。
|
||||
|
||||
数据全部来自中台(sector_daily / fund_flow_daily / sentiment_daily / dragon_tiger / stock_metrics),
|
||||
AI 点评与明日策略复用 ai.py(有 key 走大模型,无 key 规则降级)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
from sqlalchemy import select, func
|
||||
|
||||
import ai
|
||||
import llm
|
||||
import notifier
|
||||
from db import get_session
|
||||
from models import (SectorDaily, FundFlowDaily, SentimentDaily, DragonTiger,
|
||||
StockMetric, DailyReport)
|
||||
|
||||
|
||||
def _gather(date=None):
|
||||
with get_session() as s:
|
||||
d = (dt.date.fromisoformat(date) if date
|
||||
else s.execute(select(func.max(SectorDaily.date))).scalar())
|
||||
if not d:
|
||||
return None
|
||||
secs = s.execute(select(SectorDaily).where(SectorDaily.date == d)
|
||||
.order_by(SectorDaily.pct.desc())).scalars().all()
|
||||
flows = s.execute(select(FundFlowDaily).where(FundFlowDaily.date == d)
|
||||
.order_by(FundFlowDaily.net.desc())).scalars().all()
|
||||
senti = s.execute(select(SentimentDaily).where(SentimentDaily.date == d)).scalar_one_or_none()
|
||||
lhb = s.execute(select(DragonTiger).where(DragonTiger.date == d)
|
||||
.order_by(DragonTiger.net.desc()).limit(6)).scalars().all()
|
||||
mdate = s.execute(select(func.max(StockMetric.date))).scalar()
|
||||
gainers = streak = []
|
||||
if mdate:
|
||||
gainers = s.execute(select(StockMetric).where(StockMetric.date == mdate)
|
||||
.order_by(StockMetric.pct.desc()).limit(8)).scalars().all()
|
||||
streak = s.execute(select(StockMetric).where(StockMetric.date == mdate, StockMetric.up_streak >= 3)
|
||||
.order_by(StockMetric.up_streak.desc(), StockMetric.ret5.desc()).limit(8)).scalars().all()
|
||||
return {
|
||||
"date": d, "senti": senti, "secs": secs, "flows": flows, "lhb": lhb,
|
||||
"gainers": [(x.name, x.code, x.pct) for x in gainers],
|
||||
"streak": [(x.name, x.up_streak, x.ret5) for x in streak],
|
||||
}
|
||||
|
||||
|
||||
def _build_markdown(g):
|
||||
d = g["date"]
|
||||
s = g["senti"]
|
||||
secs, flows, lhb = g["secs"], g["flows"], g["lhb"]
|
||||
parts = [f"# {d} 收盘复盘日报\n"]
|
||||
|
||||
# 一、市场温度
|
||||
if s:
|
||||
ratio = (s.up / max(1, s.up + s.down)) * 100
|
||||
mood = "赚钱效应较好" if s.up > s.down * 1.5 else ("情绪偏弱" if s.down > s.up else "多空分歧")
|
||||
parts.append("## 一、市场温度")
|
||||
parts.append(f"- 涨跌家数:上涨 **{s.up}** / 下跌 **{s.down}** / 平盘 {s.flat}(上涨占比 {ratio:.0f}%)")
|
||||
parts.append(f"- 涨停 **{s.limit_up}** 家 / 跌停 **{s.limit_down}** 家")
|
||||
parts.append(f"- 综合判断:{mood}\n")
|
||||
|
||||
# 二、板块表现
|
||||
if secs:
|
||||
top = secs[:6]
|
||||
bot = secs[-4:]
|
||||
parts.append("## 二、板块表现")
|
||||
parts.append("- 领涨:" + ",".join(f"{x.name}({x.pct:+.2f}%)" for x in top))
|
||||
parts.append("- 领跌:" + ",".join(f"{x.name}({x.pct:+.2f}%)" for x in bot) + "\n")
|
||||
|
||||
# 三、资金流向
|
||||
if flows:
|
||||
parts.append("## 三、资金流向(主力净额)")
|
||||
parts.append("- 净流入前列:" + ",".join(f"{x.name}({x.net:+.1f}亿)" for x in flows[:6]))
|
||||
parts.append("- 净流出前列:" + ",".join(f"{x.name}({x.net:+.1f}亿)" for x in flows[-4:][::-1]) + "\n")
|
||||
|
||||
# 四、强势梯队
|
||||
if g["streak"]:
|
||||
parts.append("## 四、强势梯队(连涨)")
|
||||
parts.append("、".join(f"{n}({k}连阳, 5日{r:+.1f}%)" for n, k, r in g["streak"]) + "\n")
|
||||
|
||||
# 五、龙虎榜焦点
|
||||
if lhb:
|
||||
parts.append("## 五、龙虎榜焦点(净买额)")
|
||||
parts.append("、".join(f"{n}({v:+.2f}亿)" for n, v in [(x.name, x.net) for x in lhb]) + "\n")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def generate(date=None, push=False):
|
||||
"""生成(或重生成)某日复盘日报。默认最新交易日。"""
|
||||
g = _gather(date)
|
||||
if not g:
|
||||
return {"ok": False, "msg": "暂无入库数据,请先到数据中台入库"}
|
||||
d = g["date"]
|
||||
body = _build_markdown(g)
|
||||
|
||||
# AI 综合点评 + 明日策略(复用 ai.py)
|
||||
rv = ai.review_daily_comment(d.isoformat())
|
||||
st = ai.today_strategy()
|
||||
source = "llm" if (rv.get("source") == "llm" or st.get("source") == "llm") else "rule"
|
||||
body += "\n## 六、AI 综合点评\n" + (rv.get("text", "") if rv.get("ok") else "")
|
||||
body += "\n\n## 七、明日策略\n" + (st.get("text", "") if st.get("ok") else "")
|
||||
|
||||
title = f"{d} 收盘复盘日报"
|
||||
with get_session() as s:
|
||||
row = s.get(DailyReport, d)
|
||||
if row:
|
||||
row.source, row.title, row.content = source, title, body
|
||||
else:
|
||||
row = DailyReport(date=d, source=source, title=title, content=body)
|
||||
s.add(row)
|
||||
s.commit()
|
||||
|
||||
result = {"ok": True, "date": d.isoformat(), "source": source, "title": title, "content": body}
|
||||
if push and notifier.any_enabled():
|
||||
try:
|
||||
# 推送精简版(情绪 + 领涨 + AI 点评首段)
|
||||
brief = _push_brief(g, rv)
|
||||
res = notifier.notify("【智策】" + title, brief)
|
||||
with get_session() as s:
|
||||
r2 = s.get(DailyReport, d)
|
||||
if r2:
|
||||
r2.pushed = True
|
||||
s.commit()
|
||||
result["push"] = res
|
||||
except Exception as e:
|
||||
result["push"] = {"error": repr(e)[:120]}
|
||||
return result
|
||||
|
||||
|
||||
def _push_brief(g, rv):
|
||||
s = g["senti"]
|
||||
lines = [f"{g['date']} 收盘复盘"]
|
||||
if s:
|
||||
lines.append(f"涨{s.up}/跌{s.down},涨停{s.limit_up}/跌停{s.limit_down}")
|
||||
if g["secs"]:
|
||||
lines.append("领涨:" + "、".join(f"{x.name}({x.pct:+.1f}%)" for x in g["secs"][:4]))
|
||||
if g["flows"]:
|
||||
lines.append("吸金:" + "、".join(x.name for x in g["flows"][:4]))
|
||||
txt = (rv.get("text") or "").strip().split("\n")[0]
|
||||
if txt:
|
||||
lines.append("点评:" + txt[:80])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def latest():
|
||||
with get_session() as s:
|
||||
row = s.execute(select(DailyReport).order_by(DailyReport.date.desc())).scalars().first()
|
||||
if not row:
|
||||
return {"ok": False, "msg": "暂无日报,请先生成"}
|
||||
return {"ok": True, "date": row.date.isoformat(), "source": row.source,
|
||||
"title": row.title, "content": row.content, "pushed": row.pushed}
|
||||
|
||||
|
||||
def get_by_date(date):
|
||||
with get_session() as s:
|
||||
row = s.get(DailyReport, dt.date.fromisoformat(date))
|
||||
if not row:
|
||||
return {"ok": False, "msg": "该日无日报"}
|
||||
return {"ok": True, "date": row.date.isoformat(), "source": row.source,
|
||||
"title": row.title, "content": row.content, "pushed": row.pushed}
|
||||
|
||||
|
||||
def history(limit=30):
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(DailyReport).order_by(DailyReport.date.desc()).limit(limit)).scalars().all()
|
||||
return {"list": [{"date": r.date.isoformat(), "source": r.source,
|
||||
"title": r.title, "pushed": r.pushed} for r in rows]}
|
||||
8
backend/requirements.txt
Normal file
8
backend/requirements.txt
Normal file
@@ -0,0 +1,8 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
akshare>=1.18.60
|
||||
pandas>=2.2.2
|
||||
cachetools==5.5.0
|
||||
SQLAlchemy>=2.0.30
|
||||
APScheduler>=3.10.4
|
||||
psycopg2-binary>=2.9.9
|
||||
144
backend/scheduler.py
Normal file
144
backend/scheduler.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""定时任务:收盘后自动入库。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
|
||||
import config
|
||||
import ingest
|
||||
import alerts
|
||||
import report
|
||||
import signals
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_lock = threading.Lock()
|
||||
_running = {"flag": False}
|
||||
|
||||
|
||||
def _job():
|
||||
with _lock:
|
||||
if _running["flag"]:
|
||||
return
|
||||
_running["flag"] = True
|
||||
try:
|
||||
ingest.run_daily_ingest()
|
||||
finally:
|
||||
_running["flag"] = False
|
||||
|
||||
|
||||
def trigger_async():
|
||||
"""手动触发一次入库(后台线程,不阻塞请求)。"""
|
||||
t = threading.Thread(target=_job, daemon=True)
|
||||
t.start()
|
||||
return {"started": True}
|
||||
|
||||
|
||||
def _job_all():
|
||||
with _lock:
|
||||
if _running["flag"]:
|
||||
return
|
||||
_running["flag"] = True
|
||||
try:
|
||||
ingest.ingest_quotes_all()
|
||||
finally:
|
||||
_running["flag"] = False
|
||||
|
||||
|
||||
def trigger_all_async():
|
||||
"""手动触发全市场回填(后台线程,耗时较长)。"""
|
||||
if _running["flag"]:
|
||||
return {"started": False, "msg": "已有任务在执行"}
|
||||
t = threading.Thread(target=_job_all, daemon=True)
|
||||
t.start()
|
||||
return {"started": True}
|
||||
|
||||
|
||||
def is_running():
|
||||
return _running["flag"]
|
||||
|
||||
|
||||
def _job_report():
|
||||
try:
|
||||
report.generate(push=True)
|
||||
except Exception as e:
|
||||
print("[report] generate error:", repr(e)[:160])
|
||||
|
||||
|
||||
def trigger_report_async(push=True):
|
||||
"""手动触发生成日报(后台线程)。"""
|
||||
t = threading.Thread(target=lambda: report.generate(push=push), daemon=True)
|
||||
t.start()
|
||||
return {"started": True}
|
||||
|
||||
|
||||
_sig_running = {"flag": False}
|
||||
|
||||
|
||||
def _job_signal_stats(sample=500, horizon=5):
|
||||
if _sig_running["flag"]:
|
||||
return
|
||||
_sig_running["flag"] = True
|
||||
try:
|
||||
signals.compute_signal_stats(sample_limit=sample, horizon=horizon)
|
||||
except Exception as e:
|
||||
print("[signals] compute error:", repr(e)[:160])
|
||||
finally:
|
||||
_sig_running["flag"] = False
|
||||
|
||||
|
||||
def trigger_signal_stats_async(sample=500, horizon=5):
|
||||
if _sig_running["flag"]:
|
||||
return {"started": False, "msg": "胜率回测进行中"}
|
||||
t = threading.Thread(target=lambda: _job_signal_stats(sample, horizon), daemon=True)
|
||||
t.start()
|
||||
return {"started": True}
|
||||
|
||||
|
||||
def _job_verify():
|
||||
try:
|
||||
signals.verify_predictions()
|
||||
except Exception as e:
|
||||
print("[predict] verify error:", repr(e)[:160])
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
global _scheduler
|
||||
if _scheduler is not None:
|
||||
return _scheduler
|
||||
_scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
|
||||
_scheduler.add_job(
|
||||
_job, CronTrigger(day_of_week="mon-fri", hour=config.INGEST_HOUR, minute=config.INGEST_MINUTE),
|
||||
id="daily_ingest", replace_existing=True, misfire_grace_time=3600,
|
||||
)
|
||||
_scheduler.add_job(
|
||||
_safe_check_alerts, IntervalTrigger(seconds=60),
|
||||
id="alert_check", replace_existing=True, max_instances=1,
|
||||
)
|
||||
# 收盘入库之后 10 分钟生成 AI 复盘日报并推送
|
||||
_rep_total = config.INGEST_HOUR * 60 + config.INGEST_MINUTE + 10
|
||||
_scheduler.add_job(
|
||||
_job_report, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=_rep_total % 60),
|
||||
id="daily_report", replace_existing=True, misfire_grace_time=3600,
|
||||
)
|
||||
# 收盘后核验到期预测(实测准确率)
|
||||
_scheduler.add_job(
|
||||
_job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60),
|
||||
id="verify_pred", replace_existing=True, misfire_grace_time=3600,
|
||||
)
|
||||
# 每周六重算信号历史胜率
|
||||
_scheduler.add_job(
|
||||
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
|
||||
id="signal_stats", replace_existing=True, misfire_grace_time=7200,
|
||||
)
|
||||
_scheduler.start()
|
||||
return _scheduler
|
||||
|
||||
|
||||
def _safe_check_alerts():
|
||||
try:
|
||||
alerts.check_alerts()
|
||||
except Exception as e:
|
||||
print("[alert] check error:", repr(e)[:120])
|
||||
194
backend/signals.py
Normal file
194
backend/signals.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""信号历史胜率回测 + AI 预测留痕核验。
|
||||
|
||||
- compute_signal_stats: 对全市场历史日线回测各技术信号「N 日后上涨概率/平均收益」,
|
||||
作为 AI 证据链『历史命中率』的客观依据。
|
||||
- record_prediction / verify_predictions / accuracy: 记录每次 AI 诊断,N 日后核验真实涨跌,
|
||||
形成可回溯的『实测准确率』。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sqlalchemy import select, func, distinct
|
||||
|
||||
from db import get_session
|
||||
from models import DailyQuote, StockMetric, SignalStat, Prediction
|
||||
|
||||
SIGNAL_DEFS = {
|
||||
"ma_bull": "均线多头排列(MA5>MA10>MA20)",
|
||||
"macd_gold": "MACD 金叉",
|
||||
"up_streak3": "三连阳",
|
||||
"vol_breakout": "放量上涨(量比>2)",
|
||||
"rsi_oversold": "RSI 超卖(<30)抄底",
|
||||
"new_high60": "创60日新高",
|
||||
}
|
||||
|
||||
|
||||
def _indicators(df: pd.DataFrame) -> pd.DataFrame:
|
||||
c = df["close"]
|
||||
df["ma5"] = c.rolling(5).mean()
|
||||
df["ma10"] = c.rolling(10).mean()
|
||||
df["ma20"] = c.rolling(20).mean()
|
||||
e12 = c.ewm(span=12, adjust=False).mean()
|
||||
e26 = c.ewm(span=26, adjust=False).mean()
|
||||
df["dif"] = e12 - e26
|
||||
df["dea"] = df["dif"].ewm(span=9, adjust=False).mean()
|
||||
delta = c.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = -delta.clip(upper=0)
|
||||
ag = gain.rolling(14).mean()
|
||||
al = loss.rolling(14).mean()
|
||||
df["rsi"] = 100 - 100 / (1 + ag / al.replace(0, np.nan))
|
||||
df["vavg5"] = df["volume"].rolling(5).mean().shift(1)
|
||||
df["high60"] = c.rolling(60).max()
|
||||
return df
|
||||
|
||||
|
||||
def _masks(df: pd.DataFrame) -> dict:
|
||||
c = df["close"]
|
||||
c1 = c.shift(1)
|
||||
return {
|
||||
"ma_bull": (df.ma5 > df.ma10) & (df.ma10 > df.ma20),
|
||||
"macd_gold": (df.dif.shift(1) < df.dea.shift(1)) & (df.dif >= df.dea),
|
||||
"up_streak3": (c > c1) & (c1 > c.shift(2)) & (c.shift(2) > c.shift(3)),
|
||||
"vol_breakout": (df.volume / df.vavg5 > 2) & (c > c1),
|
||||
"rsi_oversold": df.rsi < 30,
|
||||
"new_high60": c >= df.high60,
|
||||
}
|
||||
|
||||
|
||||
def compute_signal_stats(sample_limit: int = 500, horizon: int = 5):
|
||||
"""对样本股历史回测各信号 N 日后表现并落库。"""
|
||||
with get_session() as s:
|
||||
codes = [r[0] for r in s.execute(select(StockMetric.code).limit(sample_limit)).all()]
|
||||
if not codes:
|
||||
codes = [r[0] for r in s.execute(select(distinct(DailyQuote.code)).limit(sample_limit)).all()]
|
||||
|
||||
agg = {k: {"n": 0, "win": 0, "sum": 0.0} for k in SIGNAL_DEFS}
|
||||
used = 0
|
||||
with get_session() as s:
|
||||
for code in codes:
|
||||
rows = s.execute(select(DailyQuote.date, DailyQuote.close, DailyQuote.volume)
|
||||
.where(DailyQuote.code == code).order_by(DailyQuote.date)).all()
|
||||
if len(rows) < 80:
|
||||
continue
|
||||
used += 1
|
||||
df = pd.DataFrame(rows, columns=["date", "close", "volume"])
|
||||
df["close"] = df["close"].astype(float)
|
||||
df["volume"] = df["volume"].astype(float)
|
||||
df = _indicators(df)
|
||||
fwd = df["close"].shift(-horizon) / df["close"] - 1
|
||||
for k, m in _masks(df).items():
|
||||
m = m & fwd.notna()
|
||||
r = fwd[m]
|
||||
if len(r):
|
||||
agg[k]["n"] += int(len(r))
|
||||
agg[k]["win"] += int((r > 0).sum())
|
||||
agg[k]["sum"] += float(r.sum())
|
||||
|
||||
with get_session() as s:
|
||||
for k, a in agg.items():
|
||||
if a["n"] == 0:
|
||||
continue
|
||||
wr = round(a["win"] / a["n"] * 100, 1)
|
||||
ar = round(a["sum"] / a["n"] * 100, 2)
|
||||
row = s.execute(select(SignalStat).where(SignalStat.signal == k, SignalStat.horizon == horizon)).scalar_one_or_none()
|
||||
if row:
|
||||
row.samples, row.win_rate, row.avg_ret, row.updated_at = a["n"], wr, ar, dt.datetime.now()
|
||||
else:
|
||||
s.add(SignalStat(signal=k, horizon=horizon, samples=a["n"], win_rate=wr, avg_ret=ar))
|
||||
s.commit()
|
||||
return {"ok": True, "horizon": horizon, "sampled": used,
|
||||
"result": {k: {"samples": a["n"], "win_rate": round(a["win"] / a["n"] * 100, 1) if a["n"] else None}
|
||||
for k, a in agg.items()}}
|
||||
|
||||
|
||||
def get_stats(horizon: int = 5) -> dict:
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(SignalStat).where(SignalStat.horizon == horizon)).scalars().all()
|
||||
return {r.signal: {"label": SIGNAL_DEFS.get(r.signal, r.signal), "win_rate": r.win_rate,
|
||||
"avg_ret": r.avg_ret, "samples": r.samples} for r in rows}
|
||||
|
||||
|
||||
def active_signals(m: StockMetric) -> list[str]:
|
||||
"""根据最新因子快照判断当前激活的信号。"""
|
||||
out = []
|
||||
if m.ma_bull:
|
||||
out.append("ma_bull")
|
||||
if m.macd_gold:
|
||||
out.append("macd_gold")
|
||||
if m.up_streak >= 3:
|
||||
out.append("up_streak3")
|
||||
if m.vol_ratio > 2 and m.pct > 0:
|
||||
out.append("vol_breakout")
|
||||
if m.rsi14 < 30:
|
||||
out.append("rsi_oversold")
|
||||
if m.pos60 >= 0.99:
|
||||
out.append("new_high60")
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- 预测留痕与核验 ----------------
|
||||
def record_prediction(code, name, date, score, confidence, direction, base_close, horizon=5, kind="diagnose"):
|
||||
with get_session() as s:
|
||||
exist = s.execute(select(Prediction).where(
|
||||
Prediction.code == code, Prediction.date == date, Prediction.kind == kind)).scalar_one_or_none()
|
||||
if exist:
|
||||
return False
|
||||
s.add(Prediction(date=date, code=code, name=name, kind=kind, score=score,
|
||||
confidence=confidence, direction=direction, horizon=horizon, base_close=base_close))
|
||||
s.commit()
|
||||
return True
|
||||
|
||||
|
||||
def verify_predictions():
|
||||
"""对到期(已有 horizon 个交易日)的 open 预测核验真实涨跌。"""
|
||||
with get_session() as s:
|
||||
opens = s.execute(select(Prediction).where(Prediction.status == "open")).scalars().all()
|
||||
closed = 0
|
||||
for p in opens:
|
||||
future = s.execute(select(DailyQuote.close).where(
|
||||
DailyQuote.code == p.code, DailyQuote.date > p.date)
|
||||
.order_by(DailyQuote.date).limit(p.horizon)).all()
|
||||
if len(future) < p.horizon:
|
||||
continue
|
||||
end = float(future[-1][0])
|
||||
ret = (end / p.base_close - 1) * 100 if p.base_close else 0.0
|
||||
if p.direction == "up":
|
||||
hit = ret > 0
|
||||
elif p.direction == "down":
|
||||
hit = ret < 0
|
||||
else:
|
||||
hit = abs(ret) < 2
|
||||
p.actual_ret = round(ret, 2)
|
||||
p.hit = bool(hit)
|
||||
p.status = "closed"
|
||||
closed += 1
|
||||
s.commit()
|
||||
return {"ok": True, "closed": closed}
|
||||
|
||||
|
||||
def accuracy():
|
||||
with get_session() as s:
|
||||
rows = s.execute(select(Prediction).where(Prediction.status == "closed")).scalars().all()
|
||||
opens = s.execute(select(func.count()).select_from(Prediction).where(Prediction.status == "open")).scalar()
|
||||
n = len(rows)
|
||||
hits = sum(1 for r in rows if r.hit)
|
||||
by_dir = {}
|
||||
for r in rows:
|
||||
d = by_dir.setdefault(r.direction, {"n": 0, "hit": 0})
|
||||
d["n"] += 1
|
||||
d["hit"] += 1 if r.hit else 0
|
||||
recent = sorted(rows, key=lambda r: (r.date, r.id), reverse=True)[:25]
|
||||
return {
|
||||
"closed": n, "open": opens or 0,
|
||||
"hit_rate": round(hits / n * 100, 1) if n else None,
|
||||
"avg_ret": round(sum(r.actual_ret for r in rows) / n, 2) if n else None,
|
||||
"by_direction": {k: {"n": v["n"], "hit_rate": round(v["hit"] / v["n"] * 100, 1)}
|
||||
for k, v in by_dir.items()},
|
||||
"recent": [{"date": r.date.isoformat(), "code": r.code, "name": r.name,
|
||||
"direction": r.direction, "score": r.score, "confidence": r.confidence,
|
||||
"actual_ret": r.actual_ret, "hit": r.hit, "horizon": r.horizon} for r in recent],
|
||||
}
|
||||
Reference in New Issue
Block a user