Skip to content

Commit ed0949c

Browse files
author
AdvancingTitans
committed
feat: require validated position memory
1 parent ac87ccb commit ed0949c

8 files changed

Lines changed: 328 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ 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.16] - 2026-06-04
9+
10+
### Changed
11+
- `young profile add-stock/add-fund` now requires `--buy-date` and `--quantity`, validates symbols/fund codes before writing memory, and confirms the resolved security name/code in Chinese.
12+
- Personalized daily reports now show only markets relevant to the user's direct stock holdings and fund top-10 holdings instead of defaulting to a global market section.
13+
- Fund and stock advice now varies by buy-date return, same-day move, and available news signal instead of repeating the same holding sentence.
14+
815
## [0.1.15] - 2026-06-04
916

1017
### Added

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +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
36+
young profile add-stock 600519 --buy-date 2026-01-15 --quantity 100
3737
young profile add-stock NVDA --buy-date 2026-01-15 --quantity 10
38-
young profile add-fund 161725
38+
young profile add-fund 161725 --buy-date 2026-01-10 --quantity 1000
3939
young profile add-fund 021528 --buy-date 2026-01-10 --quantity 1000
4040
young profile list
4141
young profile clear-stocks # clear all saved stocks/ETFs only
@@ -90,7 +90,7 @@ The internals are being split into focused modules: `young_stock.calendar` handl
9090
- **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.
9191
- **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.
9292
- **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.
93-
- **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 portfolio-style suggestions grounded in your funds, stocks, holding dates, quantities, and available news. First use: add symbols with `young profile add-stock 600519` and `young profile add-fund 161725`; add `--buy-date` and `--quantity` to estimate return since purchase.
93+
- **Personal daily report**`young daily` reads your local investment memory from `~/.young_stock/profile.json`, then prints saved stock/ETF trends, fund estimates, only the markets relevant to your stocks and fund top holdings, and portfolio-style suggestions grounded in your funds, stocks, holding dates, quantities, and available news. First use requires a verified symbol plus `--buy-date` and `--quantity`, for example `young profile add-stock 600519 --buy-date 2026-01-15 --quantity 100` or `young profile add-fund 161725 --buy-date 2026-01-10 --quantity 1000`.
9494
- **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.
9595
- **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`.
9696
- **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.

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.15"
7+
version = "0.1.16"
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.15"
3+
__version__ = "0.1.16"

src/young_stock/cli.py

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import subprocess
55
import sys
6+
from datetime import datetime
67

78
import click
89

@@ -54,10 +55,10 @@ def _run(market: str, date: str | None, refresh: bool, include_news: bool = True
5455
def _print_first_use_guide() -> None:
5556
click.echo("# 每日行情日报")
5657
click.echo()
57-
click.echo("尚未设置投资记忆。首次使用请先添加你关注的股票、ETF 或基金:")
58-
click.echo(" young profile add-stock 600519")
59-
click.echo(" young profile add-stock 0700.HK")
60-
click.echo(" young profile add-fund 161725")
58+
click.echo("尚未设置投资记忆。首次使用请先添加你关注的股票、ETF 或基金,并补充买入日期和数量:")
59+
click.echo(" young profile add-stock 600519 --buy-date 2026-01-15 --quantity 100")
60+
click.echo(" young profile add-stock 0700.HK --buy-date 2026-01-15 --quantity 200")
61+
click.echo(" young profile add-fund 161725 --buy-date 2026-01-10 --quantity 1000")
6162
click.echo()
6263
click.echo(f"配置会保存到: {profile_path()}")
6364

@@ -220,28 +221,66 @@ def profile() -> None:
220221
pass
221222

222223

224+
def _normalize_buy_date(value: str) -> str:
225+
compact = _core._compact_date(value.strip())
226+
try:
227+
parsed = datetime.strptime(compact, "%Y%m%d")
228+
except ValueError as exc:
229+
raise click.ClickException("buy-date 必须是 YYYYMMDD 或 YYYY-MM-DD") from exc
230+
return parsed.strftime("%Y-%m-%d")
231+
232+
233+
def _validate_quantity(value: float) -> float:
234+
if value <= 0:
235+
raise click.ClickException("quantity 必须大于 0")
236+
return value
237+
238+
239+
def _stock_invalid_message(symbol: str) -> str:
240+
return f"{symbol} 不是有效的股票代码,请删除重新输入"
241+
242+
243+
def _fund_invalid_message(code: str) -> str:
244+
return f"{code} 不是有效的基金代码,请删除重新输入"
245+
246+
223247
@profile.command("add-stock", help="Add a stock/ETF symbol to your daily watchlist.")
224248
@click.argument("symbol")
225-
@click.option("--buy-date", default=None, help="Buy date YYYYMMDD or YYYY-MM-DD for return analysis.")
226-
@click.option("--quantity", type=float, default=None, help="Holding quantity/shares.")
227-
def profile_add_stock(symbol: str, buy_date: str | None, quantity: float | None) -> None:
228-
data = add_profile_item("stocks", symbol, buy_date=buy_date, quantity=quantity)
229-
click.echo(f"Added stock: {symbol.strip()}")
230-
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
231-
if buy_date or quantity is not None:
232-
click.echo("Position: buy_date=" + (buy_date or "-") + f"; quantity={quantity:g}" if quantity is not None else "Position: buy_date=" + (buy_date or "-"))
249+
@click.option("--buy-date", required=True, help="Buy date YYYYMMDD or YYYY-MM-DD for return analysis.")
250+
@click.option("--quantity", required=True, type=float, help="Holding quantity/shares.")
251+
def profile_add_stock(symbol: str, buy_date: str, quantity: float) -> None:
252+
normalized_date = _normalize_buy_date(buy_date)
253+
normalized_quantity = _validate_quantity(quantity)
254+
date_str = _core.nearest_trade_date()
255+
try:
256+
quote = _core.get_single_stock_quote(symbol, date_str)
257+
except ValueError as exc:
258+
raise click.ClickException(_stock_invalid_message(symbol)) from exc
259+
if not quote or quote.price is None or quote.market not in {"cn_market", "hk_market", "us_market"}:
260+
raise click.ClickException(_stock_invalid_message(symbol))
261+
add_profile_item("stocks", quote.symbol, buy_date=normalized_date, quantity=normalized_quantity)
262+
click.echo(f"您的投资记忆已添加:{quote.name or quote.symbol}{quote.symbol})")
263+
click.echo(f"Position: buy_date={normalized_date}; quantity={normalized_quantity:g}")
233264

234265

235266
@profile.command("add-fund", help="Add a fund code to your daily watchlist.")
236267
@click.argument("code")
237-
@click.option("--buy-date", default=None, help="Buy date YYYYMMDD or YYYY-MM-DD for return analysis.")
238-
@click.option("--quantity", type=float, default=None, help="Holding shares/units.")
239-
def profile_add_fund(code: str, buy_date: str | None, quantity: float | None) -> None:
240-
data = add_profile_item("funds", code, buy_date=buy_date, quantity=quantity)
241-
click.echo(f"Added fund: {code.strip()}")
242-
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
243-
if buy_date or quantity is not None:
244-
click.echo("Position: buy_date=" + (buy_date or "-") + f"; quantity={quantity:g}" if quantity is not None else "Position: buy_date=" + (buy_date or "-"))
268+
@click.option("--buy-date", required=True, help="Buy date YYYYMMDD or YYYY-MM-DD for return analysis.")
269+
@click.option("--quantity", required=True, type=float, help="Holding shares/units.")
270+
def profile_add_fund(code: str, buy_date: str, quantity: float) -> None:
271+
normalized_date = _normalize_buy_date(buy_date)
272+
normalized_quantity = _validate_quantity(quantity)
273+
try:
274+
fund_code = _core.normalize_fund_code(code)
275+
except ValueError as exc:
276+
raise click.ClickException(_fund_invalid_message(code)) from exc
277+
data = _core.fetch_fund_estimate(fund_code, _core.nearest_trade_date())
278+
if "_error" in data or not data.get("name"):
279+
raise click.ClickException(_fund_invalid_message(code))
280+
saved_code = str(data.get("fundcode") or fund_code)
281+
add_profile_item("funds", saved_code, buy_date=normalized_date, quantity=normalized_quantity)
282+
click.echo(f"您的投资记忆已添加:{data.get('name')}{saved_code})")
283+
click.echo(f"Position: buy_date={normalized_date}; quantity={normalized_quantity:g}")
245284

246285

247286
@profile.command("list", help="Show saved daily-report investment memory.")
@@ -337,8 +376,8 @@ def diagnose() -> None:
337376

338377
@cli.command(help="New-user guide.")
339378
def guide() -> None:
340-
click.echo("1. young profile add-stock 600519")
341-
click.echo("2. young profile add-fund 161725")
379+
click.echo("1. young profile add-stock 600519 --buy-date 2026-01-15 --quantity 100")
380+
click.echo("2. young profile add-fund 161725 --buy-date 2026-01-10 --quantity 1000")
342381
click.echo("3. young daily --format summary")
343382
click.echo("4. young profile list / young diagnose")
344383

src/young_stock/reports.py

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,10 @@ def run_daily_report(
116116
if _include_section("watchlist", only, sections):
117117
print_daily_watchlist(core, watchlist, date_str, include_news=include_news and not quick)
118118

119-
if not quick and _include_section("global", only, sections):
120-
print("## 二、全球指数与大盘概览\n")
121-
core.run_global_market(date_str)
119+
if not quick and _include_section("markets", only, sections):
120+
print_relevant_markets(core, watchlist, date_str, include_news=include_news)
122121

123-
if _include_section("a", only, sections):
124-
print("## 三、A股大盘与市场情绪\n")
125-
core.run_a_share(date_str, include_news=include_news and not quick)
126-
127-
print("## 四、投资建议\n")
122+
print("## 三、投资建议\n")
128123
for note in _portfolio_advice(core, watchlist, date_str, include_news=include_news and not quick):
129124
print(f"- {note}")
130125
print()
@@ -134,6 +129,48 @@ def run_daily_report(
134129
core.print_report_footer()
135130

136131

132+
def print_relevant_markets(core: Any, watchlist: dict[str, list[str]] | None, date_str: str, include_news: bool = True) -> None:
133+
markets = _relevant_markets(core, watchlist, date_str)
134+
if not markets:
135+
return
136+
print("## 二、相关市场概览\n")
137+
print("说明: 仅展示与用户持仓股票或基金 top10 持仓相关的市场,不再默认展开全球市场。\n")
138+
if "cn_market" in markets:
139+
print("### A股相关市场\n")
140+
core.run_a_share(date_str, include_news=include_news)
141+
if "hk_market" in markets:
142+
print("### 港股相关市场\n")
143+
core.run_hk_market(date_str, include_news=include_news)
144+
if "us_market" in markets:
145+
print("### 美股相关市场\n")
146+
core.run_us_market(date_str, include_news=include_news)
147+
148+
149+
def _relevant_markets(core: Any, watchlist: dict[str, list[str]] | None, date_str: str) -> set[str]:
150+
markets: set[str] = set()
151+
for symbol in _daily_watchlist_items(watchlist, "stocks"):
152+
try:
153+
_, market = core.normalize_stock_symbol(symbol)
154+
except ValueError:
155+
continue
156+
if market in {"cn_market", "hk_market", "us_market"}:
157+
markets.add(market)
158+
for code in _daily_watchlist_items(watchlist, "funds"):
159+
try:
160+
holdings = core.fetch_fund_holdings(code, date_str, limit=10)
161+
except Exception:
162+
holdings = {}
163+
for item in holdings.get("holdings", []) if isinstance(holdings, dict) else []:
164+
raw_code = str(item.get("code") or "")
165+
try:
166+
_, market = core.normalize_stock_symbol(raw_code)
167+
except ValueError:
168+
continue
169+
if market in {"cn_market", "hk_market", "us_market"}:
170+
markets.add(market)
171+
return markets
172+
173+
137174
def print_daily_summary(
138175
core: Any,
139176
date_str: str,
@@ -391,14 +428,18 @@ def _fund_position_notes(core: Any, context: dict[str, Any], compact: bool = Fal
391428
current_nav = _to_float(fund.get("estimate_nav")) or _to_float(fund.get("nav"))
392429
pnl = _position_return(current_nav, buy_nav.get("nav"), position.get("quantity"))
393430
pnl_text = _pnl_text(core, pnl)
394-
stance = "持有观察" if pct is None or pct >= -2 else "谨慎观察"
431+
name = str(fund.get("name") or code)
432+
stance, reason = _fund_manager_stance(pct, pnl)
433+
asof = str(fund.get("holding_asof") or fund.get("asof") or "")
395434
if compact:
396-
notes.append(f"{code}{core.fmt_pct(pct)}{pnl_text},建议“{stance}”。")
435+
notes.append(f"{name}({code}) {core.fmt_pct(pct)}{pnl_text},建议“{stance}”。")
397436
else:
398-
base = f"{code} 今日估值 {core.fmt_pct(pct)}{pnl_text},建议“{stance}”。"
437+
base = f"{name}({code}) 今日估值 {core.fmt_pct(pct)}{pnl_text},建议“{stance}”。{reason}"
399438
if position.get("buy_date"):
400439
base += f"买入日 {position.get('buy_date')},买入净值采用 {buy_nav.get('date', '待获取')} 附近可用净值。"
401-
base += "重点跟踪基金持仓时效、基金经理风格漂移,以及与自选个股是否重复暴露。"
440+
if asof:
441+
base += f"重仓股披露截止 {asof},若距今较久,需把实时估值作为调仓后的补充信号。"
442+
base += "跟踪重点:基金风格是否仍匹配买入逻辑、top10 持仓是否与自选股重复暴露、回撤是否超过自己的承受阈值。"
402443
notes.append(base)
403444
return notes
404445

@@ -415,8 +456,8 @@ def _stock_position_notes(core: Any, context: dict[str, Any], compact: bool = Fa
415456
position = positions.get(symbol) or positions.get(symbol.upper()) or {}
416457
buy_price = buy_prices.get(symbol) or buy_prices.get(symbol.upper()) or {}
417458
news_label, news_reason, news_score = _news_signal(news_titles.get(symbol, []))
418-
stance, action_reason = _manager_stance(qd.change_pct, news_score)
419459
pnl = _position_return(qd.price, buy_price.get("close"), position.get("quantity"))
460+
stance, action_reason = _manager_stance(qd.change_pct, news_score, pnl)
420461
pnl_text = _pnl_text(core, pnl)
421462
if compact:
422463
notes.append(f"{name}({symbol}) {core.fmt_pct(qd.change_pct)}{news_label}{pnl_text},建议“{stance}”。")
@@ -516,8 +557,35 @@ def _news_signal(headlines: list[str]) -> tuple[str, str, int]:
516557
return "新闻偏中性", f"({headlines[0]})", score
517558

518559

519-
def _manager_stance(change_pct: float | None, news_score: int) -> tuple[str, str]:
560+
def _fund_manager_stance(change_pct: float | None, pnl: dict[str, float] | None) -> tuple[str, str]:
561+
pct = change_pct or 0
562+
pnl_pct = pnl["pct"] if pnl else None
563+
if pnl_pct is not None and pnl_pct >= 15:
564+
if pct >= 1:
565+
return "继续持有但分批锁定收益", "买入以来收益较厚且当日估值仍偏强,适合把止盈线从成本转向回撤阈值。"
566+
return "持有并保护收益", "买入以来收益较厚但短线动能一般,重点看净值回撤和重仓方向是否转弱。"
567+
if pnl_pct is not None and pnl_pct <= -10:
568+
if pct <= -1:
569+
return "降低加仓冲动", "买入以来回撤较大且当日估值偏弱,先确认基金风格是否失效。"
570+
return "修复观察", "买入以来仍亏损但当日估值修复,可继续观察连续性,暂不因单日反弹追高。"
571+
if pct <= -2:
572+
return "谨慎观察", "当日估值明显走弱,优先检查重仓行业是否出现系统性负面信号。"
573+
if pct >= 2:
574+
return "持有观察", "当日估值较强,可跟踪上涨是否来自核心持仓贡献而非短线情绪。"
575+
return "中性持有", "收益和日内估值都未给出强动作信号,适合按原定配置纪律跟踪。"
576+
577+
578+
def _manager_stance(change_pct: float | None, news_score: int, pnl: dict[str, float] | None = None) -> tuple[str, str]:
520579
pct = change_pct or 0
580+
pnl_pct = pnl["pct"] if pnl else None
581+
if pnl_pct is not None and pnl_pct >= 20 and news_score < 0:
582+
return "保护收益", "买入以来收益较厚但新闻边际转弱,先上移止盈/回撤线,避免好仓位回吐成普通波动。"
583+
if pnl_pct is not None and pnl_pct >= 20 and pct >= 2:
584+
return "持有但不追高", "买入以来已有较大安全垫,当日继续走强时更适合让利润奔跑而不是追加风险。"
585+
if pnl_pct is not None and pnl_pct <= -12 and news_score < 0:
586+
return "降低仓位观察", "买入以来亏损叠加负面新闻,需重新验证买入逻辑,避免用补仓掩盖判断错误。"
587+
if pnl_pct is not None and pnl_pct <= -12 and news_score >= 0:
588+
return "修复观察", "买入以来仍处亏损,但消息面未明显恶化,可等待价格重新站稳后再决定是否补仓。"
521589
if pct >= 3 and news_score >= 0:
522590
return "持有观察", "趋势和消息面同向,但上涨后更要防止为好消息支付过高价格。"
523591
if pct >= 3 and news_score < 0:
@@ -542,8 +610,19 @@ def _to_float(value: Any) -> float | None:
542610

543611
def _section_order(order: str | None) -> list[str]:
544612
if not order:
545-
return ["watchlist", "global", "a"]
546-
mapping = {"基金": "watchlist", "个股": "watchlist", "关注": "watchlist", "A股": "a", "a": "a", "港股": "global", "美股": "global", "global": "global"}
613+
return ["watchlist", "markets"]
614+
mapping = {
615+
"基金": "watchlist",
616+
"个股": "watchlist",
617+
"关注": "watchlist",
618+
"A股": "markets",
619+
"a": "markets",
620+
"港股": "markets",
621+
"美股": "markets",
622+
"markets": "markets",
623+
"相关市场": "markets",
624+
"global": "markets",
625+
}
547626
return [mapping.get(part.strip(), part.strip()) for part in order.split(",") if part.strip()]
548627

549628

@@ -554,6 +633,7 @@ def _include_only(section: str, only: str | None) -> bool:
554633
"funds": {"funds", "基金"},
555634
"stocks": {"stocks", "stock", "个股", "股票"},
556635
"a": {"a", "A股", "a股"},
636+
"markets": {"markets", "相关市场", "A股", "a股", "a", "港股", "美股", "hk", "us"},
557637
"news": {"news", "新闻"},
558638
}
559639
requested = {part.strip() for part in only.split(",") if part.strip()}

0 commit comments

Comments
 (0)