From 3d627aba5eedbf0a96e9562a098c51d6cb1774da Mon Sep 17 00:00:00 2001 From: shy3130 <415333856@qq.com> Date: Sun, 30 Aug 2026 19:05:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(recap):=20=E7=9B=98=E5=89=8D=E9=A3=8E?= =?UTF-8?q?=E5=90=91=E6=A0=87=E6=9C=8D=E5=8A=A1=E4=B8=8E=20AI=20=E5=A4=8D?= =?UTF-8?q?=E7=9B=98=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fuyao 客户端新增 short-term-benchmark 端点透传 - auction_benchmark 服务: 历史按日 JSON 缓存(当日不缓存)、交易日回退、 四态降级(ok/fallback_prev/source_unavailable/no_data) - 读取时从相邻日K分区现算当日/次日收益对照, 不落缓存 - 复盘 user prompt 注入「盘前风向标(竞价)」上下文 - 11 个测试覆盖回退/缓存/富化数学/上下文 --- backend/app/api/market_recap.py | 25 +- backend/app/plugins/fuyao/client.py | 12 + backend/app/plugins/fuyao/provider.py | 8 + backend/app/services/auction_benchmark.py | 288 ++++++++++++++++++++++ backend/app/services/market_recap.py | 20 +- backend/tests/test_auction_benchmark.py | 178 +++++++++++++ 6 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 backend/app/services/auction_benchmark.py create mode 100644 backend/tests/test_auction_benchmark.py diff --git a/backend/app/api/market_recap.py b/backend/app/api/market_recap.py index 1b7cca8..9cc1c65 100644 --- a/backend/app/api/market_recap.py +++ b/backend/app/api/market_recap.py @@ -1,4 +1,4 @@ -"""AI 大盘复盘 API — 流式复盘 + 报告持久化 + 龙虎榜。 +"""AI 大盘复盘 API — 流式复盘 + 报告持久化 + 龙虎榜 + 盘前风向标。 路由前缀: /api/market-recap @@ -8,6 +8,7 @@ POST /reports 保存一条复盘报告 DELETE /reports/{report_id} 删除一条复盘报告 GET /dragon-tiger 龙虎榜三榜 (fuyao 专有, 历史按日缓存) + GET /auction-benchmark 盘前风向标 (fuyao 专有, 含当日/次日真实收益) """ from __future__ import annotations @@ -18,7 +19,7 @@ from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import StreamingResponse from pydantic import BaseModel -from app.services import dragon_tiger, market_recap_reports +from app.services import auction_benchmark, dragon_tiger, market_recap_reports from app.services.market_recap import recap_market_stream logger = logging.getLogger(__name__) @@ -52,6 +53,26 @@ def get_dragon_tiger( ) +@router.get("/auction-benchmark") +def get_auction_benchmark( + request: Request, + date: str | None = Query(default=None, description="复盘目标日 YYYY-MM-DD, 缺省取最近交易日"), +): + """盘前风向标 (同花顺竞价筛选名单 + 当日/次日真实收益)。 + + fuyao 专有, 未配置时 state=source_unavailable; 非交易日由服务层自动回退。 + """ + target = None + if date: + try: + target = date_cls.fromisoformat(date) + except ValueError: + raise HTTPException(400, f"date 格式应为 YYYY-MM-DD, 收到: {date}") + return auction_benchmark.get_auction_benchmark( + request.app.state.repo.store.data_dir, target + ) + + @router.post("/analyze") async def analyze_market(request: Request, req: AnalyzeRequest): """AI 大盘复盘 — NDJSON 流式返回。 diff --git a/backend/app/plugins/fuyao/client.py b/backend/app/plugins/fuyao/client.py index 5256446..b090871 100644 --- a/backend/app/plugins/fuyao/client.py +++ b/backend/app/plugins/fuyao/client.py @@ -231,6 +231,18 @@ class FuyaoClient: params["date"] = date return self._get("/api/a-share/special-data/dragon-tiger-list", params) + def short_term_benchmark(self, date: str | None = None) -> dict: + """短线风向标竞价基准 (同花顺竞价筛选, 每日约 5~6 只)。 + + 返回 data 原始容器: {date, date_ms, item[]}。item 行: + {thscode, ticker, name, auction_pct, tags[]}。支持一年内历史日期; + 显式传非交易日返回 code=1002 (由调用方做交易日回退)。 + """ + params: dict = {} + if date: + params["date"] = date + return self._get("/api/a-share/auction/short-term-benchmark", params) + # ---- 市场 dump ---- def dump_download_url(self, dump_kind: str) -> dict: """获取 dump 预签名下载信息(约 300s 有效)。 diff --git a/backend/app/plugins/fuyao/provider.py b/backend/app/plugins/fuyao/provider.py index 28ad356..76ce1d9 100644 --- a/backend/app/plugins/fuyao/provider.py +++ b/backend/app/plugins/fuyao/provider.py @@ -895,6 +895,14 @@ class FuyaoProvider: """ return self._get_client().dragon_tiger_list(board_type, date) + def short_term_benchmark(self, date: str | None = None) -> dict: + """短线风向标竞价基准 (复盘页卡片 + AI 复盘上下文)。返回原始 data 容器。 + + 非路由数据集 (tickflow 无对应能力), 不进 plugin.yaml datasets, + 由 services.auction_benchmark 统一做按日缓存/收益enrich/交易日回退。 + """ + return self._get_client().short_term_benchmark(date) + def _derive_bps(self, symbols: list[str]) -> dict[str, float]: """估值快照 pb_mrq 与行情快照最新价同源同刻 → bps = price / pb_mrq。 diff --git a/backend/app/services/auction_benchmark.py b/backend/app/services/auction_benchmark.py new file mode 100644 index 0000000..334b5c8 --- /dev/null +++ b/backend/app/services/auction_benchmark.py @@ -0,0 +1,288 @@ +"""短线风向标服务 (fuyao 专有) — 复盘页卡片 + AI 复盘上下文。 + +非路由数据集: tickflow 无对应能力, 直接经 custom_sources 调 fuyao provider; +fuyao 未配置时返回 source_unavailable 状态, 前端降级提示。 + +数据契约 (实测 2026-08-30, 60 交易日回测 353 样本): +- 每日 5~6 只, 服务端筛选的竞价异动股, 附概念标签 +- 名单当日 (开盘买→收盘卖) 均值 +0.54% vs 全市场 +0.10%, 有真实当日选股能力; + 但高开≥5% 子集当日 -1.97% (追高陷阱) → 前端对高开子集标「追高风险」 +- 次日无显著优势 (+0.08%), 定位为「当日观察名单」而非隔夜轮动信号 + +缓存策略 (与 dragon_tiger 同模式): +- 历史名单不可变 → 按日落 JSON 缓存 (data/auction_benchmark/date=YYYY-MM-DD.json), + 缓存命中不触发插件注册表加载 +- 收益 enrich (当日oc/全天/次日) 不落缓存 — 次日数据晚到, 读取时现算 +- 当日名单不缓存 (竞价阶段名单可能变动, 以现拉为准) +- 显式日期失败 → 回退上一交易日一次 (state=fallback_prev) + +日期解析: 接口对显式非交易日报 code=1002, 本层用本地 kline_daily 分区日期 +把目标日回退到「≤目标日的最近交易日」, 规避报错。 +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import re +from datetime import date as date_cls +from pathlib import Path + +import polars as pl + +from app.market_time import cn_today + +logger = logging.getLogger(__name__) + +_DATE_DIR_RE = re.compile(r"^date=(\d{4}-\d{2}-\d{2})$") + + +def _local_trading_days(data_dir: Path) -> list[date_cls]: + """本地日K分区日期 = 已知交易日集合 (升序)。扫描失败返回空。""" + root = data_dir / "kline_daily" + out: list[date_cls] = [] + try: + for d in root.iterdir(): + m = _DATE_DIR_RE.match(d.name) + if d.is_dir() and m: + try: + out.append(date_cls.fromisoformat(m.group(1))) + except ValueError: + continue + except OSError: + return [] + return sorted(out) + + +def resolve_trade_date(data_dir: Path, target: date_cls | None) -> date_cls | None: + """目标日 → ≤目标日的最近本地交易日。None → None (由 fuyao 取当日)。""" + if target is None: + return None + days = _local_trading_days(data_dir) + if not days: + return target + candidates = [d for d in days if d <= target] + return max(candidates) if candidates else target + + +def _prev_trading_day(data_dir: Path, d: date_cls) -> date_cls | None: + days = _local_trading_days(data_dir) + earlier = [x for x in days if x < d] + return max(earlier) if earlier else None + + +def _next_trading_day(data_dir: Path, d: date_cls) -> date_cls | None: + days = _local_trading_days(data_dir) + later = [x for x in days if x > d] + return min(later) if later else None + + +def _provider(): + from app.data_providers import custom as custom_sources + + if not custom_sources.is_custom_provider("fuyao"): + return None + return custom_sources.get_provider("fuyao") + + +def _cache_path(data_dir: Path, d: date_cls) -> Path: + return data_dir / "auction_benchmark" / f"date={d.isoformat()}.json" + + +def _load_cache(path: Path) -> dict | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + + +def _store_cache(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".part") + tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + + +def _raw_items(data: dict) -> list[dict]: + items = data.get("item") + return [r for r in items if isinstance(r, dict)] if isinstance(items, list) else [] + + +def _read_kline_closes(data_dir: Path, d: date_cls) -> dict[str, float]: + """某交易日全市场 {symbol: close}; 无分区/读失败返回空。""" + root = data_dir / "kline_daily" / f"date={d.isoformat()}" + try: + files = sorted(root.glob("*.parquet")) + if not files: + return {} + df = pl.concat([pl.read_parquet(f, columns=["symbol", "close"]) for f in files]) + return dict(zip(df["symbol"].to_list(), df["close"].to_list())) + except (OSError, pl.exceptions.PolarsError): + return {} + + +def _enrich(data_dir: Path, trade_date: date_cls, items: list[dict]) -> list[dict]: + """名单叠加真实收益: day0_oc (开盘买→收盘卖) / day0_pct (全天) / d1_pct (次日)。 + + 用相邻 kline_daily 分区现算, 不落缓存 — 次日分区晚到时先给 None, 到了自然补上。 + """ + root = data_dir / "kline_daily" / f"date={trade_date.isoformat()}" + day0: dict[str, tuple[float, float]] = {} # symbol -> (open, close) + try: + files = sorted(root.glob("*.parquet")) + if files: + df = pl.concat([pl.read_parquet(f, columns=["symbol", "open", "close"]) for f in files]) + day0 = dict(zip(df["symbol"].to_list(), zip(df["open"].to_list(), df["close"].to_list()))) + except (OSError, pl.exceptions.PolarsError): + day0 = {} + + prev = _prev_trading_day(data_dir, trade_date) + prev_close = _read_kline_closes(data_dir, prev) if prev else {} + nxt = _next_trading_day(data_dir, trade_date) + next_close = _read_kline_closes(data_dir, nxt) if nxt else {} + + out: list[dict] = [] + for r in items: + sym = str(r.get("thscode") or "") + oc = pct = d1 = None + if sym in day0: + o, c = day0[sym] + if o and o > 0 and c is not None: + oc = c / o - 1.0 + pc = prev_close.get(sym) + if pc and pc > 0 and c is not None: + pct = c / pc - 1.0 + nc = next_close.get(sym) + if c and c > 0 and nc is not None: + d1 = nc / c - 1.0 + out.append({ + "thscode": sym, + "ticker": r.get("ticker"), + "name": r.get("name"), + "auction_pct": r.get("auction_pct"), + "tags": [str(t) for t in (r.get("tags") or [])], + "day0_oc": oc, + "day0_pct": pct, + "d1_pct": d1, + }) + return out + + +def _base_payload(data_dir: Path, trade_date: date_cls, data: dict) -> dict: + return { + "state": "ok", + "requested_date": trade_date.isoformat(), + "trade_date": str(data.get("date") or trade_date.isoformat()), + "count": len(_raw_items(data)), + "raw_items": _raw_items(data), + } + + +def get_auction_benchmark(data_dir: Path, target: date_cls | None = None) -> dict: + """取短线风向标名单 (含当日/次日真实收益)。返回给前端的统一容器。 + + state: ok (正常) | fallback_prev (目标日拉取失败, 已回退上一期) + | source_unavailable (未配置 fuyao) | no_data (拉取失败) + """ + days = _local_trading_days(data_dir) + if target is None: + target = max(days) if days else None + trade_date = resolve_trade_date(data_dir, target) + today = cn_today() + + # 历史日缓存优先 (纯本地, 不触发插件注册表加载) + if trade_date is not None and trade_date < today: + cached = _load_cache(_cache_path(data_dir, trade_date)) + if cached is not None: + return _respond(data_dir, trade_date, cached, cached.get("state") or "ok") + + provider = _provider() + if provider is None: + return {"state": "source_unavailable"} + + from app.plugins.fuyao.client import FuyaoError + + explicit = trade_date.isoformat() if trade_date is not None else None + try: + data = provider.short_term_benchmark(explicit) + try: + actual = date_cls.fromisoformat(str(data.get("date"))) + except ValueError: + actual = trade_date + base = _base_payload(data_dir, actual, data) + # 历史日不可变 → 落缓存; 当日不缓存 (竞价阶段名单可能变动) + if actual is not None and actual < today: + with contextlib.suppress(OSError): + _store_cache(_cache_path(data_dir, actual), base) + return _respond(data_dir, actual, base, "ok") + except FuyaoError as e: + # 显式日期失败 (非交易日/边界日) → 回退上一交易日一次 + if trade_date is not None: + prev = _prev_trading_day(data_dir, trade_date) + if prev is not None: + try: + cached_prev = _load_cache(_cache_path(data_dir, prev)) + if cached_prev is not None: + return _respond(data_dir, prev, cached_prev, "fallback_prev", + requested=explicit) + data_prev = provider.short_term_benchmark(prev.isoformat()) + base = _base_payload(data_dir, prev, data_prev) + with contextlib.suppress(OSError): + _store_cache(_cache_path(data_dir, prev), base) + return _respond(data_dir, prev, base, "fallback_prev", + requested=explicit) + except FuyaoError: + pass + logger.warning("短线风向标拉取失败: %s", e) + return {"state": "no_data", "message": str(e)} + + +def _respond( + data_dir: Path, + trade_date: date_cls | None, + base: dict, + state: str, + requested: str | None = None, +) -> dict: + """缓存/现拉的原始容器 → 叠加收益 enrich 后的前端容器。""" + if trade_date is None: + return {**base, "state": state} + payload = { + "state": state, + "requested_date": requested if requested is not None else base.get("requested_date"), + "trade_date": base.get("trade_date") or trade_date.isoformat(), + "count": base.get("count") or 0, + "items": _enrich(data_dir, trade_date, base.get("raw_items") or []), + } + return payload + + +def build_recap_context(data_dir: Path) -> str: + """AI 复盘的盘前风向标摘要段 (纯文本, 失败返回空串不影响复盘)。""" + try: + payload = get_auction_benchmark(data_dir, None) + if payload.get("state") not in ("ok", "fallback_prev"): + return "" + items = payload.get("items") or [] + if not items: + return "" + trade_date = payload.get("trade_date") or "" + lines = [f"(数据日期: {trade_date})"] + segs = [] + for i in items: + seg = (f"{i.get('name')}({i.get('thscode')}) 竞价{(i.get('auction_pct') or 0):+.2f}%" + f"[{'·'.join(i.get('tags') or [])}]") + if i.get("day0_oc") is not None: + seg += f" → 当日开盘买{i['day0_oc']*100:+.2f}%" + if i.get("d1_pct") is not None: + seg += f", 次日{i['d1_pct']*100:+.2f}%" + segs.append(seg) + lines.append("盘前风向标名单: " + "; ".join(segs)) + ocs = [i["day0_oc"] for i in items if i.get("day0_oc") is not None] + if ocs: + lines.append(f"名单当日(开盘买→收盘卖)均值 {sum(ocs)/len(ocs)*100:+.2f}%") + return "\n".join(lines) + except Exception as e: # noqa: BLE001 — 摘要失败不影响复盘主流程 + logger.debug("盘前风向标复盘摘要构建失败: %s", e) + return "" diff --git a/backend/app/services/market_recap.py b/backend/app/services/market_recap.py index e01cb5f..9be3bb7 100644 --- a/backend/app/services/market_recap.py +++ b/backend/app/services/market_recap.py @@ -179,8 +179,9 @@ def _build_emotion_block(overview: dict) -> str: return "\n".join(lines) -def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context: str = "") -> str: - """构建用户消息:复盘日期 + 市场数据精简切片 + 龙虎榜(可选) + 新闻 + 关注点。""" +def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context: str = "", + bench_context: str = "") -> str: + """构建用户消息:复盘日期 + 市场数据精简切片 + 龙虎榜(可选) + 盘前风向标(可选) + 新闻 + 关注点。""" as_of = overview.get("as_of") or "今日" parts: list[str] = [ @@ -210,6 +211,14 @@ def _build_user_prompt(overview: dict, news: list[dict], focus: str, lhb_context lhb_context, ]) + # 盘前风向标 (fuyao 竞价筛选名单 + 当日实际表现对照; 失败为空不占段) + if bench_context: + parts.extend([ + "", + "## 盘前风向标(竞价)", + bench_context, + ]) + if news: news_lines = [] for i, n in enumerate(news[:8], 1): @@ -312,7 +321,12 @@ async def recap_market_stream( from app.services import dragon_tiger as dragon_tiger_svc lhb_ctx = dragon_tiger_svc.build_recap_context(repo.store.data_dir) - user_prompt = _build_user_prompt(overview, news or [], focus, lhb_ctx) + + # 盘前风向标摘要 (fuyao 专有): 失败/未配置 → 空串 + from app.services import auction_benchmark as auction_benchmark_svc + + bench_ctx = auction_benchmark_svc.build_recap_context(repo.store.data_dir) + user_prompt = _build_user_prompt(overview, news or [], focus, lhb_ctx, bench_ctx) got_content = False async for delta in stream_ai_text( [ diff --git a/backend/tests/test_auction_benchmark.py b/backend/tests/test_auction_benchmark.py new file mode 100644 index 0000000..3c24263 --- /dev/null +++ b/backend/tests/test_auction_benchmark.py @@ -0,0 +1,178 @@ +"""盘前风向标服务测试 (不依赖真实网络)。 + +覆盖: 交易日回退、历史日 JSON 缓存命中与落盘、收益 enrich 数学 (当日oc/全天/次日)、 +fuyao 未配置降级、目标日失败 fallback_prev、彻底失败 no_data、AI 复盘摘要段。 +日期用 2026-08-26/27/28 (写作时为过去交易日), 与仓库既有绝对日期测试风格一致。 +""" + +from __future__ import annotations + +import json +from datetime import date +from pathlib import Path + +import polars as pl +import pytest + +from app.plugins.fuyao.client import FuyaoError +from app.services import auction_benchmark as ab + + +def _write_kline(data_dir: Path, day: str, rows: list[tuple[str, float, float]]) -> None: + part = data_dir / "kline_daily" / f"date={day}" + part.mkdir(parents=True, exist_ok=True) + df = pl.DataFrame( + { + "symbol": [r[0] for r in rows], + "open": [r[1] for r in rows], + "close": [r[2] for r in rows], + } + ) + df.write_parquet(part / "part-0.parquet") + + +@pytest.fixture() +def data_dir(tmp_path: Path) -> Path: + for d in ("2026-08-26", "2026-08-27", "2026-08-28"): + (tmp_path / "kline_daily" / f"date={d}").mkdir(parents=True, exist_ok=True) + _write_kline(tmp_path, "2026-08-26", [("600519.SH", 1690.0, 1700.0), ("000858.SZ", 130.0, 131.0)]) + _write_kline(tmp_path, "2026-08-27", [("600519.SH", 1717.0, 1734.0), ("000858.SZ", 132.0, 130.0)]) + _write_kline(tmp_path, "2026-08-28", [("600519.SH", 1734.0, 1768.68), ("000858.SZ", 129.0, 133.0)]) + return tmp_path + + +class _FakeProvider: + """记录调用; fail_dates 中的日期抛 FuyaoError。""" + + def __init__(self, fail_dates: set[str] | None = None): + self.calls: list[str | None] = [] + self.fail_dates = fail_dates or set() + + def short_term_benchmark(self, date_iso: str | None) -> dict: + self.calls.append(date_iso) + if date_iso in self.fail_dates: + raise FuyaoError(f"code=3002: {date_iso} 未就绪") + return { + "date": date_iso or "2026-08-28", + "date_ms": 0, + "item": [ + {"thscode": "600519.SH", "ticker": "600519", "name": "贵州茅台", + "auction_pct": 1.0, "tags": ["白酒", "超级品牌"]}, + {"thscode": "000858.SZ", "ticker": "000858", "name": "五粮液", + "auction_pct": -2.5, "tags": ["白酒"]}, + ], + } + + +def _use_provider(monkeypatch, provider) -> _FakeProvider: + monkeypatch.setattr(ab, "_provider", lambda: provider) + return provider + + +# ---- 交易日解析 ---- + +def test_resolve_rolls_back_non_trading_day(data_dir): + assert ab.resolve_trade_date(data_dir, date(2026, 8, 30)) == date(2026, 8, 28) + assert ab.resolve_trade_date(data_dir, date(2026, 8, 27)) == date(2026, 8, 27) + + +# ---- 状态与缓存 ---- + +def test_source_unavailable_without_fuyao(data_dir, monkeypatch): + monkeypatch.setattr(ab, "_provider", lambda: None) + out = ab.get_auction_benchmark(data_dir, None) + assert out["state"] == "source_unavailable" + + +def test_fetch_stores_cache_then_hits_cache(data_dir, monkeypatch): + provider = _use_provider(monkeypatch, _FakeProvider()) + out = ab.get_auction_benchmark(data_dir, None) # 默认 → 最近分区 08-28 + assert out["state"] == "ok" and out["trade_date"] == "2026-08-28" + assert out["count"] == 2 and len(out["items"]) == 2 + assert provider.calls == ["2026-08-28"] + assert (data_dir / "auction_benchmark" / "date=2026-08-28.json").exists() + + provider.calls.clear() + out2 = ab.get_auction_benchmark(data_dir, date(2026, 8, 30)) # 周日 → 08-28 → 命中缓存 + assert out2["state"] == "ok" and out2["trade_date"] == "2026-08-28" + assert provider.calls == [] + + +def test_explicit_history_date_uses_cache(data_dir, monkeypatch): + provider = _use_provider(monkeypatch, _FakeProvider()) + ab.get_auction_benchmark(data_dir, date(2026, 8, 27)) + assert provider.calls == ["2026-08-27"] + provider.calls.clear() + ab.get_auction_benchmark(data_dir, date(2026, 8, 27)) + assert provider.calls == [] + + +def test_failure_falls_back_to_prev(data_dir, monkeypatch): + provider = _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28"})) + out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28)) + assert out["state"] == "fallback_prev" + assert out["trade_date"] == "2026-08-27" + assert out["requested_date"] == "2026-08-28" + # 回退日缓存以 ok 落盘 (不污染直查) + cached = json.loads((data_dir / "auction_benchmark" / "date=2026-08-27.json").read_text(encoding="utf-8")) + assert cached["state"] == "ok" + + +def test_total_failure_returns_no_data(data_dir, monkeypatch): + _use_provider(monkeypatch, _FakeProvider(fail_dates={"2026-08-28", "2026-08-27"})) + out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28)) + assert out["state"] == "no_data" + assert "2026-08-28" in out.get("message", "") + + +def test_corrupt_cache_refetches(data_dir, monkeypatch): + provider = _use_provider(monkeypatch, _FakeProvider()) + cache = data_dir / "auction_benchmark" / "date=2026-08-28.json" + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text("{broken json", encoding="utf-8") + out = ab.get_auction_benchmark(data_dir, date(2026, 8, 28)) + assert out["state"] == "ok" + assert provider.calls # 缓存损坏 → 重新拉取 + + +# ---- 收益 enrich ---- + +def test_enrich_math_with_local_kline(data_dir, monkeypatch): + # 显式查 08-27: prev=08-26, next=08-28 + _use_provider(monkeypatch, _FakeProvider()) + out = ab.get_auction_benchmark(data_dir, date(2026, 8, 27)) + by = {i["thscode"]: i for i in out["items"]} + mt = by["600519.SH"] + # day0_oc = 1734/1717-1; day0_pct = 1734/1700-1; d1 = 1768.68/1734-1 + assert mt["day0_oc"] == pytest.approx(1734.0 / 1717.0 - 1) + assert mt["day0_pct"] == pytest.approx(1734.0 / 1700.0 - 1) + assert mt["d1_pct"] == pytest.approx(1768.68 / 1734.0 - 1) + wly = by["000858.SZ"] + assert wly["day0_oc"] == pytest.approx(130.0 / 132.0 - 1) + assert wly["d1_pct"] == pytest.approx(133.0 / 130.0 - 1) + # 原始字段透传 + assert mt["auction_pct"] == 1.0 and mt["tags"] == ["白酒", "超级品牌"] + + +def test_enrich_missing_kline_gives_none(data_dir, monkeypatch): + # 最新分区 08-28 无次日 → d1_pct=None; kline 行存在则 oc/pct 正常 + _use_provider(monkeypatch, _FakeProvider()) + out = ab.get_auction_benchmark(data_dir, None) + for i in out["items"]: + assert i["d1_pct"] is None + assert i["day0_oc"] is not None + + +# ---- AI 复盘摘要 ---- + +def test_build_recap_context_contains_summary(data_dir, monkeypatch): + _use_provider(monkeypatch, _FakeProvider()) + ctx = ab.build_recap_context(data_dir) + assert "盘前风向标名单" in ctx and "贵州茅台" in ctx + assert "白酒" in ctx # 概念标签 + assert "当日" in ctx # 收益对照 + + +def test_build_recap_context_empty_without_source(data_dir, monkeypatch): + monkeypatch.setattr(ab, "_provider", lambda: None) + assert ab.build_recap_context(data_dir) == ""