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