Skip to content

Commit ee4c9a2

Browse files
author
AdvancingTitans
committed
refactor: split calendar profile and report modules
1 parent 6896b11 commit ee4c9a2

9 files changed

Lines changed: 373 additions & 140 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111
- Added `young profile add-stock`, `young profile add-fund`, and `young profile show` to maintain local investment memory in `~/.young_stock/profile.json`.
1212
- Added `young daily`, a personalized daily market report that combines saved stock/fund watchlists, global indices, A-share sentiment, fund flow, and risk-oriented suggestions.
1313
- Exposed `run_daily_report()` in `young_stock._core` so agent skills can depend on the PyPI package instead of copying the core script.
14+
- Added focused `calendar`, `profile`, `reports`, and `health` modules as the first step of the v2 architecture split.
15+
- Added an explicit 2026 market-calendar layer for A-share, HK, and US holiday-aware nearest-trade-day resolution.
16+
- Added lightweight data-source health snapshots that track recent success rate and latency for public quote/news APIs.
1417

1518
### Changed
1619
- The daily report uses the existing nearest-trade-date logic, so pre-close weekday runs still review the latest settled trading day.
20+
- CLI investment-memory logic moved out of `cli.py`; `_core.run_daily_report()` and `_core.nearest_trade_date()` remain compatibility wrappers over the new modules.
1721

1822
## [0.1.11] - 2026-06-02
1923

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ Most A-share data libraries either (a) require paid accounts, (b) break the mome
7171

7272
It is also a foundation for analysis pipelines: every subcommand maps to a Python function in `young_stock._core`, so you can `from young_stock._core import get_zt_pool, get_fund_flow` and feed the dicts into your own notebook or LLM prompt.
7373

74+
The internals are being split into focused modules: `young_stock.calendar` handles holiday-aware trade dates, `young_stock.profile` handles local investment memory, `young_stock.reports` composes daily reports, and `young_stock.health` tracks lightweight public-source health. Compatibility wrappers remain in `_core` for existing users.
75+
7476
## What's in the box
7577

7678
- **Multiple public quote sources** — Tencent Finance, Sina Finance, and Eastmoney are tried in sequence so temporary source failures can be filled by another no-login endpoint.
@@ -80,6 +82,8 @@ It is also a foundation for analysis pipelines: every subcommand maps to a Pytho
8082
- **Single-stock news**`young news 3690.HK` prints only the news/momentum view, with each item showing source and link status.
8183
- **Smart caching**`~/.young_stock/cache/`, 7-day TTL, auto-pruned. Pass `--refresh` to skip.
8284
- **Trade-day awareness** — nearest-trade-day resolution including weekends and (best-effort) holidays.
85+
- **Formal calendar layer** — A-share/HK/US holiday sets are separated into `young_stock.calendar`, so trading-day rules can evolve without touching data-source code.
86+
- **Source health tracking** — common JSON fetches update `young_stock._core.SOURCE_HEALTH`, giving downstream agents a recent success-rate/latency signal for public sources.
8387
- **Rich terminal tables** — readable on dark and light terminals.
8488
- **Verified A-share fund flow**`young flow` uses Tonghuashun concept-board fund flow only when both net-inflow and net-outflow rankings are available, then falls back to Eastmoney main-capital flow/page indicators, Sina Finance sector fund-flow, Sina/Tencent market-activity references, and finally the most recent locally cached good record. Non-equivalent fallbacks are clearly labeled instead of being presented as whole-market main-capital net inflow.
8589
- **News heat ranking** — HK/US focus stocks can be ranked by filtered news heat from multiple no-login sources: Futu, Sina Finance, and Eastmoney fast news. Xueqiu/THS are intentionally not hardwired unless a stable no-login interface is available.

src/young_stock/_core.py

Lines changed: 27 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
from pathlib import Path
3535
from typing import Any
3636

37+
from .calendar import nearest_trade_date as calendar_nearest_trade_date
38+
from .health import SourceHealthBook
39+
3740
# ------------------------------------------------------------------
3841
# 配置
3942
# ------------------------------------------------------------------
@@ -184,6 +187,7 @@
184187
# 诊断记录
185188
DIAGNOSTICS: list[str] = []
186189
NEWS_URL_VALIDATION_CACHE: dict[str, bool] = {}
190+
SOURCE_HEALTH = SourceHealthBook()
187191

188192

189193
def diag(msg: str) -> None:
@@ -439,13 +443,31 @@ def _fetch_raw(url: str, headers: dict[str, str] | None = None, timeout: int = 1
439443

440444

441445
def fetch_json(url: str, headers: dict[str, str] | None = None) -> dict[str, Any]:
446+
started = time.monotonic()
442447
try:
443448
raw = _fetch_raw(url, headers)
444449
except Exception as e:
450+
SOURCE_HEALTH.record(_source_from_url(url), ok=False, latency_ms=(time.monotonic() - started) * 1000)
445451
return {"_error": str(e)}
452+
SOURCE_HEALTH.record(_source_from_url(url), ok=True, latency_ms=(time.monotonic() - started) * 1000)
446453
return _parse_json_text(raw)
447454

448455

456+
def _source_from_url(url: str) -> str:
457+
host = urllib.parse.urlparse(url).netloc.lower()
458+
if "eastmoney" in host:
459+
return "eastmoney"
460+
if "sina" in host:
461+
return "sina"
462+
if "gtimg" in host or "qq.com" in host:
463+
return "tencent"
464+
if "10jqka" in host:
465+
return "ths"
466+
if "futunn" in host:
467+
return "futu"
468+
return host or "unknown"
469+
470+
449471
def _parse_json_text(raw: str) -> dict[str, Any]:
450472
raw = raw.strip()
451473
if raw.startswith("(") and raw.endswith(")"):
@@ -2953,101 +2975,20 @@ def run_stock_news(symbol: str, date_str: str, size: int = 8) -> None:
29532975
print_report_footer()
29542976

29552977

2956-
def _daily_watchlist_items(watchlist: dict[str, list[str]] | None, key: str) -> list[str]:
2957-
if not watchlist:
2958-
return []
2959-
values = watchlist.get(key) or []
2960-
return [str(v).strip() for v in values if str(v).strip()]
2961-
2962-
2963-
def _quote_trend_label(qd: QuoteData) -> str:
2964-
pct = qd.change_pct
2965-
if pct is None:
2966-
return "趋势待确认"
2967-
if pct >= 2:
2968-
return "强势上行"
2969-
if pct >= 0.3:
2970-
return "偏强"
2971-
if pct <= -2:
2972-
return "明显走弱"
2973-
if pct <= -0.3:
2974-
return "偏弱"
2975-
return "震荡"
2976-
2977-
29782978
def print_daily_watchlist(watchlist: dict[str, list[str]] | None, date_str: str, include_news: bool = True) -> None:
2979-
stocks = _daily_watchlist_items(watchlist, "stocks")
2980-
funds = _daily_watchlist_items(watchlist, "funds")
2981-
print("## 一、关注标的\n")
2982-
if not stocks and not funds:
2983-
print(" 尚未设置关注股票或基金。")
2984-
print()
2985-
return
2979+
from .reports import print_daily_watchlist as _print_daily_watchlist
29862980

2987-
if stocks:
2988-
print("### 个股/ETF行情与趋势\n")
2989-
for symbol in stocks:
2990-
try:
2991-
qd = get_single_stock_quote(symbol, date_str)
2992-
except ValueError as e:
2993-
print(f"- {symbol}: 暂未拿到可核验行情({e})")
2994-
continue
2995-
if not qd:
2996-
print(f"- {symbol}: 暂未拿到可核验行情")
2997-
continue
2998-
name = qd.name or qd.symbol
2999-
print(
3000-
f"- {name} ({qd.symbol}): 最新 {fmt_price(qd.price)} {qd.currency}, "
3001-
f"涨跌幅 {fmt_pct(qd.change_pct)}, {_quote_trend_label(qd)};"
3002-
f"来源 {_source_label(qd.source)},数据日 {qd.date or '-'}。"
3003-
)
3004-
if include_news:
3005-
keyword = qd.name if qd.market in ("cn_market", "hk_market") and qd.name else qd.symbol
3006-
lang = "zh-CN" if qd.market in ("cn_market", "hk_market") else "en"
3007-
news = combined_news_search(keyword, size=3, lang=lang, aliases=_news_aliases(qd.symbol, qd.name), date_str=date_str)
3008-
items = news.get("data", []) if "_error" not in news else []
3009-
if items:
3010-
title = _clean_news_title(str(items[0].get("title") or ""))
3011-
print(f" 相关新闻: {title}")
3012-
print()
3013-
3014-
if funds:
3015-
print("### 基金估值与持仓\n")
3016-
for code in funds:
3017-
run_fund_report(code, date_str, include_news=include_news)
2981+
_print_daily_watchlist(sys.modules[__name__], watchlist, date_str, include_news=include_news)
30182982

30192983

30202984
def run_daily_report(
30212985
date_str: str,
30222986
watchlist: dict[str, list[str]] | None = None,
30232987
include_news: bool = True,
30242988
) -> None:
3025-
DIAGNOSTICS.clear()
3026-
display_date = _display_date(date_str)
3027-
print(f"# 每日行情日报({display_date}\n")
3028-
print_stage_line(date_str)
3029-
print("数据来源: young-stock-cli 核心模块,多源免登录行情与新闻聚合。")
3030-
print("说明: 以下内容仅供复盘参考,不构成投资建议。\n")
3031-
print("=" * 60 + "\n")
3032-
3033-
print_daily_watchlist(watchlist, date_str, include_news=include_news)
3034-
3035-
print("## 二、全球指数与大盘概览\n")
3036-
run_global_market(date_str)
3037-
3038-
print("## 三、A股大盘与市场情绪\n")
3039-
run_a_share(date_str, include_news=include_news)
2989+
from .reports import run_daily_report as _run_daily_report
30402990

3041-
print("## 四、投资建议\n")
3042-
print("- 先看交易日与数据源日期是否一致;若出现最新可用数据提示,不把旧行情当成当日信号。")
3043-
print("- 个股/基金优先结合持仓成本、仓位上限和新闻催化验证,不因单日涨跌直接追涨杀跌。")
3044-
print("- 市场情绪偏弱或资金流口径降级时,降低加仓冲动;情绪修复时再观察量能和领涨方向持续性。")
3045-
print("- 基金估值是盘中/收盘估算,正式净值以基金公司晚间披露为准。")
3046-
print()
3047-
3048-
if DIAGNOSTICS:
3049-
print_diagnostic_summary()
3050-
print_report_footer()
2991+
_run_daily_report(sys.modules[__name__], date_str, watchlist, include_news=include_news)
30512992

30522993

30532994
def print_futu_news(news_data: dict, keyword: str, limit: int = 5) -> None:
@@ -3219,16 +3160,7 @@ def print_a_share_news(date_str: str, zt_data: dict | None = None) -> None:
32193160
# ------------------------------------------------------------------
32203161

32213162
def nearest_trade_date(dt: datetime | None = None) -> str:
3222-
if dt is None:
3223-
dt = datetime.now()
3224-
if dt.weekday() < 5 and (dt.hour, dt.minute) < (15, 0):
3225-
dt -= timedelta(days=1)
3226-
wd = dt.weekday()
3227-
if wd == 5:
3228-
dt -= timedelta(days=1)
3229-
elif wd == 6:
3230-
dt -= timedelta(days=2)
3231-
return dt.strftime("%Y%m%d")
3163+
return calendar_nearest_trade_date(dt)
32323164

32333165

32343166
def _session_label() -> str:

src/young_stock/calendar.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Trading-calendar helpers used by CLI commands and skills."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import date, datetime, timedelta
6+
7+
A_SHARE_HOLIDAYS_2026 = {
8+
"20260101",
9+
"20260216", "20260217", "20260218", "20260219", "20260220", "20260221", "20260222",
10+
"20260404", "20260405", "20260406",
11+
"20260501", "20260502", "20260503", "20260504", "20260505",
12+
"20260619", "20260620", "20260621",
13+
"20260925", "20260926", "20260927",
14+
"20261001", "20261002", "20261003", "20261004", "20261005", "20261006", "20261007", "20261008",
15+
}
16+
17+
HK_MARKET_HOLIDAYS_2026 = {
18+
"20260101",
19+
"20260217", "20260218", "20260219",
20+
"20260403", "20260406", "20260407",
21+
"20260501", "20260525",
22+
"20260701",
23+
"20260926",
24+
"20261001", "20261019",
25+
"20261225",
26+
}
27+
28+
US_MARKET_HOLIDAYS_2026 = {
29+
"20260101", "20260119", "20260216", "20260403", "20260525",
30+
"20260619", "20260703", "20260907", "20261126", "20261225",
31+
}
32+
33+
34+
def _yyyymmdd(value: date | datetime | str) -> str:
35+
if isinstance(value, str):
36+
return value.replace("-", "")
37+
return value.strftime("%Y%m%d")
38+
39+
40+
def market_holidays(market: str = "a") -> set[str]:
41+
market = market.lower()
42+
if market in {"a", "cn", "cn_market", "ashare"}:
43+
return A_SHARE_HOLIDAYS_2026
44+
if market in {"hk", "hk_market"}:
45+
return HK_MARKET_HOLIDAYS_2026
46+
if market in {"us", "us_market"}:
47+
return US_MARKET_HOLIDAYS_2026
48+
return set()
49+
50+
51+
def is_trade_day(value: date | datetime | str, market: str = "a") -> bool:
52+
day = _yyyymmdd(value)
53+
if isinstance(value, str):
54+
dt = datetime.strptime(day, "%Y%m%d")
55+
else:
56+
dt = value
57+
return dt.weekday() < 5 and day not in market_holidays(market)
58+
59+
60+
def previous_trade_day(value: date | datetime | str, market: str = "a") -> str:
61+
if isinstance(value, str):
62+
dt = datetime.strptime(_yyyymmdd(value), "%Y%m%d")
63+
elif isinstance(value, datetime):
64+
dt = value
65+
else:
66+
dt = datetime.combine(value, datetime.min.time())
67+
dt -= timedelta(days=1)
68+
while not is_trade_day(dt, market):
69+
dt -= timedelta(days=1)
70+
return dt.strftime("%Y%m%d")
71+
72+
73+
def nearest_trade_date(dt: datetime | None = None, market: str = "a") -> str:
74+
if dt is None:
75+
dt = datetime.now()
76+
if market.lower() in {"a", "cn", "cn_market", "ashare"} and (dt.hour, dt.minute) < (15, 0):
77+
return previous_trade_day(dt, market)
78+
while not is_trade_day(dt, market):
79+
dt -= timedelta(days=1)
80+
return dt.strftime("%Y%m%d")

src/young_stock/cli.py

Lines changed: 6 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
11
"""young-stock-cli command line interface."""
22
from __future__ import annotations
33

4-
import json
5-
import os
64
import subprocess
75
import sys
8-
from pathlib import Path
96

107
import click
118

129
from . import __version__, _core
10+
from .profile import add_profile_item, load_profile, profile_path
1311

1412

1513
@click.group(
@@ -43,43 +41,6 @@ def _run(market: str, date: str | None, refresh: bool, include_news: bool = True
4341
_refresh_opt = click.option("--refresh", is_flag=True, help="Skip cache and force re-fetch.")
4442

4543

46-
def _profile_path() -> Path:
47-
override = os.environ.get("YOUNG_STOCK_PROFILE")
48-
if override:
49-
return Path(override).expanduser()
50-
return Path.home() / ".young_stock" / "profile.json"
51-
52-
53-
def _load_profile() -> dict[str, list[str]]:
54-
path = _profile_path()
55-
if not path.exists():
56-
return {"stocks": [], "funds": []}
57-
try:
58-
data = json.loads(path.read_text(encoding="utf-8"))
59-
except (OSError, json.JSONDecodeError):
60-
return {"stocks": [], "funds": []}
61-
return {
62-
"stocks": [str(v) for v in data.get("stocks", []) if str(v).strip()],
63-
"funds": [str(v) for v in data.get("funds", []) if str(v).strip()],
64-
}
65-
66-
67-
def _save_profile(profile: dict[str, list[str]]) -> None:
68-
path = _profile_path()
69-
path.parent.mkdir(parents=True, exist_ok=True)
70-
path.write_text(json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
71-
72-
73-
def _add_profile_item(kind: str, value: str) -> dict[str, list[str]]:
74-
profile = _load_profile()
75-
items = profile.setdefault(kind, [])
76-
normalized = value.strip()
77-
if normalized and normalized not in items:
78-
items.append(normalized)
79-
_save_profile(profile)
80-
return profile
81-
82-
8344
def _print_first_use_guide() -> None:
8445
click.echo("# 每日行情日报")
8546
click.echo()
@@ -88,7 +49,7 @@ def _print_first_use_guide() -> None:
8849
click.echo(" young profile add-stock 0700.HK")
8950
click.echo(" young profile add-fund 161725")
9051
click.echo()
91-
click.echo(f"配置会保存到: {_profile_path()}")
52+
click.echo(f"配置会保存到: {profile_path()}")
9253

9354

9455
@cli.command(help="A-share dashboard: indices, ZT/DT pool, verified A-share fund flow, boards.")
@@ -224,7 +185,7 @@ def daily(date: str | None, refresh: bool, no_news: bool) -> None:
224185
_core.NO_CACHE = True
225186
_core.cache_clear_old(days=7)
226187
date_str = date or _core.nearest_trade_date()
227-
profile = _load_profile()
188+
profile = load_profile()
228189
if not profile.get("stocks") and not profile.get("funds"):
229190
_print_first_use_guide()
230191
return
@@ -239,22 +200,22 @@ def profile() -> None:
239200
@profile.command("add-stock", help="Add a stock/ETF symbol to your daily watchlist.")
240201
@click.argument("symbol")
241202
def profile_add_stock(symbol: str) -> None:
242-
data = _add_profile_item("stocks", symbol)
203+
data = add_profile_item("stocks", symbol)
243204
click.echo(f"Added stock: {symbol.strip()}")
244205
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
245206

246207

247208
@profile.command("add-fund", help="Add a fund code to your daily watchlist.")
248209
@click.argument("code")
249210
def profile_add_fund(code: str) -> None:
250-
data = _add_profile_item("funds", code)
211+
data = add_profile_item("funds", code)
251212
click.echo(f"Added fund: {code.strip()}")
252213
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
253214

254215

255216
@profile.command("show", help="Show saved daily-report investment memory.")
256217
def profile_show() -> None:
257-
data = _load_profile()
218+
data = load_profile()
258219
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
259220
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
260221

0 commit comments

Comments
 (0)