Initial commit: stock analysis backend and prototype UI.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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]}
|
||||
Reference in New Issue
Block a user