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.
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/PKG-INFO +22 -1
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/README.md +21 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/pyproject.toml +1 -1
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/__init__.py +1 -1
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/_core.py +75 -9
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/artifacts.py +35 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/cli.py +10 -5
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/llm.py +36 -2
- young_stock_cli-0.2.3/src/young_stock/market_routes.py +60 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/pdf.py +6 -4
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/reports.py +6 -3
- young_stock_cli-0.2.3/src/young_stock/research_style.py +176 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/.gitignore +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/LICENSE +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/calendar.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/__init__.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/base.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/channels/feishu.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/chat.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/config.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/evidence.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/health.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/local_store.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/methodology.py +0 -0
- {young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/profile.py +0 -0
- {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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
1999
|
-
activity =
|
|
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
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
+
|
|
206
|
+
+ research_methodology,
|
|
204
207
|
}
|
|
205
208
|
)
|
|
206
209
|
messages.extend((history or [])[-10:])
|
|
207
|
-
report_context =
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{young_stock_cli-0.2.2 → young_stock_cli-0.2.3}/src/young_stock/templates/equity-report.html
RENAMED
|
File without changes
|