Files
stock_cursor_v0/backend/akshare_service.py
2026-06-15 01:26:39 +08:00

575 lines
23 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
from redis_cache import cache
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):
"""缓存装饰器:优先使用 Redis降级到内存缓存"""
def deco(fn):
local = TTLCache(maxsize=64, ttl=ttl)
@wraps(fn)
def wrapper(*args, **kwargs):
# 生成缓存键
key = f"akshare:{fn.__name__}:{args}:{tuple(sorted(kwargs.items()))}"
# 优先从 Redis 读取
if cache.enabled:
cached_value = cache.get(key)
if cached_value is not None:
return cached_value
# Redis 未命中,从内存缓存读取
local_key = (fn.__name__, args, tuple(sorted(kwargs.items())))
if local_key in local:
return local[local_key]
# 执行函数
val = fn(*args, **kwargs)
# 写入 Redis
if cache.enabled:
cache.set(key, val, expire=ttl)
# 写入内存缓存(降级)
local[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": []}
# 已知指数代码 → 新浪前缀映射
_INDEX_CODES = {"000001", "000300", "000016", "399001", "399006", "899050"}
def _is_index(code: str) -> bool:
return code in _INDEX_CODES or code.startswith(("sh0", "sz3990", "bj8990"))
def _sina_symbol(code: str) -> str:
if code in ("000001", "000016"): # 上证系列
return "sh" + code
if code in ("000300",): # 沪深300
return "sh" + code
if code in ("399001", "399006"): # 深证
return "sz" + code
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 = "000001", days: int = 120):
if AK_OK:
# 指数走专用接口
if symbol in _INDEX_CODES:
try:
sym = _sina_symbol(symbol)
df = ak.stock_zh_index_daily(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(r["open"]), float(r["close"]), float(r["low"]), float(r["high"])]
for _, r in df.iterrows()]
vols = [int(r["volume"]) if "volume" in df.columns else 0 for _, r in df.iterrows()]
return {"source": "akshare", "symbol": symbol, "dates": dates, "ohlc": ohlc, "vols": vols}
except Exception:
pass
# 个股主源:新浪日线(更稳定);备源:腾讯
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(120)
def get_us_treemap():
"""美股热门板块云图按成交额取前100只"""
if AK_OK:
try:
df = ak.stock_us_spot_em()
if df is not None and not df.empty:
top = df.sort_values("成交额", ascending=False).head(100)
items = [{"name": str(r.get("名称","")), "value": round(float(r.get("成交额",0))/1e8, 2),
"pct": round(float(r.get("涨跌幅",0)), 2)} for _, r in top.iterrows()]
items = [x for x in items if x["name"]]
return {"source": "akshare", "market": "us", "items": items}
except Exception:
pass
names = ["苹果","微软","谷歌","亚马逊","英伟达","特斯拉","Meta","台积电","巴菲特","摩根"]
return {"source": "mock", "market": "us",
"items": [{"name": n, "value": _rnd(10,200), "pct": round(_rnd(-4,4),2)} for n in names]}
@cached(120)
def get_hk_treemap():
"""港股热门板块云图按成交额取前100只"""
if AK_OK:
try:
df = ak.stock_hk_spot_em()
if df is not None and not df.empty:
top = df.sort_values("成交额", ascending=False).head(100)
items = [{"name": str(r.get("名称","")), "value": round(float(r.get("成交额",0))/1e4, 2),
"pct": round(float(r.get("涨跌幅",0)), 2)} for _, r in top.iterrows()]
items = [x for x in items if x["name"]]
return {"source": "akshare", "market": "hk", "items": items}
except Exception:
pass
names = ["腾讯","阿里巴巴","美团","京东","小米","百度","网易","中国平安","汇丰","友邦"]
return {"source": "mock", "market": "hk",
"items": [{"name": n, "value": _rnd(5,100), "pct": round(_rnd(-4,4),2)} for n in names]}
@cached(120)
def get_all_sector_leaders(top_n: int = 5):
"""一次性获取所有板块的前N只龙头股"""
boards = get_industry_boards()
result = {}
for b in boards.get("list", []):
name = b["name"]
try:
r = get_sector_stocks(name, top_n + 1)
result[name] = r.get("stocks", [])[:top_n]
except Exception:
result[name] = []
return {"source": "akshare", "sectors": result}
@cached(300)
def get_sector_stocks(sector_name: str, limit: int = 20):
"""获取板块成分股,按成交额排序"""
if AK_OK:
try:
df = ak.stock_board_industry_cons_em(symbol=sector_name)
if df is not None and not df.empty:
if "成交额" in df.columns:
df = df.sort_values("成交额", ascending=False)
stocks = []
for _, r in df.head(limit).iterrows():
try:
stocks.append({
"code": str(r.get("代码", "")),
"name": str(r.get("名称", "")),
"pct": round(float(r.get("涨跌幅", 0)), 2),
"price": round(float(r.get("最新价", 0)), 2),
"amount": round(float(r.get("成交额", 0)) / 1e8, 2),
})
except Exception:
continue
return {"source": "akshare", "name": sector_name, "stocks": stocks}
except Exception:
pass
# mock
stocks = [{"code": f"60000{i}", "name": f"{sector_name}{i+1}",
"pct": round(_rnd(-5, 5), 2), "price": round(_rnd(5, 100), 2), "amount": round(_rnd(1, 50), 2)}
for i in range(10)]
return {"source": "mock", "name": sector_name, "stocks": stocks}
# ============================================================
# 资金流向(行业)
# ============================================================
@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]}