dragon_html_reports.py 111 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959
  1. from __future__ import annotations
  2. import json
  3. import re
  4. from html import escape
  5. from pathlib import Path
  6. from urllib.parse import urlencode
  7. import pandas as pd
  8. from dragon_branch_configs import (
  9. alpha_first_glued_refined_hot_cap_config,
  10. alpha_first_selective_veto_config,
  11. workbook_preserving_config,
  12. )
  13. from dragon_indicators import DragonIndicatorConfig, DragonIndicatorEngine
  14. from dragon_strategy import DragonRuleEngine
  15. BRANCH_ORDER = ["workbook_preserving", "alpha_first_selective_veto", "alpha_first_glued_refined_hot_cap"]
  16. BRANCH_LABELS = {
  17. "workbook_preserving": "workbook_preserving",
  18. "alpha_first_selective_veto": "alpha_first_selective_veto",
  19. "alpha_first_glued_refined_hot_cap": "RC1 / alpha_first_glued_refined_hot_cap",
  20. "system_monitor": "系统监控",
  21. }
  22. BRANCH_ROLES = {
  23. "workbook_preserving": "重构基线",
  24. "alpha_first_selective_veto": "平衡对照",
  25. "alpha_first_glued_refined_hot_cap": "前向默认 / RC1",
  26. "system_monitor": "系统汇总",
  27. }
  28. BRANCH_COLORS = {
  29. "workbook_preserving": "#8b6f47",
  30. "alpha_first_selective_veto": "#0f766e",
  31. "alpha_first_glued_refined_hot_cap": "#c05621",
  32. }
  33. _REPORT_INDICATOR_CACHE: tuple[pd.DataFrame, dict[str, object]] | None = None
  34. _REPORT_INDICATOR_FULL_CACHE: pd.DataFrame | None = None
  35. _REPORT_EVENT_CACHE: pd.DataFrame | None = None
  36. def _load_csv(path: Path) -> pd.DataFrame:
  37. return pd.DataFrame() if not path.exists() else pd.read_csv(path, encoding="utf-8-sig")
  38. def _prepare_indicator_snapshot(df: pd.DataFrame) -> pd.DataFrame:
  39. if df.empty:
  40. return pd.DataFrame()
  41. result = df.copy()
  42. if "date" not in result.columns and result.index.name == "date":
  43. result = result.reset_index()
  44. if "date" not in result.columns:
  45. return pd.DataFrame()
  46. result["date"] = pd.to_datetime(result["date"], errors="coerce")
  47. result = result.dropna(subset=["date"]).sort_values("date").drop_duplicates("date", keep="last")
  48. for col in ["close", "a1", "b1", "c1"]:
  49. if col not in result.columns:
  50. result[col] = float("nan")
  51. result[col] = pd.to_numeric(result[col], errors="coerce")
  52. for col in ["kdj_buy", "kdj_sell", "ql_buy", "ql_sell"]:
  53. if col not in result.columns:
  54. result[col] = False
  55. if result[col].dtype == object:
  56. result[col] = result[col].astype(str).str.lower().map({"true": True, "false": False}).fillna(False)
  57. result[col] = result[col].fillna(False).astype(bool)
  58. return result[["date", "close", "a1", "b1", "c1", "kdj_buy", "kdj_sell", "ql_buy", "ql_sell"]].copy()
  59. def _prepare_indicator_full(df: pd.DataFrame) -> pd.DataFrame:
  60. if df.empty:
  61. return pd.DataFrame()
  62. result = df.copy()
  63. if "date" not in result.columns and result.index.name == "date":
  64. result = result.reset_index()
  65. if "date" not in result.columns:
  66. return pd.DataFrame()
  67. result["date"] = pd.to_datetime(result["date"], errors="coerce")
  68. result = result.dropna(subset=["date"]).sort_values("date").drop_duplicates("date", keep="last")
  69. for bool_col in ["kdj_buy", "kdj_sell", "ql_buy", "ql_sell"]:
  70. if bool_col in result.columns:
  71. if result[bool_col].dtype == object:
  72. result[bool_col] = result[bool_col].astype(str).str.lower().map({"true": True, "false": False}).fillna(False)
  73. result[bool_col] = result[bool_col].fillna(False).astype(bool)
  74. return result
  75. def _indicator_coverage_ok(df: pd.DataFrame, min_required: pd.Timestamp, max_required: pd.Timestamp) -> bool:
  76. if df.empty:
  77. return False
  78. return bool(df["date"].min() <= min_required and df["date"].max() >= max_required)
  79. def _load_indicator_snapshot_for_report(
  80. base_dir: Path,
  81. min_required_date: str,
  82. max_required_date: str,
  83. ) -> tuple[pd.DataFrame, dict[str, object]]:
  84. global _REPORT_INDICATOR_CACHE, _REPORT_INDICATOR_FULL_CACHE
  85. min_required = pd.to_datetime(min_required_date)
  86. max_required = pd.to_datetime(max_required_date)
  87. if _REPORT_INDICATOR_CACHE is not None:
  88. cached_df, cached_meta = _REPORT_INDICATOR_CACHE
  89. if _indicator_coverage_ok(cached_df, min_required, max_required):
  90. return cached_df.copy(), dict(cached_meta)
  91. cache_candidates = [
  92. (base_dir / "dragon_indicator_snapshot_full.csv", "本地完整缓存"),
  93. (base_dir / "dragon_indicator_snapshot.csv", "本地快照"),
  94. ]
  95. local_best = pd.DataFrame()
  96. local_source = "无本地指标快照"
  97. for path, label in cache_candidates:
  98. if not path.exists():
  99. continue
  100. candidate = _prepare_indicator_snapshot(pd.read_csv(path, encoding="utf-8-sig"))
  101. if candidate.empty:
  102. continue
  103. if local_best.empty or candidate["date"].max() > local_best["date"].max():
  104. local_best = candidate
  105. local_source = label
  106. if _indicator_coverage_ok(candidate, min_required, max_required):
  107. meta = {
  108. "source_label": label,
  109. "coverage_start": candidate["date"].min().date().isoformat(),
  110. "coverage_end": candidate["date"].max().date().isoformat(),
  111. "coverage_ok": True,
  112. "fetch_status": "local_ok",
  113. }
  114. _REPORT_INDICATOR_CACHE = (candidate.copy(), dict(meta))
  115. return candidate, meta
  116. try:
  117. engine = DragonIndicatorEngine(DragonIndicatorConfig(start_date="2015-01-01"))
  118. live_full = engine.compute(engine.fetch_daily_data())
  119. if "date" not in live_full.columns:
  120. live_full = live_full.reset_index()
  121. live_full = _prepare_indicator_full(live_full)
  122. live = _prepare_indicator_snapshot(live_full)
  123. if not live.empty:
  124. _REPORT_INDICATOR_FULL_CACHE = live_full.copy()
  125. live_full.to_csv(base_dir / "dragon_indicator_snapshot_full.csv", index=False, encoding="utf-8-sig")
  126. meta = {
  127. "source_label": "实时重算完整指标",
  128. "coverage_start": live["date"].min().date().isoformat(),
  129. "coverage_end": live["date"].max().date().isoformat(),
  130. "coverage_ok": _indicator_coverage_ok(live, min_required, max_required),
  131. "fetch_status": "live_ok",
  132. }
  133. _REPORT_INDICATOR_CACHE = (live.copy(), dict(meta))
  134. return live, meta
  135. except Exception as exc: # pragma: no cover - report fallback path
  136. if not local_best.empty:
  137. meta = {
  138. "source_label": f"{local_source}(实时重算失败,已回退)",
  139. "coverage_start": local_best["date"].min().date().isoformat(),
  140. "coverage_end": local_best["date"].max().date().isoformat(),
  141. "coverage_ok": _indicator_coverage_ok(local_best, min_required, max_required),
  142. "fetch_status": f"fallback:{exc}",
  143. }
  144. _REPORT_INDICATOR_CACHE = (local_best.copy(), dict(meta))
  145. return local_best, meta
  146. return pd.DataFrame(), {
  147. "source_label": f"指标数据不可用:{exc}",
  148. "coverage_start": "",
  149. "coverage_end": "",
  150. "coverage_ok": False,
  151. "fetch_status": f"empty:{exc}",
  152. }
  153. if not local_best.empty:
  154. meta = {
  155. "source_label": local_source,
  156. "coverage_start": local_best["date"].min().date().isoformat(),
  157. "coverage_end": local_best["date"].max().date().isoformat(),
  158. "coverage_ok": _indicator_coverage_ok(local_best, min_required, max_required),
  159. "fetch_status": "local_partial",
  160. }
  161. _REPORT_INDICATOR_CACHE = (local_best.copy(), dict(meta))
  162. return local_best, meta
  163. return pd.DataFrame(), {
  164. "source_label": "指标数据不可用",
  165. "coverage_start": "",
  166. "coverage_end": "",
  167. "coverage_ok": False,
  168. "fetch_status": "empty",
  169. }
  170. def _load_full_indicator_for_report(base_dir: Path, min_required_date: str, max_required_date: str) -> pd.DataFrame:
  171. global _REPORT_INDICATOR_FULL_CACHE
  172. min_required = pd.to_datetime(min_required_date)
  173. max_required = pd.to_datetime(max_required_date)
  174. if _REPORT_INDICATOR_FULL_CACHE is not None and _indicator_coverage_ok(_REPORT_INDICATOR_FULL_CACHE, min_required, max_required):
  175. return _REPORT_INDICATOR_FULL_CACHE.copy()
  176. full_path = base_dir / "dragon_indicator_snapshot_full.csv"
  177. if full_path.exists():
  178. full_df = _prepare_indicator_full(pd.read_csv(full_path, encoding="utf-8-sig"))
  179. if _indicator_coverage_ok(full_df, min_required, max_required):
  180. _REPORT_INDICATOR_FULL_CACHE = full_df.copy()
  181. return full_df
  182. _load_indicator_snapshot_for_report(base_dir, min_required_date, max_required_date)
  183. if _REPORT_INDICATOR_FULL_CACHE is not None and _indicator_coverage_ok(_REPORT_INDICATOR_FULL_CACHE, min_required, max_required):
  184. return _REPORT_INDICATOR_FULL_CACHE.copy()
  185. if full_path.exists():
  186. full_df = _prepare_indicator_full(pd.read_csv(full_path, encoding="utf-8-sig"))
  187. _REPORT_INDICATOR_FULL_CACHE = full_df.copy()
  188. return full_df
  189. return pd.DataFrame()
  190. def _load_strategy_events_for_report(base_dir: Path, min_required_date: str, max_required_date: str) -> pd.DataFrame:
  191. global _REPORT_EVENT_CACHE
  192. min_required = pd.to_datetime(min_required_date)
  193. max_required = pd.to_datetime(max_required_date)
  194. if _REPORT_EVENT_CACHE is not None and not _REPORT_EVENT_CACHE.empty:
  195. event_dates = pd.to_datetime(_REPORT_EVENT_CACHE["date"], errors="coerce")
  196. if event_dates.min() <= min_required and event_dates.max() >= max_required:
  197. return _REPORT_EVENT_CACHE.copy()
  198. indicators = _load_full_indicator_for_report(base_dir, min_required_date, max_required_date)
  199. if indicators.empty:
  200. return pd.DataFrame(columns=["branch", "date", "side", "layer", "reason"])
  201. branch_builders = {
  202. "workbook_preserving": workbook_preserving_config,
  203. "alpha_first_selective_veto": alpha_first_selective_veto_config,
  204. "alpha_first_glued_refined_hot_cap": alpha_first_glued_refined_hot_cap_config,
  205. }
  206. event_frames: list[pd.DataFrame] = []
  207. indicator_input = indicators.set_index("date", drop=False)
  208. for branch, config_builder in branch_builders.items():
  209. engine = DragonRuleEngine(config=config_builder())
  210. events, _ = engine.run(indicator_input)
  211. if events.empty:
  212. continue
  213. branch_events = events.copy()
  214. branch_events["branch"] = branch
  215. event_frames.append(branch_events[["branch", "date", "side", "layer", "reason"]].copy())
  216. if not event_frames:
  217. return pd.DataFrame(columns=["branch", "date", "side", "layer", "reason"])
  218. all_events = pd.concat(event_frames, ignore_index=True)
  219. all_events["date"] = pd.to_datetime(all_events["date"], errors="coerce").dt.date.astype(str)
  220. _REPORT_EVENT_CACHE = all_events.copy()
  221. return all_events
  222. def _state(base_dir: Path) -> dict[str, object]:
  223. path = base_dir / "dragon_forward_observation_state.json"
  224. return {} if not path.exists() else json.loads(path.read_text(encoding="utf-8"))
  225. def _missing(value: object) -> bool:
  226. return value is None or pd.isna(value)
  227. def _fmt_pct(value: object) -> str:
  228. return "" if _missing(value) else f"{float(value):.2%}"
  229. def _fmt_num(value: object, digits: int = 2) -> str:
  230. return "" if _missing(value) else f"{float(value):.{digits}f}"
  231. def _fmt_int(value: object) -> str:
  232. return "" if _missing(value) else str(int(float(value)))
  233. def _fmt_bool(value: object) -> str:
  234. return "" if _missing(value) else ("是" if bool(value) else "否")
  235. def _badge(value: object) -> str:
  236. label = "" if _missing(value) else str(value)
  237. color = {
  238. "ok": "#207868",
  239. "warning": "#b7791f",
  240. "hard_breach": "#c53030",
  241. "missing_data": "#7b341e",
  242. "none": "#5a6472",
  243. "mild": "#b7791f",
  244. "material": "#c05621",
  245. "review_required": "#9b2c2c",
  246. }.get(label, "#5a6472")
  247. return f'<span class="badge" style="background:{color};">{escape(label or "n/a")}</span>'
  248. def _branch_name(branch: object) -> str:
  249. return BRANCH_LABELS.get(str(branch), str(branch))
  250. def _metric(label: str, value: str, tone: str = "plain") -> str:
  251. return f'<div class="metric {tone}"><div class="metric-label">{escape(label)}</div><div class="metric-value">{value}</div></div>'
  252. def _detail_query_href(hrefs: dict[str, str], branch: str | None = None, keyword: str | None = None, year: str | None = None) -> str:
  253. params: dict[str, str] = {}
  254. if branch:
  255. params["branch"] = branch
  256. if keyword:
  257. params["keyword"] = keyword
  258. if year:
  259. params["year"] = year
  260. query = urlencode(params)
  261. return hrefs["detail"] if not query else f'{hrefs["detail"]}?{query}'
  262. def _table(df: pd.DataFrame, formatters: dict[str, object] | None = None) -> str:
  263. if df.empty:
  264. return '<div class="empty">暂无数据</div>'
  265. formatters = formatters or {}
  266. head = "".join(f"<th>{escape(str(col))}</th>" for col in df.columns)
  267. body: list[str] = []
  268. for _, row in df.iterrows():
  269. cells: list[str] = []
  270. for col in df.columns:
  271. value = row[col]
  272. formatter = formatters.get(col)
  273. rendered = formatter(value) if callable(formatter) else ("" if _missing(value) else escape(str(value)))
  274. cells.append(f"<td>{rendered}</td>")
  275. body.append("<tr>" + "".join(cells) + "</tr>")
  276. return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"
  277. def _hrefs(latest_bar_date: str, archive_mode: bool) -> dict[str, str]:
  278. if archive_mode:
  279. return {
  280. "home": "index.html",
  281. "daily": f"dragon_daily_signal_report_{latest_bar_date}.html",
  282. "weekly": f"dragon_forward_weekly_review_{latest_bar_date}.html",
  283. "detail": f"dragon_historical_trade_details_{latest_bar_date}.html",
  284. "daily_archive": f"dragon_daily_signal_report_{latest_bar_date}.html",
  285. "weekly_archive": f"dragon_forward_weekly_review_{latest_bar_date}.html",
  286. "guide": "dragon_indicator_strategy_guide_cn.html",
  287. "usage": "dragon_html_report_usage_cn.html",
  288. "quickstart": "dragon_html_report_quickstart_cn.html",
  289. }
  290. return {
  291. "home": "dragon_reports_index.html",
  292. "daily": "dragon_daily_signal_report.html",
  293. "weekly": "dragon_forward_weekly_review.html",
  294. "detail": "dragon_historical_trade_details.html",
  295. "daily_archive": f"html_reports/dragon_daily_signal_report_{latest_bar_date}.html",
  296. "weekly_archive": f"html_reports/dragon_forward_weekly_review_{latest_bar_date}.html",
  297. "guide": "dragon_indicator_strategy_guide_cn.html",
  298. "usage": "dragon_html_report_usage_cn.html",
  299. "quickstart": "dragon_html_report_quickstart_cn.html",
  300. }
  301. def _nav(hrefs: dict[str, str]) -> str:
  302. return (
  303. '<div class="nav">'
  304. f'<a href="{escape(hrefs["home"])}">总览首页</a>'
  305. f'<a href="{escape(hrefs["daily"])}">每日报告</a>'
  306. f'<a href="{escape(hrefs["weekly"])}">每周报告</a>'
  307. f'<a href="{escape(hrefs["detail"])}">历史明细</a>'
  308. f'<a href="{escape(hrefs["guide"])}">指标原理</a>'
  309. f'<a href="{escape(hrefs["quickstart"])}">极简说明</a>'
  310. f'<a href="{escape(hrefs["usage"])}">详细说明</a>'
  311. f'<a href="{escape(hrefs["daily_archive"])}">日报归档</a>'
  312. f'<a href="{escape(hrefs["weekly_archive"])}">周报归档</a>'
  313. "</div>"
  314. )
  315. def _shell(title: str, body: str) -> str:
  316. return f"""<!doctype html>
  317. <html lang="zh-CN">
  318. <head>
  319. <meta charset="utf-8">
  320. <meta name="viewport" content="width=device-width, initial-scale=1">
  321. <title>{escape(title)}</title>
  322. <style>
  323. :root{{--bg:#efe6d4;--paper:#fffaf1;--ink:#1c2430;--muted:#5f6875;--line:#d9cfbc;--deep:#173f35;--teal:#0f766e;--sand:#f4ebdc;--shadow:0 10px 24px rgba(41,52,66,.06)}}
  324. *{{box-sizing:border-box}} body{{margin:0;font-family:"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink)}}
  325. .wrap{{max-width:1380px;margin:0 auto;padding:22px 18px 42px}} .nav{{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:18px}}
  326. .nav a{{text-decoration:none;color:var(--deep);background:#fff8ef;border:1px solid var(--line);padding:9px 14px;border-radius:999px;font-size:13px}}
  327. .hero{{background:linear-gradient(135deg,#183f36 0%,#216858 52%,#eadbc0 52%,#f9f4e7 100%);color:#fff;padding:28px 30px;border-radius:22px;margin-bottom:18px;box-shadow:var(--shadow)}}
  328. .hero h1{{margin:0 0 8px;font-size:34px}} .hero p{{margin:0;max-width:980px;line-height:1.7;font-size:14px;color:rgba(255,255,255,.92)}}
  329. .chips{{display:flex;flex-wrap:wrap;gap:10px;margin-top:14px}} .chip{{background:rgba(255,255,255,.14);padding:7px 12px;border-radius:999px;font-size:12px}}
  330. .grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-bottom:18px}} .panel,.section{{background:var(--paper);border:1px solid var(--line);border-radius:18px;padding:18px;box-shadow:var(--shadow)}}
  331. .panel h2,.section h2{{margin:0 0 12px;color:var(--teal);font-size:20px}} .metrics{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}}
  332. .metric{{background:var(--sand);border-radius:12px;padding:12px}} .metric.good{{background:#dff3ee}} .metric.warn{{background:#f7ecd1}} .metric.risk{{background:#f7dede}}
  333. .metric-label{{font-size:12px;color:var(--muted);margin-bottom:4px}} .metric-value{{font-size:20px;font-weight:700}}
  334. .cards{{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}} .card{{border:1px solid var(--line);border-radius:16px;padding:16px;background:#fffdf9}}
  335. .role{{display:inline-block;background:#e4f4f0;color:var(--teal);border-radius:999px;padding:4px 10px;font-size:12px;font-weight:700;margin-bottom:8px}} .name{{font-size:18px;font-weight:700;margin-bottom:6px}}
  336. .desc{{font-size:13px;color:var(--muted);line-height:1.7;margin-bottom:10px}} .facts{{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}} .fact{{background:#faf4e8;border:1px solid #e6dcc9;border-radius:12px;padding:10px}}
  337. .callout{{margin-top:12px;padding:12px 14px;border-left:4px solid #c28a2c;background:#f8efdf;border-radius:10px;font-size:13px;line-height:1.7;color:#5b4a2f}}
  338. .chart-card{{background:#fffdf9;border:1px solid var(--line);border-radius:16px;padding:14px}} .chart-title{{font-size:16px;font-weight:700;margin-bottom:8px;color:var(--deep)}} .chart-sub{{font-size:12px;color:var(--muted);margin-bottom:10px}} .chart-wrap{{overflow-x:auto}}
  339. svg{{width:100%;height:auto;display:block}} table{{width:100%;border-collapse:collapse;font-size:13px}} th,td{{padding:10px 12px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}} th{{background:#f2e8d7}}
  340. .snapshot-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px;margin-top:14px}}
  341. .snapshot-card{{border:1px solid var(--line);border-radius:14px;background:#fffdf9;padding:12px}}
  342. .snapshot-title{{font-size:14px;font-weight:700;color:var(--deep);margin-bottom:8px}}
  343. .snapshot-rule{{margin-bottom:8px;font-size:12px;color:var(--muted);line-height:1.5}}
  344. .mini-table-wrap{{overflow-x:auto}}
  345. .mini-table{{width:100%;border-collapse:collapse;font-size:12px}}
  346. .mini-table th,.mini-table td{{padding:6px 8px;border-bottom:1px solid #eadfcd;white-space:nowrap}}
  347. .mini-table th{{background:#f7efe0;font-size:11px;color:var(--muted)}}
  348. .mini-table tr.event-row td{{background:#e8f6f1;font-weight:700}}
  349. .event-pill{{display:inline-block;padding:2px 8px;border-radius:999px;background:#0f766e;color:#fff;font-size:11px}}
  350. .signal-pill{{display:inline-block;padding:2px 6px;border-radius:999px;background:#f1e5c9;color:#6b4f1d;font-size:11px}}
  351. .trade-pill{{display:inline-block;padding:2px 6px;border-radius:999px;color:#fff;font-size:11px;margin-right:4px}}
  352. .trade-pill.real-buy{{background:#1f7a5c}}
  353. .trade-pill.real-sell{{background:#b45309}}
  354. .trade-pill.aux-buy{{background:#2563eb}}
  355. .trade-pill.aux-sell{{background:#7c3aed}}
  356. .summary-pill{{display:inline-block;padding:2px 8px;border-radius:999px;background:#e9efe8;color:#1f2937;font-size:11px;margin:0 6px 6px 0}}
  357. .snapshot-summary{{margin-top:10px;padding:10px 12px;border:1px solid #e6dcc9;border-radius:12px;background:#faf6ed;font-size:12px;line-height:1.7;color:#3f4956}}
  358. .snapshot-note{{margin-top:8px;font-size:12px;color:var(--muted);line-height:1.6}}
  359. .badge{{display:inline-block;padding:3px 9px;border-radius:999px;color:#fff;font-size:12px;font-weight:700}} .empty{{color:var(--muted)}} .footer{{margin-top:18px;text-align:right;color:var(--muted);font-size:12px}}
  360. @media (max-width:720px){{.hero h1{{font-size:28px}} .metrics,.facts{{grid-template-columns:1fr}} th,td{{padding:8px 9px;font-size:12px}}}}
  361. </style>
  362. </head>
  363. <body><div class="wrap">{body}<div class="footer">Dragon v2 HTML reports</div></div></body></html>"""
  364. def _svg_line_chart(df: pd.DataFrame, y_col: str, width: int = 1120, height: int = 320) -> str:
  365. if df.empty:
  366. return '<div class="empty">暂无图表数据</div>'
  367. pad_left, pad_right, pad_top, pad_bottom = 46, 20, 16, 32
  368. inner_w, inner_h = width - pad_left - pad_right, height - pad_top - pad_bottom
  369. y_min, y_max = float(df[y_col].min()), float(df[y_col].max())
  370. if y_min == y_max:
  371. y_min -= 1.0
  372. y_max += 1.0
  373. parts: list[str] = []
  374. for level in range(5):
  375. ratio = level / 4
  376. y_val = y_max - (y_max - y_min) * ratio
  377. y_px = pad_top + inner_h * ratio
  378. parts.append(f'<line x1="{pad_left}" y1="{y_px:.1f}" x2="{width - pad_right}" y2="{y_px:.1f}" stroke="#e8decb" stroke-width="1"/>')
  379. parts.append(f'<text x="{pad_left - 8}" y="{y_px + 4:.1f}" text-anchor="end" font-size="11" fill="#6b7280">{y_val:.2f}</text>')
  380. for idx, branch in enumerate(BRANCH_ORDER):
  381. sub = df[df["branch"] == branch].reset_index(drop=True)
  382. if sub.empty:
  383. continue
  384. coords: list[str] = []
  385. for pos, (_, row) in enumerate(sub.iterrows()):
  386. x_ratio = 0 if len(sub) == 1 else pos / (len(sub) - 1)
  387. x_px = pad_left + inner_w * x_ratio
  388. y_ratio = (float(row[y_col]) - y_min) / (y_max - y_min)
  389. y_px = pad_top + inner_h * (1 - y_ratio)
  390. coords.append(f"{x_px:.1f},{y_px:.1f}")
  391. parts.append(f'<polyline fill="none" stroke="{BRANCH_COLORS[branch]}" stroke-width="2.5" points="{" ".join(coords)}"/>')
  392. ly = pad_top + 18 + idx * 18
  393. parts.append(f'<line x1="{width - 240}" y1="{ly}" x2="{width - 220}" y2="{ly}" stroke="{BRANCH_COLORS[branch]}" stroke-width="3"/>')
  394. parts.append(f'<text x="{width - 212}" y="{ly + 4}" font-size="12" fill="#374151">{escape(_branch_name(branch))}</text>')
  395. return f'<div class="chart-wrap"><svg viewBox="0 0 {width} {height}">{"".join(parts)}</svg></div>'
  396. def _svg_bar_chart(df: pd.DataFrame, width: int = 1120, height: int = 320) -> str:
  397. if df.empty:
  398. return '<div class="empty">暂无图表数据</div>'
  399. years = [str(v) for v in sorted(df["sell_year"].unique())]
  400. pad_left, pad_right, pad_top, pad_bottom = 46, 20, 16, 36
  401. inner_w, inner_h = width - pad_left - pad_right, height - pad_top - pad_bottom
  402. y_min = float(min(0.0, df["return_pct"].min()))
  403. y_max = float(max(0.0, df["return_pct"].max()))
  404. if y_min == y_max:
  405. y_min -= 1.0
  406. y_max += 1.0
  407. zero_y = pad_top + inner_h * (1 - ((0.0 - y_min) / (y_max - y_min)))
  408. parts = [f'<line x1="{pad_left}" y1="{zero_y:.1f}" x2="{width - pad_right}" y2="{zero_y:.1f}" stroke="#c9b89f" stroke-width="1.5"/>']
  409. group_w = inner_w / max(1, len(years))
  410. bar_w = max(8.0, min(18.0, group_w / 5))
  411. for year_idx, year in enumerate(years):
  412. group_x = pad_left + group_w * year_idx
  413. parts.append(f'<text x="{group_x + group_w / 2:.1f}" y="{height - 12}" text-anchor="middle" font-size="11" fill="#6b7280">{escape(year)}</text>')
  414. for branch_idx, branch in enumerate(BRANCH_ORDER):
  415. sub = df[(df["sell_year"].astype(str) == year) & (df["branch"] == branch)]
  416. if sub.empty:
  417. continue
  418. value = float(sub.iloc[0]["return_pct"])
  419. value_y = pad_top + inner_h * (1 - ((value - y_min) / (y_max - y_min)))
  420. rect_y = value_y if value >= 0 else zero_y
  421. rect_h = abs(zero_y - value_y)
  422. rect_x = group_x + 8 + branch_idx * (bar_w + 5)
  423. parts.append(f'<rect x="{rect_x:.1f}" y="{rect_y:.1f}" width="{bar_w:.1f}" height="{max(rect_h,1):.1f}" fill="{BRANCH_COLORS[branch]}" rx="3"/>')
  424. for idx, branch in enumerate(BRANCH_ORDER):
  425. ly = pad_top + 18 + idx * 18
  426. parts.append(f'<rect x="{width - 245}" y="{ly - 8}" width="14" height="14" fill="{BRANCH_COLORS[branch]}" rx="3"/>')
  427. parts.append(f'<text x="{width - 223}" y="{ly + 3}" font-size="12" fill="#374151">{escape(_branch_name(branch))}</text>')
  428. return f'<div class="chart-wrap"><svg viewBox="0 0 {width} {height}">{"".join(parts)}</svg></div>'
  429. def _strategy_cards(overview: pd.DataFrame, status_df: pd.DataFrame) -> str:
  430. desc = {
  431. "workbook_preserving": "尽量保留工作簿结构,适合重构核对与审计。",
  432. "alpha_first_selective_veto": "平衡版本,兼顾一致性与收益质量。",
  433. "alpha_first_glued_refined_hot_cap": "当前量化结果最强的 RC1 前向默认分支。",
  434. }
  435. status_map = {str(row["branch"]): row for _, row in status_df.iterrows()}
  436. cards: list[str] = []
  437. for branch in BRANCH_ORDER:
  438. row_df = overview[overview["branch"] == branch]
  439. if row_df.empty:
  440. continue
  441. row = row_df.iloc[0]
  442. status = status_map.get(branch)
  443. latest_event = "暂无" if status is None else f"{status['latest_real_event_date']} {status['latest_real_event_side']} {status['latest_real_event_reason']}".strip()
  444. latest_pos = "" if status is None else _fmt_bool(status["in_position"])
  445. facts = [
  446. ("年化", _fmt_pct(row["cagr"])),
  447. ("总收益", _fmt_pct(row["compounded_return"])),
  448. ("PF", _fmt_num(row["profit_factor"])),
  449. ("交易笔数", _fmt_int(row["trades"])),
  450. ("BUY对齐", _fmt_int(row["real_buy_overlap"])),
  451. ("最新持仓", latest_pos),
  452. ]
  453. facts_html = "".join(f'<div class="fact"><div class="metric-label">{escape(k)}</div><div class="metric-value">{escape(v)}</div></div>' for k, v in facts)
  454. cards.append(
  455. f'<div class="card"><div class="role">{escape(BRANCH_ROLES.get(branch, ""))}</div><div class="name">{escape(_branch_name(branch))}</div><div class="desc">{escape(desc.get(branch, ""))}</div><div class="facts">{facts_html}</div><div class="callout">最新真实事件:{escape(latest_event)}<br>SELL 对齐:{_fmt_int(row["real_sell_overlap"])}</div></div>'
  456. )
  457. return '<div class="cards">' + "".join(cards) + "</div>"
  458. def _index_charts(base_dir: Path) -> str:
  459. equity = _load_csv(base_dir / "dragon_equity_curve_review.csv")
  460. drawdown = _load_csv(base_dir / "dragon_drawdown_review.csv")
  461. yearly = _load_csv(base_dir / "dragon_yearly_return_review.csv")
  462. if not equity.empty:
  463. equity["trade_no"] = equity.groupby("branch").cumcount() + 1
  464. if not drawdown.empty:
  465. drawdown["策略"] = drawdown["branch"].map(_branch_name)
  466. drawdown = drawdown[["策略", "trades", "compounded_return", "cagr", "max_drawdown", "drawdown_duration_trades", "calmar"]].rename(columns={"trades": "交易笔数", "compounded_return": "总收益", "cagr": "年化", "max_drawdown": "最大回撤", "drawdown_duration_trades": "回撤持续笔数", "calmar": "Calmar"})
  467. return f"""
  468. <div class="section">
  469. <h2>收益与回撤可视化</h2>
  470. <div class="grid">
  471. <div class="chart-card"><div class="chart-title">累计净值曲线</div><div class="chart-sub">按交易结束顺序绘制三条策略的累计净值。</div>{_svg_line_chart(equity, 'equity')}</div>
  472. <div class="chart-card"><div class="chart-title">年度收益对比</div><div class="chart-sub">按卖出年份聚合,观察不同年份的稳定性。</div>{_svg_bar_chart(yearly)}</div>
  473. </div>
  474. <div style="margin-top:16px;">{_table(drawdown, {"总收益": _fmt_pct, "年化": _fmt_pct, "最大回撤": _fmt_pct, "Calmar": _fmt_num})}</div>
  475. </div>
  476. """
  477. def _inline_code(text: str) -> str:
  478. return re.sub(r"`([^`]+)`", lambda m: f"<code>{escape(m.group(1))}</code>", escape(text))
  479. def _markdown_to_html(text: str) -> str:
  480. blocks: list[str] = []
  481. lines = text.splitlines()
  482. in_ul = False
  483. in_ol = False
  484. in_code = False
  485. code_lines: list[str] = []
  486. para_lines: list[str] = []
  487. def close_para() -> None:
  488. nonlocal para_lines
  489. if para_lines:
  490. blocks.append(f"<p>{'<br>'.join(_inline_code(line) for line in para_lines)}</p>")
  491. para_lines = []
  492. def close_lists() -> None:
  493. nonlocal in_ul, in_ol
  494. if in_ul:
  495. blocks.append("</ul>")
  496. in_ul = False
  497. if in_ol:
  498. blocks.append("</ol>")
  499. in_ol = False
  500. for raw in lines:
  501. line = raw.rstrip()
  502. stripped = line.strip()
  503. if stripped.startswith("```"):
  504. close_para()
  505. close_lists()
  506. if in_code:
  507. blocks.append(f"<pre><code>{escape(chr(10).join(code_lines))}</code></pre>")
  508. code_lines = []
  509. in_code = False
  510. else:
  511. in_code = True
  512. continue
  513. if in_code:
  514. code_lines.append(raw)
  515. continue
  516. if not stripped:
  517. close_para()
  518. close_lists()
  519. continue
  520. if stripped.startswith("# "):
  521. close_para()
  522. close_lists()
  523. blocks.append(f"<h1>{_inline_code(stripped[2:])}</h1>")
  524. continue
  525. if stripped.startswith("## "):
  526. close_para()
  527. close_lists()
  528. blocks.append(f"<h2>{_inline_code(stripped[3:])}</h2>")
  529. continue
  530. if stripped.startswith("### "):
  531. close_para()
  532. close_lists()
  533. blocks.append(f"<h3>{_inline_code(stripped[4:])}</h3>")
  534. continue
  535. if stripped.startswith("- "):
  536. close_para()
  537. if in_ol:
  538. blocks.append("</ol>")
  539. in_ol = False
  540. if not in_ul:
  541. blocks.append("<ul>")
  542. in_ul = True
  543. blocks.append(f"<li>{_inline_code(stripped[2:])}</li>")
  544. continue
  545. if re.match(r"^\d+\.\s", stripped):
  546. close_para()
  547. if in_ul:
  548. blocks.append("</ul>")
  549. in_ul = False
  550. if not in_ol:
  551. blocks.append("<ol>")
  552. in_ol = True
  553. item = re.sub(r"^\d+\.\s+", "", stripped)
  554. blocks.append(f"<li>{_inline_code(item)}</li>")
  555. continue
  556. para_lines.append(stripped)
  557. close_para()
  558. close_lists()
  559. if in_code:
  560. blocks.append(f"<pre><code>{escape(chr(10).join(code_lines))}</code></pre>")
  561. return "".join(blocks)
  562. def build_doc_html(base_dir: Path, md_name: str, title: str, subtitle: str, archive_mode: bool = False) -> str:
  563. s = _state(base_dir)
  564. latest_bar_date = str(s.get("latest_bar_date", "latest"))
  565. hrefs = _hrefs(latest_bar_date, archive_mode)
  566. text = (base_dir / md_name).read_text(encoding="utf-8")
  567. hero = (
  568. f'{_nav(hrefs)}'
  569. f'<div class="hero"><h1>{escape(title)}</h1><p>{escape(subtitle)}</p>'
  570. f'<div class="chips"><div class="chip">{escape(md_name)}</div></div></div>'
  571. )
  572. home_link = f'<a href="{escape(hrefs["home"])}">打开</a>'
  573. daily_link = f'<a href="{escape(hrefs["daily"])}">打开</a>'
  574. weekly_link = f'<a href="{escape(hrefs["weekly"])}">打开</a>'
  575. quick_link = f'<a href="{escape(hrefs["quickstart"])}">打开</a>'
  576. guide_link = f'<a href="{escape(hrefs["guide"])}">打开</a>'
  577. links = (
  578. '<div class="section"><h2>相关入口</h2><div class="metrics">'
  579. + _metric("总览首页", home_link)
  580. + _metric("每日报告", daily_link)
  581. + _metric("每周报告", weekly_link)
  582. + _metric("指标原理", guide_link)
  583. + _metric("极简说明", quick_link)
  584. + "</div></div>"
  585. )
  586. content = f'<div class="section doc">{_markdown_to_html(text)}</div>'
  587. return _shell(title, hero + links + content)
  588. def _indicator_guide_visuals() -> str:
  589. system_svg = """
  590. <div class="chart-card">
  591. <div class="chart-title">指标如何分工</div>
  592. <div class="chart-sub">先看位置,再看强弱,最后决定时点。</div>
  593. <div class="chart-wrap">
  594. <svg viewBox="0 0 1120 320">
  595. <rect x="40" y="70" width="200" height="110" rx="18" fill="#f8efdf" stroke="#c28a2c"/>
  596. <text x="140" y="110" text-anchor="middle" font-size="24" fill="#173f35">C1</text>
  597. <text x="140" y="140" text-anchor="middle" font-size="14" fill="#5f6875">看市场在高位、中位</text>
  598. <text x="140" y="162" text-anchor="middle" font-size="14" fill="#5f6875">还是低位</text>
  599. <rect x="310" y="70" width="240" height="110" rx="18" fill="#e4f4f0" stroke="#0f766e"/>
  600. <text x="430" y="110" text-anchor="middle" font-size="24" fill="#173f35">A1 / B1</text>
  601. <text x="430" y="140" text-anchor="middle" font-size="14" fill="#5f6875">看趋势强不强</text>
  602. <text x="430" y="162" text-anchor="middle" font-size="14" fill="#5f6875">动能有没有衰减</text>
  603. <rect x="620" y="70" width="240" height="110" rx="18" fill="#efe7fb" stroke="#7c3aed"/>
  604. <text x="740" y="110" text-anchor="middle" font-size="24" fill="#173f35">KDJ / QL</text>
  605. <text x="740" y="140" text-anchor="middle" font-size="14" fill="#5f6875">看短期拐点和突破确认</text>
  606. <text x="740" y="162" text-anchor="middle" font-size="14" fill="#5f6875">决定什么时候动手</text>
  607. <rect x="930" y="70" width="150" height="110" rx="18" fill="#ffe8e0" stroke="#c05621"/>
  608. <text x="1005" y="110" text-anchor="middle" font-size="24" fill="#173f35">交易动作</text>
  609. <text x="1005" y="140" text-anchor="middle" font-size="14" fill="#5f6875">买入 / 持有</text>
  610. <text x="1005" y="162" text-anchor="middle" font-size="14" fill="#5f6875">止盈 / 卖出</text>
  611. <line x1="240" y1="125" x2="310" y2="125" stroke="#8b6f47" stroke-width="4"/>
  612. <polygon points="310,125 296,117 296,133" fill="#8b6f47"/>
  613. <line x1="550" y1="125" x2="620" y2="125" stroke="#0f766e" stroke-width="4"/>
  614. <polygon points="620,125 606,117 606,133" fill="#0f766e"/>
  615. <line x1="860" y1="125" x2="930" y2="125" stroke="#7c3aed" stroke-width="4"/>
  616. <polygon points="930,125 916,117 916,133" fill="#7c3aed"/>
  617. </svg>
  618. </div>
  619. </div>
  620. """
  621. branch_svg = """
  622. <div class="chart-card">
  623. <div class="chart-title">三种策略版本的区别</div>
  624. <div class="chart-sub">不是换了一套完全不同的指标,而是对同一套规则做不同程度的过滤与取舍。</div>
  625. <div class="chart-wrap">
  626. <svg viewBox="0 0 1120 320">
  627. <rect x="60" y="55" width="280" height="180" rx="20" fill="#faf4e8" stroke="#8b6f47"/>
  628. <text x="200" y="95" text-anchor="middle" font-size="24" fill="#173f35">workbook_preserving</text>
  629. <text x="200" y="130" text-anchor="middle" font-size="15" fill="#5f6875">最像原始工作簿</text>
  630. <text x="200" y="156" text-anchor="middle" font-size="15" fill="#5f6875">优先保留历史路径</text>
  631. <text x="200" y="182" text-anchor="middle" font-size="15" fill="#5f6875">适合重构核对</text>
  632. <rect x="420" y="55" width="280" height="180" rx="20" fill="#e4f4f0" stroke="#0f766e"/>
  633. <text x="560" y="95" text-anchor="middle" font-size="24" fill="#173f35">alpha_first_selective_veto</text>
  634. <text x="560" y="130" text-anchor="middle" font-size="15" fill="#5f6875">平衡版本</text>
  635. <text x="560" y="156" text-anchor="middle" font-size="15" fill="#5f6875">保留一致性</text>
  636. <text x="560" y="182" text-anchor="middle" font-size="15" fill="#5f6875">同时删掉部分低质量交易</text>
  637. <rect x="780" y="55" width="280" height="180" rx="20" fill="#ffe8e0" stroke="#c05621"/>
  638. <text x="920" y="95" text-anchor="middle" font-size="24" fill="#173f35">RC1 / refined</text>
  639. <text x="920" y="130" text-anchor="middle" font-size="15" fill="#5f6875">当前收益质量最强</text>
  640. <text x="920" y="156" text-anchor="middle" font-size="15" fill="#5f6875">继续过滤弱短持交易</text>
  641. <text x="920" y="182" text-anchor="middle" font-size="15" fill="#5f6875">更偏实战 alpha</text>
  642. <line x1="340" y1="145" x2="420" y2="145" stroke="#8b6f47" stroke-width="4"/>
  643. <polygon points="420,145 406,137 406,153" fill="#8b6f47"/>
  644. <line x1="700" y1="145" x2="780" y2="145" stroke="#0f766e" stroke-width="4"/>
  645. <polygon points="780,145 766,137 766,153" fill="#0f766e"/>
  646. <text x="560" y="265" text-anchor="middle" font-size="14" fill="#5f6875">从左到右:越来越强调收益质量,和原始工作簿的距离也越来越大</text>
  647. </svg>
  648. </div>
  649. </div>
  650. """
  651. flow_svg = """
  652. <div class="chart-card">
  653. <div class="chart-title">从看到信号到完成交易的流程</div>
  654. <div class="chart-sub">普通投资者可以把它理解为一套分层决策流程,而不是单一指标下指令。</div>
  655. <div class="chart-wrap">
  656. <svg viewBox="0 0 1120 380">
  657. <rect x="80" y="40" width="190" height="70" rx="16" fill="#f8efdf" stroke="#c28a2c"/>
  658. <text x="175" y="82" text-anchor="middle" font-size="20" fill="#173f35">先看 C1</text>
  659. <text x="175" y="102" text-anchor="middle" font-size="13" fill="#5f6875">判断高位/中位/低位</text>
  660. <rect x="330" y="40" width="220" height="70" rx="16" fill="#e4f4f0" stroke="#0f766e"/>
  661. <text x="440" y="82" text-anchor="middle" font-size="20" fill="#173f35">再看 A1 / B1</text>
  662. <text x="440" y="102" text-anchor="middle" font-size="13" fill="#5f6875">判断趋势和动能是否支持</text>
  663. <rect x="610" y="40" width="220" height="70" rx="16" fill="#efe7fb" stroke="#7c3aed"/>
  664. <text x="720" y="82" text-anchor="middle" font-size="20" fill="#173f35">看 KDJ / QL</text>
  665. <text x="720" y="102" text-anchor="middle" font-size="13" fill="#5f6875">决定时点是否确认</text>
  666. <rect x="890" y="40" width="150" height="70" rx="16" fill="#ffe8e0" stroke="#c05621"/>
  667. <text x="965" y="82" text-anchor="middle" font-size="20" fill="#173f35">执行买入</text>
  668. <text x="965" y="102" text-anchor="middle" font-size="13" fill="#5f6875">或放弃</text>
  669. <rect x="360" y="190" width="190" height="90" rx="16" fill="#f9f4e7" stroke="#8b6f47"/>
  670. <text x="455" y="230" text-anchor="middle" font-size="18" fill="#173f35">持仓后继续观察</text>
  671. <text x="455" y="252" text-anchor="middle" font-size="13" fill="#5f6875">是否继续走强</text>
  672. <text x="455" y="272" text-anchor="middle" font-size="13" fill="#5f6875">是否出现辅助信号</text>
  673. <rect x="650" y="190" width="190" height="90" rx="16" fill="#ffe8e0" stroke="#c05621"/>
  674. <text x="745" y="230" text-anchor="middle" font-size="18" fill="#173f35">卖出或止盈</text>
  675. <text x="745" y="252" text-anchor="middle" font-size="13" fill="#5f6875">趋势转弱</text>
  676. <text x="745" y="272" text-anchor="middle" font-size="13" fill="#5f6875">或达到风险退出条件</text>
  677. <line x1="270" y1="75" x2="330" y2="75" stroke="#8b6f47" stroke-width="4"/><polygon points="330,75 316,67 316,83" fill="#8b6f47"/>
  678. <line x1="550" y1="75" x2="610" y2="75" stroke="#0f766e" stroke-width="4"/><polygon points="610,75 596,67 596,83" fill="#0f766e"/>
  679. <line x1="830" y1="75" x2="890" y2="75" stroke="#7c3aed" stroke-width="4"/><polygon points="890,75 876,67 876,83" fill="#7c3aed"/>
  680. <line x1="965" y1="110" x2="965" y2="145" stroke="#c05621" stroke-width="4"/>
  681. <line x1="965" y1="145" x2="455" y2="145" stroke="#c05621" stroke-width="4"/>
  682. <line x1="455" y1="145" x2="455" y2="190" stroke="#c05621" stroke-width="4"/>
  683. <polygon points="455,190 447,176 463,176" fill="#c05621"/>
  684. <line x1="550" y1="235" x2="650" y2="235" stroke="#8b6f47" stroke-width="4"/><polygon points="650,235 636,227 636,243" fill="#8b6f47"/>
  685. </svg>
  686. </div>
  687. </div>
  688. """
  689. return f'<div class="section"><h2>图解说明</h2><div class="grid">{system_svg}{branch_svg}</div><div style="margin-top:16px;">{flow_svg}</div></div>'
  690. def _indicator_guide_indicator_cards() -> str:
  691. cards = [
  692. {
  693. "name": "C1",
  694. "tag": "先看位置",
  695. "summary": "先判断现在更像高位、中位,还是低位/超跌区。",
  696. "facts": [
  697. ("主要作用", "先做市场分层"),
  698. ("更像什么", "市场所在楼层"),
  699. ("高位时", "更重风险控制"),
  700. ("低位时", "更重反转与恢复"),
  701. ],
  702. "callout": "同样一个金叉,放在低位和高位,含义完全不同。C1 不是告诉你今天涨不涨,而是先告诉你现在站在哪里。",
  703. },
  704. {
  705. "name": "A1",
  706. "tag": "再看趋势热度",
  707. "summary": "看短中期结构是在扩张,还是开始降温。",
  708. "facts": [
  709. ("主要作用", "趋势背景过滤"),
  710. ("更像什么", "趋势温度计"),
  711. ("走强时", "环境更支持持有"),
  712. ("回落时", "先警惕,不急着恋战"),
  713. ],
  714. "callout": "A1 更像背景,不是单独的买卖按钮。它回答的是:现在这段行情值不值得参与、值不值得继续拿。",
  715. },
  716. {
  717. "name": "B1",
  718. "tag": "再看动能后劲",
  719. "summary": "看这波行情还有没有继续扩张的力量。",
  720. "facts": [
  721. ("主要作用", "真假反弹过滤"),
  722. ("更像什么", "行情后劲表"),
  723. ("偏强时", "趋势更容易延续"),
  724. ("走坏时", "更容易触发减仓/退出"),
  725. ],
  726. "callout": "很多看起来像反弹的地方,最后没做成交易,往往不是因为没信号,而是 B1 告诉策略:这波后劲不够。",
  727. },
  728. {
  729. "name": "KDJ",
  730. "tag": "决定短期时点",
  731. "summary": "看短期节奏有没有拐头,适合用来点火或确认。",
  732. "facts": [
  733. ("主要作用", "短期时点确认"),
  734. ("更像什么", "点火器"),
  735. ("买入端", "低位拐头时更有用"),
  736. ("卖出端", "先发现节奏转弱"),
  737. ],
  738. "callout": "KDJ 更灵敏,能更早发现变化,但也更容易受短期波动影响,所以通常不会单独决定整笔交易。",
  739. },
  740. {
  741. "name": "QL 凤凰线",
  742. "tag": "决定是否真突破",
  743. "summary": "看价格是否真的冲出了动态边界,或真的跌破了支撑带。",
  744. "facts": [
  745. ("主要作用", "突破/跌破确认"),
  746. ("更像什么", "动态通道边界"),
  747. ("QL buy", "价格真突破"),
  748. ("QL sell", "价格真掉下来"),
  749. ],
  750. "callout": "QL 比 KDJ 更偏确认。KDJ 先告诉你节奏变了,QL 再告诉你价格层面也真的动了。",
  751. },
  752. ]
  753. parts: list[str] = ['<div class="section"><h2>指标速查卡</h2><div class="cards">']
  754. for item in cards:
  755. facts_html = "".join(
  756. f'<div class="fact"><div class="metric-label">{escape(label)}</div><div class="metric-value">{escape(value)}</div></div>'
  757. for label, value in item["facts"]
  758. )
  759. parts.append(
  760. '<div class="card">'
  761. f'<div class="role">{escape(item["tag"])}</div>'
  762. f'<div class="name">{escape(item["name"])}</div>'
  763. f'<div class="desc">{escape(item["summary"])}</div>'
  764. f'<div class="facts">{facts_html}</div>'
  765. f'<div class="callout">{escape(item["callout"])}</div>'
  766. "</div>"
  767. )
  768. parts.append("</div></div>")
  769. return "".join(parts)
  770. def _indicator_guide_scene_table() -> str:
  771. scene_df = pd.DataFrame(
  772. [
  773. {
  774. "市场状态": "C1 低位 / 超跌区",
  775. "普通话理解": "市场可能已经跌深,开始找反转或恢复机会。",
  776. "策略重点": "先防假反弹,再等拐点确认。",
  777. "A1 / B1 关注点": "A1 不一定强,但 B1 不能太差;否则只是弱反抽。",
  778. "KDJ / QL 作用": "KDJ 更像点火,QL 用来确认不是一日游。",
  779. },
  780. {
  781. "市场状态": "C1 中位区",
  782. "普通话理解": "最像趋势延续区,容易出现顺势型交易。",
  783. "策略重点": "判断趋势是否继续、有没有必要跟。",
  784. "A1 / B1 关注点": "A1、B1 同时较稳时,更容易走出主升或延续段。",
  785. "KDJ / QL 作用": "用于控制入场时点和持仓中的退出节奏。",
  786. },
  787. {
  788. "市场状态": "C1 高位区",
  789. "普通话理解": "市场不一定马上见顶,但风险回报比开始变差。",
  790. "策略重点": "更重保护利润,不轻易追最后一段。",
  791. "A1 / B1 关注点": "A1 回落、B1 走坏时,风险提示意义更强。",
  792. "KDJ / QL 作用": "更多用于确认转弱、止盈或风险退出。",
  793. },
  794. ]
  795. )
  796. return (
  797. '<div class="section"><h2>按市场状态来理解指标</h2>'
  798. '<div class="callout">普通投资者最容易误解的一点,是把同一个信号放到所有位置都按同样方法解释。实际不是。先看 C1 分层,再看 A1/B1 强弱,最后再让 KDJ/QL 决定什么时候动手。</div>'
  799. f'{_table(scene_df)}'
  800. "</div>"
  801. )
  802. def _indicator_guide_compare_table(base_dir: Path) -> str:
  803. overview = _load_csv(base_dir / "dragon_strategy_overview.csv")
  804. if overview.empty:
  805. return '<div class="section"><h2>三版本当前统计对照</h2><div class="empty">暂无策略统计数据</div></div>'
  806. role_desc = {
  807. "workbook_preserving": "最重历史一致性",
  808. "alpha_first_selective_veto": "平衡控制版",
  809. "alpha_first_glued_refined_hot_cap": "RC1,收益质量优先",
  810. }
  811. overview["策略"] = overview["branch"].map(_branch_name)
  812. overview["定位"] = overview["branch"].map(lambda x: role_desc.get(str(x), ""))
  813. overview["真实交易对齐"] = overview.apply(
  814. lambda row: f'{_fmt_int(row["real_buy_overlap"])}/{_fmt_int(row["real_sell_overlap"])}',
  815. axis=1,
  816. )
  817. table_df = overview[
  818. ["策略", "定位", "trades", "win_rate", "avg_return", "profit_factor", "compounded_return", "cagr", "真实交易对齐"]
  819. ].rename(
  820. columns={
  821. "trades": "交易笔数",
  822. "win_rate": "胜率",
  823. "avg_return": "单笔均值",
  824. "profit_factor": "PF",
  825. "compounded_return": "总收益",
  826. "cagr": "年化",
  827. }
  828. )
  829. return (
  830. '<div class="section"><h2>三版本当前统计对照</h2>'
  831. '<div class="callout">三种版本看的还是同一套指标,核心差别不在“换指标”,而在“过滤强度”和“是否为了更强收益而主动放弃一部分历史路径一致性”。</div>'
  832. f'{_table(table_df, {"胜率": _fmt_pct, "单笔均值": _fmt_pct, "PF": _fmt_num, "总收益": _fmt_pct, "年化": _fmt_pct})}'
  833. "</div>"
  834. )
  835. def _indicator_guide_threshold_tables() -> str:
  836. rc1 = alpha_first_glued_refined_hot_cap_config()
  837. base_df = pd.DataFrame(
  838. [
  839. {
  840. "类别": "A1 基础判读",
  841. "具体值 / 区间": "`|A1| < 0.02`",
  842. "说明": "代码里把它视为 glued 一类的粘合状态,常用于趋势延续判断。",
  843. },
  844. {
  845. "类别": "A1 偏强",
  846. "具体值 / 区间": "`A1 > 0.028`",
  847. "说明": "属于明显正向扩张区,持仓中更容易被视为强势环境。",
  848. },
  849. {
  850. "类别": "A1 明显转弱",
  851. "具体值 / 区间": "`A1 < -0.04`",
  852. "说明": "代码里属于 big negative 区,风险退出和低位反转分支都会重点关注。",
  853. },
  854. {
  855. "类别": "B1 明显偏强",
  856. "具体值 / 区间": "`B1 > 0.17`",
  857. "说明": "属于 strong positive,通常表示中期动能足够强。",
  858. },
  859. {
  860. "类别": "B1 明显偏弱",
  861. "具体值 / 区间": "`B1 < -0.17`",
  862. "说明": "属于 hard negative,风险信号意义很强。",
  863. },
  864. {
  865. "类别": "C1 深低位",
  866. "具体值 / 区间": "`C1 < 16`",
  867. "说明": "当前策略里是深超跌/深低位的重要参考区间,很多低位反转逻辑从这里展开。",
  868. },
  869. {
  870. "类别": "C1 恢复区",
  871. "具体值 / 区间": "`18 <= C1 < 22`",
  872. "说明": "是 oversold recovery 一类规则的典型区间。",
  873. },
  874. {
  875. "类别": "C1 中高位",
  876. "具体值 / 区间": "`C1 > 60`",
  877. "说明": "开始进入更强调风险和止盈的区间,很多卖出/辅助看空规则会加强。",
  878. },
  879. {
  880. "类别": "C1 很高位",
  881. "具体值 / 区间": "`C1 > 80` / `C1 > 85`",
  882. "说明": "80 以上常视为明显高位,85 以上会触发更强的高位防守和辅助卖出过滤。",
  883. },
  884. {
  885. "类别": "KDJ / QL",
  886. "具体值 / 区间": "无固定单一数值",
  887. "说明": "它们是“是否发生金叉/死叉、上穿/下穿”的事件信号,不是像 A1/B1/C1 那样直接看一个绝对数值。",
  888. },
  889. ]
  890. )
  891. example_df = pd.DataFrame(
  892. [
  893. {
  894. "规则类型": "RC1 热区 glued 过滤举例",
  895. "当前典型值": f'`{_fmt_num(rc1.glued_selective_hot_c1_min, 1)} <= C1 <= {_fmt_num(rc1.glued_selective_hot_c1_max, 1)}` 且 `B1 >= {_fmt_num(rc1.glued_selective_hot_b1_min, 2)}`',
  896. "用途": "删掉一部分热区但质量不高的 glued 短持仓交易。",
  897. },
  898. {
  899. "规则类型": "RC1 低位弱区过滤举例",
  900. "当前典型值": f'`{_fmt_num(rc1.glued_selective_low_c1_min, 1)} <= C1 < {_fmt_num(rc1.glued_selective_low_c1_max, 1)}` 且 `B1 <= {_fmt_num(rc1.glued_selective_low_b1_max, 2)}`',
  901. "用途": "过滤低位弱修复但后劲不足的 glued 交易。",
  902. },
  903. {
  904. "规则类型": "深超跌反弹入场举例",
  905. "当前典型值": "`C1 < 16`,`A1 >= -0.09`,`B1 >= -0.10`",
  906. "用途": "给 deep_oversold_rebound_buy 提供基础入场框架;但会再叠加真假反弹过滤。",
  907. },
  908. {
  909. "规则类型": "oversold recovery 举例",
  910. "当前典型值": "`18 <= C1 < 22`,`-0.03 <= A1 <= 0`,`B1 >= -0.02`",
  911. "用途": "识别已经跌深、但正在恢复的结构。",
  912. },
  913. {
  914. "规则类型": "QL 后反转入场举例",
  915. "当前典型值": "`20 <= C1 < 26`,`-0.04 <= A1 <= 0`,`-0.22 <= B1 < 0`",
  916. "用途": "对应 oversold_reversal_after_ql_buy 的典型区间。",
  917. },
  918. {
  919. "规则类型": "QL 后反转弱样本阻断举例",
  920. "当前典型值": "`23 <= C1 < 26`,`B1 > -0.12`,`A1 > -0.035`",
  921. "用途": "这类组合更像弱反抽,所以在当前版本里会被主动拦掉。",
  922. },
  923. {
  924. "规则类型": "post-sell rebound 举例",
  925. "当前典型值": "`18 <= C1 < 30`,`-0.045 <= A1 <= 0`,`-0.09 <= B1 <= -0.04`",
  926. "用途": "卖出后再恢复的一类重启单,仍要求不是太弱的假反弹。",
  927. },
  928. {
  929. "规则类型": "predictive break 短持仓退出举例",
  930. "当前典型值": "`持仓 <= 2天`,`50 < C1 < 70`,`-0.02 < A1 < 0`,`B1 < -0.13`",
  931. "用途": "短持仓刚买就发现后劲不对,尽快认错退出。",
  932. },
  933. {
  934. "规则类型": "predictive break 长持仓退出举例",
  935. "当前典型值": "`持仓 >= 40天`,`60 < C1 < 65`,`-0.02 < A1 < 0`,`B1 < -0.12`,且 `7天内出现过 QL sell`",
  936. "用途": "长持仓后期在高位衰减中确认退出。",
  937. },
  938. {
  939. "规则类型": "空仓后辅助看空举例",
  940. "当前典型值": "`真实卖出后 10 天内` 的再次确认;重复同侧信号还会受 `5 天冷却` 约束",
  941. "用途": "这是辅助看空信号,不是新的真实卖点。",
  942. },
  943. ]
  944. )
  945. return (
  946. '<div class="section"><h2>详细指标值与触发举例</h2>'
  947. '<div class="callout">这部分分两层来看。第一张表是相对稳定的基础判读阈值;第二张表是当前 RC1 / 研究版本里常见规则的“典型触发区间”。后者不是全市场永远不变的唯一真理,而是当前代码里经常用到的实战阈值举例。</div>'
  948. f'{_table(base_df)}'
  949. '<div style="margin-top:16px;"></div>'
  950. f'{_table(example_df)}'
  951. "</div>"
  952. )
  953. def _indicator_guide_rule_tables(base_dir: Path, hrefs: dict[str, str]) -> str:
  954. details = _load_csv(base_dir / "dragon_historical_trade_details.csv")
  955. details = details[details["branch"] == "alpha_first_glued_refined_hot_cap"].copy() if not details.empty else details
  956. def count_exact(col: str, value: str) -> int:
  957. if details.empty:
  958. return 0
  959. return int((details[col] == value).sum())
  960. def count_prefix(col: str, prefix: str) -> int:
  961. if details.empty:
  962. return 0
  963. series = details[col].fillna("").astype(str)
  964. return int(series.str.startswith(prefix).sum())
  965. def rule_link(label: str, keyword: str) -> str:
  966. href = _detail_query_href(hrefs, branch="alpha_first_glued_refined_hot_cap", keyword=keyword)
  967. return f'<a href="{escape(href)}">{escape(label)}</a>'
  968. buy_df = pd.DataFrame(
  969. [
  970. {
  971. "规则名": rule_link("glued_buy", "glued_buy"),
  972. "RC1 历史次数": count_exact("buy_reason", "glued_buy"),
  973. "触发方式": "状态型,不靠单一金叉",
  974. "典型值 / 条件": "|A1| < 0.02;B1 不能继续走弱;不能落入 RC1 的热区/低位弱区过滤",
  975. "这条规则在做什么": "寻找中位到中高位的趋势延续,不做太弱、太短命的粘合单。",
  976. },
  977. {
  978. "规则名": rule_link("dual_gold_resonance_buy", "dual_gold_resonance_buy"),
  979. "RC1 历史次数": count_exact("buy_reason", "dual_gold_resonance_buy"),
  980. "触发方式": "KDJ / QL 共振 + 改善确认",
  981. "典型值 / 条件": "需要 dual_gold 共振;A1/B1 相比上一买入拐点不变差;若 18 < C1 < 20 且 A1 > -0.05 且 B1 < -0.09,会被视作假反弹拦掉",
  982. "这条规则在做什么": "做低位或恢复段里的共振型反转,但要防止低质量假起势。",
  983. },
  984. {
  985. "规则名": rule_link("deep_oversold_rebound_buy", "deep_oversold_rebound_buy"),
  986. "RC1 历史次数": count_prefix("buy_reason", "deep_oversold_rebound_buy"),
  987. "触发方式": "深超跌后的恢复尝试",
  988. "典型值 / 条件": "C1 < 16;A1 > -0.09;B1 > -0.10;若 13 < C1 < 15 且 A1 > -0.04 且 B1 < -0.08,或 13 < C1 < 14.5 且 A1 > -0.04 且 B1 > -0.06,会被过滤",
  989. "这条规则在做什么": "专门抓跌深后的恢复,但同时过滤一部分弱反抽和浅层假启动。",
  990. },
  991. {
  992. "规则名": rule_link("oversold_recovery_buy", "oversold_recovery_buy"),
  993. "RC1 历史次数": count_exact("buy_reason", "oversold_recovery_buy"),
  994. "触发方式": "区间恢复型",
  995. "典型值 / 条件": "18 <= C1 < 22;-0.03 <= A1 < 0;B1 > -0.02",
  996. "这条规则在做什么": "市场已经不在最恐慌位置,但仍在恢复段,尝试接恢复而不是接最低点。",
  997. },
  998. {
  999. "规则名": rule_link("post_sell_rebound_buy", "post_sell_rebound_buy"),
  1000. "RC1 历史次数": count_exact("buy_reason", "post_sell_rebound_buy"),
  1001. "触发方式": "卖出后再恢复",
  1002. "典型值 / 条件": "距离上次 KDJ sell 不超过 7 天;常见区间是 18 <= C1 < 30、-0.045 <= A1 < 0、-0.09 < B1 < -0.04;也有更低位版本 C1 < 19、-0.13 < B1 < -0.09",
  1003. "这条规则在做什么": "承认前面先退出,后面发现市场并没继续坏,再重新上车。",
  1004. },
  1005. {
  1006. "规则名": rule_link("early_crash_probe_buy", "early_crash_probe_buy"),
  1007. "RC1 历史次数": count_exact("buy_reason", "early_crash_probe_buy"),
  1008. "触发方式": "恐慌试探",
  1009. "典型值 / 条件": "C1 < 20;-0.07 < A1 < -0.04;-0.04 < B1 < 0",
  1010. "这条规则在做什么": "在急跌后的极少数时刻做试探性进场,风险更高,不是常规顺势单。",
  1011. },
  1012. {
  1013. "规则名": rule_link("oversold_reversal_after_ql_buy", "oversold_reversal_after_ql_buy"),
  1014. "RC1 历史次数": count_exact("buy_reason", "oversold_reversal_after_ql_buy"),
  1015. "触发方式": "前一根先出现 QL sell,后一根做反转",
  1016. "典型值 / 条件": "20 <= C1 < 26;-0.04 <= A1 < 0;-0.22 < B1 < 0;若 23 < C1 < 26 且 B1 > -0.12 且 A1 > -0.035,会被视为弱反抽拦掉",
  1017. "这条规则在做什么": "处理先被砸一下、但很快重新恢复的低位反转。",
  1018. },
  1019. {
  1020. "规则名": rule_link("predictive_error_reentry_buy", "predictive_error_reentry_buy"),
  1021. "RC1 历史次数": count_exact("buy_reason", "predictive_error_reentry_buy"),
  1022. "触发方式": "认错后回补",
  1023. "典型值 / 条件": "常见于上次真实卖出后较短时间内;-0.02 < A1 < 0.01;B1 > -0.16;C1 > 50",
  1024. "这条规则在做什么": "前面提前卖了,后面发现卖错了,就按更稳的恢复条件回补。",
  1025. },
  1026. ]
  1027. )
  1028. sell_df = pd.DataFrame(
  1029. [
  1030. {
  1031. "规则名": rule_link("knife_take_profit_2_glued", "knife_take_profit_2_glued"),
  1032. "RC1 历史次数": count_exact("sell_reason", "knife_take_profit_2_glued"),
  1033. "触发方式": "持仓中的 glued 止盈",
  1034. "典型值 / 条件": "前提是 entry_reason = glued_buy 且当前仍属 glued;若 B1 < 0.17 即可止盈;如果先出现 ql_sell 但还想再等确认,会转入 wait_ql 版本",
  1035. "这条规则在做什么": "RC1 最核心的止盈规则,用来把很多低质量短持仓单提早收掉。",
  1036. },
  1037. {
  1038. "规则名": rule_link("knife_take_profit_2_wait_ql_s", "knife_take_profit_2_wait_ql_s"),
  1039. "RC1 历史次数": count_exact("sell_reason", "knife_take_profit_2_wait_ql_s"),
  1040. "触发方式": "QL-only 确认止盈",
  1041. "典型值 / 条件": "常见于 glued_buy 持仓且仍在 glued 状态;先出现 ql_sell,再作为确认版止盈",
  1042. "这条规则在做什么": "不急着在第一个轻微信号就走,而是等价格层面的确认再止盈。",
  1043. },
  1044. {
  1045. "规则名": rule_link("glued_exit:kdj_sell / ql_sell", "glued_exit:"),
  1046. "RC1 历史次数": count_prefix("sell_reason", "glued_exit:"),
  1047. "触发方式": "glued 状态退出",
  1048. "典型值 / 条件": "当 A1 仍是 glued 区(|A1| < 0.02),且不再满足继续持有条件时,配合 kdj_sell 或 ql_sell 退出",
  1049. "这条规则在做什么": "趋势延续单走完后,按节奏和价格确认离场。",
  1050. },
  1051. {
  1052. "规则名": rule_link("negative_a1_no_b1_recovery:kdj_sell / ql_sell", "negative_a1_no_b1_recovery:"),
  1053. "RC1 历史次数": count_prefix("sell_reason", "negative_a1_no_b1_recovery:"),
  1054. "触发方式": "负 A1 且 B1 没恢复",
  1055. "典型值 / 条件": "-0.04 < A1 < -0.02,且 B1 <= 0",
  1056. "这条规则在做什么": "趋势环境已经转弱,中期后劲也没回来,直接退出,不再等反弹。",
  1057. },
  1058. {
  1059. "规则名": rule_link("small_positive_a1_declining:ql_sell / kdj_sell", "small_positive_a1_declining:"),
  1060. "RC1 历史次数": count_prefix("sell_reason", "small_positive_a1_declining:"),
  1061. "触发方式": "小正 A1 区衰减",
  1062. "典型值 / 条件": "0.02 < A1 < 0.028;A1 下降;B1 < 0.17;若伴随 ql_sell / kdj_sell 则更容易触发",
  1063. "这条规则在做什么": "热度还没彻底坏,但已经从小正值开始往下掉,优先做谨慎止盈。",
  1064. },
  1065. {
  1066. "规则名": rule_link("prewarning_reduction_exit", "prewarning_reduction_exit"),
  1067. "RC1 历史次数": count_exact("sell_reason", "prewarning_reduction_exit"),
  1068. "触发方式": "高位预警减仓",
  1069. "典型值 / 条件": "0.033 <= A1 <= 0.05;C1 > 80;且从峰值回落,B1 也明显衰减;要求 kdj_sell 已出现",
  1070. "这条规则在做什么": "高位还没完全坏,但先提高警惕,提前保护利润。",
  1071. },
  1072. {
  1073. "规则名": rule_link("high_regime_confirmed_exit:kdj_sell / ql_sell", "high_regime_confirmed_exit:"),
  1074. "RC1 历史次数": count_prefix("sell_reason", "high_regime_confirmed_exit:"),
  1075. "触发方式": "高位确认退出",
  1076. "典型值 / 条件": "通常发生在高位序列中,且 B1 <= 0;再叠加 A1 <= 0.022、或 C1 < 80、或多次 sell 信号确认",
  1077. "这条规则在做什么": "不是只看到一次转弱就跑,而是等高位结构确认坏掉后退出。",
  1078. },
  1079. {
  1080. "规则名": rule_link("ql_mid_zone_take_profit", "ql_mid_zone_take_profit"),
  1081. "RC1 历史次数": count_exact("sell_reason", "ql_mid_zone_take_profit"),
  1082. "触发方式": "QL-only 中位止盈",
  1083. "典型值 / 条件": "必须是 ql_sell 且不是 kdj_sell;max_b1_since_entry > 0.15;max_c1_since_entry >= 78;0 < A1 <= 0.02;B1 <= 0.12",
  1084. "这条规则在做什么": "涨过一段以后,价格通道先给出明确走弱信号,就先锁利润。",
  1085. },
  1086. {
  1087. "规则名": rule_link("crash_protection_exit", "crash_protection_exit"),
  1088. "RC1 历史次数": count_exact("sell_reason", "crash_protection_exit"),
  1089. "触发方式": "防崩保护",
  1090. "典型值 / 条件": "曾经出现过 C1 > 80;max_A1 > 0.05;当前 A1 < 0.03;当前 B1 < -0.08",
  1091. "这条规则在做什么": "高位转坏后不再犹豫,优先防大幅回撤。",
  1092. },
  1093. ]
  1094. )
  1095. return (
  1096. '<div class="section"><h2>常见真实买卖规则速查表</h2>'
  1097. '<div class="callout">这张表只挑 RC1 里最常见、最有代表性的真实交易规则。这里的“典型值”是为了帮助普通读者理解,不表示这条规则在任何历史样本里都只有这一种触发形式。很多规则还会叠加事件条件,例如是否出现了 KDJ sell、QL sell、是否刚卖出过、是否处在持仓中等。</div>'
  1098. '<h3>常见真实买点</h3>'
  1099. f'{_table(buy_df, {"规则名": lambda v: str(v)})}'
  1100. '<div style="margin-top:16px;"></div>'
  1101. '<h3>常见真实卖点</h3>'
  1102. f'{_table(sell_df, {"规则名": lambda v: str(v)})}'
  1103. "</div>"
  1104. )
  1105. def _indicator_guide_misconceptions(hrefs: dict[str, str]) -> str:
  1106. items = [
  1107. {
  1108. "title": "看到 `SELL` 不是立刻等于做空",
  1109. "text": "在这套口径里,空仓后再次出现的 SELL,多数只是辅助看空信号,表示环境偏弱,不代表又发生了一笔新的真实交易。",
  1110. },
  1111. {
  1112. "title": "持仓中再次 `BUY` 不是加仓",
  1113. "text": "它只是辅助看多信号,说明原有多头结构得到再次确认。真实交易层仍然只认那一笔原始开仓。",
  1114. },
  1115. {
  1116. "title": "RC1 更强,不是因为交易更频繁",
  1117. "text": "恰恰相反,RC1 的优势主要来自少做了若干低质量、短持仓、容易亏损的交易,而不是多做交易。",
  1118. },
  1119. {
  1120. "title": "指标不是预测神器",
  1121. "text": "这些指标更像一套分层判断流程:先定位,再过滤,再确认。它们不是在猜明天一定涨跌,而是在提高决策质量。",
  1122. },
  1123. ]
  1124. cards = []
  1125. for item in items:
  1126. cards.append(
  1127. '<div class="card">'
  1128. f'<div class="name">{escape(item["title"])}</div>'
  1129. f'<div class="desc">{escape(item["text"])}</div>'
  1130. "</div>"
  1131. )
  1132. report_map = (
  1133. '<div class="section"><h2>看完原理后,下一步看哪里</h2>'
  1134. '<div class="grid">'
  1135. + _metric("每日报告", f'<a href="{escape(hrefs["daily"])}">看最新状态</a>')
  1136. + _metric("历史明细", f'<a href="{escape(hrefs["detail"])}">核对每笔交易</a>')
  1137. + _metric("每周报告", f'<a href="{escape(hrefs["weekly"])}">看前向观察</a>')
  1138. + _metric("详细使用说明", f'<a href="{escape(hrefs["usage"])}">看报告怎么用</a>')
  1139. + "</div>"
  1140. '<div class="callout">最实用的阅读顺序通常是:先看这页理解逻辑,再去每日报告看最新状态,最后到历史明细核对某一笔交易为什么买、为什么卖。</div>'
  1141. "</div>"
  1142. )
  1143. return (
  1144. '<div class="section"><h2>普通投资者最容易误解的 4 件事</h2><div class="cards">'
  1145. + "".join(cards)
  1146. + "</div></div>"
  1147. + report_map
  1148. )
  1149. def build_indicator_guide_html(base_dir: Path, archive_mode: bool = False) -> str:
  1150. s = _state(base_dir)
  1151. latest_bar_date = str(s.get("latest_bar_date", "latest"))
  1152. hrefs = _hrefs(latest_bar_date, archive_mode)
  1153. text = (base_dir / "dragon_indicator_strategy_guide_cn.md").read_text(encoding="utf-8")
  1154. hero = (
  1155. f'{_nav(hrefs)}'
  1156. '<div class="hero"><h1>Dragon 指标与策略原理说明</h1>'
  1157. '<p>这页专门面向普通投资者,解释这套系统到底看什么指标、这些指标各自负责什么,以及三种策略版本为什么会得出不同的交易结果。</p>'
  1158. '<div class="chips"><div class="chip">面向普通投资者</div><div class="chip">先看图,再读正文</div></div></div>'
  1159. )
  1160. quick = (
  1161. '<div class="section"><h2>先记住这 6 句话</h2><div class="grid">'
  1162. + _metric("C1", "看位置:高位、中位、低位")
  1163. + _metric("A1", "看趋势热度是否扩张")
  1164. + _metric("B1", "看动能还有没有后劲")
  1165. + _metric("KDJ", "看短期节奏拐点")
  1166. + _metric("QL", "看价格是否真正突破/跌破")
  1167. + _metric("三版本", "差别主要在过滤强度,不是换了全新指标")
  1168. + "</div></div>"
  1169. )
  1170. body = f'<div class="section doc">{_markdown_to_html(text)}</div>'
  1171. return _shell(
  1172. "Dragon 指标与策略原理说明",
  1173. hero
  1174. + quick
  1175. + _indicator_guide_visuals()
  1176. + _indicator_guide_indicator_cards()
  1177. + _indicator_guide_scene_table()
  1178. + _indicator_guide_threshold_tables()
  1179. + _indicator_guide_rule_tables(base_dir, hrefs)
  1180. + _indicator_guide_compare_table(base_dir)
  1181. + _indicator_guide_misconceptions(hrefs)
  1182. + body,
  1183. )
  1184. def build_index_html(base_dir: Path, archive_mode: bool = False) -> str:
  1185. overview = _load_csv(base_dir / "dragon_strategy_overview.csv")
  1186. status_df = _load_csv(base_dir / "dragon_daily_branch_status.csv")
  1187. s = _state(base_dir)
  1188. latest_bar_date = str(s.get("latest_bar_date", "latest"))
  1189. request_date = str(s.get("request_date", ""))
  1190. hrefs = _hrefs(latest_bar_date, archive_mode)
  1191. overview_map = {str(row["branch"]): row for _, row in overview.iterrows()}
  1192. rc1 = overview_map.get("alpha_first_glued_refined_hot_cap")
  1193. control = overview_map.get("alpha_first_selective_veto")
  1194. base = overview_map.get("workbook_preserving")
  1195. hero = f'{_nav(hrefs)}<div class="hero"><h1>Dragon 策略 HTML 总览</h1><p>这里是 dragon/v2 的统一 HTML 入口。当前覆盖 `399673` 的三条策略:重构基线、平衡对照、RC1 前向默认。直接打开日报和周报即可查看,不需要再翻 markdown。</p><div class="chips"><div class="chip">请求日期 {escape(request_date)}</div><div class="chip">最新交易日 {escape(latest_bar_date)}</div><div class="chip">标的 399673</div></div></div>'
  1196. summary = f'<div class="grid"><div class="panel"><h2>RC1 摘要</h2><div class="metrics">{_metric("RC1 年化", _fmt_pct(None if rc1 is None else rc1["cagr"]), "good")}{_metric("RC1 总收益", _fmt_pct(None if rc1 is None else rc1["compounded_return"]), "good")}{_metric("RC1 PF", _fmt_num(None if rc1 is None else rc1["profit_factor"]), "good")}{_metric("RC1 交易笔数", _fmt_int(None if rc1 is None else rc1["trades"]))}</div></div><div class="panel"><h2>分支对照</h2><div class="metrics">{_metric("control 年化", _fmt_pct(None if control is None else control["cagr"]))}{_metric("baseline 年化", _fmt_pct(None if base is None else base["cagr"]))}{_metric("RC1 BUY对齐", _fmt_int(None if rc1 is None else rc1["real_buy_overlap"]))}{_metric("RC1 SELL对齐", _fmt_int(None if rc1 is None else rc1["real_sell_overlap"]))}</div></div></div>'
  1197. links = f'<div class="section"><h2>可直接打开的文件</h2><table><thead><tr><th>页面</th><th>路径</th><th>说明</th></tr></thead><tbody><tr><td>总览首页</td><td>{escape(hrefs["home"])}</td><td>统一入口</td></tr><tr><td>每日报告</td><td>{escape(hrefs["daily"])}</td><td>三策略最新状态、监控、分歧</td></tr><tr><td>每周报告</td><td>{escape(hrefs["weekly"])}</td><td>前向观察周度汇总</td></tr><tr><td>历史明细</td><td>{escape(hrefs["detail"])}</td><td>逐笔核对历史交易与资金变化</td></tr><tr><td>指标原理</td><td>{escape(hrefs["guide"])}</td><td>面向普通投资者的指标与策略讲解</td></tr><tr><td>极简说明</td><td>{escape(hrefs["quickstart"])}</td><td>给非技术使用者看的快速上手</td></tr><tr><td>详细说明</td><td>{escape(hrefs["usage"])}</td><td>完整使用说明</td></tr><tr><td>日报归档</td><td>{escape(hrefs["daily_archive"])}</td><td>当日快照</td></tr><tr><td>周报归档</td><td>{escape(hrefs["weekly_archive"])}</td><td>当周快照</td></tr></tbody></table></div>'
  1198. cards = f'<div class="section"><h2>三策略对照</h2>{_strategy_cards(overview, status_df)}</div>'
  1199. return _shell("Dragon 策略 HTML 总览", hero + summary + cards + _index_charts(base_dir) + links)
  1200. def build_daily_html(base_dir: Path, archive_mode: bool = False) -> str:
  1201. overview = _load_csv(base_dir / "dragon_strategy_overview.csv")
  1202. branch_status = _load_csv(base_dir / "dragon_daily_branch_status.csv")
  1203. monitor = _load_csv(base_dir / "dragon_daily_monitor_snapshot.csv")
  1204. signal_change = _load_csv(base_dir / "dragon_signal_change_log.csv").tail(10).copy()
  1205. divergence = _load_csv(base_dir / "dragon_branch_divergence_log.csv").tail(10).copy()
  1206. s = _state(base_dir)
  1207. latest_bar_date = str(s.get("latest_bar_date", ""))
  1208. request_date = str(s.get("request_date", ""))
  1209. monitor_summary = s.get("monitor_summary", {}) if isinstance(s.get("monitor_summary"), dict) else {}
  1210. divergence_info = s.get("divergence", {}) if isinstance(s.get("divergence"), dict) else {}
  1211. hrefs = _hrefs(latest_bar_date, archive_mode)
  1212. hero = f'{_nav(hrefs)}<div class="hero"><h1>Dragon 每日报告</h1><p>这张页面用于看最新交易日的三策略状态、监控指标以及 refined vs control 的结构分歧。当前目标是做前向观察,不再继续做历史盲调。</p><div class="chips"><div class="chip">请求日期 {escape(request_date)}</div><div class="chip">最新交易日 {escape(latest_bar_date)}</div><div class="chip">RC1 前向默认</div></div></div>'
  1213. grid = f'<div class="grid"><div class="panel"><h2>系统监控</h2><div class="metrics">{_metric("预警数", _fmt_int(monitor_summary.get("warning_count", 0)), "warn")}{_metric("硬告警数", _fmt_int(monitor_summary.get("hard_breach_count", 0)), "risk")}{_metric("缺失指标数", _fmt_int(monitor_summary.get("missing_data_count", 0)), "risk")}{_metric("分歧等级", escape(str(divergence_info.get("divergence_level", "none"))))}{_metric("最新交易日", escape(latest_bar_date))}</div></div><div class="panel"><h2>RC1 相对优势</h2><div class="metrics">{_metric("next_open 平均收益差", _fmt_pct(divergence_info.get("next_open_avg_return_delta")), "good")}{_metric("next_open PF 差", _fmt_num(divergence_info.get("next_open_pf_delta")), "good")}{_metric("RC1 最大回撤", _fmt_pct(divergence_info.get("next_open_max_drawdown_refined")))}{_metric("RC1 最大亏损串", _fmt_int(divergence_info.get("next_open_max_loss_streak_refined")))}</div></div></div><div class="section"><h2>快速入口</h2><div class="metrics">' + _metric("历史明细", f'<a href="{escape(hrefs["detail"])}">打开明细页</a>') + _metric("详细说明", f'<a href="{escape(hrefs["usage"])}">打开说明页</a>') + "</div></div>"
  1214. if not overview.empty:
  1215. overview["策略"] = overview["branch"].map(_branch_name)
  1216. overview["角色"] = overview["branch"].map(lambda x: BRANCH_ROLES.get(str(x), ""))
  1217. overview = overview[["策略", "角色", "trades", "win_rate", "avg_return", "profit_factor", "compounded_return", "cagr", "real_buy_overlap", "real_sell_overlap"]].rename(columns={"trades": "交易笔数", "win_rate": "胜率", "avg_return": "单笔均值", "profit_factor": "PF", "compounded_return": "总收益", "cagr": "年化", "real_buy_overlap": "BUY对齐", "real_sell_overlap": "SELL对齐"})
  1218. sec1 = f'<div class="section"><h2>策略总览对照</h2>{_table(overview, {"胜率": _fmt_pct, "单笔均值": _fmt_pct, "PF": _fmt_num, "总收益": _fmt_pct, "年化": _fmt_pct})}</div>'
  1219. if not branch_status.empty:
  1220. branch_status["策略"] = branch_status["branch"].map(_branch_name)
  1221. branch_status["角色"] = branch_status["branch"].map(lambda x: BRANCH_ROLES.get(str(x), ""))
  1222. branch_status = branch_status[["策略", "角色", "as_of_date", "latest_close", "latest_a1", "latest_b1", "latest_c1", "latest_real_event_date", "latest_real_event_side", "latest_real_event_reason", "events_today_count", "events_today", "in_position", "open_entry_date", "open_entry_reason", "open_holding_days", "open_return_pct"]].rename(columns={"as_of_date": "交易日", "latest_close": "收盘", "latest_a1": "A1", "latest_b1": "B1", "latest_c1": "C1", "latest_real_event_date": "最近真实事件日", "latest_real_event_side": "最近真实事件方向", "latest_real_event_reason": "最近真实事件原因", "events_today_count": "当日事件数", "events_today": "当日事件", "in_position": "是否持仓", "open_entry_date": "持仓开仓日", "open_entry_reason": "持仓开仓原因", "open_holding_days": "持仓天数", "open_return_pct": "持仓浮盈亏"})
  1223. sec2 = f'<div class="section"><h2>最新分支状态</h2>{_table(branch_status, {"收盘": lambda v: _fmt_num(v, 3), "A1": lambda v: _fmt_num(v, 4), "B1": lambda v: _fmt_num(v, 4), "C1": lambda v: _fmt_num(v, 2), "是否持仓": _fmt_bool, "持仓天数": _fmt_int, "持仓浮盈亏": _fmt_pct})}</div>'
  1224. if not monitor.empty:
  1225. monitor = monitor[["metric", "actual_value", "warning_threshold", "hard_threshold", "status", "rationale"]].rename(columns={"metric": "指标", "actual_value": "当前值", "warning_threshold": "预警阈值", "hard_threshold": "硬阈值", "status": "状态", "rationale": "含义"})
  1226. sec3 = f'<div class="section"><h2>监控快照</h2>{_table(monitor, {"状态": _badge})}</div>'
  1227. if not signal_change.empty:
  1228. signal_change["策略"] = signal_change["branch"].map(_branch_name)
  1229. signal_change = signal_change[["latest_bar_date", "策略", "change_type", "old_value", "new_value", "reason"]].rename(columns={"latest_bar_date": "交易日", "change_type": "变化类型", "old_value": "旧值", "new_value": "新值", "reason": "原因"})
  1230. sec4 = f'<div class="section"><h2>最近信号变化</h2>{_table(signal_change)}</div>'
  1231. if not divergence.empty:
  1232. divergence = divergence[["latest_bar_date", "same_position_flag", "same_latest_real_event_flag", "next_open_avg_return_delta", "next_open_pf_delta", "divergence_level"]].rename(columns={"latest_bar_date": "交易日", "same_position_flag": "同仓位", "same_latest_real_event_flag": "同最新真实事件", "next_open_avg_return_delta": "收益差", "next_open_pf_delta": "PF差", "divergence_level": "分歧等级"})
  1233. sec5 = f'<div class="section"><h2>refined vs control 分歧记录</h2>{_table(divergence, {"同仓位": _fmt_bool, "同最新真实事件": _fmt_bool, "收益差": _fmt_pct, "PF差": _fmt_num, "分歧等级": _badge})}</div>'
  1234. return _shell("Dragon 每日报告", hero + grid + sec1 + sec2 + sec3 + sec4 + sec5)
  1235. def build_weekly_html(base_dir: Path, archive_mode: bool = False) -> str:
  1236. weekly = _load_csv(base_dir / "dragon_forward_weekly_summary.csv")
  1237. observation = _load_csv(base_dir / "dragon_forward_observation_log.csv").tail(12).copy()
  1238. divergence = _load_csv(base_dir / "dragon_branch_divergence_log.csv").tail(12).copy()
  1239. monitor_history = _load_csv(base_dir / "dragon_monitor_history.csv")
  1240. s = _state(base_dir)
  1241. latest_bar_date = str(s.get("latest_bar_date", ""))
  1242. warning_days = 0 if monitor_history.empty else int(monitor_history[monitor_history["status"] == "warning"]["latest_bar_date"].nunique())
  1243. hard_days = 0 if monitor_history.empty else int(monitor_history[monitor_history["status"] == "hard_breach"]["latest_bar_date"].nunique())
  1244. latest_divergence = "none" if divergence.empty else str(divergence.iloc[-1]["divergence_level"])
  1245. obs_days = 0 if weekly.empty else int(weekly["observation_days"].max())
  1246. hrefs = _hrefs(latest_bar_date, archive_mode)
  1247. hero = f'{_nav(hrefs)}<div class="hero"><h1>Dragon 每周报告</h1><p>这张页面汇总前向观察窗口的状态演化,主要看三策略有没有新事件、有没有持仓变化、监控是否连续告警,以及 refined 与 control 是否出现结构性分歧。</p><div class="chips"><div class="chip">窗口末日 {escape(latest_bar_date)}</div><div class="chip">观察样本日 {obs_days}</div><div class="chip">最新分歧等级 {escape(latest_divergence)}</div></div></div>'
  1248. grid = f'<div class="grid"><div class="panel"><h2>监控摘要</h2><div class="metrics">{_metric("预警日数", _fmt_int(warning_days), "warn")}{_metric("硬告警日数", _fmt_int(hard_days), "risk")}{_metric("观察样本日", _fmt_int(obs_days))}{_metric("最新分歧等级", escape(latest_divergence))}</div></div><div class="panel"><h2>当前口径</h2><div class="callout">当前阶段的目标是前向观察 RC1,不是继续做历史对齐。只有在连续 warning、hard breach 或 material divergence 出现时,才重新进入研究优化。</div></div></div>'
  1249. if not weekly.empty:
  1250. weekly["策略"] = weekly["branch"].map(_branch_name)
  1251. weekly["角色"] = weekly["branch"].map(lambda x: BRANCH_ROLES.get(str(x), ""))
  1252. weekly = weekly[["策略", "角色", "window_start", "window_end", "observation_days", "days_in_position", "latest_real_event_changed_count", "new_event_days", "warning_days", "hard_breach_days", "material_divergence_days"]].rename(columns={"window_start": "窗口开始", "window_end": "窗口结束", "observation_days": "观察日数", "days_in_position": "持仓日数", "latest_real_event_changed_count": "真实事件变更次数", "new_event_days": "新事件日数", "warning_days": "预警日数", "hard_breach_days": "硬告警日数", "material_divergence_days": "显著分歧日数"})
  1253. sec1 = f'<div class="section"><h2>周度汇总</h2>{_table(weekly)}</div>'
  1254. if not observation.empty:
  1255. if "monitor_missing_data_count" not in observation.columns:
  1256. observation["monitor_missing_data_count"] = 0
  1257. observation["策略"] = observation["branch"].map(_branch_name)
  1258. observation = observation[["run_timestamp", "latest_bar_date", "策略", "in_position", "latest_real_event", "events_today_count", "monitor_warning_count", "monitor_hard_breach_count", "monitor_missing_data_count"]].rename(columns={"run_timestamp": "运行时间", "latest_bar_date": "交易日", "in_position": "是否持仓", "latest_real_event": "最近真实事件", "events_today_count": "当日事件数", "monitor_warning_count": "预警数", "monitor_hard_breach_count": "硬告警数", "monitor_missing_data_count": "缺失指标数"})
  1259. sec2 = f'<div class="section"><h2>最近观察记录</h2>{_table(observation, {"是否持仓": _fmt_bool})}</div>'
  1260. if not divergence.empty:
  1261. if "missing_data_count" not in divergence.columns:
  1262. divergence["missing_data_count"] = 0
  1263. divergence = divergence[["latest_bar_date", "same_position_flag", "same_latest_real_event_flag", "next_open_avg_return_delta", "next_open_pf_delta", "warning_count", "hard_breach_count", "missing_data_count", "divergence_level"]].rename(columns={"latest_bar_date": "交易日", "same_position_flag": "同仓位", "same_latest_real_event_flag": "同最新真实事件", "next_open_avg_return_delta": "收益差", "next_open_pf_delta": "PF差", "warning_count": "预警数", "hard_breach_count": "硬告警数", "missing_data_count": "缺失指标数", "divergence_level": "分歧等级"})
  1264. sec3 = f'<div class="section"><h2>refined vs control 分歧历史</h2>{_table(divergence, {"同仓位": _fmt_bool, "同最新真实事件": _fmt_bool, "收益差": _fmt_pct, "PF差": _fmt_num, "分歧等级": _badge})}</div>'
  1265. return _shell("Dragon 每周报告", hero + grid + sec1 + sec2 + sec3)
  1266. def build_historical_detail_html(base_dir: Path, archive_mode: bool = False) -> str:
  1267. details = _load_csv(base_dir / "dragon_historical_trade_details.csv")
  1268. s = _state(base_dir)
  1269. latest_bar_date = str(s.get("latest_bar_date", ""))
  1270. hrefs = _hrefs(latest_bar_date, archive_mode)
  1271. hero = (
  1272. f'{_nav(hrefs)}'
  1273. '<div class="hero"><h1>Dragon 历史交易全量明细</h1>'
  1274. '<p>这张页面用于核对完整历史交易流水。表中包含买卖时间、买卖价格、买卖触发条件、持有天数、单笔收益、交易前资金、单笔盈亏和交易后资金。</p>'
  1275. f'<div class="chips"><div class="chip">最新交易日 {escape(latest_bar_date)}</div><div class="chip">可用于日报直接跳转核对</div></div></div>'
  1276. )
  1277. if details.empty:
  1278. return _shell("Dragon 历史交易全量明细", hero + '<div class="section"><h2>历史明细</h2><div class="empty">暂无数据</div></div>')
  1279. details = details.copy()
  1280. details["策略"] = details["branch"].map(_branch_name)
  1281. details["buy_year"] = details["buy_date"].astype(str).str.slice(0, 4)
  1282. details["search_blob"] = (
  1283. details["策略"].astype(str)
  1284. + " "
  1285. + details["buy_date"].astype(str)
  1286. + " "
  1287. + details["sell_date"].astype(str)
  1288. + " "
  1289. + details["buy_reason"].astype(str)
  1290. + " "
  1291. + details["sell_reason"].astype(str)
  1292. ).str.lower()
  1293. trade_min_date = str(details["buy_date"].min())
  1294. trade_max_date = str(details["sell_date"].max())
  1295. indicator_snapshot, indicator_meta = _load_indicator_snapshot_for_report(base_dir, trade_min_date, trade_max_date)
  1296. indicator_payload = []
  1297. if not indicator_snapshot.empty:
  1298. indicator_payload = [
  1299. {
  1300. "date": row["date"].date().isoformat(),
  1301. "close": None if pd.isna(row["close"]) else round(float(row["close"]), 3),
  1302. "a1": None if pd.isna(row["a1"]) else round(float(row["a1"]), 4),
  1303. "b1": None if pd.isna(row["b1"]) else round(float(row["b1"]), 4),
  1304. "c1": None if pd.isna(row["c1"]) else round(float(row["c1"]), 2),
  1305. "kdj_buy": bool(row["kdj_buy"]),
  1306. "kdj_sell": bool(row["kdj_sell"]),
  1307. "ql_buy": bool(row["ql_buy"]),
  1308. "ql_sell": bool(row["ql_sell"]),
  1309. }
  1310. for _, row in indicator_snapshot.iterrows()
  1311. ]
  1312. indicator_payload_json = json.dumps(indicator_payload, ensure_ascii=False, separators=(",", ":"))
  1313. indicator_meta_json = json.dumps(indicator_meta, ensure_ascii=False, separators=(",", ":"))
  1314. event_payload = _load_strategy_events_for_report(base_dir, trade_min_date, trade_max_date)
  1315. event_payload_json = json.dumps(
  1316. [] if event_payload.empty else event_payload.to_dict(orient="records"),
  1317. ensure_ascii=False,
  1318. separators=(",", ":"),
  1319. )
  1320. coverage_suffix = "" if indicator_meta.get("coverage_ok") else "。当前覆盖不足时,超出范围的交易会显示缺失提示"
  1321. indicator_callout = (
  1322. '<div class="callout" style="margin-top:14px;">'
  1323. f'详情区的“前后 10 个交易日指标快照”当前使用 <strong>{escape(str(indicator_meta.get("source_label", "")))}</strong>,'
  1324. f'覆盖区间 <strong>{escape(str(indicator_meta.get("coverage_start", "")))}</strong> 至 '
  1325. f'<strong>{escape(str(indicator_meta.get("coverage_end", "")))}</strong>{coverage_suffix}。'
  1326. "</div>"
  1327. )
  1328. summary_rows = []
  1329. for branch in BRANCH_ORDER:
  1330. sub = details[details["branch"] == branch].copy()
  1331. if sub.empty:
  1332. continue
  1333. summary_rows.append(
  1334. {
  1335. "策略": _branch_name(branch),
  1336. "交易数": len(sub),
  1337. "首笔买入": sub["buy_date"].min(),
  1338. "末笔卖出": sub["sell_date"].max(),
  1339. "期末资金": float(sub["capital_after"].iloc[-1]),
  1340. "累计收益": float(sub["capital_after"].iloc[-1] / sub["capital_before"].iloc[0] - 1.0),
  1341. }
  1342. )
  1343. summary_df = pd.DataFrame(summary_rows)
  1344. summary_html = _table(summary_df, {"期末资金": lambda v: _fmt_num(v, 2), "累计收益": _fmt_pct})
  1345. branch_options = ['<option value="all">全部策略</option>'] + [
  1346. f'<option value="{escape(branch)}">{escape(_branch_name(branch))}</option>'
  1347. for branch in BRANCH_ORDER
  1348. if not details[details["branch"] == branch].empty
  1349. ]
  1350. year_values = sorted(details["buy_year"].dropna().unique().tolist())
  1351. year_options = ['<option value="all">全部年份</option>'] + [
  1352. f'<option value="{escape(str(year))}">{escape(str(year))}</option>' for year in year_values
  1353. ]
  1354. rows: list[str] = []
  1355. sorted_details = details.sort_values(["buy_date", "branch", "trade_no"]).reset_index(drop=True)
  1356. sorted_details["row_id"] = sorted_details.apply(lambda r: f'{r["branch"]}-{int(r["trade_no"])}', axis=1)
  1357. sorted_details["prev_row_id"] = sorted_details.groupby("branch")["row_id"].shift(1).fillna("")
  1358. sorted_details["next_row_id"] = sorted_details.groupby("branch")["row_id"].shift(-1).fillna("")
  1359. for _, row in sorted_details.iterrows():
  1360. row_id = str(row["row_id"])
  1361. prev_row_id = str(row["prev_row_id"])
  1362. next_row_id = str(row["next_row_id"])
  1363. buy_rule_href = _detail_query_href(
  1364. hrefs,
  1365. branch=str(row["branch"]),
  1366. keyword=str(row["buy_reason"]),
  1367. )
  1368. sell_rule_href = _detail_query_href(
  1369. hrefs,
  1370. branch=str(row["branch"]),
  1371. keyword=str(row["sell_reason"]),
  1372. )
  1373. year_href = _detail_query_href(
  1374. hrefs,
  1375. branch=str(row["branch"]),
  1376. year=str(row["buy_year"]),
  1377. )
  1378. pnl = float(row["pnl_amount"])
  1379. points = float(row["sell_price"]) - float(row["buy_price"])
  1380. capital_before = float(row["capital_before"])
  1381. units = 0.0 if float(row["buy_price"]) == 0 else capital_before / float(row["buy_price"])
  1382. holding_days = int(float(row["holding_days"]))
  1383. if holding_days <= 5:
  1384. holding_bucket = "00-05天"
  1385. elif holding_days <= 10:
  1386. holding_bucket = "06-10天"
  1387. elif holding_days <= 20:
  1388. holding_bucket = "11-20天"
  1389. elif holding_days <= 40:
  1390. holding_bucket = "21-40天"
  1391. else:
  1392. holding_bucket = "41天以上"
  1393. verdict = "盈利单" if pnl > 0 else ("亏损单" if pnl < 0 else "持平单")
  1394. verdict_tone = "good" if pnl > 0 else ("risk" if pnl < 0 else "plain")
  1395. prev_link = (
  1396. f'<a href="#trade-row-{escape(prev_row_id)}" class="neighbor-link" data-target-id="{escape(prev_row_id)}">上一笔同策略交易</a>'
  1397. if prev_row_id
  1398. else "无"
  1399. )
  1400. next_link = (
  1401. f'<a href="#trade-row-{escape(next_row_id)}" class="neighbor-link" data-target-id="{escape(next_row_id)}">下一笔同策略交易</a>'
  1402. if next_row_id
  1403. else "无"
  1404. )
  1405. cells = [
  1406. f'<button class="detail-toggle" type="button" data-detail-id="{escape(row_id)}">详情</button>',
  1407. escape(str(row["策略"])),
  1408. _fmt_int(row["trade_no"]),
  1409. escape(str(row["buy_date"])),
  1410. _fmt_num(row["buy_price"], 3),
  1411. escape(str(row["buy_reason"])),
  1412. escape(str(row["sell_date"])),
  1413. _fmt_num(row["sell_price"], 3),
  1414. escape(str(row["sell_reason"])),
  1415. _fmt_int(row["holding_days"]),
  1416. _fmt_pct(row["return_pct"]),
  1417. _fmt_num(row["capital_before"], 2),
  1418. _fmt_num(row["pnl_amount"], 2),
  1419. _fmt_num(row["capital_after"], 2),
  1420. ]
  1421. row_html = "".join(f"<td>{cell}</td>" for cell in cells)
  1422. rows.append(
  1423. f'<tr id="trade-row-{escape(row_id)}" class="detail-row" data-branch="{escape(str(row["branch"]))}" '
  1424. f'data-year="{escape(str(row["buy_year"]))}" '
  1425. f'data-search="{escape(str(row["search_blob"]))}" '
  1426. f'data-row-id="{escape(row_id)}">{row_html}</tr>'
  1427. )
  1428. detail_inner = (
  1429. '<div class="grid" style="margin-bottom:0;">'
  1430. f'{_metric("交易结论", escape(verdict), verdict_tone)}'
  1431. f'{_metric("指数点差", _fmt_num(points, 3))}'
  1432. f'{_metric("等效份额", _fmt_num(units, 2))}'
  1433. f'{_metric("持有分组", escape(holding_bucket))}'
  1434. "</div>"
  1435. '<div class="grid" style="margin-top:12px;margin-bottom:0;">'
  1436. + _metric("同买入规则", f'<a href="{escape(buy_rule_href)}">查看同类买点明细</a>')
  1437. + _metric("同卖出规则", f'<a href="{escape(sell_rule_href)}">查看同类卖点明细</a>')
  1438. + _metric("同策略同年份", f'<a href="{escape(year_href)}">查看同年度同策略</a>')
  1439. + _metric("回到全量", f'<a href="{escape(hrefs["detail"])}">查看完整明细</a>')
  1440. + "</div>"
  1441. + '<div class="grid" style="margin-top:12px;margin-bottom:0;">'
  1442. + _metric("上一笔", prev_link)
  1443. + _metric("下一笔", next_link)
  1444. + "</div>"
  1445. + '<div class="callout" style="margin-top:12px;">'
  1446. f'买入规则:<code>{escape(str(row["buy_reason"]))}</code>。'
  1447. f' 卖出规则:<code>{escape(str(row["sell_reason"]))}</code>。'
  1448. f' 本笔交易从 <strong>{escape(str(row["buy_date"]))}</strong> 持有到 <strong>{escape(str(row["sell_date"]))}</strong>,'
  1449. f' 持有 <strong>{holding_days}</strong> 天,收益 <strong>{_fmt_pct(row["return_pct"])}</strong>,'
  1450. f' 资金从 <strong>{_fmt_num(row["capital_before"], 2)}</strong> 变化到 <strong>{_fmt_num(row["capital_after"], 2)}</strong>。'
  1451. '</div>'
  1452. + '<div class="snapshot-grid">'
  1453. + (
  1454. '<div class="snapshot-card">'
  1455. '<div class="snapshot-title">买入日前后 10 个交易日</div>'
  1456. f'<div class="snapshot-rule">事件规则:<code>{escape(str(row["buy_reason"]))}</code></div>'
  1457. f'<div class="snapshot-host" data-event-date="{escape(str(row["buy_date"]))}" data-marker-label="买入日">'
  1458. '<div class="empty">展开后加载指标快照</div>'
  1459. '</div></div>'
  1460. )
  1461. + (
  1462. '<div class="snapshot-card">'
  1463. '<div class="snapshot-title">卖出日前后 10 个交易日</div>'
  1464. f'<div class="snapshot-rule">事件规则:<code>{escape(str(row["sell_reason"]))}</code></div>'
  1465. f'<div class="snapshot-host" data-event-date="{escape(str(row["sell_date"]))}" data-marker-label="卖出日">'
  1466. '<div class="empty">展开后加载指标快照</div>'
  1467. '</div></div>'
  1468. )
  1469. + '</div>'
  1470. )
  1471. rows.append(
  1472. f'<tr class="detail-extra" data-parent-id="{escape(row_id)}" style="display:none;">'
  1473. f'<td colspan="14"><div class="panel">{detail_inner}</div></td></tr>'
  1474. )
  1475. detail_table = (
  1476. '<table id="detail-table"><thead><tr>'
  1477. "<th>操作</th><th>策略</th><th>序号</th><th>买入日期</th><th>买入价格</th><th>买入触发条件</th>"
  1478. "<th>卖出日期</th><th>卖出价格</th><th>卖出触发条件</th><th>持有天数</th>"
  1479. "<th>单笔收益</th><th>交易前资金</th><th>单笔盈亏</th><th>交易后资金</th>"
  1480. "</tr></thead><tbody>"
  1481. + "".join(rows)
  1482. + "</tbody></table>"
  1483. )
  1484. filter_section = f"""
  1485. <div class="section">
  1486. <h2>筛选与搜索</h2>
  1487. <div id="filter-origin" class="callout" style="display:none;margin-bottom:16px;"></div>
  1488. <div class="grid">
  1489. <div class="panel">
  1490. <h2>筛选条件</h2>
  1491. <div class="metrics">
  1492. <div class="metric"><div class="metric-label">策略</div><div class="metric-value"><select id="branch-filter">{''.join(branch_options)}</select></div></div>
  1493. <div class="metric"><div class="metric-label">年份</div><div class="metric-value"><select id="year-filter">{''.join(year_options)}</select></div></div>
  1494. <div class="metric"><div class="metric-label">关键词</div><div class="metric-value"><input id="keyword-filter" type="text" placeholder="输入日期、触发条件、策略名"></div></div>
  1495. <div class="metric good"><div class="metric-label">当前结果数</div><div class="metric-value" id="result-count">{len(sorted_details)}</div></div>
  1496. </div>
  1497. <div class="callout" style="margin-top:14px;">如果你是从指标原理页点进来的,这里会自动带好规则名和策略筛选。<a id="clear-filters-link" href="{escape(hrefs["detail"])}" style="margin-left:8px;">清空筛选,查看完整明细</a></div>
  1498. </div>
  1499. </div>
  1500. </div>
  1501. """
  1502. detail_section = f'<div class="section"><h2>历史全量明细</h2>{detail_table}</div>'
  1503. script = f"""
  1504. <script>
  1505. (function() {{
  1506. const branchFilter = document.getElementById('branch-filter');
  1507. const yearFilter = document.getElementById('year-filter');
  1508. const keywordFilter = document.getElementById('keyword-filter');
  1509. const rows = Array.from(document.querySelectorAll('.detail-row'));
  1510. const detailRows = Array.from(document.querySelectorAll('.detail-extra'));
  1511. const toggleButtons = Array.from(document.querySelectorAll('.detail-toggle'));
  1512. const neighborLinks = Array.from(document.querySelectorAll('.neighbor-link'));
  1513. const countNode = document.getElementById('result-count');
  1514. const originNode = document.getElementById('filter-origin');
  1515. const clearLink = document.getElementById('clear-filters-link');
  1516. const params = new URLSearchParams(window.location.search);
  1517. const indicatorRows = {indicator_payload_json};
  1518. const indicatorMeta = {indicator_meta_json};
  1519. const eventRows = {event_payload_json};
  1520. const indicatorIndexByDate = new Map(indicatorRows.map((row, index) => [row.date, index]));
  1521. const eventMap = new Map();
  1522. eventRows.forEach((row) => {{
  1523. const key = `${{row.branch}}|${{row.date}}`;
  1524. if (!eventMap.has(key)) eventMap.set(key, []);
  1525. eventMap.get(key).push(row);
  1526. }});
  1527. function escapeHtml(text) {{
  1528. return String(text)
  1529. .replace(/&/g, '&amp;')
  1530. .replace(/</g, '&lt;')
  1531. .replace(/>/g, '&gt;')
  1532. .replace(/"/g, '&quot;')
  1533. .replace(/'/g, '&#39;');
  1534. }}
  1535. function fmtNum(value, digits) {{
  1536. if (value === null || value === undefined || Number.isNaN(Number(value))) return '';
  1537. return Number(value).toFixed(digits);
  1538. }}
  1539. function signalLabel(buyFlag, sellFlag) {{
  1540. if (buyFlag && sellFlag) return 'B/S';
  1541. if (buyFlag) return 'B';
  1542. if (sellFlag) return 'S';
  1543. return '';
  1544. }}
  1545. function eventBadge(evt) {{
  1546. const cls = evt.layer === 'real_trade'
  1547. ? (evt.side === 'BUY' ? 'real-buy' : 'real-sell')
  1548. : (evt.side === 'BUY' ? 'aux-buy' : 'aux-sell');
  1549. const label = evt.layer === 'real_trade'
  1550. ? `真实${{evt.side}}`
  1551. : `辅助${{evt.side}}`;
  1552. return `<span class="trade-pill ${{cls}}">${{escapeHtml(label)}}</span>${{escapeHtml(evt.reason || '')}}`;
  1553. }}
  1554. function eventLabel(evt) {{
  1555. return evt.layer === 'real_trade'
  1556. ? `真实${{evt.side}}`
  1557. : `辅助${{evt.side}}`;
  1558. }}
  1559. function eventBucketKey(evt) {{
  1560. const prefix = evt.layer === 'real_trade' ? 'real_' : 'aux_';
  1561. return `${{prefix}}${{String(evt.side || '').toLowerCase()}}`;
  1562. }}
  1563. function eventSummaryLine(evt) {{
  1564. const reason = evt.reason ? ` ${{evt.reason}}` : '';
  1565. return `${{evt.date}} ${{eventLabel(evt)}}${{reason}}`;
  1566. }}
  1567. function renderWindowSummary(branch, eventDate, start, end) {{
  1568. const counts = {{
  1569. real_buy: 0,
  1570. real_sell: 0,
  1571. aux_buy: 0,
  1572. aux_sell: 0,
  1573. }};
  1574. const windowEvents = [];
  1575. indicatorRows.slice(start, end + 1).forEach((row) => {{
  1576. const dayEvents = eventMap.get(`${{branch}}|${{row.date}}`) || [];
  1577. dayEvents.forEach((evt) => {{
  1578. const enriched = {{
  1579. date: row.date,
  1580. side: evt.side || '',
  1581. layer: evt.layer || '',
  1582. reason: evt.reason || '',
  1583. }};
  1584. const bucket = eventBucketKey(enriched);
  1585. if (Object.prototype.hasOwnProperty.call(counts, bucket)) {{
  1586. counts[bucket] += 1;
  1587. }}
  1588. windowEvents.push(enriched);
  1589. }});
  1590. }});
  1591. const pills = [
  1592. `总事件 ${{windowEvents.length}}`,
  1593. `真实BUY ${{counts.real_buy}}`,
  1594. `真实SELL ${{counts.real_sell}}`,
  1595. `辅助BUY ${{counts.aux_buy}}`,
  1596. `辅助SELL ${{counts.aux_sell}}`,
  1597. ].map((label) => `<span class="summary-pill">${{escapeHtml(label)}}</span>`).join('');
  1598. if (!windowEvents.length) {{
  1599. return `
  1600. <div class="snapshot-summary">
  1601. ${{pills}}
  1602. <div>这个前后 10 个交易日窗口内暂时没有这条策略的其他事件。</div>
  1603. </div>
  1604. `;
  1605. }}
  1606. const beforeEvents = windowEvents.filter((evt) => evt.date < eventDate);
  1607. const sameDayEvents = windowEvents.filter((evt) => evt.date === eventDate);
  1608. const afterEvents = windowEvents.filter((evt) => evt.date > eventDate);
  1609. const beforeText = beforeEvents.length ? eventSummaryLine(beforeEvents[beforeEvents.length - 1]) : '无';
  1610. const sameDayText = sameDayEvents.length ? sameDayEvents.map(eventSummaryLine).join(';') : '无';
  1611. const afterText = afterEvents.length ? eventSummaryLine(afterEvents[0]) : '无';
  1612. return `
  1613. <div class="snapshot-summary">
  1614. <div>${{pills}}</div>
  1615. <div>前一条:${{escapeHtml(beforeText)}}</div>
  1616. <div>当日:${{escapeHtml(sameDayText)}}</div>
  1617. <div>后一条:${{escapeHtml(afterText)}}</div>
  1618. </div>
  1619. `;
  1620. }}
  1621. function renderIndicatorWindow(branch, eventDate, markerLabel) {{
  1622. if (!indicatorRows.length) {{
  1623. return '<div class="empty">当前没有可用的指标快照数据。</div>';
  1624. }}
  1625. const centerIndex = indicatorIndexByDate.get(eventDate);
  1626. if (centerIndex === undefined) {{
  1627. const coverage = indicatorMeta.coverage_start && indicatorMeta.coverage_end
  1628. ? `${{indicatorMeta.coverage_start}} 至 ${{indicatorMeta.coverage_end}}`
  1629. : '当前无可用覆盖';
  1630. return `<div class="empty">未找到 ${{escapeHtml(eventDate)}} 的指标数据。当前覆盖:${{escapeHtml(coverage)}}</div>`;
  1631. }}
  1632. const start = Math.max(0, centerIndex - 10);
  1633. const end = Math.min(indicatorRows.length - 1, centerIndex + 10);
  1634. const summaryHtml = renderWindowSummary(branch, eventDate, start, end);
  1635. const body = indicatorRows.slice(start, end + 1).map((row) => {{
  1636. const isEvent = row.date === eventDate;
  1637. const marker = isEvent ? `<span class="event-pill">${{escapeHtml(markerLabel)}}</span>` : '';
  1638. const kdj = signalLabel(row.kdj_buy, row.kdj_sell);
  1639. const ql = signalLabel(row.ql_buy, row.ql_sell);
  1640. const dayEvents = eventMap.get(`${{branch}}|${{row.date}}`) || [];
  1641. const eventHtml = dayEvents.length ? dayEvents.map(eventBadge).join('<br>') : '';
  1642. return `
  1643. <tr class="${{isEvent ? 'event-row' : ''}}">
  1644. <td>${{escapeHtml(row.date)}}</td>
  1645. <td>${{marker}}</td>
  1646. <td>${{fmtNum(row.close, 3)}}</td>
  1647. <td>${{fmtNum(row.a1, 4)}}</td>
  1648. <td>${{fmtNum(row.b1, 4)}}</td>
  1649. <td>${{fmtNum(row.c1, 2)}}</td>
  1650. <td>${{kdj ? `<span class="signal-pill">KDJ ${{escapeHtml(kdj)}}</span>` : ''}}</td>
  1651. <td>${{ql ? `<span class="signal-pill">QL ${{escapeHtml(ql)}}</span>` : ''}}</td>
  1652. <td>${{eventHtml}}</td>
  1653. </tr>
  1654. `;
  1655. }}).join('');
  1656. return `
  1657. <div class="mini-table-wrap">
  1658. <table class="mini-table">
  1659. <thead>
  1660. <tr>
  1661. <th>date</th>
  1662. <th>标记</th>
  1663. <th>close</th>
  1664. <th>a1</th>
  1665. <th>b1</th>
  1666. <th>c1</th>
  1667. <th>KDJ</th>
  1668. <th>QL</th>
  1669. <th>策略事件</th>
  1670. </tr>
  1671. </thead>
  1672. <tbody>${{body}}</tbody>
  1673. </table>
  1674. </div>
  1675. ${{summaryHtml}}
  1676. <div class="snapshot-note">close 为收盘价;a1 为趋势斜率;b1 为动量差;c1 为位置温度;KDJ/QL 为指标 B/S;策略事件按当前这条策略分支显示真实交易与辅助信号。</div>
  1677. `;
  1678. }}
  1679. function ensureSnapshots(rowId) {{
  1680. const detailRow = document.querySelector(`.detail-extra[data-parent-id="${{rowId}}"]`);
  1681. if (!detailRow || detailRow.dataset.snapshotReady === '1') return;
  1682. const branch = document.querySelector(`.detail-row[data-row-id="${{rowId}}"]`)?.dataset.branch || '';
  1683. detailRow.querySelectorAll('.snapshot-host').forEach((host) => {{
  1684. host.innerHTML = renderIndicatorWindow(branch, host.dataset.eventDate || '', host.dataset.markerLabel || '事件日');
  1685. }});
  1686. detailRow.dataset.snapshotReady = '1';
  1687. }}
  1688. function closeAllDetails() {{
  1689. detailRows.forEach((row) => {{
  1690. row.style.display = 'none';
  1691. row.dataset.open = '0';
  1692. }});
  1693. toggleButtons.forEach((btn) => {{
  1694. btn.textContent = '详情';
  1695. }});
  1696. }}
  1697. function openDetail(rowId, shouldScroll) {{
  1698. if (!rowId) return;
  1699. const mainRow = document.querySelector(`.detail-row[data-row-id="${{rowId}}"]`);
  1700. const detailRow = document.querySelector(`.detail-extra[data-parent-id="${{rowId}}"]`);
  1701. const button = document.querySelector(`.detail-toggle[data-detail-id="${{rowId}}"]`);
  1702. if (!mainRow || !detailRow || mainRow.style.display === 'none') return;
  1703. closeAllDetails();
  1704. ensureSnapshots(rowId);
  1705. detailRow.style.display = '';
  1706. detailRow.dataset.open = '1';
  1707. if (button) button.textContent = '收起';
  1708. if (shouldScroll) {{
  1709. mainRow.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
  1710. }}
  1711. }}
  1712. function applyFilters() {{
  1713. const branchValue = branchFilter.value;
  1714. const yearValue = yearFilter.value;
  1715. const keywordValue = keywordFilter.value.trim().toLowerCase();
  1716. let visible = 0;
  1717. rows.forEach((row) => {{
  1718. const matchBranch = branchValue === 'all' || row.dataset.branch === branchValue;
  1719. const matchYear = yearValue === 'all' || row.dataset.year === yearValue;
  1720. const matchKeyword = !keywordValue || row.dataset.search.includes(keywordValue);
  1721. const show = matchBranch && matchYear && matchKeyword;
  1722. row.style.display = show ? '' : 'none';
  1723. const detailRow = document.querySelector(`.detail-extra[data-parent-id="${{row.dataset.rowId}}"]`);
  1724. if (!show && detailRow) {{
  1725. detailRow.style.display = 'none';
  1726. detailRow.dataset.open = '0';
  1727. const btn = row.querySelector('.detail-toggle');
  1728. if (btn) btn.textContent = '详情';
  1729. }}
  1730. if (show) visible += 1;
  1731. }});
  1732. countNode.textContent = String(visible);
  1733. }}
  1734. const presetBranch = params.get('branch');
  1735. const presetYear = params.get('year');
  1736. const presetKeyword = params.get('keyword');
  1737. if (presetBranch && Array.from(branchFilter.options).some((opt) => opt.value === presetBranch)) {{
  1738. branchFilter.value = presetBranch;
  1739. }}
  1740. if (presetYear && Array.from(yearFilter.options).some((opt) => opt.value === presetYear)) {{
  1741. yearFilter.value = presetYear;
  1742. }}
  1743. if (presetKeyword) {{
  1744. keywordFilter.value = presetKeyword;
  1745. }}
  1746. const originParts = [];
  1747. if (presetBranch) {{
  1748. const branchLabel = branchFilter.options[branchFilter.selectedIndex] ? branchFilter.options[branchFilter.selectedIndex].text : presetBranch;
  1749. originParts.push(`策略:${{branchLabel}}`);
  1750. }}
  1751. if (presetYear) {{
  1752. originParts.push(`年份:${{presetYear}}`);
  1753. }}
  1754. if (presetKeyword) {{
  1755. originParts.push(`关键词:${{presetKeyword}}`);
  1756. }}
  1757. if (originParts.length) {{
  1758. originNode.style.display = '';
  1759. originNode.innerHTML = `当前来自外部跳转筛选:${{originParts.join(' | ')}}。你可以直接核对下方明细,或点击右下方“清空筛选”。`;
  1760. }}
  1761. if (clearLink) {{
  1762. clearLink.href = window.location.pathname;
  1763. }}
  1764. branchFilter.addEventListener('change', applyFilters);
  1765. yearFilter.addEventListener('change', applyFilters);
  1766. keywordFilter.addEventListener('input', applyFilters);
  1767. toggleButtons.forEach((btn) => {{
  1768. btn.addEventListener('click', () => {{
  1769. const rowId = btn.dataset.detailId;
  1770. const detailRow = document.querySelector(`.detail-extra[data-parent-id="${{rowId}}"]`);
  1771. if (!detailRow) return;
  1772. if (detailRow.dataset.open === '1') {{
  1773. closeAllDetails();
  1774. return;
  1775. }}
  1776. openDetail(rowId, false);
  1777. }});
  1778. }});
  1779. neighborLinks.forEach((link) => {{
  1780. link.addEventListener('click', (event) => {{
  1781. event.preventDefault();
  1782. const targetId = link.dataset.targetId;
  1783. if (!targetId) return;
  1784. history.replaceState(null, '', `#trade-row-${{targetId}}`);
  1785. openDetail(targetId, true);
  1786. }});
  1787. }});
  1788. applyFilters();
  1789. if (window.location.hash.startsWith('#trade-row-')) {{
  1790. const targetId = window.location.hash.replace('#trade-row-', '');
  1791. openDetail(targetId, true);
  1792. }}
  1793. }})();
  1794. </script>
  1795. """
  1796. return _shell("Dragon 历史交易全量明细", hero + f'<div class="section"><h2>明细摘要</h2>{summary_html}</div>' + filter_section + detail_section + script)
  1797. def main() -> None:
  1798. base_dir = Path(__file__).resolve().parent
  1799. html_dir = base_dir / "html_reports"
  1800. html_dir.mkdir(exist_ok=True)
  1801. latest_bar_date = str(_state(base_dir).get("latest_bar_date", "latest"))
  1802. (base_dir / "dragon_reports_index.html").write_text(build_index_html(base_dir, False), encoding="utf-8")
  1803. (base_dir / "dragon_daily_signal_report.html").write_text(build_daily_html(base_dir, False), encoding="utf-8")
  1804. (base_dir / "dragon_forward_weekly_review.html").write_text(build_weekly_html(base_dir, False), encoding="utf-8")
  1805. (base_dir / "dragon_historical_trade_details.html").write_text(build_historical_detail_html(base_dir, False), encoding="utf-8")
  1806. (base_dir / "dragon_indicator_strategy_guide_cn.html").write_text(build_indicator_guide_html(base_dir, False), encoding="utf-8")
  1807. (base_dir / "dragon_html_report_usage_cn.html").write_text(
  1808. build_doc_html(base_dir, "dragon_html_report_usage_cn.md", "Dragon HTML 报告使用说明", "完整说明,适合首次接触这套报告时阅读。", False),
  1809. encoding="utf-8",
  1810. )
  1811. (base_dir / "dragon_html_report_quickstart_cn.html").write_text(
  1812. build_doc_html(base_dir, "dragon_html_report_quickstart_cn.md", "Dragon HTML 报告极简说明", "面向非技术使用者,只保留最核心的打开与更新步骤。", False),
  1813. encoding="utf-8",
  1814. )
  1815. (html_dir / "index.html").write_text(build_index_html(base_dir, True), encoding="utf-8")
  1816. (html_dir / f"dragon_reports_index_{latest_bar_date}.html").write_text(build_index_html(base_dir, True), encoding="utf-8")
  1817. (html_dir / f"dragon_daily_signal_report_{latest_bar_date}.html").write_text(build_daily_html(base_dir, True), encoding="utf-8")
  1818. (html_dir / f"dragon_forward_weekly_review_{latest_bar_date}.html").write_text(build_weekly_html(base_dir, True), encoding="utf-8")
  1819. (html_dir / f"dragon_historical_trade_details_{latest_bar_date}.html").write_text(build_historical_detail_html(base_dir, True), encoding="utf-8")
  1820. (html_dir / "dragon_indicator_strategy_guide_cn.html").write_text(build_indicator_guide_html(base_dir, True), encoding="utf-8")
  1821. (html_dir / "dragon_html_report_usage_cn.html").write_text(
  1822. build_doc_html(base_dir, "dragon_html_report_usage_cn.md", "Dragon HTML 报告使用说明", "完整说明,适合首次接触这套报告时阅读。", True),
  1823. encoding="utf-8",
  1824. )
  1825. (html_dir / "dragon_html_report_quickstart_cn.html").write_text(
  1826. build_doc_html(base_dir, "dragon_html_report_quickstart_cn.md", "Dragon HTML 报告极简说明", "面向非技术使用者,只保留最核心的打开与更新步骤。", True),
  1827. encoding="utf-8",
  1828. )
  1829. if __name__ == "__main__":
  1830. main()