Skip to content

Commit ac87ccb

Browse files
author
AdvancingTitans
committed
feat: add position-aware daily advice
1 parent 42a7053 commit ac87ccb

11 files changed

Lines changed: 503 additions & 106 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,23 @@ 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.15] - 2026-06-04
9+
10+
### Added
11+
- Added optional position memory on `young profile add-stock/add-fund` through `--buy-date` and `--quantity`; reports automatically look up the buy-date stock close or fund NAV instead of asking users to enter cost price.
12+
- Added personalized fund-only, stock-only, and combined portfolio analysis in daily reports, including estimated return since purchase, news trend, holding stance, and portfolio concentration guidance.
13+
14+
### Changed
15+
- Reworked daily advice from generic framework language into separate `基金分析`, `个股分析`, and `综合持仓` sections.
16+
817
## [0.1.14] - 2026-06-03
918

1019
### Added
1120
- Added `young uninstall` to remove the package from the current Python environment with one command.
1221
- Added `young profile clear-stocks` and `young profile clear-funds` for one-click stock/fund memory cleanup without deleting the other side.
1322

1423
### 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.
24+
- Daily report 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.
1625

1726
## [0.1.13] - 2026-06-03
1827

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ 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
3636
young profile add-stock 600519
37+
young profile add-stock NVDA --buy-date 2026-01-15 --quantity 10
3738
young profile add-fund 161725
39+
young profile add-fund 021528 --buy-date 2026-01-10 --quantity 1000
3840
young profile list
3941
young profile clear-stocks # clear all saved stocks/ETFs only
4042
young profile clear-funds # clear all saved funds only
@@ -88,7 +90,7 @@ The internals are being split into focused modules: `young_stock.calendar` handl
8890
- **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.
8991
- **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.
9092
- **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.
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`.
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.
9294
- **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.
9395
- **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`.
9496
- **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.14"
7+
version = "0.1.15"
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.14"
3+
__version__ = "0.1.15"

src/young_stock/_core.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,16 @@
177177
"https://fundf10.eastmoney.com/FundArchivesDatas.aspx"
178178
"?type=jjcc&code={code}&topline={topline}&year=&month=&rt={ts}"
179179
)
180+
FUND_NAV_HISTORY_URL = (
181+
"https://api.fund.eastmoney.com/f10/lsjz"
182+
"?fundCode={code}&pageIndex=1&pageSize=20&startDate={start}&endDate={end}&_={ts}"
183+
)
184+
EM_KLINE_URL = (
185+
"https://push2his.eastmoney.com/api/qt/stock/kline/get"
186+
"?secid={secid}&fields1=f1,f2,f3,f4,f5,f6"
187+
"&fields2=f51,f52,f53,f54,f55,f56,f57,f58,f59,f60,f61"
188+
"&klt=101&fqt=1&beg={beg}&end={end}&_={ts}"
189+
)
180190

181191
# 新浪财经(免登录、GBK 编码、对美股/港股最稳,作为 stock/get 之外的主路径)
182192
SINA_HQ_URL = "https://hq.sinajs.cn/list={codes}"
@@ -2613,6 +2623,91 @@ def normalize_fund_code(code: str) -> str:
26132623
return code
26142624

26152625

2626+
def _compact_date(value: str) -> str:
2627+
return re.sub(r"\D", "", str(value or ""))[:8]
2628+
2629+
2630+
def _date_plus_days(date_str: str, days: int) -> str:
2631+
return (datetime.strptime(_compact_date(date_str), "%Y%m%d") + timedelta(days=days)).strftime("%Y%m%d")
2632+
2633+
2634+
def _stock_secid_for_history(symbol: str) -> tuple[str, str, str]:
2635+
normalized, market = normalize_stock_symbol(symbol)
2636+
if market == "cn_market":
2637+
return normalized, market, _cn_secid(normalized)
2638+
if market == "hk_market":
2639+
return normalized, market, _hk_secid(normalized)
2640+
secid = _us_secid(normalized)
2641+
if not secid:
2642+
raise ValueError(f"暂不支持自动回溯该美股历史价格: {symbol}")
2643+
return normalized, market, secid
2644+
2645+
2646+
def fetch_stock_close_on_or_after(symbol: str, buy_date: str) -> dict[str, Any]:
2647+
"""Fetch first available split-adjusted daily close on/after buy_date."""
2648+
compact = _compact_date(buy_date)
2649+
if not re.fullmatch(r"\d{8}", compact):
2650+
return {"_error": "买入日期应为 YYYYMMDD 或 YYYY-MM-DD"}
2651+
try:
2652+
normalized, market, secid = _stock_secid_for_history(symbol)
2653+
except ValueError as e:
2654+
return {"_error": str(e)}
2655+
end = _date_plus_days(compact, 10)
2656+
cached = cache_load(f"stock_buy_{normalized}", compact, "eastmoney_kline", ttl=86400)
2657+
if cached:
2658+
return cached
2659+
url = EM_KLINE_URL.format(secid=secid, beg=compact, end=end, ts=int(time.time() * 1000))
2660+
data = fetch_json(url, {"Referer": "https://quote.eastmoney.com/"})
2661+
if "_error" in data:
2662+
return data
2663+
klines = (data.get("data") or {}).get("klines") or []
2664+
for row in klines:
2665+
parts = str(row).split(",")
2666+
if len(parts) >= 3:
2667+
result = {
2668+
"symbol": normalized,
2669+
"market": market,
2670+
"date": parts[0],
2671+
"close": _safe_float(parts[2]),
2672+
"_source": "东方财富历史K线",
2673+
}
2674+
if result["close"] is not None:
2675+
cache_save(f"stock_buy_{normalized}", compact, "eastmoney_kline", result)
2676+
return result
2677+
return {"_error": "买入日附近未获取到可用股票收盘价"}
2678+
2679+
2680+
def fetch_fund_nav_on_or_after(fund_code: str, buy_date: str) -> dict[str, Any]:
2681+
"""Fetch first available fund NAV on/after buy_date."""
2682+
fund_code = normalize_fund_code(fund_code)
2683+
compact = _compact_date(buy_date)
2684+
if not re.fullmatch(r"\d{8}", compact):
2685+
return {"_error": "买入日期应为 YYYYMMDD 或 YYYY-MM-DD"}
2686+
start = datetime.strptime(compact, "%Y%m%d").strftime("%Y-%m-%d")
2687+
end = datetime.strptime(_date_plus_days(compact, 20), "%Y%m%d").strftime("%Y-%m-%d")
2688+
cached = cache_load(f"fund_buy_{fund_code}", compact, "eastmoney_fund_nav", ttl=86400)
2689+
if cached:
2690+
return cached
2691+
url = FUND_NAV_HISTORY_URL.format(code=fund_code, start=start, end=end, ts=int(time.time() * 1000))
2692+
data = fetch_json(url, {"Referer": f"https://fundf10.eastmoney.com/jjjz_{fund_code}.html"})
2693+
if "_error" in data:
2694+
return data
2695+
rows = ((data.get("Data") or {}).get("LSJZList") or [])
2696+
rows = sorted(rows, key=lambda row: str(row.get("FSRQ") or ""))
2697+
for row in rows:
2698+
nav = _safe_float(row.get("DWJZ"))
2699+
if nav is not None:
2700+
result = {
2701+
"fundcode": fund_code,
2702+
"date": row.get("FSRQ") or "",
2703+
"nav": nav,
2704+
"_source": "东方财富历史净值",
2705+
}
2706+
cache_save(f"fund_buy_{fund_code}", compact, "eastmoney_fund_nav", result)
2707+
return result
2708+
return {"_error": "买入日附近未获取到可用基金净值"}
2709+
2710+
26162711
def fetch_fund_estimate(fund_code: str, date_str: str) -> dict[str, Any]:
26172712
fund_code = normalize_fund_code(fund_code)
26182713
cached = cache_load(f"fund_estimate_{fund_code}", date_str, "eastmoney_fund")

src/young_stock/cli.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -222,25 +222,41 @@ def profile() -> None:
222222

223223
@profile.command("add-stock", help="Add a stock/ETF symbol to your daily watchlist.")
224224
@click.argument("symbol")
225-
def profile_add_stock(symbol: str) -> None:
226-
data = add_profile_item("stocks", 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)
227229
click.echo(f"Added stock: {symbol.strip()}")
228230
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 "-"))
229233

230234

231235
@profile.command("add-fund", help="Add a fund code to your daily watchlist.")
232236
@click.argument("code")
233-
def profile_add_fund(code: str) -> None:
234-
data = add_profile_item("funds", 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)
235241
click.echo(f"Added fund: {code.strip()}")
236242
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 "-"))
237245

238246

239247
@profile.command("list", help="Show saved daily-report investment memory.")
240248
def profile_show() -> None:
241249
data = load_profile()
242250
click.echo(f"Stocks: {', '.join(data.get('stocks', [])) or '-'}")
243251
click.echo(f"Funds: {', '.join(data.get('funds', [])) or '-'}")
252+
positions = data.get("positions", {})
253+
if positions.get("stocks") or positions.get("funds"):
254+
click.echo("Positions:")
255+
for kind, label in (("stocks", "stock"), ("funds", "fund")):
256+
for code, position in positions.get(kind, {}).items():
257+
buy_date = position.get("buy_date", "-")
258+
quantity = position.get("quantity", "-")
259+
click.echo(f" {label} {code}: buy_date={buy_date}; quantity={quantity}")
244260
groups = data.get("groups", {})
245261
if groups:
246262
click.echo("Groups:")

src/young_stock/profile.py

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import os
77
from pathlib import Path
88

9-
EMPTY_PROFILE = {"stocks": [], "funds": [], "groups": {}}
9+
EMPTY_PROFILE = {"stocks": [], "funds": [], "groups": {}, "positions": {"stocks": {}, "funds": {}}}
1010

1111

1212
def profile_path() -> Path:
@@ -19,15 +19,16 @@ def profile_path() -> Path:
1919
def load_profile() -> dict[str, list[str]]:
2020
path = profile_path()
2121
if not path.exists():
22-
return {k: list(v) for k, v in EMPTY_PROFILE.items()}
22+
return _empty_profile()
2323
try:
2424
data = json.loads(path.read_text(encoding="utf-8"))
2525
except (OSError, json.JSONDecodeError):
26-
return {k: list(v) for k, v in EMPTY_PROFILE.items()}
26+
return _empty_profile()
2727
return {
2828
"stocks": [str(v) for v in data.get("stocks", []) if str(v).strip()],
2929
"funds": [str(v) for v in data.get("funds", []) if str(v).strip()],
3030
"groups": _normalize_groups(data.get("groups", {})),
31+
"positions": _normalize_positions(data.get("positions", {})),
3132
}
3233

3334

@@ -37,14 +38,25 @@ def save_profile(profile: dict[str, list[str]]) -> None:
3738
path.write_text(json.dumps(profile, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
3839

3940

40-
def add_profile_item(kind: str, value: str) -> dict[str, list[str]]:
41+
def add_profile_item(
42+
kind: str,
43+
value: str,
44+
buy_date: str | None = None,
45+
quantity: float | None = None,
46+
) -> dict[str, list[str]]:
4147
if kind not in {"stocks", "funds"}:
4248
raise ValueError(f"unknown profile item kind: {kind}")
4349
profile = load_profile()
4450
items = profile.setdefault(kind, [])
4551
normalized = value.strip()
4652
if normalized and normalized not in items:
4753
items.append(normalized)
54+
if normalized and (buy_date or quantity is not None):
55+
position = profile.setdefault("positions", {}).setdefault(kind, {}).setdefault(normalized, {})
56+
if buy_date:
57+
position["buy_date"] = buy_date.strip()
58+
if quantity is not None:
59+
position["quantity"] = quantity
4860
save_profile(profile)
4961
return profile
5062

@@ -55,12 +67,13 @@ def remove_profile_item(kind: str, value: str) -> dict[str, list[str]]:
5567
profile = load_profile()
5668
normalized = value.strip()
5769
profile[kind] = [item for item in profile.get(kind, []) if item != normalized]
70+
profile.setdefault("positions", {}).setdefault(kind, {}).pop(normalized, None)
5871
save_profile(profile)
5972
return profile
6073

6174

6275
def clear_profile() -> dict[str, list[str]]:
63-
profile = {k: ({} if k == "groups" else []) for k in EMPTY_PROFILE}
76+
profile = _empty_profile()
6477
save_profile(profile)
6578
return profile
6679

@@ -70,6 +83,7 @@ def clear_profile_kind(kind: str) -> dict[str, list[str]]:
7083
raise ValueError(f"unknown profile item kind: {kind}")
7184
profile = load_profile()
7285
profile[kind] = []
86+
profile.setdefault("positions", {})[kind] = {}
7387
for group in profile.get("groups", {}).values():
7488
if isinstance(group, dict):
7589
group[kind] = []
@@ -109,3 +123,31 @@ def _normalize_groups(groups) -> dict[str, dict[str, list[str]]]:
109123
"funds": [str(v) for v in data.get("funds", []) if str(v).strip()],
110124
}
111125
return result
126+
127+
128+
def _normalize_positions(positions) -> dict[str, dict[str, dict[str, float | str]]]:
129+
result: dict[str, dict[str, dict[str, float | str]]] = {"stocks": {}, "funds": {}}
130+
if not isinstance(positions, dict):
131+
return result
132+
for kind in ("stocks", "funds"):
133+
raw_items = positions.get(kind, {})
134+
if not isinstance(raw_items, dict):
135+
continue
136+
for code, raw_position in raw_items.items():
137+
if not isinstance(raw_position, dict):
138+
continue
139+
position: dict[str, float | str] = {}
140+
buy_date = str(raw_position.get("buy_date") or "").strip()
141+
if buy_date:
142+
position["buy_date"] = buy_date
143+
try:
144+
position["quantity"] = float(raw_position["quantity"])
145+
except (KeyError, TypeError, ValueError):
146+
pass
147+
if position:
148+
result[kind][str(code)] = position
149+
return result
150+
151+
152+
def _empty_profile() -> dict[str, list[str]]:
153+
return {"stocks": [], "funds": [], "groups": {}, "positions": {"stocks": {}, "funds": {}}}

0 commit comments

Comments
 (0)