Initial commit: stock analysis backend and prototype UI.

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

444
backend/akshare_service.py Normal file
View File

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