"""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]}