mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
* feat(market): v2.1 — consume tw 三大法人 in report + LLM prompt, fix TWD currency label The merged 三大法人 (institutional flows) data (#1829/#1841/#1855/#1863) reached get_fundamental_context but was never surfaced: the report renderer and the LLM prompt-builder never read the institution block, so a real analysis run showed a tw report with no 三大法人 figures. This wires the last mile (tw-only, additive, fail-open) and fixes a TWD currency mislabel found in the same run: - report render (src/notification.py): _append_institutional_flow renders a 三大法人 net-buy/sell table (外资/投信/自营/合计 in 万股/亿股) when the institution block is status='ok'; _get_fundamental_blocks now extracts it. + zh/en/ko labels. - LLM prompt (src/analyzer.py): _format_prompt injects a 三大法人 section (raw net figures + a qualitative 台股筹码过滤器 note, mirroring the capital_flow section) when status='ok' and all four core nets are present. - fetch availability (data_provider/base.py): the tw institution fetch (a ~4-5s whole-market download) now uses the remaining stage budget instead of the ~3s per-symbol fetch cap that starved it and made the first/only stock of a run coin-flip to not_supported. Still bounded by the stage deadline (fail-open). - currency (src/notification.py): _CURRENCY_SUFFIX gains TWD -> 新台币 so TWD amounts (revenue/profit/dividend) no longer render as the A-share default 元 (reads as RMB). Verified end-to-end via a real analysis run: the tw report now shows the 三大法人 table and 新台币-labelled financials, and institution is reliably 'ok' on a single-stock run. Strictly additive — cn/hk/us/jp/kr byte-identical (gated on market=='tw' / status=='ok'); no capital_flow_signal / scoring / schema. + 11 tests. Dual review (code-reviewer APPROVE + silent-failure-hunter): the hunter caught a prompt-gate asymmetry (only total_net checked vs all four in the render gate); tightened to require all four core nets, matching the render/base.py gate. * fix(market): honour FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=0 for the tw institution fetch Review on #1866: the v2.1 institution-timeout change (use the remaining stage budget instead of the ~3s per-fetch cap) inadvertently bypassed the existing FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=0 semantic — with fetch_timeout=0, valuation and bundle are disabled (they gate on fetch_timeout) but the institution fetch still ran. Gate the institution fetch on `fetch_timeout > 0` as well, so fetch_timeout=0 disables it like the other fundamental fetches; when enabled it still uses the remaining stage budget (the whole-market download needs more than the per-symbol cap). + a regression test asserting fetch_timeout=0 -> institution not_supported and the fetcher is not called. --------- Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
@@ -2981,11 +2981,17 @@ class DataFetcherManager:
|
||||
except Exception as exc: # noqa: BLE001 - wiring failure: loud but fail-open
|
||||
logger.error("[tw-inst] fetcher init failed (wiring bug?) code=%s: %s", stock_code, exc)
|
||||
fetcher = None
|
||||
if fetcher is not None:
|
||||
# Run the fetch under the SAME fundamental stage/fetch budget as the other
|
||||
# offshore blocks (_run_with_retry) so a slow / rate-limited TWSE/TPEx call
|
||||
# cannot push the analysis past its deadline — it fails open on timeout.
|
||||
inst_timeout = min(fetch_timeout, max(stage_timeout - (time.time() - start_ts), 0.0))
|
||||
# fetch_timeout == 0 disables per-fetch fundamental fetches (same as valuation /
|
||||
# bundle above, which gate on fetch_timeout); honour that for institution too so
|
||||
# the FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=0 config semantic is not bypassed.
|
||||
if fetcher is not None and fetch_timeout > 0:
|
||||
# The tw institution block is a WHOLE-MARKET download (~4-5s), far slower
|
||||
# than the per-symbol quote/bundle fetches, and it is the LAST offshore
|
||||
# block. When enabled, give it the full REMAINING stage budget rather than
|
||||
# the ~3s per-fetch cap that starves it and makes the first/only stock of a
|
||||
# run coin-flip between ok and not_supported. Bounded by the stage deadline
|
||||
# via _run_with_retry, so it fails open (never blocks).
|
||||
inst_timeout = max(stage_timeout - (time.time() - start_ts), 0.0)
|
||||
if inst_timeout > 0:
|
||||
tw_record, inst_err, _inst_ms = self._run_with_retry(
|
||||
lambda: fetcher.get_institutional_net(stock_code),
|
||||
|
||||
@@ -28,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
- [新功能] #1743 Phase 4 新增 `opencode_cli` generation-only 本地 CLI backend,使用 OpenCode `run --format json --file` prompt-file 路径、JSON event extractor、Agent 边界和 provider credential 不接管约束。
|
||||
- [文档] #1743 Phase 4 同步本地 CLI backend 隐私/部署边界:local CLI 不是离线模型,Docker/CI/远端需自行安装登录,DSA 不读取 Claude/OpenCode credential 文件。
|
||||
- [新功能] 台股报告接入三大法人:tw 个股分析报告的 institution 区块改为展示 TWSE T86 / TPEx 三大法人原始买卖超净额(外资/投信/自营/合计,单位:股);tw-only、严格 additive(A股/港股/美股/日韩股 offshore 流程字节不变)、fail-open(取不到数据维持 not_supported,绝不中断分析);不接 Web、不派生 capital_flow_signal、不改评分权重或 schema。
|
||||
- [改进] 台股报告完整消费三大法人:tw 个股报告的 `institution` 区块现会在报告中渲染三大法人净买卖超表格,并注入 LLM 分析 prompt 作为台股筹码过滤器(此前仅接入数据层,报告与 prompt 均未消费,导致报告出现「筹码结构:数据缺失」);同时三大法人整市场抓取改用剩余 stage 预算而非较小的 per-symbol fetch 超时,避免单股/首档分析因冷抓取(~4-5s)超时而降级为 not_supported。tw-only、严格 additive、fail-open。
|
||||
- [修复] 台股财务金额币别标示:TWD 金额此前落入默认「元」(在 A 股语境易误读为人民币),`_CURRENCY_SUFFIX` 补入 TWD→「新台币」,营业收入/归母净利润/经营现金流/每股现金分红均正确标注新台币。
|
||||
- [改进] 台股三大法人 fetcher 韧性加固:(1) 接入熔断器(复用 `realtime_types.CircuitBreaker`,按市场 twse/tpex 分流,连续失败 3 次→冷却 ~5min→半开探测),TWSE/TPEx 端点异常时快速跳过网络往返并 fail-open,避免端点故障时每档个股都付 timeout+throttle;(2) TPEx OpenAPI 仅服务最新交易日,调用方传入与服务日期不符的明确日期时改为 fail-open(返回无数据),避免静默返回错日资料。
|
||||
|
||||
## [3.24.1] - 2026-06-28
|
||||
|
||||
@@ -3776,6 +3776,40 @@ class GeminiAnalyzer:
|
||||
| 资金流出靠前板块 | {bottom_sector_text} | 板块风险参考 |
|
||||
|
||||
> 资金流向只能作为价格位置的过滤器:接近压力且主力流出时不得追买;接近支撑且未放量跌破时,优先判断为持有观察、震荡或洗盘观察。
|
||||
"""
|
||||
|
||||
# 添加三大法人动向(台股筹码过滤器)— tw-only;仅当 institution 区块 status='ok'
|
||||
# 且有净额时注入,其他市场 status='not_supported' 会跳过,严格 additive。
|
||||
institution_block = (
|
||||
fundamental_context.get("institution", {})
|
||||
if isinstance(fundamental_context, dict)
|
||||
else {}
|
||||
)
|
||||
institution_data = (
|
||||
institution_block.get("data", {})
|
||||
if isinstance(institution_block, dict)
|
||||
else {}
|
||||
)
|
||||
if (
|
||||
isinstance(institution_block, dict)
|
||||
and institution_block.get("status") == "ok"
|
||||
and isinstance(institution_data, dict)
|
||||
and all(
|
||||
institution_data.get(key) is not None
|
||||
for key in ("foreign_net", "trust_net", "dealer_net", "total_net")
|
||||
)
|
||||
):
|
||||
prompt += f"""
|
||||
### 三大法人动向(台股筹码过滤器,净买卖超,单位:股)
|
||||
| 法人 | 净买卖超 | 决策含义 |
|
||||
|------|------|----------|
|
||||
| 外资 | {institution_data.get('foreign_net', 'N/A')} | 正值=净买超偏支持,负值=净卖超偏压制 |
|
||||
| 投信 | {institution_data.get('trust_net', 'N/A')} | 投信持续买超常伴随中线做多 |
|
||||
| 自营商 | {institution_data.get('dealer_net', 'N/A')} | 短线避险/自营方向参考 |
|
||||
| 三大法人合计 | {institution_data.get('total_net', 'N/A')} | 台股最受关注的筹码信号 |
|
||||
| 资料日期 | {institution_data.get('date', 'N/A')} | 来源 {institution_data.get('source', 'N/A')} |
|
||||
|
||||
> 三大法人是台股的筹码过滤器(相当于 A 股主力资金/龙虎榜的角色,但口径不同、不可混用):外资与投信同向净买支持价格、同向净卖压制价格。请据此判断台股筹码结构,不要在有本数据时写“筹码结构:数据缺失”。
|
||||
"""
|
||||
|
||||
# 添加筹码分布数据
|
||||
|
||||
@@ -1982,6 +1982,7 @@ class NotificationService(
|
||||
"CNY": "元",
|
||||
"RMB": "元",
|
||||
"CNH": "元",
|
||||
"TWD": "新台币", # 台股 (TWSE/TPEx) 以新台币计价,避免与 A 股「元」(人民币) 混淆
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -2047,6 +2048,8 @@ class NotificationService(
|
||||
"sector_bottom": [],
|
||||
"concept_top": [],
|
||||
"concept_bottom": [],
|
||||
"institution": {},
|
||||
"institution_status": None,
|
||||
}
|
||||
|
||||
earnings_block = ctx.get("earnings") if isinstance(ctx.get("earnings"), dict) else {}
|
||||
@@ -2074,6 +2077,11 @@ class NotificationService(
|
||||
|
||||
belong_boards = ctx.get("belong_boards") if isinstance(ctx.get("belong_boards"), list) else []
|
||||
|
||||
# 三大法人 (institutional flows) — tw-only; other markets keep status='not_supported'
|
||||
# and an empty data dict, so this block only renders for a Taiwan stock with data.
|
||||
institution_block = ctx.get("institution") if isinstance(ctx.get("institution"), dict) else {}
|
||||
institution_data = institution_block.get("data") if isinstance(institution_block.get("data"), dict) else {}
|
||||
|
||||
return {
|
||||
"financial_report": financial_report,
|
||||
"growth": growth_data,
|
||||
@@ -2083,6 +2091,8 @@ class NotificationService(
|
||||
"sector_bottom": sector_bottom,
|
||||
"concept_top": concept_top,
|
||||
"concept_bottom": concept_bottom,
|
||||
"institution": institution_data,
|
||||
"institution_status": institution_block.get("status"),
|
||||
}
|
||||
|
||||
def _append_fundamental_blocks(self, lines: List[str], result: AnalysisResult) -> None:
|
||||
@@ -2098,6 +2108,7 @@ class NotificationService(
|
||||
|
||||
self._append_financial_summary(lines, blocks, labels)
|
||||
self._append_shareholder_return(lines, blocks, labels)
|
||||
self._append_institutional_flow(lines, blocks, labels)
|
||||
self._append_related_boards(lines, blocks, labels)
|
||||
|
||||
def _append_financial_summary(
|
||||
@@ -2185,6 +2196,65 @@ class NotificationService(
|
||||
"",
|
||||
])
|
||||
|
||||
@classmethod
|
||||
def _format_net_shares(cls, value: Any) -> str:
|
||||
"""Format an institutional net buy/sell in 万股/亿股, signed (+ = net buy).
|
||||
|
||||
Thresholds: abs >= 1e8 -> 亿股, >= 1e4 -> 万股, else 股. None/NaN/non-numeric -> N/A.
|
||||
"""
|
||||
try:
|
||||
amount = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return "N/A"
|
||||
if amount != amount: # NaN
|
||||
return "N/A"
|
||||
sign = "+" if amount > 0 else ("-" if amount < 0 else "")
|
||||
a = abs(amount)
|
||||
if a >= 1e8:
|
||||
return f"{sign}{a / 1e8:.2f} 亿股"
|
||||
if a >= 1e4:
|
||||
return f"{sign}{a / 1e4:.2f} 万股"
|
||||
return f"{sign}{a:.0f} 股"
|
||||
|
||||
def _append_institutional_flow(
|
||||
self,
|
||||
lines: List[str],
|
||||
blocks: Dict[str, Any],
|
||||
labels: Dict[str, str],
|
||||
) -> None:
|
||||
"""Append the 三大法人 (institutional flows) table — tw-only.
|
||||
|
||||
Renders only when the institution block reached status='ok' (a Taiwan stock
|
||||
whose TWSE T86 / TPEx fetch succeeded); every other market keeps
|
||||
status='not_supported' and is skipped, so this is strictly additive.
|
||||
"""
|
||||
if blocks.get("institution_status") != "ok":
|
||||
return
|
||||
inst = blocks.get("institution") or {}
|
||||
cells = {
|
||||
"foreign": self._format_net_shares(inst.get("foreign_net")),
|
||||
"trust": self._format_net_shares(inst.get("trust_net")),
|
||||
"dealer": self._format_net_shares(inst.get("dealer_net")),
|
||||
"total": self._format_net_shares(inst.get("total_net")),
|
||||
}
|
||||
if all(v == "N/A" for v in cells.values()):
|
||||
return
|
||||
date = self._format_text(inst.get("date"))
|
||||
source = self._format_text(inst.get("source"))
|
||||
lines.extend([
|
||||
f"### 📊 {labels['institutional_flow_heading']}({date} · {source})",
|
||||
"",
|
||||
f"> {labels['institutional_flow_note']}",
|
||||
"",
|
||||
(
|
||||
f"| {labels['inst_foreign_label']} | {labels['inst_trust_label']} | "
|
||||
f"{labels['inst_dealer_label']} | {labels['inst_total_label']} |"
|
||||
),
|
||||
"|-----:|-----:|------:|------------:|",
|
||||
f"| {cells['foreign']} | {cells['trust']} | {cells['dealer']} | {cells['total']} |",
|
||||
"",
|
||||
])
|
||||
|
||||
def _append_related_boards(
|
||||
self,
|
||||
lines: List[str],
|
||||
|
||||
@@ -331,6 +331,12 @@ _REPORT_LABELS: Dict[str, Dict[str, str]] = {
|
||||
"ttm_event_count_label": "近12月分红次数",
|
||||
"ttm_dividend_yield_label": "TTM 股息率",
|
||||
"latest_ex_dividend_label": "最近除息日",
|
||||
"institutional_flow_heading": "三大法人动向",
|
||||
"institutional_flow_note": "正数=净买超,负数=净卖超;单位为股。",
|
||||
"inst_foreign_label": "外资",
|
||||
"inst_trust_label": "投信",
|
||||
"inst_dealer_label": "自营商",
|
||||
"inst_total_label": "三大法人合计",
|
||||
"related_boards_heading": "关联板块",
|
||||
"industry_boards_heading": "行业板块",
|
||||
"concept_boards_heading": "概念板块",
|
||||
@@ -449,6 +455,12 @@ _REPORT_LABELS: Dict[str, Dict[str, str]] = {
|
||||
"ttm_event_count_label": "TTM Dividend Events",
|
||||
"ttm_dividend_yield_label": "TTM Dividend Yield",
|
||||
"latest_ex_dividend_label": "Latest Ex-dividend Date",
|
||||
"institutional_flow_heading": "Institutional Flows (3 Majors)",
|
||||
"institutional_flow_note": "Positive = net buy, negative = net sell; unit: shares.",
|
||||
"inst_foreign_label": "Foreign",
|
||||
"inst_trust_label": "Inv. Trust",
|
||||
"inst_dealer_label": "Dealer",
|
||||
"inst_total_label": "Total (3 Majors)",
|
||||
"related_boards_heading": "Related Boards",
|
||||
"industry_boards_heading": "Industry Sectors",
|
||||
"concept_boards_heading": "Concept Themes",
|
||||
@@ -567,6 +579,12 @@ _REPORT_LABELS: Dict[str, Dict[str, str]] = {
|
||||
"ttm_event_count_label": "최근 12개월 배당 횟수",
|
||||
"ttm_dividend_yield_label": "TTM 배당수익률",
|
||||
"latest_ex_dividend_label": "최근 배당락일",
|
||||
"institutional_flow_heading": "3대 기관 동향",
|
||||
"institutional_flow_note": "양수=순매수, 음수=순매도; 단위: 주.",
|
||||
"inst_foreign_label": "외국인",
|
||||
"inst_trust_label": "투신",
|
||||
"inst_dealer_label": "딜러",
|
||||
"inst_total_label": "3대 기관 합계",
|
||||
"related_boards_heading": "관련 섹터",
|
||||
"industry_boards_heading": "업종 섹터",
|
||||
"concept_boards_heading": "테마 섹터",
|
||||
|
||||
@@ -202,5 +202,60 @@ class TestTwInstitutionReportWiring(unittest.TestCase):
|
||||
self.assertNotIn(key, data, f"derived key '{key}' leaked into institution data")
|
||||
|
||||
|
||||
# ---- institution must use the STAGE budget, not the small per-symbol fetch cap -----
|
||||
def test_tw_institution_not_starved_by_small_fetch_timeout(self):
|
||||
# The 三大法人 block is a whole-market download (~4-5s). It must run under the
|
||||
# remaining STAGE budget, not the (small) per-symbol fetch_timeout — otherwise the
|
||||
# first/only stock of a run coin-flips to not_supported. A fetch slower than
|
||||
# fetch_timeout but within the stage budget must still land as 'ok'.
|
||||
cfg = SimpleNamespace(
|
||||
enable_fundamental_pipeline=True,
|
||||
fundamental_cache_ttl_seconds=0,
|
||||
fundamental_stage_timeout_seconds=8.0, # generous stage budget
|
||||
fundamental_fetch_timeout_seconds=0.3, # tiny per-symbol cap (would starve institution)
|
||||
fundamental_retry_max=1,
|
||||
)
|
||||
manager = DataFetcherManager(fetchers=[])
|
||||
|
||||
def _slowish(_code):
|
||||
time.sleep(1.0) # > fetch_timeout(0.3) but << stage budget(8) -> must complete
|
||||
return dict(_FAKE_REC)
|
||||
|
||||
with patch("src.config.get_config", return_value=cfg), \
|
||||
patch.object(manager, "get_realtime_quote", return_value=None), \
|
||||
patch(
|
||||
"data_provider.yfinance_fundamental_adapter.YfinanceFundamentalAdapter.get_fundamental_bundle",
|
||||
return_value=_EMPTY_BUNDLE,
|
||||
), \
|
||||
patch(_TW_FETCHER_METHOD, side_effect=_slowish):
|
||||
ctx = manager.get_fundamental_context("2330.TW")
|
||||
self.assertEqual(ctx["coverage"].get("institution"), "ok")
|
||||
self.assertEqual(ctx["institution"]["data"]["total_net"], _FAKE_REC["total_net"])
|
||||
|
||||
# ---- FUNDAMENTAL_FETCH_TIMEOUT_SECONDS=0 disables per-fetch fetches — incl institution
|
||||
def test_tw_institution_disabled_when_fetch_timeout_zero(self):
|
||||
# fetch_timeout=0 is the existing "disable per-fetch fundamental fetches" config
|
||||
# (valuation/bundle skip). Institution must honour it too, not bypass it via the
|
||||
# stage budget.
|
||||
cfg = SimpleNamespace(
|
||||
enable_fundamental_pipeline=True,
|
||||
fundamental_cache_ttl_seconds=0,
|
||||
fundamental_stage_timeout_seconds=8.0,
|
||||
fundamental_fetch_timeout_seconds=0.0, # disabled
|
||||
fundamental_retry_max=1,
|
||||
)
|
||||
manager = DataFetcherManager(fetchers=[])
|
||||
with patch("src.config.get_config", return_value=cfg), \
|
||||
patch.object(manager, "get_realtime_quote", return_value=None), \
|
||||
patch(
|
||||
"data_provider.yfinance_fundamental_adapter.YfinanceFundamentalAdapter.get_fundamental_bundle",
|
||||
return_value=_EMPTY_BUNDLE,
|
||||
), \
|
||||
patch(_TW_FETCHER_METHOD, return_value=dict(_FAKE_REC)) as tw_mock:
|
||||
ctx = manager.get_fundamental_context("2330.TW")
|
||||
self.assertEqual(ctx["coverage"].get("institution"), "not_supported")
|
||||
self.assertEqual(tw_mock.call_count, 0) # institution fetch skipped when disabled
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
151
tests/test_tw_report_consumption.py
Normal file
151
tests/test_tw_report_consumption.py
Normal file
@@ -0,0 +1,151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""v2.1 tw report-consumption tests.
|
||||
|
||||
Covers the last-mile that makes the merged 三大法人 (institutional-flows) data actually
|
||||
usable in a tw report: currency labelling (TWD, not RMB), rendering the institution
|
||||
block into the report, injecting it into the LLM prompt, and fetch availability on the
|
||||
first/only stock. Fully offline (no network / no LLM).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
try:
|
||||
import litellm # noqa: F401
|
||||
except ModuleNotFoundError:
|
||||
from tests.litellm_stub import ensure_litellm_stub
|
||||
|
||||
ensure_litellm_stub()
|
||||
|
||||
from src.notification import NotificationService
|
||||
from src.report_language import get_report_labels
|
||||
from src.analyzer import GeminiAnalyzer
|
||||
|
||||
# real 2330.TW 三大法人 net figures (shares)
|
||||
_INST_REC = {
|
||||
"foreign_net": -1912490, "trust_net": -89595, "dealer_net": 652455,
|
||||
"total_net": -862914, "unit": "shares", "date": "20260630", "source": "TWSE-T86",
|
||||
}
|
||||
|
||||
|
||||
class TestTwCurrencyLabel(unittest.TestCase):
|
||||
"""Point D: TWD amounts must not silently render as the A-share default 元 (RMB)."""
|
||||
|
||||
def test_twd_amount_labeled_new_taiwan_dollar_not_rmb(self):
|
||||
twd = NotificationService._format_amount_cn(1_134_103_440_000.0, "TWD")
|
||||
cny = NotificationService._format_amount_cn(1_134_103_440_000.0, "CNY")
|
||||
self.assertIn("新台币", twd)
|
||||
self.assertNotIn("新台币", cny)
|
||||
self.assertNotEqual(twd, cny) # a TWD amount must not be byte-identical to CNY
|
||||
|
||||
def test_twd_per_share_labeled(self):
|
||||
self.assertIn("新台币", NotificationService._format_per_share(24.0, "TWD"))
|
||||
|
||||
def test_other_currencies_byte_identical(self):
|
||||
# strictly additive: cn / us / hk display unchanged
|
||||
self.assertEqual(NotificationService._format_amount_cn(1e8, "CNY"), "1.00 亿元")
|
||||
self.assertIn("美元", NotificationService._format_amount_cn(1e8, "USD"))
|
||||
self.assertIn("港元", NotificationService._format_amount_cn(1e8, "HKD"))
|
||||
# unknown currency still falls back to 元 (unchanged behaviour)
|
||||
self.assertIn("元", NotificationService._format_amount_cn(1e8, "ZZZ"))
|
||||
|
||||
|
||||
class TestTwInstitutionRender(unittest.TestCase):
|
||||
"""Point A: 三大法人 renders into the report only for a tw stock with data."""
|
||||
|
||||
def _render(self, status, data):
|
||||
svc = NotificationService.__new__(NotificationService) # methods use no instance state
|
||||
lines = []
|
||||
blocks = {"institution": data, "institution_status": status}
|
||||
svc._append_institutional_flow(lines, blocks, get_report_labels("zh"))
|
||||
return "\n".join(lines)
|
||||
|
||||
def test_institution_rendered_when_ok(self):
|
||||
out = self._render("ok", dict(_INST_REC))
|
||||
self.assertIn("三大法人动向", out)
|
||||
for token in ("外资", "投信", "自营商", "三大法人合计", "TWSE-T86", "20260630"):
|
||||
self.assertIn(token, out)
|
||||
self.assertIn("-191.25 万股", out) # foreign_net -1,912,490
|
||||
self.assertIn("+65.25 万股", out) # dealer_net +652,455 (net buy shows +)
|
||||
|
||||
def test_institution_not_rendered_when_not_supported(self):
|
||||
self.assertEqual(self._render("not_supported", {}), "")
|
||||
self.assertEqual(self._render(None, {}), "")
|
||||
self.assertEqual(self._render("ok", {}), "") # ok but empty data -> skip
|
||||
|
||||
def test_institution_renders_all_languages_without_keyerror(self):
|
||||
# every new label key must exist in zh/en/ko so a non-zh tw report never KeyErrors.
|
||||
svc = NotificationService.__new__(NotificationService)
|
||||
for lang, token in (("zh", "三大法人"), ("en", "Institutional Flows"), ("ko", "3대 기관")):
|
||||
lines = []
|
||||
blocks = {"institution": dict(_INST_REC), "institution_status": "ok"}
|
||||
svc._append_institutional_flow(lines, blocks, get_report_labels(lang))
|
||||
self.assertIn(token, "\n".join(lines), lang)
|
||||
|
||||
def test_get_fundamental_blocks_extracts_institution(self):
|
||||
svc = NotificationService.__new__(NotificationService)
|
||||
res = SimpleNamespace(fundamental_context={"institution": {"status": "ok", "data": dict(_INST_REC)}})
|
||||
blocks = svc._get_fundamental_blocks(res)
|
||||
self.assertEqual(blocks["institution_status"], "ok")
|
||||
self.assertEqual(blocks["institution"]["total_net"], -862914)
|
||||
# non-tw / missing institution -> empty + no status
|
||||
res2 = SimpleNamespace(fundamental_context={"earnings": {}})
|
||||
blocks2 = svc._get_fundamental_blocks(res2)
|
||||
self.assertEqual(blocks2["institution"], {})
|
||||
self.assertIsNone(blocks2["institution_status"])
|
||||
|
||||
def test_format_net_shares_signed(self):
|
||||
self.assertEqual(NotificationService._format_net_shares(-1912490), "-191.25 万股")
|
||||
self.assertEqual(NotificationService._format_net_shares(652455), "+65.25 万股")
|
||||
self.assertEqual(NotificationService._format_net_shares(0), "0 股")
|
||||
self.assertEqual(NotificationService._format_net_shares(250000000), "+2.50 亿股")
|
||||
self.assertEqual(NotificationService._format_net_shares(None), "N/A")
|
||||
|
||||
|
||||
class TestTwInstitutionPrompt(unittest.TestCase):
|
||||
"""Point B: 三大法人 is injected into the LLM analysis prompt for a tw stock with data."""
|
||||
|
||||
def _prompt(self, fundamental_context):
|
||||
with patch.object(GeminiAnalyzer, "_init_litellm", return_value=None):
|
||||
analyzer = GeminiAnalyzer()
|
||||
context = {
|
||||
"code": "2330.TW",
|
||||
"stock_name": "台积电",
|
||||
"date": "2026-06-30",
|
||||
"today": {"close": 2410, "ma5": 2380, "ma10": 2409, "ma20": 2369},
|
||||
"fundamental_context": fundamental_context,
|
||||
}
|
||||
return analyzer._format_prompt(context, "台积电", news_context=None)
|
||||
|
||||
def test_institution_injected_when_ok(self):
|
||||
p = self._prompt({"institution": {"status": "ok", "data": dict(_INST_REC)}})
|
||||
self.assertIn("三大法人动向", p)
|
||||
for token in ("外资", "投信", "自营商", "筹码过滤器"):
|
||||
self.assertIn(token, p)
|
||||
self.assertIn("-1912490", p) # raw foreign_net reaches the prompt
|
||||
self.assertIn("-862914", p) # raw total_net
|
||||
|
||||
def test_institution_absent_when_not_supported(self):
|
||||
p = self._prompt({"institution": {"status": "not_supported", "data": {}}})
|
||||
self.assertNotIn("三大法人动向", p)
|
||||
|
||||
def test_institution_absent_when_any_core_net_missing(self):
|
||||
# prompt gate matches the render / base.py gate: ALL four core nets required,
|
||||
# so a partial record never reaches the LLM as an unqualified chip signal.
|
||||
partials = (
|
||||
{"foreign_net": 1, "trust_net": 1, "dealer_net": 1, "total_net": None},
|
||||
{"foreign_net": None, "trust_net": 1, "dealer_net": 1, "total_net": 1},
|
||||
{"trust_net": 1, "dealer_net": 1, "total_net": 1}, # foreign_net absent
|
||||
)
|
||||
for data in partials:
|
||||
p = self._prompt({"institution": {"status": "ok", "data": data}})
|
||||
self.assertNotIn("三大法人动向", p, data)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user