young-stock-cli 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/PKG-INFO +22 -1
  2. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/README.md +21 -0
  3. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/pyproject.toml +1 -1
  4. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/__init__.py +1 -1
  5. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/_core.py +75 -9
  6. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/artifacts.py +35 -0
  7. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/cli.py +10 -5
  8. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/llm.py +36 -2
  9. young_stock_cli-0.2.3/src/young_stock/market_routes.py +60 -0
  10. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/pdf.py +6 -4
  11. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/reports.py +6 -3
  12. young_stock_cli-0.2.3/src/young_stock/research_style.py +176 -0
  13. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/.gitignore +0 -0
  14. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/LICENSE +0 -0
  15. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/calendar.py +0 -0
  16. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/__init__.py +0 -0
  17. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/base.py +0 -0
  18. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/feishu.py +0 -0
  19. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/chat.py +0 -0
  20. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/config.py +0 -0
  21. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/evidence.py +0 -0
  22. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/health.py +0 -0
  23. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/local_store.py +0 -0
  24. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/methodology.py +0 -0
  25. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/profile.py +0 -0
  26. {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/templates/equity-report.html +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: young-stock-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data.
5
5
  Project-URL: Homepage, https://github.com/AdvancingTitans/young-stock-cli
6
6
  Project-URL: Repository, https://github.com/AdvancingTitans/young-stock-cli
@@ -269,6 +269,27 @@ checks. Markdown, metadata, and `evidence.json` are retained under:
269
269
  ~/.young_stock/reports/YYYYMMDD/
270
270
  ```
271
271
 
272
+ Before the model sees any data, young converts internal evidence into research-only Chinese terminology. The same
273
+ conversion is applied to the downloaded stock-analysis methodology, so implementation guidance never enters the
274
+ report-writing context. The returned Markdown is reviewed again before it can be saved or exported. Formal reports
275
+ use only these source phrases:
276
+
277
+ - normal data: `据公开市场数据`, `据交易所及财经终端披露`
278
+ - missing data: `该指标当日未披露`, `历史数据不可得`, `本模块证据暂缺`
279
+ - historical lookback: `按惯例回溯至该日`, `历史口径回溯`
280
+
281
+ Report artifacts include the date, market session, and topic:
282
+
283
+ ```text
284
+ 20260618-早盘-A股深度复盘.md
285
+ 20260618-盘中-A股深度复盘.md
286
+ 20260618-盘后-A股深度复盘.md
287
+ 20260618-盘后-600519深度分析.md
288
+ ```
289
+
290
+ Generating the same topic again in the same session replaces the previous artifact. A report from another session
291
+ is retained.
292
+
272
293
  ### Professional PDF reports
273
294
 
274
295
  Install the optional renderer:
@@ -234,6 +234,27 @@ checks. Markdown, metadata, and `evidence.json` are retained under:
234
234
  ~/.young_stock/reports/YYYYMMDD/
235
235
  ```
236
236
 
237
+ Before the model sees any data, young converts internal evidence into research-only Chinese terminology. The same
238
+ conversion is applied to the downloaded stock-analysis methodology, so implementation guidance never enters the
239
+ report-writing context. The returned Markdown is reviewed again before it can be saved or exported. Formal reports
240
+ use only these source phrases:
241
+
242
+ - normal data: `据公开市场数据`, `据交易所及财经终端披露`
243
+ - missing data: `该指标当日未披露`, `历史数据不可得`, `本模块证据暂缺`
244
+ - historical lookback: `按惯例回溯至该日`, `历史口径回溯`
245
+
246
+ Report artifacts include the date, market session, and topic:
247
+
248
+ ```text
249
+ 20260618-早盘-A股深度复盘.md
250
+ 20260618-盘中-A股深度复盘.md
251
+ 20260618-盘后-A股深度复盘.md
252
+ 20260618-盘后-600519深度分析.md
253
+ ```
254
+
255
+ Generating the same topic again in the same session replaces the previous artifact. A report from another session
256
+ is retained.
257
+
237
258
  ### Professional PDF reports
238
259
 
239
260
  Install the optional renderer:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "young-stock-cli"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -1,3 +1,3 @@
1
1
  """young-stock-cli: A-share after-hours CLI."""
2
2
 
3
- __version__ = "0.2.2"
3
+ __version__ = "0.2.3"
@@ -36,6 +36,7 @@ from typing import Any
36
36
 
37
37
  from .calendar import nearest_trade_date as calendar_nearest_trade_date
38
38
  from .health import SourceHealthBook
39
+ from .market_routes import route_board_data
39
40
 
40
41
  # ------------------------------------------------------------------
41
42
  # 配置
@@ -716,6 +717,32 @@ def fetch_ths_concept_money_flow_snapshot(date_str: str) -> dict[str, str]:
716
717
  return result
717
718
 
718
719
 
720
+ def fetch_browser_fund_flow_snapshot(date_str: str) -> dict[str, str]:
721
+ raw = _playwright_html(THS_CONCEPT_MONEY_FLOW_URL)
722
+ if not raw:
723
+ return {}
724
+ rows = _parse_ths_money_flow_table(raw)
725
+ top_in_rows = sorted(
726
+ [row for row in rows if float(row.get("net") or 0) > 0],
727
+ key=lambda item: float(item.get("net") or 0),
728
+ reverse=True,
729
+ )[:5]
730
+ top_out_rows = sorted(
731
+ [row for row in rows if float(row.get("net") or 0) < 0],
732
+ key=lambda item: float(item.get("net") or 0),
733
+ )[:5]
734
+ if not top_in_rows or not top_out_rows:
735
+ return {}
736
+ return {
737
+ "date": _display_date(nearest_trade_date()),
738
+ "_source": "公开财经页面概念资金流",
739
+ "_scope": "A股",
740
+ "_fallback_indicator": "concept_money_flow",
741
+ "_concept_in": json.dumps(top_in_rows, ensure_ascii=False),
742
+ "_concept_out": json.dumps(top_out_rows, ensure_ascii=False),
743
+ }
744
+
745
+
719
746
  def _market_activity_snapshot(rows: list[dict[str, Any]], source: str, date_str: str) -> dict[str, str]:
720
747
  usable = [r for r in rows if r.get("f12") in {"000001", "399001"}]
721
748
  if not usable:
@@ -1995,8 +2022,15 @@ def get_fund_flow(date_str: str, *, strict_date: bool = True) -> dict[str, str]:
1995
2022
  latest_result["_date_note"] = "latest_available"
1996
2023
  cache_save("fund_flow", date_str, "eastmoney", latest_result)
1997
2024
  return latest_result
1998
- for fallback in (fetch_sina_sector_money_flow_snapshot, fetch_sina_market_activity_snapshot, fetch_tencent_market_activity_snapshot):
1999
- activity = fallback(date_str)
2025
+ for online_reference in (fetch_sina_sector_money_flow_snapshot,):
2026
+ activity = online_reference(date_str)
2027
+ if activity:
2028
+ return activity
2029
+ browser_flow = fetch_browser_fund_flow_snapshot(date_str)
2030
+ if browser_flow:
2031
+ return browser_flow
2032
+ for market_reference in (fetch_sina_market_activity_snapshot, fetch_tencent_market_activity_snapshot):
2033
+ activity = market_reference(date_str)
2000
2034
  if activity:
2001
2035
  return activity
2002
2036
  cached_latest = load_latest_fund_flow_cache(date_str)
@@ -2171,13 +2205,15 @@ def fetch_eastmoney_board_list(board_type: str, date_str: str, limit: int = 100)
2171
2205
 
2172
2206
  def get_board_list(board_type: str, date_str: str, limit: int = 100) -> dict[str, Any]:
2173
2207
  """Return board rankings through the stock-analysis source order."""
2174
- result = fetch_eastmoney_board_list(board_type, date_str, limit=limit)
2175
- if result.get("rows"):
2176
- return result
2177
- browser_result = camofox_board_list(board_type)
2178
- if browser_result.get("rows"):
2179
- return browser_result
2180
- return result
2208
+ return route_board_data(
2209
+ board_type,
2210
+ date_str,
2211
+ direct=fetch_eastmoney_board_list,
2212
+ camofox=camofox_board_list,
2213
+ playwright=playwright_board_list,
2214
+ limit=limit,
2215
+ current_trade_date=nearest_trade_date(),
2216
+ )
2181
2217
 
2182
2218
 
2183
2219
  @retry_on_recoverable(max_retries=MAX_RETRIES, initial_delay=INITIAL_BACKOFF)
@@ -2751,6 +2787,36 @@ def _parse_camofox_board_snapshot(markdown: str) -> list[dict[str, Any]]:
2751
2787
  return rows
2752
2788
 
2753
2789
 
2790
+ def _playwright_html(url: str) -> str:
2791
+ try:
2792
+ from playwright.sync_api import sync_playwright
2793
+ except ImportError:
2794
+ return ""
2795
+ try:
2796
+ with sync_playwright() as runtime:
2797
+ browser = runtime.chromium.launch(headless=True)
2798
+ page = browser.new_page()
2799
+ page.goto(url, wait_until="networkidle", timeout=20_000)
2800
+ content = page.content()
2801
+ browser.close()
2802
+ return content
2803
+ except Exception as exc:
2804
+ diag(f"Browser page unavailable: {exc}")
2805
+ return ""
2806
+
2807
+
2808
+ def playwright_board_list(board_type: str = "industry") -> dict[str, Any]:
2809
+ anchor = "industry_board" if board_type == "industry" else "concept_board"
2810
+ html_text = _playwright_html(f"https://quote.eastmoney.com/center/gridlist.html#{anchor}")
2811
+ if not html_text:
2812
+ return {"board_type": board_type, "rows": [], "_unavailable": "browser unavailable"}
2813
+ text = re.sub(r"<[^>]+>", " ", html.unescape(html_text))
2814
+ rows = _parse_camofox_board_snapshot(
2815
+ "\n".join(f'row "{line.strip()}"' for line in text.splitlines() if re.match(r"^\s*\d+\s+", line))
2816
+ )
2817
+ return {"board_type": board_type, "rows": rows, "count": len(rows), "_source": "公开财经页面"}
2818
+
2819
+
2754
2820
  def camofox_board_list(board_type: str = "industry") -> dict[str, Any]:
2755
2821
  base = os.environ.get("CAMOFOX_URL", "http://localhost:9377")
2756
2822
  user_id = os.environ.get("CAMOFOX_USER_ID", "")
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import re
6
7
  from dataclasses import dataclass
7
8
  from datetime import datetime
8
9
  from pathlib import Path
@@ -11,6 +12,30 @@ from typing import Any
11
12
  from .local_store import young_home
12
13
 
13
14
 
15
+ def market_session(now: datetime | None = None) -> str:
16
+ now = now or datetime.now()
17
+ minute = now.hour * 60 + now.minute
18
+ if 9 * 60 <= minute < 9 * 60 + 30:
19
+ return "早盘"
20
+ if 11 * 60 + 30 <= minute < 13 * 60:
21
+ return "午间"
22
+ if 9 * 60 + 30 <= minute < 11 * 60 + 30 or 13 * 60 <= minute < 15 * 60:
23
+ return "盘中"
24
+ return "盘后"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ReportIdentity:
29
+ trade_date: str
30
+ session: str
31
+ topic: str
32
+
33
+ @property
34
+ def prefix(self) -> str:
35
+ safe_topic = "".join(character for character in self.topic if character.isalnum() or character in "-_")
36
+ return f"{self.trade_date}-{self.session}-{safe_topic}"
37
+
38
+
14
39
  @dataclass
15
40
  class ReportArtifacts:
16
41
  trade_date: str
@@ -36,6 +61,9 @@ class ReportArtifacts:
36
61
  path.write_text(content.rstrip() + "\n", encoding="utf-8")
37
62
  return path
38
63
 
64
+ def write_report_markdown(self, identity: ReportIdentity, content: str) -> Path:
65
+ return self.write_markdown(identity.prefix, content)
66
+
39
67
  def write_json(self, name: str, data: dict[str, Any]) -> Path:
40
68
  path = self.path(name, "json")
41
69
  path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
@@ -48,6 +76,13 @@ class ReportArtifacts:
48
76
  candidates = list((reports / trade_date).glob("*.md"))
49
77
  else:
50
78
  candidates = list(reports.glob("*/*.md"))
79
+ identified = [
80
+ path
81
+ for path in candidates
82
+ if re.match(r"^\d{8}-(?:早盘|盘中|午间|盘后)-.+\.md$", path.name)
83
+ ]
84
+ if identified:
85
+ candidates = identified
51
86
  return max(candidates, key=lambda path: path.stat().st_mtime) if candidates else None
52
87
 
53
88
  @classmethod
@@ -10,7 +10,7 @@ from datetime import datetime
10
10
  import click
11
11
 
12
12
  from . import __version__, _core
13
- from .artifacts import ReportArtifacts
13
+ from .artifacts import ReportArtifacts, ReportIdentity, market_session
14
14
  from .config import (
15
15
  add_feishu_channel,
16
16
  config_path,
@@ -279,7 +279,10 @@ def _run_llm_replay(date_str: str, kind: str = "replay", symbol: str | None = No
279
279
  else build_daily_evidence(_core, date_str, profile)
280
280
  )
281
281
  artifacts = ReportArtifacts(date_str)
282
- artifacts.write_json("evidence" if not symbol else f"{symbol}-evidence", evidence.to_dict())
282
+ session = market_session()
283
+ topic = f"{symbol}深度分析" if symbol else "A股深度复盘"
284
+ identity = ReportIdentity(date_str, session, topic)
285
+ artifacts.write_json(f"{identity.prefix}-evidence", evidence.to_dict())
283
286
  config = load_config(strict=False).get("llm", {})
284
287
  methodology = sync_stock_analysis_methodology()
285
288
  try:
@@ -292,9 +295,11 @@ def _run_llm_replay(date_str: str, kind: str = "replay", symbol: str | None = No
292
295
  raise click.ClickException(str(exc)) from exc
293
296
  metadata["stock_analysis_version"] = methodology.version
294
297
  metadata["stock_analysis_updated"] = methodology.updated
295
- name = f"analyze-{symbol}" if symbol else kind
296
- path = artifacts.write_markdown(name, markdown)
297
- artifacts.write_metadata({"kind": name, **metadata})
298
+ path = artifacts.write_report_markdown(identity, markdown)
299
+ artifacts.write_json(
300
+ f"{identity.prefix}-metadata",
301
+ {"kind": kind, "session": session, "topic": topic, **metadata},
302
+ )
298
303
  from rich.console import Console
299
304
  from rich.markdown import Markdown
300
305
 
@@ -92,6 +92,7 @@ class LLMClient:
92
92
  def _post(self, url: str, **kwargs: Any) -> Any:
93
93
  timeout = float(self.config.get("timeout") or 60)
94
94
  last_error: Exception | None = None
95
+ provider = str(self.config.get("provider") or "").lower()
95
96
  for attempt in range(3):
96
97
  try:
97
98
  response = self.session.post(url, timeout=timeout, **kwargs)
@@ -105,7 +106,8 @@ class LLMClient:
105
106
  time.sleep(0.2 * (attempt + 1))
106
107
  continue
107
108
  if response.status_code in {401, 403}:
108
- raise LLMError("LLM 认证失败,请检查 API key 和 provider 配置。")
109
+ detail = self._error_detail(response)
110
+ raise LLMError(self._auth_error_message("LLM", provider, detail))
109
111
  if response.status_code >= 400:
110
112
  raise LLMError(f"LLM 请求失败(HTTP {response.status_code})")
111
113
  return response
@@ -113,16 +115,48 @@ class LLMClient:
113
115
 
114
116
  def _get(self, url: str, **kwargs: Any) -> Any:
115
117
  timeout = float(self.config.get("timeout") or 60)
118
+ provider = str(self.config.get("provider") or "").lower()
116
119
  try:
117
120
  response = self.session.get(url, timeout=timeout, **kwargs)
118
121
  except requests.RequestException as exc:
119
122
  raise LLMError(f"模型列表请求失败: {exc.__class__.__name__}") from exc
120
123
  if response.status_code in {401, 403}:
121
- raise LLMError("模型列表认证失败,请检查 API key、provider 和 api_base。")
124
+ detail = self._error_detail(response)
125
+ raise LLMError(self._auth_error_message("模型列表", provider, detail))
122
126
  if response.status_code >= 400:
123
127
  raise LLMError(f"模型列表请求失败(HTTP {response.status_code})")
124
128
  return response
125
129
 
130
+ def _error_detail(self, response: Any) -> str:
131
+ try:
132
+ payload = response.json()
133
+ except Exception:
134
+ payload = None
135
+ if isinstance(payload, dict):
136
+ error = payload.get("error")
137
+ if isinstance(error, dict):
138
+ message = error.get("message") or error.get("type") or error.get("code")
139
+ if message:
140
+ return str(message)
141
+ message = payload.get("message") or payload.get("detail") or payload.get("error_description")
142
+ if message:
143
+ return str(message)
144
+ text = str(getattr(response, "text", "") or "").strip()
145
+ return text[:160]
146
+
147
+ def _auth_error_message(self, surface: str, provider: str, detail: str) -> str:
148
+ message = f"{surface}认证失败,请检查 API key、provider 和 api_base。"
149
+ normalized = detail.strip()
150
+ if normalized:
151
+ message = f"{message} 服务端提示:{normalized}"
152
+ if provider in {"ark", "openai"}:
153
+ message = (
154
+ f"{message} 如使用 Ark,建议先运行 "
155
+ "`young config models --provider ark --api-key-env ARK_API_KEY` "
156
+ "或用 README 中的 curl 示例核对可用模型 ID。"
157
+ )
158
+ return message
159
+
126
160
  def _openai_compatible(
127
161
  self,
128
162
  api_base: str,
@@ -0,0 +1,60 @@
1
+ """Shared market-data routing for reports and terminal dashboards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Callable
6
+
7
+
8
+ def _rows(result: Any) -> bool:
9
+ return isinstance(result, dict) and bool(result.get("rows"))
10
+
11
+
12
+ def route_board_data(
13
+ board_type: str,
14
+ trade_date: str,
15
+ *,
16
+ direct: Callable[[str, str, int], dict[str, Any]],
17
+ camofox: Callable[[str], dict[str, Any]],
18
+ playwright: Callable[[str], dict[str, Any]],
19
+ limit: int = 100,
20
+ current_trade_date: str | None = None,
21
+ ) -> dict[str, Any]:
22
+ result = direct(board_type, trade_date, limit)
23
+ if _rows(result):
24
+ return result
25
+ if current_trade_date and trade_date != current_trade_date:
26
+ return {"board_type": board_type, "rows": [], "_unavailable": "历史数据不可得"}
27
+ for browser_source in (camofox, playwright):
28
+ candidate = browser_source(board_type)
29
+ if _rows(candidate):
30
+ return candidate
31
+ return result or {"board_type": board_type, "rows": [], "_unavailable": "本模块证据暂缺"}
32
+
33
+
34
+ def _flow_available(result: Any) -> bool:
35
+ if not isinstance(result, dict) or result.get("_error") or result.get("_unavailable"):
36
+ return False
37
+ return any(
38
+ key != "date" and not str(key).startswith("_") and value not in (None, "")
39
+ for key, value in result.items()
40
+ )
41
+
42
+
43
+ def route_fund_flow_data(
44
+ trade_date: str,
45
+ *,
46
+ online_sources: list[Callable[[str], dict[str, Any]]],
47
+ browser: Callable[[str], dict[str, Any]],
48
+ cached: Callable[[str], dict[str, Any]],
49
+ ) -> dict[str, Any]:
50
+ for source in online_sources:
51
+ result = source(trade_date)
52
+ if _flow_available(result):
53
+ return result
54
+ browser_result = browser(trade_date)
55
+ if _flow_available(browser_result):
56
+ return browser_result
57
+ cached_result = cached(trade_date)
58
+ if _flow_available(cached_result):
59
+ return cached_result
60
+ return {"date": trade_date, "_unavailable": "本模块证据暂缺"}
@@ -13,7 +13,7 @@ from importlib import resources
13
13
  from pathlib import Path
14
14
  from typing import Any, Callable
15
15
 
16
- from .artifacts import ReportArtifacts
16
+ from .artifacts import ReportArtifacts, ReportIdentity, market_session
17
17
  from .local_store import load_store
18
18
 
19
19
 
@@ -159,7 +159,8 @@ def export_report_pdf(
159
159
  markdown = _capture_daily(core, trade_date, profile)
160
160
  else:
161
161
  raise ValueError("没有可用报告;请先运行 `young daily` 或提供日报生成器。")
162
- markdown_path = artifacts.write_markdown("daily", markdown)
162
+ identity = ReportIdentity(trade_date, market_session(), "A股投资日报")
163
+ markdown_path = artifacts.write_report_markdown(identity, markdown)
163
164
  markdown = markdown_path.read_text(encoding="utf-8")
164
165
  body = markdown_to_html(markdown)
165
166
  title_match = re.search(r"^#\s+(.+)$", markdown, flags=re.MULTILINE)
@@ -170,8 +171,9 @@ def export_report_pdf(
170
171
  .replace("{{DATE}}", trade_date)
171
172
  .replace("{{BODY}}", body)
172
173
  )
173
- html_path = artifacts.path("report", "html")
174
+ output_name = markdown_path.stem
175
+ html_path = artifacts.path(output_name, "html")
174
176
  html_path.write_text(document, encoding="utf-8")
175
- pdf_path = artifacts.path("report", "pdf")
177
+ pdf_path = artifacts.path(output_name, "pdf")
176
178
  (render or _default_render)(html_path, pdf_path)
177
179
  return markdown_path, pdf_path
@@ -5,6 +5,8 @@ import json
5
5
  import re
6
6
  from typing import Any
7
7
 
8
+ from .research_style import review_research_report, to_research_evidence, to_research_methodology
9
+
8
10
  THEME_KEYWORDS = {
9
11
  "科技成长/AI": ("AI", "人工智能", "芯片", "半导体", "算力", "CPO", "光通信", "NVDA", "英伟达", "服务器"),
10
12
  "消费品牌": ("白酒", "消费", "茅台", "五粮液", "苹果", "Apple", "品牌"),
@@ -196,15 +198,16 @@ def generate_llm_daily_report(
196
198
  ) -> tuple[str, dict[str, Any]]:
197
199
  messages = [{"role": "system", "content": LLM_REPORT_SYSTEM_PROMPT}]
198
200
  if methodology:
201
+ research_methodology = to_research_methodology(methodology)
199
202
  messages.append(
200
203
  {
201
204
  "role": "system",
202
205
  "content": "以下是当前 stock-analysis 研究规范,仅用于报告结构和表达纪律:\n"
203
- + methodology,
206
+ + research_methodology,
204
207
  }
205
208
  )
206
209
  messages.extend((history or [])[-10:])
207
- report_context = _research_context(evidence)
210
+ report_context = to_research_evidence(evidence)
208
211
  messages.append(
209
212
  {
210
213
  "role": "user",
@@ -221,7 +224,7 @@ def generate_llm_daily_report(
221
224
  "quality_score": evidence.get("_meta", {}).get("quality_score"),
222
225
  "missing_modules": evidence.get("_meta", {}).get("missing_modules", []),
223
226
  }
224
- return _sanitize_report_markdown(response.content), metadata
227
+ return review_research_report(response.content, evidence), metadata
225
228
 
226
229
 
227
230
  def _daily_watchlist_items(watchlist: dict[str, list[str]] | None, key: str) -> list[str]:
@@ -0,0 +1,176 @@
1
+ """Convert internal market evidence into publication-safe research language."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import re
7
+ from typing import Any
8
+
9
+ MODULE_TITLES = {
10
+ "M1": "大盘指数与市场广度",
11
+ "M2": "板块强弱与资金流",
12
+ "M3": "赚钱效应与涨停结构",
13
+ "M4": "下跌风险与炸板结构",
14
+ "M5": "持仓与市场风格",
15
+ "M6": "抗跌方向",
16
+ "STOCK": "个股证据",
17
+ }
18
+
19
+ FIELD_TITLES = {
20
+ "a_indices": "A股指数",
21
+ "hk_indices": "港股指数",
22
+ "us_indices": "美股指数",
23
+ "northbound": "北向资金",
24
+ "breadth": "市场广度",
25
+ "industry": "行业板块",
26
+ "concept": "概念板块",
27
+ "fund_flow": "资金流",
28
+ "zt_count": "涨停家数",
29
+ "zt_pool": "涨停明细",
30
+ "early_limit_up_count": "早盘涨停家数",
31
+ "dt_count": "跌停家数",
32
+ "zb_count": "炸板家数",
33
+ "blowup_ratio": "炸板率",
34
+ "dt_pool": "跌停明细",
35
+ "zb_pool": "炸板明细",
36
+ "holdings": "持仓",
37
+ "style_signals": "风格观察",
38
+ "growth_board_count": "科创板与创业板活跃样本数",
39
+ "resilient": "抗跌方向",
40
+ "quote": "行情",
41
+ "block_trades": "大宗交易",
42
+ "news": "公开信息",
43
+ "trade_date": "交易日",
44
+ "quality_score": "证据完整度评分",
45
+ "missing_modules": "证据暂缺模块",
46
+ "degrade_mode": "报告范围",
47
+ "analysis_symbol": "分析标的",
48
+ "report_type": "报告类型",
49
+ }
50
+
51
+ FORBIDDEN_PATTERNS = (
52
+ r"\bmodules\.[A-Za-z0-9_.]+",
53
+ r"\b(?:available|growth_board_count|degrade_mode|missing_modules)\b",
54
+ r"\b(?:fallback)\b",
55
+ r"脚本|采集|推测|不确定性|猜测",
56
+ r"(?:[A-Za-z]:)?[/~][^\s,。;:))`]+",
57
+ r"\b[\w-]+\.(?:py|js|sh)\b",
58
+ r"\bM[1-6]\s*(?:降级|缺失|不可用)",
59
+ )
60
+
61
+
62
+ class ResearchStyleError(RuntimeError):
63
+ """Report still contains publication-unsafe internal language."""
64
+
65
+
66
+ def _public_source(value: Any) -> str:
67
+ text = str(value or "")
68
+ labels = []
69
+ for token, label in (
70
+ ("腾讯", "腾讯财经"),
71
+ ("sina", "新浪财经"),
72
+ ("新浪", "新浪财经"),
73
+ ("eastmoney", "东方财富"),
74
+ ("东财", "东方财富"),
75
+ ("同花顺", "同花顺"),
76
+ ("ths", "同花顺"),
77
+ ("futu", "富途公开资讯"),
78
+ ("富途", "富途公开资讯"),
79
+ ):
80
+ if token.lower() in text.lower() and label not in labels:
81
+ labels.append(label)
82
+ return "、".join(labels) if labels else "公开市场数据"
83
+
84
+
85
+ def _convert(value: Any) -> Any:
86
+ if isinstance(value, list):
87
+ return [_convert(item) for item in value]
88
+ if not isinstance(value, dict):
89
+ return value
90
+ result = {}
91
+ for key, item in value.items():
92
+ if key == "available":
93
+ result["证据状态"] = "据公开市场数据" if item else "本模块证据暂缺"
94
+ elif key in {"source", "_source"}:
95
+ result["数据来源"] = _public_source(item)
96
+ elif key in {"_date_note", "_cache_note"}:
97
+ result["日期说明"] = "历史口径回溯"
98
+ elif key == "missing_modules":
99
+ result["证据暂缺模块"] = [MODULE_TITLES.get(name, name) for name in item]
100
+ elif key == "degrade_mode":
101
+ result["报告范围"] = {
102
+ "full": "完整报告",
103
+ "degraded": "证据受限报告",
104
+ "simplified": "简化报告",
105
+ }.get(str(item), str(item))
106
+ elif key.startswith("_"):
107
+ continue
108
+ else:
109
+ result[FIELD_TITLES.get(key, key)] = _convert(item)
110
+ return result
111
+
112
+
113
+ def to_research_evidence(evidence: dict[str, Any]) -> dict[str, Any]:
114
+ """Return a publication-oriented copy without exposing internal field names."""
115
+ source = copy.deepcopy(evidence)
116
+ return {
117
+ "研报证据": {
118
+ MODULE_TITLES.get(name, name): _convert(payload)
119
+ for name, payload in (source.get("modules") or {}).items()
120
+ },
121
+ "报告信息": _convert(source.get("_meta") or {}),
122
+ }
123
+
124
+
125
+ def to_research_methodology(text: str) -> str:
126
+ """Keep report structure and investment discipline, remove implementation guidance."""
127
+ safe_lines = []
128
+ in_code_block = False
129
+ for line in text.splitlines():
130
+ if line.strip().startswith("```"):
131
+ in_code_block = not in_code_block
132
+ continue
133
+ if in_code_block or _unsafe(line):
134
+ continue
135
+ if re.search(r"\b(?:API|HTTP|Session|Camofox|Playwright|JSON|evidence)\b", line, re.IGNORECASE):
136
+ continue
137
+ safe_lines.append(line)
138
+ return "\n".join(safe_lines).strip()
139
+
140
+
141
+ def _replacement_lines(evidence: dict[str, Any]) -> list[str]:
142
+ modules = evidence.get("modules") or {}
143
+ lines = []
144
+ if not (modules.get("M2") or {}).get("available"):
145
+ lines.append("- 本模块证据暂缺。")
146
+ growth = ((modules.get("M5") or {}).get("style_signals") or {}).get("growth_board_count")
147
+ if isinstance(growth, (int, float)):
148
+ lines.append(f"- 据公开市场数据,科创板与创业板活跃样本数为 {growth:g} 家。")
149
+ lines.append("- 据公开市场数据,相关指标以已披露口径为准。")
150
+ return lines
151
+
152
+
153
+ def _unsafe(line: str) -> bool:
154
+ return any(re.search(pattern, line, flags=re.IGNORECASE) for pattern in FORBIDDEN_PATTERNS)
155
+
156
+
157
+ def review_research_report(markdown: str, evidence: dict[str, Any]) -> str:
158
+ """Remove unsafe sentences, add evidence-backed research wording, and validate."""
159
+ internal_fields = re.findall(r"\bmodules\.([A-Za-z0-9_]+)\.([A-Za-z0-9_.]+)", markdown)
160
+ known_modules = set((evidence.get("modules") or {}).keys())
161
+ if any(module not in known_modules for module, _field in internal_fields):
162
+ raise ResearchStyleError("正式研报包含无法转换的内部字段。")
163
+ safe_lines = []
164
+ removed = False
165
+ for line in markdown.splitlines():
166
+ if _unsafe(line):
167
+ removed = True
168
+ continue
169
+ safe_lines.append(line)
170
+ if removed:
171
+ insert_at = 1 if safe_lines and safe_lines[0].startswith("#") else 0
172
+ safe_lines[insert_at:insert_at] = ["", *_replacement_lines(evidence), ""]
173
+ reviewed = "\n".join(safe_lines).strip()
174
+ if _unsafe(reviewed):
175
+ raise ResearchStyleError("正式研报未通过研究语言审校。")
176
+ return reviewed
File without changes