Files
stock_cursor_v0/backend/akshare_service.py

445 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]}