Skip to content

Commit 6896b11

Browse files
author
AdvancingTitans
committed
feat: add personalized daily report
1 parent 116fb80 commit 6896b11

8 files changed

Lines changed: 287 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.1.12] - 2026-06-03
9+
10+
### Added
11+
- Added `young profile add-stock`, `young profile add-fund`, and `young profile show` to maintain local investment memory in `~/.young_stock/profile.json`.
12+
- 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.
13+
- Exposed `run_daily_report()` in `young_stock._core` so agent skills can depend on the PyPI package instead of copying the core script.
14+
15+
### Changed
16+
- The daily report uses the existing nearest-trade-date logic, so pre-close weekday runs still review the latest settled trading day.
17+
818
## [0.1.11] - 2026-06-02
919

1020
### Fixed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ young us # US indices snapshot
3333
young global # A + HK + US in one view
3434
young stock 600519 # one stock snapshot (A-share / HK / US)
3535
young fund 161725 # fund estimate + top holdings quote/news
36+
young profile add-stock 600519
37+
young profile add-fund 161725
38+
young daily # personalized daily report from saved investment memory
3639
young news 3690.HK # multi-source news only
3740
young stock AAPL --no-news
3841
young fund 161725 --no-news
@@ -73,6 +76,7 @@ It is also a foundation for analysis pipelines: every subcommand maps to a Pytho
7376
- **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.
7477
- **Single-stock lookup**`young stock 600519`, `young stock 0700.HK`, or `young stock AAPL` prints a compact quote snapshot with source, trade date, price, change, volume, turnover when available, and optional news.
7578
- **Fund holding lookup**`young fund 161725` prints the fund's same-day estimated change, latest NAV date, top holdings, holding-stock quotes, rough contribution estimate, and same-day holding-stock news. Official fund NAVs usually update at night, so intraday/close values are clearly labeled as estimates.
79+
- **Personal daily report**`young daily` reads your local investment memory from `~/.young_stock/profile.json`, then prints saved stock/ETF trends, fund estimates, global indices, A-share sentiment, and risk-oriented suggestions. First use: add symbols with `young profile add-stock 600519` and `young profile add-fund 161725`.
7680
- **Single-stock news**`young news 3690.HK` prints only the news/momentum view, with each item showing source and link status.
7781
- **Smart caching**`~/.young_stock/cache/`, 7-day TTL, auto-pruned. Pass `--refresh` to skip.
7882
- **Trade-day awareness** — nearest-trade-day resolution including weekends and (best-effort) holidays.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "young-stock-cli"
7-
version = "0.1.11"
7+
version = "0.1.12"
88
description = "A-share (China stock market) after-hours CLI — no login, no scraping tricks, just data."
99
readme = "README.md"
1010
requires-python = ">=3.10"

src/young_stock/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""young-stock-cli: A-share after-hours CLI."""
22

3-
__version__ = "0.1.11"
3+
__version__ = "0.1.12"

src/young_stock/_core.py

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,14 +310,17 @@ def cache_clear_old(days: int = 7) -> None:
310310
cutoff = datetime.now() - timedelta(days=days)
311311
if not CACHE_DIR.exists():
312312
return
313-
for d in CACHE_DIR.iterdir():
313+
try:
314+
dirs = list(CACHE_DIR.iterdir())
315+
except OSError:
316+
return
317+
for d in dirs:
314318
if d.is_dir():
315319
try:
316320
dt = datetime.strptime(d.name, "%Y%m%d")
317321
if dt < cutoff:
318-
import shutil
319322
shutil.rmtree(d)
320-
except ValueError:
323+
except (ValueError, OSError):
321324
pass
322325

323326

@@ -2950,6 +2953,103 @@ def run_stock_news(symbol: str, date_str: str, size: int = 8) -> None:
29502953
print_report_footer()
29512954

29522955

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+
2978+
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
2986+
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)
3018+
3019+
3020+
def run_daily_report(
3021+
date_str: str,
3022+
watchlist: dict[str, list[str]] | None = None,
3023+
include_news: bool = True,
3024+
) -> 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)
3040+
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()
3051+
3052+
29533053
def print_futu_news(news_data: dict, keyword: str, limit: int = 5) -> None:
29543054
if "_error" in news_data:
29553055
return

src/young_stock/cli.py

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

4+
import json
5+
import os
46
import subprocess
57
import sys
8+
from pathlib import Path
69

710
import click
811

@@ -40,6 +43,54 @@ def _run(market: str, date: str | None, refresh: bool, include_news: bool = True
4043
_refresh_opt = click.option("--refresh", is_flag=True, help="Skip cache and force re-fetch.")
4144

4245

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+
83+
def _print_first_use_guide() -> None:
84+
click.echo("# 每日行情日报")
85+
click.echo()
86+
click.echo("尚未设置投资记忆。首次使用请先添加你关注的股票、ETF 或基金:")
87+
click.echo(" young profile add-stock 600519")
88+
click.echo(" young profile add-stock 0700.HK")
89+
click.echo(" young profile add-fund 161725")
90+
click.echo()
91+
click.echo(f"配置会保存到: {_profile_path()}")
92+
93+
4394
@cli.command(help="A-share dashboard: indices, ZT/DT pool, verified A-share fund flow, boards.")
4495
@_date_opt
4596
@_refresh_opt
@@ -164,6 +215,50 @@ def news(parts: tuple[str, ...], date: str | None, refresh: bool, limit: int) ->
164215
_core.run_stock_news(symbol, date_str, size=limit)
165216

166217

218+
@cli.command(help="Personal daily report from your saved watchlist.")
219+
@_date_opt
220+
@_refresh_opt
221+
@click.option("--no-news", is_flag=True, help="Only show market data, skip news lookup.")
222+
def daily(date: str | None, refresh: bool, no_news: bool) -> None:
223+
if refresh:
224+
_core.NO_CACHE = True
225+
_core.cache_clear_old(days=7)
226+
date_str = date or _core.nearest_trade_date()
227+
profile = _load_profile()
228+
if not profile.get("stocks") and not profile.get("funds"):
229+
_print_first_use_guide()
230+
return
231+
_core.run_daily_report(date_str, profile, include_news=not no_news)
232+
233+
234+
@cli.group(help="Manage local investment memory for daily reports.")
235+
def profile() -> None:
236+
pass
237+
238+
239+
@profile.command("add-stock", help="Add a stock/ETF symbol to your daily watchlist.")
240+
@click.argument("symbol")
241+
def profile_add_stock(symbol: str) -> None:
242+
data = _add_profile_item("stocks", symbol)
243+
click.echo(f"Added stock: {symbol.strip()}")
244+
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
245+
246+
247+
@profile.command("add-fund", help="Add a fund code to your daily watchlist.")
248+
@click.argument("code")
249+
def profile_add_fund(code: str) -> None:
250+
data = _add_profile_item("funds", code)
251+
click.echo(f"Added fund: {code.strip()}")
252+
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
253+
254+
255+
@profile.command("show", help="Show saved daily-report investment memory.")
256+
def profile_show() -> None:
257+
data = _load_profile()
258+
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
259+
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
260+
261+
167262
@cli.command(help="Clear cached responses older than N days.")
168263
@click.option("--days", default=7, show_default=True, help="Delete cache files older than this many days.")
169264
def cache_clear(days: int) -> None:

tests/test_cli.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def test_cli_subcommands_registered():
3131
from click.testing import CliRunner
3232
runner = CliRunner()
3333
result = runner.invoke(cli, ["--help"])
34-
for sub in ["a", "hk", "us", "global", "indices", "zt-pool", "flow", "stock", "fund", "news", "cache-clear", "update"]:
34+
for sub in ["a", "hk", "us", "global", "indices", "zt-pool", "flow", "stock", "fund", "news", "daily", "profile", "cache-clear", "update"]:
3535
assert sub in result.output, f"subcommand `{sub}` missing from help"
3636

3737

@@ -153,3 +153,44 @@ def fake_run(cmd, check):
153153
[sys.executable, "-m", "pip", "install", "--upgrade", "young-stock-cli", "--pre", "--user"],
154154
False,
155155
)]
156+
157+
158+
def test_cli_daily_guides_first_use_when_profile_empty(monkeypatch, tmp_path):
159+
from click.testing import CliRunner
160+
161+
monkeypatch.setenv("YOUNG_STOCK_PROFILE", str(tmp_path / "profile.json"))
162+
monkeypatch.setattr(cli_module._core, "nearest_trade_date", lambda: "20260529")
163+
monkeypatch.setattr(cli_module._core, "cache_clear_old", lambda days: None)
164+
165+
runner = CliRunner()
166+
result = runner.invoke(cli, ["daily", "--no-news"])
167+
168+
assert result.exit_code == 0
169+
assert "尚未设置投资记忆" in result.output
170+
assert "young profile add-stock 600519" in result.output
171+
assert "young profile add-fund 161725" in result.output
172+
173+
174+
def test_cli_profile_add_stock_and_fund_then_daily_uses_memory(monkeypatch, tmp_path):
175+
from click.testing import CliRunner
176+
177+
profile_path = tmp_path / "profile.json"
178+
monkeypatch.setenv("YOUNG_STOCK_PROFILE", str(profile_path))
179+
monkeypatch.setattr(cli_module._core, "nearest_trade_date", lambda: "20260529")
180+
monkeypatch.setattr(cli_module._core, "cache_clear_old", lambda days: None)
181+
182+
calls = []
183+
monkeypatch.setattr(
184+
cli_module._core,
185+
"run_daily_report",
186+
lambda date_str, watchlist=None, include_news=True: calls.append((date_str, watchlist, include_news)),
187+
)
188+
189+
runner = CliRunner()
190+
assert runner.invoke(cli, ["profile", "add-stock", "600519"]).exit_code == 0
191+
assert runner.invoke(cli, ["profile", "add-fund", "161725"]).exit_code == 0
192+
193+
result = runner.invoke(cli, ["daily", "--no-news"])
194+
195+
assert result.exit_code == 0
196+
assert calls == [("20260529", {"stocks": ["600519"], "funds": ["161725"]}, False)]

tests/test_core.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,37 @@ def test_print_single_stock_report_hides_completeness(capsys):
634634
assert "100%" not in output
635635

636636

637+
def test_run_daily_report_prints_watchlist_and_market_sections(monkeypatch, capsys):
638+
calls = []
639+
640+
monkeypatch.setattr(
641+
_core,
642+
"get_single_stock_quote",
643+
lambda symbol, date: _core.QuoteData(
644+
symbol=symbol,
645+
name="测试股票",
646+
market="cn_market",
647+
date="2026-05-29",
648+
price=10,
649+
change_pct=1.2,
650+
source="test",
651+
),
652+
)
653+
monkeypatch.setattr(_core, "run_fund_report", lambda code, date, include_news=True: calls.append(("fund", code, date, include_news)))
654+
monkeypatch.setattr(_core, "run_global_market", lambda date: calls.append(("global", date)))
655+
monkeypatch.setattr(_core, "run_a_share", lambda date, include_news=True: calls.append(("a", date, include_news)))
656+
monkeypatch.setattr(_core, "print_report_footer", lambda: None)
657+
658+
_core.run_daily_report("20260529", {"stocks": ["600519"], "funds": ["161725"]}, include_news=False)
659+
660+
output = capsys.readouterr().out
661+
assert "每日行情日报" in output
662+
assert "一、关注标的" in output
663+
assert "测试股票 (600519)" in output
664+
assert "四、投资建议" in output
665+
assert calls == [("fund", "161725", "20260529", False), ("global", "20260529"), ("a", "20260529", False)]
666+
667+
637668
def test_diagnostic_summary_is_quiet_by_default(capsys):
638669
_core.DIAGNOSTICS[:] = ["Sina missing AAPL"]
639670

0 commit comments

Comments
 (0)