Skip to content

Commit dd2fc7c

Browse files
author
AdvancingTitans
committed
feat: prefer ths concept fund flow
1 parent 0014783 commit dd2fc7c

6 files changed

Lines changed: 200 additions & 5 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.10] - 2026-06-02
9+
10+
### Added
11+
- `young flow` now tries the Tonghuashun concept fund-flow page first and displays top concept net inflow/outflow directions when available.
12+
13+
### Changed
14+
- Fund-flow output now adapts its title to the active source scope: concept board, sector board, market activity, or Eastmoney main-capital flow.
15+
- Tonghuashun concept flow is treated as a board-direction reference and is clearly labeled as not equivalent to whole-market main-capital net inflow.
16+
817
## [0.1.9] - 2026-06-01
918

1019
### Added

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ It is also a foundation for analysis pipelines: every subcommand maps to a Pytho
7777
- **Smart caching**`~/.young_stock/cache/`, 7-day TTL, auto-pruned. Pass `--refresh` to skip.
7878
- **Trade-day awareness** — nearest-trade-day resolution including weekends and (best-effort) holidays.
7979
- **Rich terminal tables** — readable on dark and light terminals.
80-
- **Verified A-share fund flow**`young flow` shows the latest Eastmoney A-share / Shanghai Composite fund-flow record and its trading date explicitly, including a warning when it differs from the requested report date; if realtime fund-flow sources are temporarily unavailable, it tries Eastmoney page indicators, Sina Finance sector fund-flow, then Sina/Tencent market-activity references, clearly labeling non-equivalent fallbacks before using the most recent locally cached good fund-flow record.
80+
- **Verified A-share fund flow**`young flow` first tries Tonghuashun concept-board fund flow for fresh board-direction money signals, then 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.
8181
- **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.
8282
- **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.
8383
- **Readable news links** — linked news items are checked for obvious empty/404/no-content pages and replaced by other same-day news when possible.
@@ -139,6 +139,6 @@ young a -d 20260530 --refresh # 指定日期 + 强制刷新
139139
young update # 在当前 Python 环境中更新 CLI
140140
```
141141

142-
数据来源:腾讯财经、新浪财经、东方财富公开行情与快讯接口(`qt.gtimg.cn` / `hq.sinajs.cn` / `push2.eastmoney.com` / `push2ex.eastmoney.com` / `np-listapi.eastmoney.com`),多源自动切换。本地缓存 7 天,目录 `~/.young_stock/cache/`,可用 `young cache-clear` 清理。命令会标注当前阶段:上午盘、午间、下午盘或盘后;若展示的是非请求日的最新可用数据,会标注为该交易日盘后数据。
142+
数据来源:同花顺概念资金流页面、腾讯财经、新浪财经、东方财富公开行情与快讯接口(`data.10jqka.com.cn` / `qt.gtimg.cn` / `hq.sinajs.cn` / `push2.eastmoney.com` / `push2ex.eastmoney.com` / `np-listapi.eastmoney.com`),多源自动切换。本地缓存 7 天,目录 `~/.young_stock/cache/`,可用 `young cache-clear` 清理。命令会标注当前阶段:上午盘、午间、下午盘或盘后;若展示的是非请求日的最新可用数据,会标注为该交易日盘后数据。
143143

144144
适用人群:每天盘后想用一条命令看完五张图的开发者 / 量化研究者 / 自动化爱好者。欢迎 issue / PR。

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.9"
7+
version = "0.1.10"
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.9"
3+
__version__ = "0.1.10"

src/young_stock/_core.py

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@
9797
"https://money.finance.sina.com.cn/quotes_service/view/xml_money_flow_fc_hy.php"
9898
"?fenlei=0&format=json&callback=gotData0&_={ts}"
9999
)
100+
THS_CONCEPT_MONEY_FLOW_URL = "http://data.10jqka.com.cn/funds/gnzjl/"
100101
INDEX_URL = (
101102
"https://push2.eastmoney.com/api/qt/ulist.np/get"
102103
"?fltt=2&secids={secids}&fields={fields}&_={ts}"
@@ -573,6 +574,84 @@ def pct(amount: float) -> str:
573574
return result
574575

575576

577+
def _parse_ths_money_flow_table(raw_html: str) -> list[dict[str, Any]]:
578+
rows: list[dict[str, Any]] = []
579+
tbody_match = re.search(r"<tbody>(.*?)</tbody>", raw_html, re.DOTALL | re.IGNORECASE)
580+
tbody = tbody_match.group(1) if tbody_match else raw_html
581+
for row in re.findall(r"<tr[^>]*>(.*?)</tr>", tbody, re.DOTALL | re.IGNORECASE):
582+
cells = re.findall(r"<td[^>]*>(.*?)</td>", row, re.DOTALL | re.IGNORECASE)
583+
if len(cells) < 11:
584+
continue
585+
text_cells = [_html_to_text(cell) for cell in cells]
586+
concept = text_cells[1]
587+
if not concept or concept in {"行业", "概念"}:
588+
continue
589+
rows.append(
590+
{
591+
"rank": _safe_int(text_cells[0]),
592+
"name": concept,
593+
"index": _safe_float(text_cells[2]),
594+
"change_pct": _safe_number(text_cells[3]),
595+
"buy": _safe_number(text_cells[4]),
596+
"sell": _safe_number(text_cells[5]),
597+
"net": _safe_number(text_cells[6]),
598+
"companies": _safe_int(text_cells[7]),
599+
"leader": text_cells[8],
600+
"leader_change_pct": _safe_number(text_cells[9]),
601+
"leader_price": _safe_number(text_cells[10]),
602+
}
603+
)
604+
return rows
605+
606+
607+
def fetch_ths_concept_money_flow_snapshot(date_str: str) -> dict[str, str]:
608+
"""同花顺概念资金流。页面可直接返回表格,作为 young flow 的优先在线源。"""
609+
cached = cache_load("fund_flow", date_str, "ths", ttl=CACHE_TTL_SECONDS)
610+
if cached:
611+
return cached
612+
headers = {
613+
"User-Agent": (
614+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
615+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0 Safari/537.36"
616+
),
617+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
618+
"Referer": "http://data.10jqka.com.cn/",
619+
}
620+
try:
621+
req = urllib.request.Request(THS_CONCEPT_MONEY_FLOW_URL, headers=headers)
622+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
623+
with opener.open(req, timeout=12) as resp:
624+
raw = resp.read().decode("gbk", errors="ignore")
625+
except Exception as e:
626+
diag(f"THS concept money-flow snapshot: {e}")
627+
return {}
628+
629+
if "Nginx forbidden" in raw or "Forbidden" in raw:
630+
diag("THS concept money-flow snapshot: forbidden")
631+
return {}
632+
rows = _parse_ths_money_flow_table(raw)
633+
if not rows:
634+
diag("THS concept money-flow snapshot: empty table")
635+
return {}
636+
top_in_rows = sorted([row for row in rows if float(row.get("net") or 0) > 0], key=lambda item: float(item.get("net") or 0), reverse=True)[:5]
637+
top_out_rows = sorted([row for row in rows if float(row.get("net") or 0) < 0], key=lambda item: float(item.get("net") or 0))[:5]
638+
trade_date = _display_date(nearest_trade_date())
639+
result = {
640+
"date": trade_date,
641+
"_source": "同花顺概念资金流",
642+
"_scope": "A股",
643+
"_fallback_indicator": "concept_money_flow",
644+
"_indicator_note": "以下为同花顺概念板块资金流,反映概念方向净额,不等同于全市场主力资金净流入。",
645+
"_concept_in": json.dumps(top_in_rows, ensure_ascii=False),
646+
"_concept_out": json.dumps(top_out_rows, ensure_ascii=False),
647+
}
648+
if result["date"].replace("-", "") != date_str:
649+
result["_requested_date"] = _display_date(date_str)
650+
result["_date_note"] = "latest_available"
651+
cache_save("fund_flow", date_str, "ths", result)
652+
return result
653+
654+
576655
def _market_activity_snapshot(rows: list[dict[str, Any]], source: str, date_str: str) -> dict[str, str]:
577656
usable = [r for r in rows if r.get("f12") in {"000001", "399001"}]
578657
if not usable:
@@ -1591,6 +1670,10 @@ def get_zb_pool(date_str: str) -> dict[str, Any]:
15911670

15921671
@retry_on_recoverable(max_retries=MAX_RETRIES, initial_delay=INITIAL_BACKOFF)
15931672
def get_fund_flow(date_str: str, *, strict_date: bool = True) -> dict[str, str]:
1673+
ths_snapshot = fetch_ths_concept_money_flow_snapshot(date_str)
1674+
if ths_snapshot:
1675+
return ths_snapshot
1676+
15941677
cached = cache_load("fund_flow", date_str, "eastmoney")
15951678
if cached:
15961679
if cached.get("_unavailable"):
@@ -2282,7 +2365,12 @@ def print_zt_analysis(zt_data: dict, dt_data: dict, zb_data: dict) -> None:
22822365

22832366

22842367
def print_fund_flow(flow_data: dict[str, str]) -> None:
2285-
print("## A股资金流向(上证指数口径)\n")
2368+
scope_title = "A股资金流向(上证指数口径)"
2369+
if flow_data.get("_fallback_indicator") == "concept_money_flow":
2370+
scope_title = "A股资金流向(概念板块口径)"
2371+
elif flow_data.get("_fallback_indicator") == "sector_money_flow":
2372+
scope_title = "A股资金流向(行业板块口径)"
2373+
print(f"## {scope_title}\n")
22862374
if not flow_data or "_error" in flow_data:
22872375
print(" 暂不展示:当前没有拿到可核验交易日的免登录资金流数据。")
22882376
print()
@@ -2308,6 +2396,33 @@ def print_fund_flow(flow_data: dict[str, str]) -> None:
23082396
print(" 提醒: 实时接口暂不可用,当前为本地最近一次可信数据")
23092397
if flow_data.get("_source"):
23102398
print(f" 来源: {flow_data['_source']}")
2399+
if flow_data.get("_fallback_indicator") == "concept_money_flow":
2400+
print(f" 说明: {flow_data.get('_indicator_note', '当前展示概念板块资金流参考,不等同于全市场主力资金净流入。')}")
2401+
try:
2402+
top_in = json.loads(flow_data.get("_concept_in", "[]"))
2403+
top_out = json.loads(flow_data.get("_concept_out", "[]"))
2404+
except json.JSONDecodeError:
2405+
top_in, top_out = [], []
2406+
if top_in:
2407+
print(" 概念净流入靠前:")
2408+
for item in top_in[:5]:
2409+
name = item.get("name", "-")
2410+
net = float(item.get("net") or 0)
2411+
leader = item.get("leader") or "-"
2412+
leader_pct = fmt_pct(item.get("leader_change_pct"))
2413+
print(f" {name}: {net:+.2f}亿,领涨股 {leader}{leader_pct})")
2414+
if top_out:
2415+
print(" 概念净流出靠前:")
2416+
for item in top_out[:5]:
2417+
name = item.get("name", "-")
2418+
net = float(item.get("net") or 0)
2419+
leader = item.get("leader") or "-"
2420+
leader_pct = fmt_pct(item.get("leader_change_pct"))
2421+
print(f" {name}: {net:+.2f}亿,领涨股 {leader}{leader_pct})")
2422+
else:
2423+
print(" 概念净流出靠前: 当前页未返回净流出概念")
2424+
print()
2425+
return
23112426
if flow_data.get("_fallback_indicator") == "sector_money_flow":
23122427
print(f" 说明: {flow_data.get('_indicator_note', '当前展示行业资金流参考,不等同于全市场主力资金净流入。')}")
23132428
try:

tests/test_core.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
from datetime import datetime
22

3+
import pytest
4+
35
from young_stock import _core
46

57

8+
@pytest.fixture(autouse=True)
9+
def disable_ths_flow_network(monkeypatch):
10+
monkeypatch.setattr(_core, "fetch_ths_concept_money_flow_snapshot", lambda date_str: {})
11+
12+
613
def test_format_helpers():
714
assert _core.fmt_price(12.345) == "12.35"
815
assert _core.fmt_pct(1.234).endswith("%")
@@ -294,6 +301,70 @@ def test_market_fund_flow_snapshot_sums_shanghai_and_shenzhen(monkeypatch):
294301
assert flow["_date_note"] == "latest_available"
295302

296303

304+
def test_parse_ths_money_flow_table():
305+
raw = """
306+
<table><tbody><tr class="even">
307+
<td class="first tc">1</td>
308+
<td class="tl"><a>ERP概念</a></td>
309+
<td class=" c-rise">1447.8</td>
310+
<td class="tr cur c-rise">4.78%</td>
311+
<td class="tr c-rise">61.53</td>
312+
<td class="tr c-fall">47.25</td>
313+
<td class="tr c-rise">14.28</td>
314+
<td class="tr">35</td>
315+
<td class="tc"><a>软通动力</a></td>
316+
<td class="tr c-rise">20.01%</td>
317+
<td class="tr c-rise">39.65</td>
318+
</tr></tbody></table>
319+
"""
320+
321+
rows = _core._parse_ths_money_flow_table(raw)
322+
323+
assert rows[0]["name"] == "ERP概念"
324+
assert rows[0]["net"] == 14.28
325+
assert rows[0]["leader"] == "软通动力"
326+
327+
328+
def test_fund_flow_prefers_ths_concept_flow(monkeypatch):
329+
monkeypatch.setattr(_core, "cache_load", lambda *args, **kwargs: None)
330+
monkeypatch.setattr(
331+
_core,
332+
"fetch_ths_concept_money_flow_snapshot",
333+
lambda date_str: {
334+
"date": "2026-06-01",
335+
"_source": "同花顺概念资金流",
336+
"_fallback_indicator": "concept_money_flow",
337+
"_concept_in": "[]",
338+
"_concept_out": "[]",
339+
},
340+
)
341+
monkeypatch.setattr(_core, "fetch_fund_flow_json", lambda url: (_ for _ in ()).throw(AssertionError("should not call Eastmoney first")))
342+
343+
flow = _core.get_fund_flow("20260601")
344+
345+
assert flow["_source"] == "同花顺概念资金流"
346+
assert flow["_fallback_indicator"] == "concept_money_flow"
347+
348+
349+
def test_print_fund_flow_concept_indicator(capsys):
350+
_core.print_fund_flow(
351+
{
352+
"date": "2026-06-01",
353+
"_source": "同花顺概念资金流",
354+
"_fallback_indicator": "concept_money_flow",
355+
"_indicator_note": "概念板块资金流参考",
356+
"_concept_in": '[{"name":"ERP概念","net":14.28,"leader":"软通动力","leader_change_pct":20.01}]',
357+
"_concept_out": '[{"name":"有色金属","net":-8.0,"leader":"示例股","leader_change_pct":-3.0}]',
358+
}
359+
)
360+
361+
output = capsys.readouterr().out
362+
assert "概念板块口径" in output
363+
assert "同花顺概念资金流" in output
364+
assert "ERP概念" in output
365+
assert "主力净流入:" not in output
366+
367+
297368
def test_fund_flow_ignores_old_unavailable_cache(monkeypatch):
298369
monkeypatch.setattr(_core, "cache_load", lambda *args, **kwargs: {"_unavailable": "old"})
299370
monkeypatch.setattr(_core, "cache_save", lambda *args, **kwargs: None)

0 commit comments

Comments
 (0)