Skip to content

Commit 42a7053

Browse files
author
AdvancingTitans
committed
feat: add buffett-style daily advice controls
1 parent dcbe07f commit 42a7053

10 files changed

Lines changed: 349 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ 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.14] - 2026-06-03
9+
10+
### Added
11+
- Added `young uninstall` to remove the package from the current Python environment with one command.
12+
- Added `young profile clear-stocks` and `young profile clear-funds` for one-click stock/fund memory cleanup without deleting the other side.
13+
14+
### Changed
15+
- Daily report advice now uses a Buffett-style framework: circle of competence, margin of safety, moat durability, valuation discipline, and Mr. Market. Summary/key-points/full modes now tie risk notes to the user's watched symbols, fund estimates, and available news instead of printing generic caution text.
16+
817
## [0.1.13] - 2026-06-03
918

1019
### Added

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ young fund 161725 # fund estimate + top holdings quote/news
3636
young profile add-stock 600519
3737
young profile add-fund 161725
3838
young profile list
39+
young profile clear-stocks # clear all saved stocks/ETFs only
40+
young profile clear-funds # clear all saved funds only
3941
young daily --format summary # concise personalized daily report
4042
young daily --format key-points # short report with trend/risk points
4143
young daily --format full # full personalized daily report
@@ -53,6 +55,7 @@ young flow # latest verified A-share fund flow
5355
young a -d 20260530 # historical date (YYYYMMDD)
5456
young a --refresh # bypass cache, force re-fetch
5557
young update # upgrade young-stock-cli in the current Python env
58+
young uninstall # uninstall from the current Python env
5659
young --help
5760
```
5861

@@ -85,9 +88,9 @@ The internals are being split into focused modules: `young_stock.calendar` handl
8588
- **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.
8689
- **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.
8790
- **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.
88-
- **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`.
91+
- **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 Buffett-style risk-oriented suggestions grounded in your symbols, fund estimates, and available news. First use: add symbols with `young profile add-stock 600519` and `young profile add-fund 161725`.
8992
- **Short report modes**`young daily --format summary` keeps terminal output compact; `--format key-points` adds a few trend/risk bullets; `--only`, `--order`, and `--quick` trim slower or irrelevant sections.
90-
- **Investment memory management** — list, remove, clear, and group saved stocks/funds with `young profile list`, `remove-stock`, `remove-fund`, `clear`, and `profile group create/add`.
93+
- **Investment memory management** — list, remove, clear, and group saved stocks/funds with `young profile list`, `remove-stock`, `remove-fund`, `clear`, `clear-stocks`, `clear-funds`, and `profile group create/add`.
9194
- **Local workflow helpers** — lightweight `portfolio`, `alert`, `note`, and `diary` commands store local records for portfolio experiments, reminder rules, investment notes, and saved daily-report text.
9295
- **Diagnostics**`young diagnose` summarizes recent source health and suggests cache/quick-mode fallbacks when public APIs are unstable.
9396
- **Single-stock news**`young news 3690.HK` prints only the news/momentum view, with each item showing source and link status.
@@ -101,7 +104,7 @@ The internals are being split into focused modules: `young_stock.calendar` handl
101104
- **Same-day news discipline** — news sections only show items published on the requested trading date, up to five items, with a clear empty-state message when nothing valid is available.
102105
- **Readable news links** — linked news items are checked for obvious empty/404/no-content pages and replaced by other same-day news when possible.
103106
- **Sector boards via browser fallback** — when Eastmoney's board API rate-limits, falls back to rendering the public web page (optional, requires a local browser engine).
104-
- **Local updater**`young update` runs `python -m pip install --upgrade young-stock-cli` with the same interpreter that launched the CLI.
107+
- **Local updater/uninstaller**`young update` runs `python -m pip install --upgrade young-stock-cli`; `young uninstall` runs `python -m pip uninstall -y young-stock-cli` with the same interpreter that launched the CLI.
105108

106109
## Library usage
107110

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.13"
7+
version = "0.1.14"
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.13"
3+
__version__ = "0.1.14"

src/young_stock/cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
add_group_item,
1414
add_profile_item,
1515
clear_profile,
16+
clear_profile_kind,
1617
load_profile,
1718
profile_path,
1819
remove_profile_item,
@@ -108,6 +109,15 @@ def update(pre: bool, user_install: bool) -> None:
108109
raise click.ClickException(f"update failed with exit code {result.returncode}")
109110

110111

112+
@cli.command(help="Uninstall young-stock-cli from the current Python environment.")
113+
def uninstall() -> None:
114+
cmd = [sys.executable, "-m", "pip", "uninstall", "-y", "young-stock-cli"]
115+
click.echo("Running: " + " ".join(cmd))
116+
result = subprocess.run(cmd, check=False)
117+
if result.returncode != 0:
118+
raise click.ClickException(f"uninstall failed with exit code {result.returncode}")
119+
120+
111121
@cli.command(help="Show A-share major indices only.")
112122
@_date_opt
113123
@_refresh_opt
@@ -265,6 +275,20 @@ def profile_clear() -> None:
265275
click.echo("Cleared investment memory.")
266276

267277

278+
@profile.command("clear-stocks", help="Clear all saved stocks/ETFs from investment memory.")
279+
def profile_clear_stocks() -> None:
280+
data = clear_profile_kind("stocks")
281+
click.echo("Cleared all saved stocks/ETFs.")
282+
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
283+
284+
285+
@profile.command("clear-funds", help="Clear all saved funds from investment memory.")
286+
def profile_clear_funds() -> None:
287+
data = clear_profile_kind("funds")
288+
click.echo("Cleared all saved funds.")
289+
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
290+
291+
268292
@profile.group("group", help="Manage investment-memory groups.")
269293
def profile_group() -> None:
270294
pass

src/young_stock/profile.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ def clear_profile() -> dict[str, list[str]]:
6565
return profile
6666

6767

68+
def clear_profile_kind(kind: str) -> dict[str, list[str]]:
69+
if kind not in {"stocks", "funds"}:
70+
raise ValueError(f"unknown profile item kind: {kind}")
71+
profile = load_profile()
72+
profile[kind] = []
73+
for group in profile.get("groups", {}).values():
74+
if isinstance(group, dict):
75+
group[kind] = []
76+
save_profile(profile)
77+
return profile
78+
79+
6880
def add_group(name: str) -> dict[str, list[str]]:
6981
profile = load_profile()
7082
group_name = name.strip()

src/young_stock/reports.py

Lines changed: 183 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@
33

44
from typing import Any
55

6+
THEME_KEYWORDS = {
7+
"科技成长/AI": ("AI", "人工智能", "芯片", "半导体", "算力", "CPO", "光通信", "NVDA", "英伟达", "服务器"),
8+
"消费品牌": ("白酒", "消费", "茅台", "五粮液", "苹果", "Apple", "品牌"),
9+
"金融地产": ("银行", "保险", "券商", "地产", "房地产"),
10+
"新能源": ("新能源", "电池", "光伏", "储能", "汽车", "特斯拉", "Tesla"),
11+
}
12+
613

714
def _daily_watchlist_items(watchlist: dict[str, list[str]] | None, key: str) -> list[str]:
815
if not watchlist:
@@ -111,10 +118,8 @@ def run_daily_report(
111118
core.run_a_share(date_str, include_news=include_news and not quick)
112119

113120
print("## 四、投资建议\n")
114-
print("- 先看交易日与数据源日期是否一致;若出现最新可用数据提示,不把旧行情当成当日信号。")
115-
print("- 个股/基金优先结合持仓成本、仓位上限和新闻催化验证,不因单日涨跌直接追涨杀跌。")
116-
print("- 市场情绪偏弱或资金流口径降级时,降低加仓冲动;情绪修复时再观察量能和领涨方向持续性。")
117-
print("- 基金估值是盘中/收盘估算,正式净值以基金公司晚间披露为准。")
121+
for note in _buffett_advice(core, watchlist, date_str, include_news=include_news and not quick):
122+
print(f"- {note}")
118123
print()
119124

120125
if core.DIAGNOSTICS:
@@ -141,7 +146,7 @@ def print_daily_summary(
141146
print("热点/资金: " + _flow_summary(core, date_str))
142147
if include_news and _include_only("news", only):
143148
print("新闻: 摘要模式不展开长新闻;需要详情可用 --format full")
144-
print("风险: 基金持仓按季报披露估算,若距今较久,可能已调仓;避免因单日涨跌追涨杀跌。")
149+
print("风险: " + _buffett_summary_risk(core, watchlist, date_str, include_news=include_news))
145150

146151

147152
def print_daily_key_points(
@@ -155,7 +160,8 @@ def print_daily_key_points(
155160
print()
156161
print("关键要点:")
157162
print(f"- 趋势: {_trend_hint(core, watchlist, date_str)}")
158-
print("- 风控: 若关注标的集中在同一主题,优先控制单一方向仓位。")
163+
for note in _buffett_key_points(core, watchlist, date_str, include_news=include_news):
164+
print(f"- 风控: {note}")
159165
print("- 操作: 使用 young diagnose 排查数据源;使用 --format full 查看完整复盘。")
160166

161167

@@ -252,6 +258,177 @@ def _trend_hint(core: Any, watchlist: dict[str, list[str]] | None, date_str: str
252258
return "关注标的分化或震荡,等待更明确方向。"
253259

254260

261+
def _buffett_advice(
262+
core: Any,
263+
watchlist: dict[str, list[str]] | None,
264+
date_str: str,
265+
include_news: bool = True,
266+
) -> list[str]:
267+
context = _buffett_context(core, watchlist, date_str, include_news=include_news)
268+
notes = _buffett_key_points_from_context(core, context)
269+
if not notes:
270+
return ["先设置关注股票/基金后再生成个性化建议;没有标的时只适合做市场复盘,不适合给仓位动作。"]
271+
return notes[:6]
272+
273+
274+
def _buffett_summary_risk(
275+
core: Any,
276+
watchlist: dict[str, list[str]] | None,
277+
date_str: str,
278+
include_news: bool = True,
279+
) -> str:
280+
context = _buffett_context(core, watchlist, date_str, include_news=include_news)
281+
notes = _buffett_key_points_from_context(core, context)
282+
if notes:
283+
return notes[0]
284+
funds = _daily_watchlist_items(watchlist, "funds")
285+
if funds:
286+
return f"{'、'.join(funds[:3])} 仅能看到净值/估值变化,先核对季报持仓时效,再决定是否调整仓位。"
287+
return "暂无关注标的,先设置投资记忆后再做能力圈、安全边际和主题集中度检查。"
288+
289+
290+
def _buffett_key_points(
291+
core: Any,
292+
watchlist: dict[str, list[str]] | None,
293+
date_str: str,
294+
include_news: bool = True,
295+
) -> list[str]:
296+
context = _buffett_context(core, watchlist, date_str, include_news=include_news)
297+
notes = _buffett_key_points_from_context(core, context)
298+
return notes[:3] or ["暂无关注标的,先设置投资记忆后再输出个性化风险。"]
299+
300+
301+
def _buffett_context(
302+
core: Any,
303+
watchlist: dict[str, list[str]] | None,
304+
date_str: str,
305+
include_news: bool = True,
306+
) -> dict[str, Any]:
307+
quotes = []
308+
news_titles: dict[str, list[str]] = {}
309+
for symbol in _daily_watchlist_items(watchlist, "stocks")[:8]:
310+
try:
311+
qd = core.get_single_stock_quote(symbol, date_str)
312+
except ValueError:
313+
qd = None
314+
if qd:
315+
quotes.append(qd)
316+
if include_news:
317+
keyword = qd.name if qd.market in ("cn_market", "hk_market") and qd.name else qd.symbol
318+
lang = "zh-CN" if qd.market in ("cn_market", "hk_market") else "en"
319+
news = core.combined_news_search(
320+
keyword,
321+
size=2,
322+
lang=lang,
323+
aliases=core._news_aliases(qd.symbol, qd.name),
324+
date_str=date_str,
325+
)
326+
items = news.get("data", []) if "_error" not in news else []
327+
news_titles[qd.symbol] = [
328+
core._clean_news_title(str(item.get("title") or ""))
329+
for item in items
330+
if item.get("title")
331+
][:2]
332+
333+
funds = []
334+
for code in _daily_watchlist_items(watchlist, "funds")[:6]:
335+
data = core.fetch_fund_estimate(code, date_str)
336+
if "_error" not in data:
337+
funds.append(data)
338+
else:
339+
funds.append({"fundcode": code, "_error": data.get("_error")})
340+
return {"quotes": quotes, "funds": funds, "news_titles": news_titles}
341+
342+
343+
def _buffett_key_points_from_context(core: Any, context: dict[str, Any]) -> list[str]:
344+
quotes = context.get("quotes", [])
345+
funds = context.get("funds", [])
346+
news_titles = context.get("news_titles", {})
347+
notes: list[str] = []
348+
349+
leaders = sorted(
350+
[q for q in quotes if q.change_pct is not None],
351+
key=lambda q: abs(q.change_pct or 0),
352+
reverse=True,
353+
)
354+
if leaders:
355+
qd = leaders[0]
356+
name = qd.name or qd.symbol
357+
headlines = news_titles.get(qd.symbol, [])
358+
news_hint = f";新闻线索:{headlines[0]}" if headlines else ""
359+
if (qd.change_pct or 0) >= 3:
360+
notes.append(
361+
f"{name}({qd.symbol}) 今日 {core.fmt_pct(qd.change_pct)},先用安全边际检查估值和盈利质量,不能把上涨直接当成内在价值提升{news_hint}。"
362+
)
363+
elif (qd.change_pct or 0) <= -3:
364+
notes.append(
365+
f"{name}({qd.symbol}) 今日 {core.fmt_pct(qd.change_pct)},按 Buffett 框架区分价格波动和护城河受损;若新闻指向监管、需求或现金流恶化,再考虑降级{news_hint}。"
366+
)
367+
else:
368+
notes.append(
369+
f"{name}({qd.symbol}) 波动最大但仅 {core.fmt_pct(qd.change_pct)},更适合观察业务质量与估值位置,不必因日内报价改变长期判断。"
370+
)
371+
372+
themes = _theme_exposure(quotes, news_titles)
373+
if themes:
374+
top_theme, names = max(themes.items(), key=lambda item: len(item[1]))
375+
if len(names) >= 2:
376+
notes.append(
377+
f"主题集中度偏高:{top_theme} 覆盖 {', '.join(names[:4])};需要确认这些标的是可理解的能力圈和可持续护城河,而不是同一条景气线的重复押注。"
378+
)
379+
else:
380+
notes.append(
381+
f"能力圈检查:{names[0]} 暂归入 {top_theme},后续建议补 PE/PB/ROE、自由现金流和竞争优势证据,再谈加仓。"
382+
)
383+
384+
strong_funds = []
385+
weak_funds = []
386+
for fund in funds:
387+
code = str(fund.get("fundcode") or fund.get("code") or "")
388+
pct = _to_float(fund.get("estimate_change_pct"))
389+
if pct is not None and pct >= 2:
390+
strong_funds.append(f"{code}{core.fmt_pct(pct)}")
391+
elif pct is not None and pct <= -2:
392+
weak_funds.append(f"{code}{core.fmt_pct(pct)}")
393+
if strong_funds:
394+
notes.append(
395+
f"基金 {', '.join(strong_funds[:3])} 估值偏强,但基金持仓来自季报且可能已调仓;把它当净值线索,不把估算涨幅当买入理由。"
396+
)
397+
elif weak_funds:
398+
notes.append(
399+
f"基金 {', '.join(weak_funds[:3])} 估值偏弱,先查重仓行业是否发生长期逻辑变化;若只是市场先生报价波动,仓位动作应服从原先配置上限。"
400+
)
401+
elif funds:
402+
codes = [str(fund.get("fundcode") or fund.get("code") or "") for fund in funds if fund.get("fundcode") or fund.get("code")]
403+
notes.append(
404+
f"基金 {', '.join(codes[:3])} 暂无极端估值波动;重点看持仓时效、基金经理风格漂移和与已关注个股的重合度。"
405+
)
406+
407+
if quotes and not any("安全边际" in note for note in notes):
408+
names = "、".join((q.name or q.symbol) for q in quotes[:3])
409+
notes.append(f"{names} 的下一步不是追涨杀跌,而是补一张估值表:合理买入价、可承受回撤、退出条件和机会成本。")
410+
411+
return notes
412+
413+
414+
def _theme_exposure(quotes: list[Any], news_titles: dict[str, list[str]]) -> dict[str, list[str]]:
415+
exposure: dict[str, list[str]] = {}
416+
for qd in quotes:
417+
blob = " ".join([qd.symbol or "", qd.name or "", *news_titles.get(qd.symbol, [])])
418+
for theme, keywords in THEME_KEYWORDS.items():
419+
if any(keyword.lower() in blob.lower() for keyword in keywords):
420+
exposure.setdefault(theme, []).append(qd.name or qd.symbol)
421+
break
422+
return exposure
423+
424+
425+
def _to_float(value: Any) -> float | None:
426+
try:
427+
return float(str(value).replace("%", "").replace(",", ""))
428+
except (TypeError, ValueError):
429+
return None
430+
431+
255432
def _section_order(order: str | None) -> list[str]:
256433
if not order:
257434
return ["watchlist", "global", "a"]

tests/test_calendar_profile_health.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
add_group_item,
88
add_profile_item,
99
clear_profile,
10+
clear_profile_kind,
1011
load_profile,
1112
profile_path,
1213
remove_profile_item,
@@ -41,6 +42,22 @@ def test_profile_read_write_uses_env_override(monkeypatch, tmp_path):
4142
"groups": {"稳健型": {"stocks": [], "funds": ["021528"]}},
4243
}
4344

45+
add_profile_item("stocks", "600000")
46+
add_group_item("稳健型", "600000")
47+
clear_profile_kind("stocks")
48+
assert load_profile() == {
49+
"stocks": [],
50+
"funds": ["161725"],
51+
"groups": {"稳健型": {"stocks": [], "funds": ["021528"]}},
52+
}
53+
54+
clear_profile_kind("funds")
55+
assert load_profile() == {
56+
"stocks": [],
57+
"funds": [],
58+
"groups": {"稳健型": {"stocks": [], "funds": []}},
59+
}
60+
4461
clear_profile()
4562
assert load_profile() == {"stocks": [], "funds": [], "groups": {}}
4663

0 commit comments

Comments
 (0)