From 537deba1caa395a6d433ee21944dff33d1d46797 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 16:01:39 +0800 Subject: [PATCH 1/6] feat: data dir migration, expanded financials, skill reference files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0: Migrate data directory from ~/.haoinvest/ to project-local .haoinvest/ for easier IDE browsing. Existing DB copied over. Phase 1a: Expand eastmoney RPT_LICO_FN_CPD from 6 fields to ALL (37), adding dividend yield, revenue/profit growth, EPS, BPS, cash flow per share. These were already in the API response but not extracted. Phase 1b: Support multi-period financial data (periods parameter) for historical trend analysis across quarterly reports. Phase 1c: Create skill reference files in references/ directory: - a-share-analysis-framework.md (5-dimension analysis for A-shares) - valuation-guide.md (relative valuation, industry-specific PE/PB) - position-management.md (add/reduce/rotate decision framework) - stock-screening-workflow.md (top-down screening with strategies) Phase 1d: Simplify hardcoded valuation judgments — code now outputs data-focused labels (PE/PB values) instead of absolute verdicts. Deep interpretation delegated to Claude via reference files. Co-Authored-By: Claude Opus 4.6 --- .../references/a-share-analysis-framework.md | 78 ++++++++++++++++ .../references/position-management.md | 73 +++++++++++++++ .../references/stock-screening-workflow.md | 90 +++++++++++++++++++ .../haoinvest/references/valuation-guide.md | 53 +++++++++++ .gitignore | 4 +- CLAUDE.md | 14 ++- haoinvest/analysis/fundamental.py | 43 +++++---- haoinvest/analysis/report.py | 11 ++- haoinvest/config.py | 20 ++++- haoinvest/market/sources/eastmoney.py | 76 ++++++++++------ haoinvest/models.py | 48 +++++++++- tests/test_analysis_report.py | 14 +-- tests/test_cli/test_analyze.py | 26 +++--- tests/test_cli/test_analyze_run.py | 6 +- 14 files changed, 475 insertions(+), 81 deletions(-) create mode 100644 .claude/skills/haoinvest/references/a-share-analysis-framework.md create mode 100644 .claude/skills/haoinvest/references/position-management.md create mode 100644 .claude/skills/haoinvest/references/stock-screening-workflow.md create mode 100644 .claude/skills/haoinvest/references/valuation-guide.md diff --git a/.claude/skills/haoinvest/references/a-share-analysis-framework.md b/.claude/skills/haoinvest/references/a-share-analysis-framework.md new file mode 100644 index 0000000..68e355a --- /dev/null +++ b/.claude/skills/haoinvest/references/a-share-analysis-framework.md @@ -0,0 +1,78 @@ +# A 股分析框架 + +A 股市场与美股/港股存在结构性差异:散户贡献约 87% 交易量,短期受政策和叙事驱动,长期价值因子有效。分析需覆盖以下 5 个维度。 + +## 1. 基本面(长期核心) + +**权重**:选股时最重要,择时时次之。 + +**关注指标**: +- **盈利能力**:ROE(与同行比)、净利润率、毛利率趋势 +- **成长性**:营收同比增长、净利润同比增长、连续几个季度的趋势方向 +- **估值**:参考 `valuation-guide.md` +- **财务健康**:负债率、现金流、股息率 + +**判断方法**: +- 不要用绝对阈值判断好坏,要与**同行业**对比(peer 分析) +- 关注**趋势方向**而非单点数值:连续 3 季 ROE 下降比当前 ROE 绝对值低更值得警惕 +- 多期数据对比:用 `get_financial_indicators(symbol, periods=8)` 获取历史 8 期 + +## 2. 政策面(A 股特有,短期权重高) + +**为什么重要**:A 股受政策影响极大,国务院/证监会/央行的政策可以直接改变行业景气度。 + +**分析方法**(通过 web search): +- 近期是否有行业相关政策出台?(产业政策、财政刺激、监管变化) +- 五年规划中的重点方向? +- 是否处于政策收紧/放松周期? + +**信号解读**: +- 政策利好 + 估值合理 → 积极关注 +- 政策利空 + 估值偏高 → 谨慎回避 +- 政策中性 → 回归基本面判断 + +## 3. 资金面 + +**关注数据**: +- 板块资金流向:`market sector-flow`(如可用) +- 北向资金持仓变化(季度粒度) +- 板块涨跌排名:`market sector-list` + +**判断方法**: +- 主力资金持续流入的板块值得关注 +- 资金流入 + 基本面支撑 = 强信号 +- 资金流入但基本面不支撑 = 可能是短期炒作,谨慎 + +## 4. 技术面 + +**使用 `analyze run --modules technical,signals`** + +**注意事项**: +- 技术面在 A 股因散户主导有更强的"自我实现"效应 +- 技术信号用于**择时**(何时买),不用于**选股**(买什么) +- 多时间框架确认:日线信号需要周线级别支持才可靠 + +## 5. 情绪面 + +**来源**:用户对话中的语言信号 + 市场整体情绪 + +**检测方法**:参考 SKILL.md 中的 Workflow 3b(隐式情绪检测) + +**注意**: +- 市场极度恐慌时(大跌 + 恐慌情绪词频高)反而可能是价值买入机会 +- 市场极度亢奋时(全民炒股 + FOMO)反而应该更谨慎 + +## 不同市场阶段的侧重 + +| 阶段 | 判断依据 | 分析侧重 | +|------|---------|---------| +| 牛市 | 指数持续上涨,成交量放大 | 重基本面选股 + 技术面择时,警惕过热 | +| 熊市 | 指数持续下跌,情绪低迷 | 重基本面安全边际,耐心等待,不急于抄底 | +| 震荡市 | 指数区间波动 | 重资金面和技术面,关注板块轮动 | + +## 综合判断原则 + +1. **至少 3 个维度指向同一方向**才形成强信号 +2. **基本面是底线**——短期可以不完美,但长期必须有支撑 +3. **政策面可以加速但不能替代基本面**——纯政策炒作的股票在政策退潮后会回归 +4. **永远标注不确定性**——说清楚哪些判断是数据支撑的,哪些是推测 diff --git a/.claude/skills/haoinvest/references/position-management.md b/.claude/skills/haoinvest/references/position-management.md new file mode 100644 index 0000000..3eaa985 --- /dev/null +++ b/.claude/skills/haoinvest/references/position-management.md @@ -0,0 +1,73 @@ +# 持仓管理决策框架 + +## 核心理念 + +每笔持仓都有一个 **thesis**(投资逻辑)。所有加仓/减仓/换仓决策都围绕 thesis 展开: +- thesis 仍成立 + 价格更便宜 → 考虑加仓 +- thesis 部分弱化 + 估值偏高 → 考虑减仓 +- thesis 被打破 → 考虑清仓或换仓 + +## 加仓决策 + +### 前提条件(全部满足才考虑) +1. 原始 thesis 仍然成立(核心假设未被打破) +2. 估值比买入时更便宜或至少没有变贵 +3. 仓位加仓后不违反 guardrails(单只 ≤15%,行业 ≤35%) +4. 不是出于"摊低成本"的心理(沉没成本谬误) + +### 数据检查清单 +```bash +uv run haoinvest analyze run --modules fundamental,risk,signals +uv run haoinvest guardrails pre-trade-data buy -m a_share +uv run haoinvest portfolio thesis review +``` + +### 判断逻辑 +- 基本面未恶化 + 估值更低 + 仓位安全 → **可以加仓** +- 基本面未恶化但估值未变 → **不急,等更好的价格** +- 基本面有恶化迹象 → **不加仓,重新审视 thesis** + +## 减仓决策 + +### 触发信号 +1. **估值过高**:PE/PB 显著高于行业中位数(peer 对比中处于前 10%) +2. **thesis 部分弱化**:核心假设中有 1-2 个不再成立,但整体逻辑未完全崩塌 +3. **技术面背离**:价格创新高但量能萎缩,或出现明显顶部形态 +4. **guardrails 触发**:浮盈超过 gain_review_threshold(默认 +30%) + +### 减仓策略 +- **分批减仓**:先减 20-30%,观察后续走势 +- **保留底仓**:如果 thesis 未被打破,保留 50% 以上仓位 +- **记录决策**:减仓后用 journal 记录理由和情绪 + +## 换仓决策 + +### 触发信号 +1. **thesis 被打破**:核心假设失效(行业变化、公司暴雷、竞争格局改变) +2. **更优机会出现**:发现估值更低、基本面更好、且能改善组合分散度的标的 +3. **行业轮动**:政策方向转变,原持仓行业前景显著恶化 + +### 换仓流程 +1. 确认现有持仓 thesis 确实被打破(不是短期波动) +2. 新标的必须通过完整的 5 维度分析 +3. 新标的的预期收益 > 现有持仓 + 交易成本 +4. 不要同时大比例换仓——分批执行 + +## 不行动也是决策 + +**持有不动的条件**: +- thesis 完好 +- 估值合理(不贵也不便宜) +- 技术面中性 +- 没有更好的替代机会 + +**不要因为"持有太久没动静"就换仓** — 价值投资需要耐心。 + +## 情绪检查 + +每次做加仓/减仓/换仓决策前,问自己: +- 我是因为**数据和逻辑**在做决策,还是因为**情绪**? +- 如果这只股票我没有持仓,以当前价格我还会买吗? +- 我是否在追涨(FOMO)或杀跌(恐慌)? + +如果对情绪不确定 → 等待 24 小时再决策。 diff --git a/.claude/skills/haoinvest/references/stock-screening-workflow.md b/.claude/skills/haoinvest/references/stock-screening-workflow.md new file mode 100644 index 0000000..3e1c341 --- /dev/null +++ b/.claude/skills/haoinvest/references/stock-screening-workflow.md @@ -0,0 +1,90 @@ +# 自上而下选股流程 + +## 总体路径 + +宏观/政策环境 → 行业/板块选择 → 个股筛选 → 深度分析 → 对比决策 + +## Step 1: 宏观和政策环境(Claude web search) + +**目标**:判断当前市场大方向和政策重点 + +**检查清单**: +- 近期央行货币政策方向?(降准/降息 = 宽松利好) +- 国务院/发改委是否有产业政策出台? +- 市场整体估值水平?(沪深 300 PE 分位数) +- 有无系统性风险?(地缘、贸易摩擦、流动性紧缩) + +**输出**:对当前市场的 1-2 句定性判断 + 看好/谨慎的行业方向 + +## Step 2: 行业/板块选择 + +**数据来源**: +```bash +uv run haoinvest market sector-list # 行业涨跌排名 +uv run haoinvest market sector-flow # 资金流向(如可用) +``` + +**选择标准**: +- 政策支持 + 资金流入 + 基本面景气 → 优先 +- 仅资金流入但无基本面支撑 → 谨慎(可能是短期炒作) +- 结合用户现有持仓的行业分布 → 选择能分散风险的行业 + +## Step 3: 个股筛选 + +**使用预置策略**(根据用户偏好和市场环境选择): + +### 价值型策略 +```bash +uv run haoinvest market screen --pe-max 20 --roe-min 15 --cap-min 10000000000 --sort roe +``` +**适用场景**:市场估值合理或偏低时 +**逻辑**:低估值 + 高盈利能力 = 被低估的好公司 +**注意**:排除 PE 异常低的(可能是周期行业利润顶点) + +### 成长型策略 +筛选条件(CLI 参数待实现后使用): +- 营收增长 > 20% +- 净利润增长 > 15% +- 市值 > 50 亿(排除小微) +**适用场景**:经济复苏期,政策鼓励的新兴行业 +**逻辑**:高增长公司在合理估值时有更大的上涨空间 + +### 高分红型策略 +筛选条件: +- 股息率 > 3% +- ROE > 10% +- 市值 > 200 亿 +**适用场景**:市场下行或震荡时,追求稳定现金回报 +**逻辑**:高分红 = 现金流充裕 + 管理层对股东友好 + +### 防御型策略 +筛选条件: +- 低波动行业(公用事业、消费必需品) +- 高现金流覆盖 +- 低负债率 +**适用场景**:市场不确定性高,系统性风险上升 +**逻辑**:防御型公司在市场下跌时跌幅更小 + +## Step 4: 深度分析(Top 3-5 候选) + +对筛选出的候选执行: +```bash +uv run haoinvest analyze run ,, --modules fundamental,risk,signals,peer +``` + +**对比维度**: +1. 估值相对位置(peer 对比中的 PE/PB 分位数) +2. 盈利能力和趋势(ROE、营收增长趋势) +3. 风险指标(最大回撤、波动率) +4. 技术面信号方向 +5. 与现有持仓的相关性(分散 vs 集中) + +## Step 5: 最终决策 + +**输出格式**: +1. 推荐排序 + 每只的 1-2 句推荐理由 +2. 每只的主要风险点 +3. 建议的买入策略(一次性 vs 分批) +4. 建议初始仓位大小 + +**提醒**:这不是投资建议,最终决定需要用户自己判断。 diff --git a/.claude/skills/haoinvest/references/valuation-guide.md b/.claude/skills/haoinvest/references/valuation-guide.md new file mode 100644 index 0000000..0621964 --- /dev/null +++ b/.claude/skills/haoinvest/references/valuation-guide.md @@ -0,0 +1,53 @@ +# 估值评判框架 + +## 核心原则 + +**不要用绝对阈值判断贵贱**。PE = 20 在银行业是天价,在科技行业是白菜价。估值必须放在行业和历史语境中评判。 + +## 相对估值方法 + +### Step 1: 获取行业对比数据 +```bash +uv run haoinvest analyze run --modules fundamental,peer +``` + +### Step 2: 判断估值位置 +- 当前 PE/PB 在同行中的**分位数**(前 25% = 偏贵,后 25% = 偏便宜) +- 如果 peer 数据不可用,参考下方行业特征表做粗略判断 + +### Step 3: 结合成长性调整 +- 高成长(营收增长 > 20%)可以接受更高的 PE +- 低成长/负增长应该要求更低的 PE +- PEG < 1 通常意味着成长性被低估 + +## 不同行业的估值特征 + +| 行业类型 | 核心估值指标 | 特点 | +|---------|------------|------| +| 银行/保险 | **PB**,股息率 | PE 通常很低(5-8x),看 PB 更有意义 | +| 白酒/消费 | **PE**,现金流 | 品牌溢价高,PE 通常 20-40x | +| 科技/成长 | **PS(市销率)、PEG** | 可能亏损或微利,PE 无意义 | +| 周期行业(钢铁/化工) | **PB**,产能利用率 | PE 在行业顶部最低(利润最高),在底部最高(利润最低)——反直觉! | +| 公用事业 | **PE、股息率** | 稳定但低增长,看分红回报 | +| 房地产 | **NAV(净资产价值)** | 传统 PE/PB 不适用 | +| 医药/生物 | **管线价值、PE** | 创新药看研发管线,仿制药看 PE | + +## 安全边际 + +**定义**:当前价格低于你估计的合理价值的幅度。 + +**实践方法**: +1. 用 peer 对比得到行业中位数 PE/PB +2. 当前股票 PE/PB 低于中位数 20% 以上 → 有安全边际 +3. 越是不确定的公司,要求的安全边际越大 + +**注意**: +- "便宜"不等于"值得买"——要先确认基本面没问题(不是因为业绩暴雷才便宜) +- 价值陷阱:PE 很低但利润在快速下降 → 未来 PE 会变高 + +## 估值禁忌 + +1. **不要只看 PE** — 至少结合 PB、股息率、PEG 中的一个 +2. **不要跨行业比 PE** — 银行 PE=5 和科技 PE=50 不可比 +3. **不要忽视增长** — 静态 PE 不反映未来 +4. **周期股在 PE 最低时最危险** — 利润顶点意味着下行周期即将开始 diff --git a/.gitignore b/.gitignore index b3ad123..baa2e9f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ dist/ .venv/ *.db .env -~/.haoinvest/ + +# User data + Obsidian vault +.haoinvest/ # claude .claude/settings.local.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index be86ca0..5b52d93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,7 @@ Python library + Claude Code skills for tracking portfolios, analyzing stocks, a - **Language**: Python 3.11+ - **Package Manager**: uv (NOT pip) -- **Database**: SQLite (~/.haoinvest/haoinvest.db) +- **Database**: SQLite (.haoinvest/haoinvest.db — project-local, gitignored) - **Data Sources**: Sina/Tencent/eastmoney direct APIs (A-shares), yfinance (US/HK), Crypto.com MCP (crypto) - **Testing**: pytest @@ -30,7 +30,7 @@ uv run ruff format --check . # Format check haoinvest/ ├── models.py # Pydantic models (Transaction, Position, JournalEntry, etc.) ├── db.py # SQLite persistence, full CRUD, WAL mode -├── config.py # Config management (~/.haoinvest/) +├── config.py # Config management (.haoinvest/ in project root) ├── fx.py # Currency conversion with fallback rates ├── journal.py # Investment journal with emotion/decision tagging ├── cli/ # Typer CLI — entry point: `uv run haoinvest` @@ -106,5 +106,13 @@ GitHub Actions runs on push/PR to `main`: ruff lint, ruff format check, pytest ( ### Data Directory -- All user data lives under `~/.haoinvest/` (configurable via `HAOINVEST_DATA_DIR`) +- All user data lives under `.haoinvest/` in the project root (configurable via `HAOINVEST_DATA_DIR`) +- Obsidian vault for research notes goes under `.haoinvest/vault/` +- Both are gitignored - Never hardcode absolute paths to data files + +### Skill Reference Files + +- Evaluation frameworks live in `.claude/skills/haoinvest/references/` +- Code outputs raw data; Claude interprets using reference files +- Reference files focus on frameworks and processes, not specific number thresholds diff --git a/haoinvest/analysis/fundamental.py b/haoinvest/analysis/fundamental.py index 0884bec..186ab9d 100644 --- a/haoinvest/analysis/fundamental.py +++ b/haoinvest/analysis/fundamental.py @@ -46,6 +46,15 @@ def analyze_stock(symbol: str, market_type: MarketType) -> FundamentalAnalysis: free_cash_flow=info.free_cash_flow, operating_cash_flow=info.operating_cash_flow, peg_ratio=info.peg_ratio, + dividend_yield=info.dividend_yield, + eps=info.eps, + book_value_per_share=info.book_value_per_share, + operating_cash_flow_per_share=info.operating_cash_flow_per_share, + net_profit_growth=info.net_profit_growth, + revenue_growth_qoq=info.revenue_growth_qoq, + net_profit_growth_qoq=info.net_profit_growth_qoq, + report_date=info.report_date, + report_type=info.report_type, financial_health=financial_health, ) @@ -53,35 +62,23 @@ def analyze_stock(symbol: str, market_type: MarketType) -> FundamentalAnalysis: def _assess_valuation( pe: float | None, pb: float | None, market_type: MarketType ) -> ValuationAssessment: - """Simple valuation assessment based on PE and PB ratios. + """Data-focused valuation summary. - This is a rough heuristic for educational purposes, not financial advice. + Shows PE/PB values with rough bucketing. Deep interpretation (industry-relative, + growth-adjusted) should be done by Claude using valuation-guide.md reference. """ pe_assessment = "N/A" pb_assessment = "N/A" overall = "无法评估" if pe is not None and pe > 0: - if pe < 15: - pe_assessment = "低估 (PE < 15)" - elif pe < 25: - pe_assessment = "合理 (15 ≤ PE < 25)" - elif pe < 40: - pe_assessment = "偏高 (25 ≤ PE < 40)" - else: - pe_assessment = "高估 (PE ≥ 40)" + pe_assessment = f"PE {pe:.1f}" if pb is not None and pb > 0: - if pb < 1: - pb_assessment = "低估 (PB < 1)" - elif pb < 3: - pb_assessment = "合理 (1 ≤ PB < 3)" - elif pb < 6: - pb_assessment = "偏高 (3 ≤ PB < 6)" - else: - pb_assessment = "高估 (PB ≥ 6)" + pb_assessment = f"PB {pb:.2f}" - # Simple overall + # Rough overall bucket — NOTE: this is a crude heuristic. + # True valuation requires peer comparison (see valuation-guide.md). scores = [] if pe is not None and pe > 0: if pe < 15: @@ -106,13 +103,13 @@ def _assess_valuation( if scores: avg = sum(scores) / len(scores) if avg <= 1.5: - overall = "偏低估" + overall = "偏低" elif avg <= 2.5: - overall = "估值合理" + overall = "中等" elif avg <= 3.5: - overall = "偏高估" + overall = "偏高" else: - overall = "明显高估" + overall = "高" return ValuationAssessment( pe_assessment=pe_assessment, diff --git a/haoinvest/analysis/report.py b/haoinvest/analysis/report.py index 927464d..4f1cd68 100644 --- a/haoinvest/analysis/report.py +++ b/haoinvest/analysis/report.py @@ -73,6 +73,15 @@ def full_stock_report( free_cash_flow=fundamental.free_cash_flow, operating_cash_flow=fundamental.operating_cash_flow, peg_ratio=fundamental.peg_ratio, + dividend_yield=fundamental.dividend_yield, + eps=fundamental.eps, + book_value_per_share=fundamental.book_value_per_share, + operating_cash_flow_per_share=fundamental.operating_cash_flow_per_share, + net_profit_growth=fundamental.net_profit_growth, + revenue_growth_qoq=fundamental.revenue_growth_qoq, + net_profit_growth_qoq=fundamental.net_profit_growth_qoq, + report_date=fundamental.report_date, + report_type=fundamental.report_type, financial_health=fundamental.financial_health, ) @@ -254,7 +263,7 @@ def _compute_checklist(report: StockReport) -> BuyReadinessChecklist: def _score_valuation(overall: str) -> int: - mapping = {"偏低估": 5, "估值合理": 4, "偏高估": 2, "明显高估": 1} + mapping = {"偏低": 5, "中等": 4, "偏高": 2, "高": 1} return mapping.get(overall, 3) diff --git a/haoinvest/config.py b/haoinvest/config.py index f91db5e..4e80c5d 100644 --- a/haoinvest/config.py +++ b/haoinvest/config.py @@ -4,9 +4,25 @@ from pathlib import Path +def _project_root() -> Path: + """Return the project root directory (where pyproject.toml lives).""" + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / "pyproject.toml").exists(): + return current + current = current.parent + return Path(__file__).resolve().parent.parent + + def get_data_dir() -> Path: - """Return the data directory (~/.haoinvest/), creating it if needed.""" - data_dir = Path(os.environ.get("HAOINVEST_DATA_DIR", Path.home() / ".haoinvest")) + """Return the data directory, creating it if needed. + + Default: /data/ + Override: HAOINVEST_DATA_DIR environment variable + """ + data_dir = Path( + os.environ.get("HAOINVEST_DATA_DIR", _project_root() / ".haoinvest") + ) data_dir.mkdir(parents=True, exist_ok=True) return data_dir diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py index a457213..f1ff1ea 100644 --- a/haoinvest/market/sources/eastmoney.py +++ b/haoinvest/market/sources/eastmoney.py @@ -17,10 +17,8 @@ _DATACENTER_URL = "https://datacenter-web.eastmoney.com/api/data/v1/get" -# Columns we request from RPT_LICO_FN_CPD (业绩报表) -_FIN_COLUMNS = ( - "SECURITY_CODE,REPORTDATE,WEIGHTAVG_ROE,XSMLL,TOTAL_OPERATE_INCOME,PARENT_NETPROFIT" -) +# Request all columns from RPT_LICO_FN_CPD (业绩报表) +_FIN_COLUMNS = "ALL" @api_retry @@ -41,43 +39,69 @@ def get_basic_info(symbol: str) -> BasicInfo: ) -def get_financial_indicators(symbol: str) -> dict: +def get_financial_indicators(symbol: str, periods: int = 1) -> dict | list[dict]: """Fetch financial indicators from eastmoney datacenter API. - Uses RPT_LICO_FN_CPD (业绩报表) for the most recent reporting period. - Returns a dict of optional fields for BasicInfo enrichment. - Gracefully returns empty dict on any failure. + Uses RPT_LICO_FN_CPD (业绩报表). + Returns a dict of optional fields for BasicInfo enrichment (periods=1), + or a list of dicts for multi-period analysis (periods>1). + Gracefully returns empty dict/list on any failure. """ try: - body = _fetch_financial_data(symbol) + body = _fetch_financial_data(symbol, page_size=periods) if not body.get("success") or not body.get("result"): - return {} + return [] if periods > 1 else {} data = body["result"].get("data") if not data: - return {} + return [] if periods > 1 else {} - latest = data[0] - result = { - "roe": parse_float(latest.get("WEIGHTAVG_ROE")), - "gross_margin": parse_float(latest.get("XSMLL")), - } + results = [_parse_financial_row(row) for row in data] - # Compute profit margin from net profit / revenue - revenue = parse_float(latest.get("TOTAL_OPERATE_INCOME")) - net_profit = parse_float(latest.get("PARENT_NETPROFIT")) - if revenue and net_profit: - result["profit_margin"] = round(net_profit / revenue * 100, 2) - - return {k: v for k, v in result.items() if v is not None} + if periods > 1: + return results + return results[0] if results else {} except Exception as e: logger.debug("Eastmoney financial indicators failed for %s: %s", symbol, e) - return {} + return [] if periods > 1 else {} + + +def _parse_financial_row(row: dict) -> dict: + """Parse a single row from RPT_LICO_FN_CPD into BasicInfo-compatible fields.""" + revenue = parse_float(row.get("TOTAL_OPERATE_INCOME")) + net_profit = parse_float(row.get("PARENT_NETPROFIT")) + + result = { + "roe": parse_float(row.get("WEIGHTAVG_ROE")), + "gross_margin": parse_float(row.get("XSMLL")), + "revenue_growth": parse_float(row.get("YSTZ")), + "net_profit_growth": parse_float(row.get("SJLTZ")), + "revenue_growth_qoq": parse_float(row.get("YSHZ")), + "net_profit_growth_qoq": parse_float(row.get("SJLHZ")), + "eps": parse_float(row.get("BASIC_EPS")), + "book_value_per_share": parse_float(row.get("BPS")), + "operating_cash_flow_per_share": parse_float(row.get("MGJYXJJE")), + "dividend_yield": parse_float(row.get("ZXGXL")), + } + + # Compute profit margin from net profit / revenue + if revenue and net_profit: + result["profit_margin"] = round(net_profit / revenue * 100, 2) + + # Report metadata + report_date = row.get("REPORTDATE", "") + if report_date: + result["report_date"] = report_date[:10] + datatype = row.get("DATATYPE") + if datatype: + result["report_type"] = datatype + + return {k: v for k, v in result.items() if v is not None} @api_retry -def _fetch_financial_data(symbol: str) -> dict: +def _fetch_financial_data(symbol: str, page_size: int = 1) -> dict: """Fetch financial report data from eastmoney datacenter (with retry).""" r = requests.get( _DATACENTER_URL, @@ -86,7 +110,7 @@ def _fetch_financial_data(symbol: str) -> dict: "columns": _FIN_COLUMNS, "filter": f'(SECURITY_CODE="{symbol}")', "pageNumber": "1", - "pageSize": "1", + "pageSize": str(page_size), "sortColumns": "NOTICE_DATE", "sortTypes": "-1", }, diff --git a/haoinvest/models.py b/haoinvest/models.py index 914eb7c..677d2ef 100644 --- a/haoinvest/models.py +++ b/haoinvest/models.py @@ -188,6 +188,32 @@ class BasicInfo(BaseModel): peg_ratio: Optional[float] = Field( default=None, description="Price/Earnings to Growth ratio" ) + # Additional financial metrics (from eastmoney RPT_LICO_FN_CPD) + dividend_yield: Optional[float] = Field( + default=None, description="Latest dividend yield (%)" + ) + eps: Optional[float] = Field(default=None, description="Basic earnings per share") + book_value_per_share: Optional[float] = Field( + default=None, description="Book value per share (BPS)" + ) + operating_cash_flow_per_share: Optional[float] = Field( + default=None, description="Operating cash flow per share" + ) + net_profit_growth: Optional[float] = Field( + default=None, description="YoY net profit growth (%)" + ) + revenue_growth_qoq: Optional[float] = Field( + default=None, description="QoQ revenue growth (%)" + ) + net_profit_growth_qoq: Optional[float] = Field( + default=None, description="QoQ net profit growth (%)" + ) + report_date: Optional[str] = Field( + default=None, description="Financial report date (e.g., '2025-09-30')" + ) + report_type: Optional[str] = Field( + default=None, description="Report type (e.g., '2025年 三季报')" + ) # --- Analysis models --- @@ -225,7 +251,7 @@ class FundamentalAnalysis(BaseModel): pb_ratio: Optional[float] = None total_market_cap: Optional[int] = None valuation: ValuationAssessment = Field(default_factory=ValuationAssessment) - # Enhanced financial metrics + # Financial metrics roe: Optional[float] = None roa: Optional[float] = None debt_to_equity: Optional[float] = None @@ -237,6 +263,15 @@ class FundamentalAnalysis(BaseModel): free_cash_flow: Optional[float] = None operating_cash_flow: Optional[float] = None peg_ratio: Optional[float] = None + dividend_yield: Optional[float] = None + eps: Optional[float] = None + book_value_per_share: Optional[float] = None + operating_cash_flow_per_share: Optional[float] = None + net_profit_growth: Optional[float] = None + revenue_growth_qoq: Optional[float] = None + net_profit_growth_qoq: Optional[float] = None + report_date: Optional[str] = None + report_type: Optional[str] = None financial_health: FinancialHealthAssessment = Field( default_factory=FinancialHealthAssessment ) @@ -294,7 +329,7 @@ class StockReport(BaseModel): technical: Optional["TechnicalIndicators"] = None volume: Optional["VolumeAnalysis"] = None signals: Optional["SignalSummary"] = None - # Enhanced fields (Phase 1+5) + # Financial metrics roe: Optional[float] = None roa: Optional[float] = None debt_to_equity: Optional[float] = None @@ -306,6 +341,15 @@ class StockReport(BaseModel): free_cash_flow: Optional[float] = None operating_cash_flow: Optional[float] = None peg_ratio: Optional[float] = None + dividend_yield: Optional[float] = None + eps: Optional[float] = None + book_value_per_share: Optional[float] = None + operating_cash_flow_per_share: Optional[float] = None + net_profit_growth: Optional[float] = None + revenue_growth_qoq: Optional[float] = None + net_profit_growth_qoq: Optional[float] = None + report_date: Optional[str] = None + report_type: Optional[str] = None financial_health: Optional[FinancialHealthAssessment] = None checklist: Optional[BuyReadinessChecklist] = None diff --git a/tests/test_analysis_report.py b/tests/test_analysis_report.py index 1cacb1e..f443ee0 100644 --- a/tests/test_analysis_report.py +++ b/tests/test_analysis_report.py @@ -133,13 +133,13 @@ def test_no_date_range_uses_stable_cache_key(self, db: Database): class TestScoreValuation: def test_undervalued(self): - assert _score_valuation("偏低估") == 5 + assert _score_valuation("偏低") == 5 def test_fair(self): - assert _score_valuation("估值合理") == 4 + assert _score_valuation("中等") == 4 def test_overvalued(self): - assert _score_valuation("偏高估") == 2 + assert _score_valuation("偏高") == 2 def test_unknown(self): assert _score_valuation("无法评估") == 3 @@ -189,7 +189,7 @@ def test_healthy_stock(self): symbol="600519", market_type="a_share", current_price=1800.0, - valuation=ValuationAssessment(overall="偏低估"), + valuation=ValuationAssessment(overall="偏低"), risk_metrics=RiskMetrics(max_drawdown_pct=-8.0, sharpe_ratio=1.5), roe=20.0, revenue_growth=25.0, @@ -212,7 +212,7 @@ def test_weak_stock(self): symbol="999999", market_type="a_share", current_price=5.0, - valuation=ValuationAssessment(overall="明显高估"), + valuation=ValuationAssessment(overall="高"), risk_metrics=RiskMetrics(max_drawdown_pct=-45.0, sharpe_ratio=-0.5), roe=2.0, revenue_growth=-20.0, @@ -240,7 +240,7 @@ def test_report_command(self, tmp_path, monkeypatch): market_type="a_share", current_price=1800.0, valuation=ValuationAssessment( - pe_assessment="偏高", pb_assessment="高估", overall="偏高估" + pe_assessment="偏高", pb_assessment="高估", overall="偏高" ), risk_metrics=RiskMetrics( annualized_volatility=25.0, @@ -253,7 +253,7 @@ def test_report_command(self, tmp_path, monkeypatch): ), checklist=BuyReadinessChecklist( items=[ - ChecklistItem(dimension="估值", score=4, assessment="偏高估"), + ChecklistItem(dimension="估值", score=4, assessment="偏高"), ChecklistItem(dimension="盈利能力", score=5, assessment="优秀"), ], total_score=9, diff --git a/tests/test_cli/test_analyze.py b/tests/test_cli/test_analyze.py index a12fe83..bbe6361 100644 --- a/tests/test_cli/test_analyze.py +++ b/tests/test_cli/test_analyze.py @@ -24,16 +24,16 @@ def test_fundamental_a_share(self): pb_ratio=12.1, total_market_cap=2100000000000, valuation=ValuationAssessment( - pe_assessment="偏高 (25 ≤ PE < 40)", - pb_assessment="高估 (PB ≥ 6)", - overall="偏高估", + pe_assessment="PE 35.2", + pb_assessment="PB 12.10", + overall="偏高", ), ) with patch("haoinvest.cli.analyze.analyze_stock", return_value=mock_result): result = runner.invoke(app, ["analyze", "fundamental", "600519"]) assert result.exit_code == 0 assert "贵州茅台" in result.output - assert "偏高估" in result.output + assert "偏高" in result.output def test_fundamental_sz_stock(self): mock_result = FundamentalAnalysis( @@ -47,9 +47,9 @@ def test_fundamental_sz_stock(self): pb_ratio=0.6, total_market_cap=240000000000, valuation=ValuationAssessment( - pe_assessment="低估 (PE < 15)", - pb_assessment="低估 (PB < 1)", - overall="偏低估", + pe_assessment="PE 5.3", + pb_assessment="PB 0.60", + overall="偏低", ), ) with patch("haoinvest.cli.analyze.analyze_stock", return_value=mock_result): @@ -75,7 +75,7 @@ def test_fundamental_json(self): pe_ratio=35.2, pb_ratio=12.1, valuation=ValuationAssessment( - pe_assessment="偏高", pb_assessment="高估", overall="偏高估" + pe_assessment="偏高", pb_assessment="高估", overall="偏高" ), ) with patch("haoinvest.cli.analyze.analyze_stock", return_value=mock_result): @@ -98,7 +98,7 @@ def test_fundamental_batch_two_symbols(self): currency="CNY", pe_ratio=35.2, pb_ratio=12.1, - valuation=ValuationAssessment(overall="偏高估"), + valuation=ValuationAssessment(overall="偏高"), roe=25.0, revenue_growth=15.0, profit_margin=50.0, @@ -113,7 +113,7 @@ def test_fundamental_batch_two_symbols(self): currency="CNY", pe_ratio=5.3, pb_ratio=0.6, - valuation=ValuationAssessment(overall="偏低估"), + valuation=ValuationAssessment(overall="偏低"), roe=10.0, revenue_growth=8.0, profit_margin=30.0, @@ -125,8 +125,8 @@ def test_fundamental_batch_two_symbols(self): assert result.exit_code == 0 assert "贵州茅台" in result.output assert "平安银行" in result.output - assert "偏高估" in result.output - assert "偏低估" in result.output + assert "偏高" in result.output + assert "偏低" in result.output def test_fundamental_batch_one_error(self): mock_result = FundamentalAnalysis( @@ -136,7 +136,7 @@ def test_fundamental_batch_one_error(self): market_type="a_share", current_price=1800.0, currency="CNY", - valuation=ValuationAssessment(overall="偏高估"), + valuation=ValuationAssessment(overall="偏高"), ) with patch( "haoinvest.cli.analyze.analyze_stock", diff --git a/tests/test_cli/test_analyze_run.py b/tests/test_cli/test_analyze_run.py index 5c8050c..23972a5 100644 --- a/tests/test_cli/test_analyze_run.py +++ b/tests/test_cli/test_analyze_run.py @@ -48,7 +48,7 @@ def _mock_fundamental(): valuation=ValuationAssessment( pe_assessment="偏高", pb_assessment="高估", - overall="偏高估", + overall="偏高", ), ) @@ -194,7 +194,7 @@ def test_json_batch(self, tmp_path, monkeypatch): valuation=ValuationAssessment( pe_assessment="合理", pb_assessment="偏高", - overall="估值合理", + overall="中等", ), ) calls = iter([mock_a, mock_b]) @@ -232,7 +232,7 @@ def test_batch_fundamental_text(self, tmp_path, monkeypatch): valuation=ValuationAssessment( pe_assessment="合理", pb_assessment="偏高", - overall="估值合理", + overall="中等", ), ) calls = iter([mock_a, mock_b]) From 010f603f8b1e744859bf7e917d3720e80e086e95 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 16:14:44 +0800 Subject: [PATCH 2/6] feat(market): stock screening and sector capital flow commands Add screen_stocks() via eastmoney xuangu API with filters (PE, PB, ROE, market cap, dividend yield) and sector_flow() via push2 endpoint (beta). New CLI commands: `market screen` and `market sector-flow`. Co-Authored-By: Claude Opus 4.6 --- README.md | 12 +- haoinvest/cli/market.py | 110 +++++++++++++++++ haoinvest/market/ashare_provider.py | 27 +++++ haoinvest/market/sources/eastmoney.py | 165 ++++++++++++++++++++++++++ 4 files changed, 312 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index df4e703..4f1a223 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Built for a beginner investor in China covering A-shares, US stocks, HK stocks, - **Market Data** — Real-time quotes from Sina/Tencent/eastmoney APIs (A-shares), Yahoo Finance (US/HK), Crypto.com (crypto) - **Fundamental Analysis** — PE/PB/ROE valuation assessment with financial health scoring; batch support for multi-symbol comparison - **Peer Comparison** — Find and compare same-sector stocks by valuation and performance +- **Stock Screening** — Screen A-share stocks by PE, PB, ROE, market cap, dividend yield via eastmoney xuangu API +- **Sector Capital Flow** — Track sector-level capital inflow/outflow (industry & concept boards, beta) - **Sector Browsing** — Browse A-share industry sectors and their constituent stocks - **Composable Analysis** — `analyze run` command with `--modules` flag to compose any combination of fundamental, technical, risk, volume, signals, peer, and checklist in a single call - **Comprehensive Report** — Full stock report with buy-readiness checklist combining fundamental, technical, and risk analysis @@ -61,9 +63,15 @@ uv run haoinvest analyze correlation 600519,NVDA # Correlation matrix uv run haoinvest analyze peer 600519 # Same-sector peer comparison uv run haoinvest analyze report 600519 # Full report with buy-readiness checklist -# Sectors +# Stock screening +uv run haoinvest market screen --roe-min 15 --pe-max 20 # Value screen +uv run haoinvest market screen --div-min 3 --limit 10 # High dividend + +# Sector data uv run haoinvest market sector-list # A-share industry sectors uv run haoinvest market sector 白酒 # Sector constituent stocks +uv run haoinvest market sector-flow # Sector capital flow (beta) +uv run haoinvest market sector-flow --type concept # Concept board flow # Strategy uv run haoinvest strategy optimize --method risk_parity # also: max_sharpe, min_volatility @@ -113,7 +121,7 @@ Use the unified `/haoinvest` skill in Claude Code for natural language interacti | Environment Variable | Default | Description | |---------------------|---------|-------------| -| `HAOINVEST_DATA_DIR` | `~/.haoinvest/` | Data directory path | +| `HAOINVEST_DATA_DIR` | `.haoinvest/` (project-local) | Data directory path | | `HAOINVEST_API_TIMEOUT` | `30` | A-share API timeout (seconds) | | `HAOINVEST_CACHE_TTL` | `14400` | Analysis cache TTL (seconds) | | `HAOINVEST_PRICE_CACHE_TTL` | `3600` | Price cache TTL (seconds) | diff --git a/haoinvest/cli/market.py b/haoinvest/cli/market.py index 090b180..e5b14e0 100644 --- a/haoinvest/cli/market.py +++ b/haoinvest/cli/market.py @@ -163,6 +163,116 @@ def sector_list( ) +@app.command("screen") +def screen( + pe_min: Optional[float] = typer.Option(None, help="Min PE ratio"), + pe_max: Optional[float] = typer.Option(None, help="Max PE ratio"), + pb_min: Optional[float] = typer.Option(None, help="Min PB ratio"), + pb_max: Optional[float] = typer.Option(None, help="Max PB ratio"), + roe_min: Optional[float] = typer.Option(None, help="Min ROE (%)"), + cap_min: Optional[float] = typer.Option( + None, help="Min market cap (yuan, e.g. 10e9 for 100亿)" + ), + cap_max: Optional[float] = typer.Option(None, help="Max market cap (yuan)"), + dividend_yield_min: Optional[float] = typer.Option( + None, "--div-min", help="Min dividend yield (%)" + ), + sort: str = typer.Option( + "ROE_WEIGHT", help="Sort field: ROE_WEIGHT, PE9, TOTAL_MARKET_CAP, ZXGXL" + ), + limit: int = typer.Option(20, help="Max results"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """条件选股 — screen A-share stocks by financial criteria.""" + from ..market.ashare_provider import AShareProvider + + try: + result = AShareProvider.screen_stocks( + pe_min=pe_min, + pe_max=pe_max, + pb_min=pb_min, + pb_max=pb_max, + roe_min=roe_min, + cap_min=cap_min, + cap_max=cap_max, + dividend_yield_min=dividend_yield_min, + sort_by=sort, + page_size=limit, + ) + except Exception as e: + error_output(str(e)) + raise typer.Exit(1) + + total = result.get("total", 0) + data = result.get("data", []) + + # Format market cap to 亿 for display + for row in data: + cap = row.get("market_cap") + if cap is not None: + row["market_cap_yi"] = f"{cap / 1e8:.0f}亿" + else: + row["market_cap_yi"] = "N/A" + # Round dividend yield + dy = row.get("dividend_yield") + if dy is not None: + row["dividend_yield"] = round(dy, 2) + + if use_json: + json_output({"total": total, "data": data}) + else: + typer.echo(f"共 {total} 只股票符合条件 (显示前 {len(data)} 只)\n") + tsv_output( + data, + columns=[ + "symbol", + "name", + "price", + "pe", + "pb", + "roe", + "market_cap_yi", + "dividend_yield", + ], + ) + + +@app.command("sector-flow") +def sector_flow( + board_type: str = typer.Option( + "industry", "--type", "-t", help="Board type: industry (行业) or concept (概念)" + ), + limit: int = typer.Option(20, "--limit", "-n", help="Number of sectors"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """板块资金流向 — sector capital flow ranking (beta).""" + from ..market.ashare_provider import AShareProvider + + try: + rows = AShareProvider.get_sector_flow(board_type=board_type, limit=limit) + except Exception as e: + error_output(str(e)) + raise typer.Exit(1) + + if not rows: + typer.echo("⚠ 板块资金流向数据暂时不可用 (push2 endpoint)") + raise typer.Exit(1) + + if use_json: + json_output(rows) + else: + tsv_output( + rows, + columns=[ + "name", + "net_inflow_yi", + "net_inflow_pct", + "super_large_pct", + "large_pct", + ], + ) + + @app.command("sector") def sector( name: str = typer.Argument(help="Sector name, e.g. 白酒, 银行, 半导体"), diff --git a/haoinvest/market/ashare_provider.py b/haoinvest/market/ashare_provider.py index b2f369b..ceb12c4 100644 --- a/haoinvest/market/ashare_provider.py +++ b/haoinvest/market/ashare_provider.py @@ -84,3 +84,30 @@ def get_sector_constituents(sector_name: str) -> list[dict]: """Get stocks in a specific industry board.""" with bypass_proxy(): return sina.get_sector_constituents(sector_name) + + @staticmethod + def screen_stocks(**kwargs) -> dict: + """Screen A-share stocks by financial criteria. + + Supported filters: pe_min, pe_max, pb_min, pb_max, roe_min, + cap_min, cap_max, dividend_yield_min, sort_by, sort_asc, + page, page_size. + + Returns dict with 'total' count and 'data' list. + """ + with bypass_proxy(): + return eastmoney.screen_stocks(**kwargs) + + @staticmethod + def get_sector_flow(board_type: str = "industry", limit: int = 20) -> list[dict]: + """Fetch sector capital flow from push2 endpoint (beta). + + Args: + board_type: "industry" or "concept" + limit: Number of sectors to return + + Returns list of dicts with net main inflow data. + Push2 endpoint has known stability issues — returns empty list on failure. + """ + with bypass_proxy(): + return eastmoney.get_sector_flow(board_type=board_type, limit=limit) diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py index f1ff1ea..076486f 100644 --- a/haoinvest/market/sources/eastmoney.py +++ b/haoinvest/market/sources/eastmoney.py @@ -3,6 +3,7 @@ Endpoints: - emweb.securities.eastmoney.com — company info (F10 page backend) - datacenter-web.eastmoney.com — financial reports and indicators +- data.eastmoney.com/dataapi/xuangu — stock screening """ import logging @@ -118,3 +119,167 @@ def _fetch_financial_data(symbol: str, page_size: int = 1) -> dict: ) r.raise_for_status() return r.json() + + +# --- Stock Screening --- + +_SCREEN_URL = "https://data.eastmoney.com/dataapi/xuangu/list" + +_SCREEN_COLUMNS = ( + "SECUCODE,SECURITY_CODE,SECURITY_NAME_ABBR,NEW_PRICE," + "CHANGE_RATE,PE9,PB_MRQ,ROE_WEIGHT,TOTAL_MARKET_CAP,ZXGXL" +) + + +@api_retry +def screen_stocks( + *, + pe_min: float | None = None, + pe_max: float | None = None, + pb_min: float | None = None, + pb_max: float | None = None, + roe_min: float | None = None, + cap_min: float | None = None, + cap_max: float | None = None, + dividend_yield_min: float | None = None, + sort_by: str = "ROE_WEIGHT", + sort_asc: bool = False, + page: int = 1, + page_size: int = 20, +) -> dict: + """Screen A-share stocks using eastmoney xuangu API. + + Returns dict with 'total' count and 'data' list of matching stocks. + Each stock has: symbol, name, price, change_pct, pe, pb, roe, market_cap, + dividend_yield. + """ + # Build filter string: each condition is (FIELD>VALUE) or (FIELD{pe_min})") + if pe_max is not None: + filters.append(f"(PE9<{pe_max})") + if pb_min is not None: + filters.append(f"(PB_MRQ>{pb_min})") + if pb_max is not None: + filters.append(f"(PB_MRQ<{pb_max})") + if roe_min is not None: + filters.append(f"(ROE_WEIGHT>{roe_min})") + if cap_min is not None: + filters.append(f"(TOTAL_MARKET_CAP>{cap_min})") + if cap_max is not None: + filters.append(f"(TOTAL_MARKET_CAP<{cap_max})") + if dividend_yield_min is not None: + filters.append(f"(ZXGXL>{dividend_yield_min})") + + # Require positive PE by default to exclude loss-making companies + if pe_min is None and pe_max is not None: + filters.append("(PE9>0)") + + params = { + "sty": _SCREEN_COLUMNS, + "filter": "".join(filters) if filters else "", + "p": str(page), + "ps": str(page_size), + "st": sort_by, + "sr": "1" if sort_asc else "-1", + } + + r = requests.get(_SCREEN_URL, params=params, timeout=15) + r.raise_for_status() + body = r.json() + + if not body.get("success"): + return {"total": 0, "data": []} + + result = body.get("result", {}) + total = result.get("count", 0) + rows = result.get("data", []) or [] + + data = [] + for row in rows: + data.append( + { + "symbol": row.get("SECURITY_CODE", ""), + "name": row.get("SECURITY_NAME_ABBR", ""), + "price": parse_float(row.get("NEW_PRICE")), + "change_pct": parse_float(row.get("CHANGE_RATE")), + "pe": parse_float(row.get("PE9")), + "pb": parse_float(row.get("PB_MRQ")), + "roe": parse_float(row.get("ROE_WEIGHT")), + "market_cap": parse_float(row.get("TOTAL_MARKET_CAP")), + "dividend_yield": parse_float(row.get("ZXGXL")), + } + ) + + return {"total": total, "data": data} + + +# --- Sector Capital Flow (push2 endpoint, beta) --- + +_PUSH2_URL = "https://push2.eastmoney.com/api/qt/clist/get" + + +def get_sector_flow(board_type: str = "industry", limit: int = 20) -> list[dict]: + """Fetch sector capital flow data from push2 endpoint. + + Args: + board_type: "industry" (行业板块) or "concept" (概念板块) + limit: Number of sectors to return + + Returns list of dicts sorted by net main inflow (descending). + NOTE: push2 endpoint has known stability issues (connection resets, + IP blocking). Callers should handle failures gracefully. + """ + fs_map = {"industry": "m:90+t:2", "concept": "m:90+t:3"} + fs = fs_map.get(board_type, "m:90+t:2") + + try: + r = requests.get( + _PUSH2_URL, + params={ + "fid": "f62", + "po": "1", + "pz": str(limit), + "pn": "1", + "np": "1", + "fs": fs, + "fields": "f12,f14,f62,f184,f66,f69,f72,f75", + }, + headers={ + "Referer": "https://data.eastmoney.com/", + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + ), + }, + timeout=10, + ) + r.raise_for_status() + body = r.json() + except Exception as e: + logger.warning("push2 sector flow request failed: %s", e) + return [] + + if body.get("rc") != 0 or not body.get("data"): + return [] + + results = [] + for item in body["data"].get("diff", []): + net_inflow = item.get("f62") + results.append( + { + "code": item.get("f12", ""), + "name": item.get("f14", ""), + "net_inflow": net_inflow, + "net_inflow_yi": ( + f"{net_inflow / 1e8:.2f}亿" if net_inflow is not None else "N/A" + ), + "net_inflow_pct": item.get("f184"), + "super_large_inflow": item.get("f66"), + "super_large_pct": item.get("f69"), + "large_inflow": item.get("f72"), + "large_pct": item.get("f75"), + } + ) + + return results From 1c1f145c6406105b073fc6a3f117a69612a1b901 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 16:25:56 +0800 Subject: [PATCH 3/6] feat(portfolio): investment thesis tracking with guardrails integration Add InvestmentThesis model, DB table + CRUD, and CLI commands (add, list, show, invalidate, realize, review). Guardrails alerts now check for overdue thesis reviews and prefer structured theses over journal entries. Co-Authored-By: Claude Opus 4.6 --- README.md | 7 + haoinvest/cli/portfolio.py | 230 ++++++++++++++++++++++++++++++++- haoinvest/db.py | 114 ++++++++++++++++ haoinvest/guardrails/alerts.py | 45 ++++++- haoinvest/models.py | 36 ++++++ 5 files changed, 428 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4f1a223..265a057 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ Built for a beginner investor in China covering A-shares, US stocks, HK stocks, - **Risk Metrics** — Annualized volatility, max drawdown, Sharpe ratio, Sortino ratio (powered by QuantStats) - **Technical Analysis** — MA, MACD, RSI, Bollinger Bands with Chinese explanations (powered by pandas-ta) - **Portfolio Optimization** — Equal weight, risk parity, minimum volatility, maximum Sharpe allocation (powered by PyPortfolioOpt) +- **Investment Thesis Tracking** — Record buy rationale, key assumptions, target/stop-loss prices; review reminders via guardrails - **Investment Journal** — Structured entries with decision type and emotion tagging for pattern analysis - **Claude Code Skill** — Natural language interface via unified `/haoinvest` skill @@ -77,6 +78,12 @@ uv run haoinvest market sector-flow --type concept # Concept board flow uv run haoinvest strategy optimize --method risk_parity # also: max_sharpe, min_volatility uv run haoinvest strategy rebalance --target '{"600519": 0.5, "NVDA": 0.5}' +# Investment thesis +uv run haoinvest portfolio thesis add 600519 1800 "白酒龙头" --target 2200 --stop-loss 1600 +uv run haoinvest portfolio thesis list # View all theses +uv run haoinvest portfolio thesis show 1 # Thesis details +uv run haoinvest portfolio thesis review 1 # Mark as reviewed + # Journal uv run haoinvest journal add "First buy of Moutai" --decision buy --emotion rational uv run haoinvest journal list diff --git a/haoinvest/cli/portfolio.py b/haoinvest/cli/portfolio.py index 4651e08..1bf1d8b 100644 --- a/haoinvest/cli/portfolio.py +++ b/haoinvest/cli/portfolio.py @@ -1,12 +1,19 @@ """CLI commands for portfolio management.""" -from datetime import datetime +import json +from datetime import date, datetime from typing import Optional import typer from ..market import get_provider -from ..models import MarketType, Transaction, TransactionAction +from ..models import ( + InvestmentThesis, + MarketType, + ThesisStatus, + Transaction, + TransactionAction, +) from ..portfolio.manager import PortfolioManager from ..portfolio.returns import portfolio_returns_summary, realized_pnl, unrealized_pnl from ._shared import init_db @@ -14,6 +21,8 @@ from .market import _detect_market_type app = typer.Typer(help="Portfolio — holdings, trades, returns.") +thesis_app = typer.Typer(help="Investment thesis — record and review buy rationale.") +app.add_typer(thesis_app, name="thesis") @app.command("list") @@ -204,3 +213,220 @@ def returns( "unrealized_pnl_pct", ], ) + + +# --- Thesis subcommands --- + + +@thesis_app.command("add") +def thesis_add( + symbol: str = typer.Argument(help="Stock symbol, e.g. 600519"), + entry_price: float = typer.Argument(help="Entry price"), + summary: str = typer.Argument(help="Core thesis / buy rationale"), + assumptions: Optional[str] = typer.Option( + None, + "--assumptions", + "-a", + help='Key assumptions as JSON list, e.g. \'["ROE>15%", "行业景气"]\'', + ), + target: Optional[float] = typer.Option(None, "--target", help="Target price"), + stop_loss: Optional[float] = typer.Option( + None, "--stop-loss", help="Stop loss price" + ), + entry_date_str: Optional[str] = typer.Option( + None, "--date", "-d", help="Entry date YYYY-MM-DD (default: today)" + ), + review_days: int = typer.Option( + 30, "--review-days", help="Review interval in days" + ), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """记录投资逻辑 — record why you bought a stock.""" + entry_date = date.fromisoformat(entry_date_str) if entry_date_str else date.today() + key_assumptions = json.loads(assumptions) if assumptions else [] + + thesis = InvestmentThesis( + symbol=symbol, + entry_date=entry_date, + entry_price=entry_price, + thesis_summary=summary, + key_assumptions=key_assumptions, + target_price=target, + stop_loss_price=stop_loss, + review_interval_days=review_days, + ) + + db = init_db() + thesis_id = db.add_thesis(thesis) + + result = {"thesis_id": thesis_id, "symbol": symbol, "summary": summary} + if use_json: + json_output(result) + else: + kv_output(result) + + +@thesis_app.command("list") +def thesis_list( + symbol: Optional[str] = typer.Option( + None, "--symbol", "-s", help="Filter by symbol" + ), + status: Optional[str] = typer.Option( + None, "--status", help="Filter: active, invalidated, realized" + ), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """查看投资逻辑 — list all investment theses.""" + db = init_db() + thesis_status = ThesisStatus(status) if status else None + theses = db.get_theses(symbol=symbol, status=thesis_status) + + if not theses: + typer.echo("(no theses found)") + return + + if use_json: + json_output([t.model_dump(mode="json") for t in theses]) + else: + rows = [] + for t in theses: + days_since_review = None + if t.last_reviewed_at: + days_since_review = (datetime.now() - t.last_reviewed_at).days + elif t.created_at: + days_since_review = (datetime.now() - t.created_at).days + + overdue = "" + if ( + days_since_review is not None + and days_since_review > t.review_interval_days + ): + overdue = " ⚠" + + rows.append( + { + "id": t.id, + "symbol": t.symbol, + "status": t.status.value, + "entry": f"{t.entry_price}@{t.entry_date}", + "target": t.target_price or "N/A", + "stop_loss": t.stop_loss_price or "N/A", + "review": f"{days_since_review}d ago{overdue}" + if days_since_review + else "N/A", + "summary": t.thesis_summary[:40], + } + ) + tsv_output( + rows, + columns=[ + "id", + "symbol", + "status", + "entry", + "target", + "stop_loss", + "review", + "summary", + ], + ) + + +@thesis_app.command("show") +def thesis_show( + thesis_id: int = typer.Argument(help="Thesis ID"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """查看单个投资逻辑详情 — show thesis details.""" + db = init_db() + thesis = db.get_thesis(thesis_id) + + if not thesis: + error_output(f"Thesis #{thesis_id} not found") + raise typer.Exit(1) + + if use_json: + json_output(thesis.model_dump(mode="json")) + else: + kv_output( + { + "ID": thesis.id, + "Symbol": thesis.symbol, + "Status": thesis.status.value, + "EntryDate": thesis.entry_date, + "EntryPrice": thesis.entry_price, + "TargetPrice": thesis.target_price or "N/A", + "StopLoss": thesis.stop_loss_price or "N/A", + "ReviewInterval": f"{thesis.review_interval_days} days", + "LastReviewed": thesis.last_reviewed_at or "never", + "Summary": thesis.thesis_summary, + "Assumptions": ", ".join(thesis.key_assumptions) + if thesis.key_assumptions + else "N/A", + "InvalidationReason": thesis.invalidation_reason or "N/A", + } + ) + + +@thesis_app.command("invalidate") +def thesis_invalidate( + thesis_id: int = typer.Argument(help="Thesis ID to invalidate"), + reason: str = typer.Argument(help="Why the thesis is no longer valid"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """标记逻辑失效 — mark a thesis as invalidated.""" + db = init_db() + thesis = db.get_thesis(thesis_id) + if not thesis: + error_output(f"Thesis #{thesis_id} not found") + raise typer.Exit(1) + + db.update_thesis_status(thesis_id, ThesisStatus.INVALIDATED, reason) + + result = {"thesis_id": thesis_id, "status": "invalidated", "reason": reason} + if use_json: + json_output(result) + else: + kv_output(result) + + +@thesis_app.command("realize") +def thesis_realize( + thesis_id: int = typer.Argument(help="Thesis ID to mark as realized"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """标记逻辑兑现 — mark a thesis as realized (target hit or sold).""" + db = init_db() + thesis = db.get_thesis(thesis_id) + if not thesis: + error_output(f"Thesis #{thesis_id} not found") + raise typer.Exit(1) + + db.update_thesis_status(thesis_id, ThesisStatus.REALIZED) + + result = {"thesis_id": thesis_id, "status": "realized"} + if use_json: + json_output(result) + else: + kv_output(result) + + +@thesis_app.command("review") +def thesis_review( + thesis_id: int = typer.Argument(help="Thesis ID to mark as reviewed"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """标记已审查 — mark a thesis as reviewed (resets review timer).""" + db = init_db() + thesis = db.get_thesis(thesis_id) + if not thesis: + error_output(f"Thesis #{thesis_id} not found") + raise typer.Exit(1) + + db.mark_thesis_reviewed(thesis_id) + + result = {"thesis_id": thesis_id, "reviewed_at": datetime.now().isoformat()} + if use_json: + json_output(result) + else: + kv_output(result) diff --git a/haoinvest/db.py b/haoinvest/db.py index 792913d..7693c93 100644 --- a/haoinvest/db.py +++ b/haoinvest/db.py @@ -9,10 +9,12 @@ from .config import get_db_path from .models import ( DailySnapshot, + InvestmentThesis, JournalEntry, MarketType, Position, PriceBar, + ThesisStatus, Transaction, TransactionAction, ) @@ -117,6 +119,26 @@ def _parse_date(val: str | None) -> date | None: value TEXT NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); + +CREATE TABLE IF NOT EXISTS investment_theses ( + id INTEGER PRIMARY KEY, + symbol TEXT NOT NULL, + entry_date DATE NOT NULL, + entry_price REAL NOT NULL, + thesis_summary TEXT NOT NULL, + key_assumptions TEXT NOT NULL DEFAULT '[]', + target_price REAL, + stop_loss_price REAL, + review_interval_days INTEGER NOT NULL DEFAULT 30, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'invalidated', 'realized')), + last_reviewed_at TIMESTAMP, + invalidation_reason TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_theses_symbol ON investment_theses(symbol); +CREATE INDEX IF NOT EXISTS idx_theses_status ON investment_theses(status); """ @@ -489,6 +511,98 @@ def set_guardrails_config(self, key: str, value: str) -> None: ) self.conn.commit() + # --- Investment Theses --- + + def add_thesis(self, thesis: InvestmentThesis) -> int: + """Insert a new investment thesis.""" + cursor = self.conn.execute( + """INSERT INTO investment_theses + (symbol, entry_date, entry_price, thesis_summary, key_assumptions, + target_price, stop_loss_price, review_interval_days, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + thesis.symbol, + thesis.entry_date.isoformat(), + thesis.entry_price, + thesis.thesis_summary, + json.dumps(thesis.key_assumptions, ensure_ascii=False), + thesis.target_price, + thesis.stop_loss_price, + thesis.review_interval_days, + thesis.status.value, + ), + ) + self.conn.commit() + return cursor.lastrowid # type: ignore[return-value] + + def get_theses( + self, + symbol: Optional[str] = None, + status: Optional[ThesisStatus] = None, + ) -> list[InvestmentThesis]: + """List theses, optionally filtered by symbol and/or status.""" + query = "SELECT * FROM investment_theses WHERE 1=1" + params: list = [] + if symbol: + query += " AND symbol = ?" + params.append(symbol) + if status: + query += " AND status = ?" + params.append(status.value) + query += " ORDER BY created_at DESC" + rows = self.conn.execute(query, params).fetchall() + return [self._row_to_thesis(r) for r in rows] + + def get_thesis(self, thesis_id: int) -> Optional[InvestmentThesis]: + """Get a single thesis by ID.""" + row = self.conn.execute( + "SELECT * FROM investment_theses WHERE id = ?", (thesis_id,) + ).fetchone() + return self._row_to_thesis(row) if row else None + + def update_thesis_status( + self, + thesis_id: int, + status: ThesisStatus, + reason: Optional[str] = None, + ) -> None: + """Update thesis status (e.g., invalidate or realize).""" + self.conn.execute( + """UPDATE investment_theses + SET status = ?, invalidation_reason = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ?""", + (status.value, reason, thesis_id), + ) + self.conn.commit() + + def mark_thesis_reviewed(self, thesis_id: int) -> None: + """Update last_reviewed_at to now.""" + self.conn.execute( + """UPDATE investment_theses + SET last_reviewed_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP + WHERE id = ?""", + (thesis_id,), + ) + self.conn.commit() + + def _row_to_thesis(self, row: sqlite3.Row) -> InvestmentThesis: + return InvestmentThesis( + id=row["id"], + symbol=row["symbol"], + entry_date=date.fromisoformat(str(row["entry_date"])), + entry_price=row["entry_price"], + thesis_summary=row["thesis_summary"], + key_assumptions=json.loads(row["key_assumptions"]), + target_price=row["target_price"], + stop_loss_price=row["stop_loss_price"], + review_interval_days=row["review_interval_days"], + status=ThesisStatus(row["status"]), + last_reviewed_at=_parse_datetime(row["last_reviewed_at"]), + invalidation_reason=row["invalidation_reason"], + created_at=_parse_datetime(row["created_at"]), + updated_at=_parse_datetime(row["updated_at"]), + ) + def get_journal_entries_by_emotion( self, emotion: str, diff --git a/haoinvest/guardrails/alerts.py b/haoinvest/guardrails/alerts.py index ccbeb54..409eb66 100644 --- a/haoinvest/guardrails/alerts.py +++ b/haoinvest/guardrails/alerts.py @@ -12,6 +12,7 @@ MarketType, PositionAlert, RecentPriceChange, + ThesisStatus, ) from .rules import load_config @@ -98,6 +99,41 @@ def scan_alerts( ) ) + # Thesis review alerts + alerts.extend(_scan_thesis_review_alerts(db)) + + return alerts + + +def _scan_thesis_review_alerts(db: Database) -> list[PositionAlert]: + """Check for active theses that are overdue for review.""" + from datetime import datetime + + theses = db.get_theses(status=ThesisStatus.ACTIVE) + alerts: list[PositionAlert] = [] + now = datetime.now() + + for thesis in theses: + last_check = thesis.last_reviewed_at or thesis.created_at + if last_check is None: + continue + + days_since = (now - last_check).days + if days_since > thesis.review_interval_days: + alerts.append( + PositionAlert( + symbol=thesis.symbol, + alert_type=AlertType.GAIN_REVIEW, + current_pnl_pct=0, + threshold_pct=0, + original_thesis=thesis.thesis_summary, + message=( + f"{thesis.symbol} 投资逻辑已 {days_since} 天未审查" + f"(间隔 {thesis.review_interval_days} 天),请审查是否仍然成立" + ), + ) + ) + return alerts @@ -149,9 +185,14 @@ def _find_closest_bar(bars: list, target_date: date): def _get_original_thesis(db: Database, symbol: str) -> str | None: - """Find the original buy thesis from journal entries.""" + """Find the original buy thesis — check investment_theses first, then journal.""" + # Prefer structured thesis + theses = db.get_theses(symbol=symbol, status=ThesisStatus.ACTIVE) + if theses: + return theses[0].thesis_summary + + # Fallback to journal entries entries = db.get_journal_entries(symbol=symbol, limit=50) - # Look for the earliest BUY decision entry buy_entries = [ e for e in entries if e.decision_type and e.decision_type.value == "buy" ] diff --git a/haoinvest/models.py b/haoinvest/models.py index 677d2ef..8af61db 100644 --- a/haoinvest/models.py +++ b/haoinvest/models.py @@ -552,6 +552,42 @@ class RebalanceTrade(BaseModel): note: Optional[str] = None +# --- Investment Thesis models --- + + +class ThesisStatus(str, Enum): + """Lifecycle status of an investment thesis.""" + + ACTIVE = "active" + INVALIDATED = "invalidated" + REALIZED = "realized" + + +class InvestmentThesis(BaseModel): + """Investment thesis for position management — records why a stock was bought + and the conditions under which the thesis remains valid.""" + + id: Optional[int] = None + symbol: str + entry_date: date + entry_price: float + thesis_summary: str = Field(description="Core reason for buying") + key_assumptions: list[str] = Field( + default_factory=list, + description="Conditions that must hold for the thesis to remain valid", + ) + target_price: Optional[float] = None + stop_loss_price: Optional[float] = None + review_interval_days: int = Field( + default=30, description="Days between mandatory thesis reviews" + ) + status: ThesisStatus = ThesisStatus.ACTIVE + last_reviewed_at: Optional[datetime] = None + invalidation_reason: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + # --- Guardrails models --- From 8a09572c17e86779ff448f065d8134f85d60e765 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 16:50:00 +0800 Subject: [PATCH 4/6] feat: obsidian vault templates and skill workflow integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create vault directory structure with note templates (个股/政策). Update SKILL.md with screening, thesis, and Obsidian workflows. Add reference files and command docs for new Phase 2-3 features. Co-Authored-By: Claude Opus 4.6 --- .claude/skills/haoinvest/SKILL.md | 108 ++++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/.claude/skills/haoinvest/SKILL.md b/.claude/skills/haoinvest/SKILL.md index 4b52eef..6069852 100644 --- a/.claude/skills/haoinvest/SKILL.md +++ b/.claude/skills/haoinvest/SKILL.md @@ -29,23 +29,39 @@ All-in-one investment management via CLI + Claude Code agent. CLI does data + co 4. Point out weak areas and suggest what else to check 5. If data is missing (e.g., financial health = N/A), say so honestly -### Workflow 2: "我有闲钱想投资" — Investment direction +### Workflow 2: "我有闲钱想投资" — Stock discovery & screening -1. Check current portfolio: +参考: `.claude/skills/haoinvest/references/stock-screening-workflow.md` + +1. Check current portfolio to identify sector gaps: ```bash uv run haoinvest portfolio list ``` -2. Identify concentration: which sectors are overweight? -3. Scan sector rankings: +2. Judge market environment (use web search if needed for policy/macro context) +3. Scan sector rankings + capital flow: ```bash uv run haoinvest market sector-list + uv run haoinvest market sector-flow # beta, may fail + uv run haoinvest market sector-flow --type concept # concept boards + ``` +4. Based on user profile + market environment, select screening strategy + (参考 `stock-screening-workflow.md` 中的预置策略: 价值型/成长型/高分红型/防御型): + ```bash + uv run haoinvest market screen --roe-min 15 --pe-max 20 --cap-min 10e9 ``` -4. Drill into promising sectors: +5. Drill into promising sectors: ```bash uv run haoinvest market sector <板块名> ``` -5. Run reports on 2-3 candidates from underrepresented sectors -6. Explain WHY each candidate diversifies the portfolio +6. Run deep analysis on top 3-5 candidates: + ```bash + uv run haoinvest analyze run ,, --modules fundamental,risk,peer + ``` +7. Rank candidates considering: 持仓互补性 + 估值 + 成长性 + 风险 +8. Save research to Obsidian vault: + ```bash + obsidian vault=".haoinvest/vault" create path="个股/-.md" content="..." silent + ``` ### Workflow 3: "我想买/卖 XXX" — Pre-Trade Review (5维度审查) @@ -75,12 +91,22 @@ All-in-one investment management via CLI + Claude Code agent. CLI does data + co 4. **隐式情绪检测** (见 Workflow 3b) -5. If proceeding, suggest recording journal: +5. If proceeding with a BUY, record investment thesis: + ```bash + uv run haoinvest portfolio thesis add "<买入理由>" \ + --assumptions '["假设1", "假设2"]' --target <目标价> --stop-loss <止损价> + ``` + And suggest recording journal: ```bash uv run haoinvest journal add "<决策理由>" --decision buy --emotion --symbols ``` -6. Always remind: **这不是投资建议,最终决定需要你自己判断** +6. Save analysis to Obsidian vault: + ```bash + obsidian vault=".haoinvest/vault" create path="个股/-.md" content="..." silent + ``` + +7. Always remind: **这不是投资建议,最终决定需要你自己判断** ### Workflow 3b: 隐式情绪检测 (每次交易讨论时自动执行) @@ -137,21 +163,40 @@ All-in-one investment management via CLI + Claude Code agent. CLI does data + co ### Workflow 5: "定期体检" — Portfolio checkup +参考: `.claude/skills/haoinvest/references/position-management.md` + 1. Portfolio holdings + P&L: ```bash uv run haoinvest portfolio list uv run haoinvest portfolio returns ``` -2. Risk assessment: +2. Check guardrails alerts + thesis review status: + ```bash + uv run haoinvest guardrails alerts --json + uv run haoinvest portfolio thesis list + ``` +3. For theses overdue for review, run analysis to check assumptions: + ```bash + uv run haoinvest analyze run --modules fundamental,risk,signals + ``` + Compare current data against thesis key_assumptions. + Mark reviewed after analysis: + ```bash + uv run haoinvest portfolio thesis review + ``` +4. Risk assessment: ```bash uv run haoinvest analyze risk ``` -3. For each holding with poor risk metrics, run a quick report -4. Check allocation via: +5. Check allocation and suggest rebalancing: ```bash uv run haoinvest strategy optimize ``` -5. Suggest rebalancing if needed +6. Search for prior research in Obsidian vault: + ```bash + obsidian vault=".haoinvest/vault" search query="" path="个股" limit=5 + ``` +7. Append checkup results to vault notes ### Workflow 6: "情绪复盘" — Decision review @@ -171,6 +216,8 @@ uv run haoinvest market quote # Price + info (b uv run haoinvest market history [--start] [--end] # OHLCV bars (30 days default) uv run haoinvest market sector-list # A-share industry board ranking uv run haoinvest market sector # Sector constituents +uv run haoinvest market screen [--pe-max] [--roe-min] [--cap-min] [--div-min] [--sort] [--limit] # Stock screening +uv run haoinvest market sector-flow [--type industry|concept] [--limit] # Sector capital flow (beta) ``` ### Analysis @@ -200,6 +247,16 @@ uv run haoinvest portfolio returns [--symbol ] # P&L ``` Actions: `buy`, `sell`, `dividend`, `split`, `transfer_in`, `transfer_out` +### Thesis +```bash +uv run haoinvest portfolio thesis add "<理由>" [--assumptions '["..."]'] [--target] [--stop-loss] +uv run haoinvest portfolio thesis list [--symbol] [--status active|invalidated|realized] +uv run haoinvest portfolio thesis show # Full thesis details +uv run haoinvest portfolio thesis review # Mark reviewed (resets timer) +uv run haoinvest portfolio thesis invalidate "" # Mark invalidated +uv run haoinvest portfolio thesis realize # Mark realized +``` + ### Strategy ```bash uv run haoinvest strategy optimize [--method equal_weight|risk_parity|min_volatility|max_sharpe] @@ -243,6 +300,31 @@ For crypto prices, **prefer Crypto.com MCP tools** when available: Fall back to CLI if MCP is unavailable. +### Obsidian Vault (Research Knowledge Base) + +Vault location: `.haoinvest/vault/` (relative to project root) + +Use the `obsidian-cli` skill for all vault operations: +```bash +obsidian vault=".haoinvest/vault" search query="" limit=5 # Search notes +obsidian vault=".haoinvest/vault" read path="个股/600519-贵州茅台.md" # Read note +obsidian vault=".haoinvest/vault" create path="个股/-.md" content="..." silent # New note +obsidian vault=".haoinvest/vault" append path="个股/-.md" content="## 分析\n..." # Add to existing +``` + +Directory structure: `个股/` (stock analysis), `行业/` (sector research), `政策/` (policy notes), `模板/` (templates). +Use Obsidian wikilinks for cross-references: `[[白酒行业]]`, `[[600519-贵州茅台]]`. + +### Reference Files + +Analysis frameworks are in `.claude/skills/haoinvest/references/`: +- `a-share-analysis-framework.md` — 5-dimension A-share analysis framework +- `valuation-guide.md` — Relative valuation methods by industry +- `position-management.md` — Add/reduce/rotate decision framework +- `stock-screening-workflow.md` — Top-down screening with preset strategies + +Refer to these when interpreting analysis results or guiding investment decisions. + ## Teaching Mode When explaining metrics, always: From 4ba3b4b79a7a912ccacb9e1ca260182045d6ca51 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 16:53:56 +0800 Subject: [PATCH 5/6] feat(analysis): financial trends, PE/PB percentile, volume-weighted signals - Add `trends` analysis module: multi-period financial data (ROE, revenue growth, profit margin, EPS across 8 quarters) - Add PE/PB percentile within sector peers for relative valuation - Volume confirmation now votes (+1) in signal aggregation when anomaly detected, boosting confidence for volume-confirmed signals Co-Authored-By: Claude Opus 4.6 --- haoinvest/analysis/peer.py | 53 +++++++++++++++++++++++++-------- haoinvest/analysis/registry.py | 50 ++++++++++++++++++++++++++++++- haoinvest/analysis/signals.py | 27 ++++++++++++----- haoinvest/analysis/trends.py | 41 +++++++++++++++++++++++++ tests/test_analysis_registry.py | 3 +- tests/test_analysis_signals.py | 8 +++-- 6 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 haoinvest/analysis/trends.py diff --git a/haoinvest/analysis/peer.py b/haoinvest/analysis/peer.py index 9d201ed..c02eb08 100644 --- a/haoinvest/analysis/peer.py +++ b/haoinvest/analysis/peer.py @@ -55,21 +55,50 @@ def find_peers( top_peers.append(c) top_codes.add(c.get("code", "")) + # Collect PE/PB values for percentile calculation (from all constituents) + all_pe = [ + c.get("pe_ratio") + for c in constituents + if c.get("pe_ratio") is not None and c.get("pe_ratio") > 0 + ] + all_pb = [ + c.get("pb_ratio") + for c in constituents + if c.get("pb_ratio") is not None and c.get("pb_ratio") > 0 + ] + all_pe.sort() + all_pb.sort() + # Build comparison rows using sector constituent data (no extra API calls) rows = [] for c in top_peers: code = c.get("code", "") - rows.append( - { - "Symbol": code, - "Name": c.get("name", ""), - "Price": c.get("price"), - "Change%": c.get("change_pct"), - "PE": c.get("pe_ratio"), - "PB": c.get("pb_ratio"), - "MarketCap": c.get("total_market_cap"), - "is_target": code == symbol, - } - ) + pe = c.get("pe_ratio") + pb = c.get("pb_ratio") + row = { + "Symbol": code, + "Name": c.get("name", ""), + "Price": c.get("price"), + "Change%": c.get("change_pct"), + "PE": pe, + "PB": pb, + "PE_Pctl": _percentile(pe, all_pe), + "PB_Pctl": _percentile(pb, all_pb), + "MarketCap": c.get("total_market_cap"), + "is_target": code == symbol, + } + rows.append(row) return rows + + +def _percentile(value: float | None, sorted_vals: list[float]) -> str | None: + """Calculate percentile of value within a sorted list. + + Returns a string like "32%" meaning cheaper than 68% of peers. + """ + if value is None or value <= 0 or not sorted_vals: + return None + count_below = sum(1 for v in sorted_vals if v < value) + pctl = round(count_below / len(sorted_vals) * 100) + return f"{pctl}%" diff --git a/haoinvest/analysis/registry.py b/haoinvest/analysis/registry.py index 9df5030..446e979 100644 --- a/haoinvest/analysis/registry.py +++ b/haoinvest/analysis/registry.py @@ -98,6 +98,20 @@ def _run_signals( return aggregate_signals(db, symbol, market_type, start, end, verbose=verbose) +def _run_trends( + db: Database, + symbol: str, + market_type: MarketType, + start: date, + end: date, + **kwargs: Any, +) -> Any: + from .trends import financial_trends + + periods = kwargs.get("periods", 8) + return financial_trends(symbol, market_type, periods=periods) + + def _run_peer( db: Database, symbol: str, @@ -206,10 +220,36 @@ def _format_signals(result: Any, verbose: bool = False) -> tuple[str, Any]: return ("kv", output) +def _format_trends(result: Any, verbose: bool = False) -> tuple[str, Any]: + if result and "message" in result[0]: + return ("kv", {"message": result[0]["message"]}) + columns = [ + "report_date", + "roe", + "revenue_growth", + "net_profit_growth", + "gross_margin", + "profit_margin", + "eps", + "dividend_yield", + ] + return ("tsv", (result, columns)) + + def _format_peer(result: Any, verbose: bool = False) -> tuple[str, Any]: if result and "message" in result[0]: return ("kv", {"message": result[0]["message"]}) - columns = ["Symbol", "Name", "Price", "Change%", "PE", "PB", "MarketCap"] + columns = [ + "Symbol", + "Name", + "Price", + "Change%", + "PE", + "PE_Pctl", + "PB", + "PB_Pctl", + "MarketCap", + ] return ("tsv", (result, columns)) @@ -285,6 +325,14 @@ def any_needs_prices(module_names: list[str]) -> bool: formatter=_format_signals, needs_prices=True, ), + "trends": AnalysisModule( + name="trends", + runner=_run_trends, + formatter=_format_trends, + needs_prices=False, + default_lookback_days=0, + extra_kwargs=["periods"], + ), "peer": AnalysisModule( name="peer", runner=_run_peer, diff --git a/haoinvest/analysis/signals.py b/haoinvest/analysis/signals.py index eb00ec9..4d1469c 100644 --- a/haoinvest/analysis/signals.py +++ b/haoinvest/analysis/signals.py @@ -91,11 +91,22 @@ def aggregate_signals( neutral += 1 details.append("布林带: 中轨附近 (中性)") - # Volume note (does not vote) - if vol.is_anomaly: - details.append(f"成交量: 放量 (ratio={vol.volume_ratio}x, 趋势确认)") - elif vol.assessment == "缩量": - details.append(f"成交量: 缩量 (ratio={vol.volume_ratio}x)") + # Volume confirmation: high volume amplifies the dominant signal + if vol.is_anomaly and vol.volume_ratio is not None: + if bullish > bearish: + bullish += 1 + details.append( + f"成交量: 放量确认多头 (+1 多, ratio={vol.volume_ratio:.1f}x)" + ) + elif bearish > bullish: + bearish += 1 + details.append( + f"成交量: 放量确认空头 (+1 空, ratio={vol.volume_ratio:.1f}x)" + ) + else: + details.append(f"成交量: 放量但方向不明 (ratio={vol.volume_ratio:.1f}x)") + elif vol.assessment == "缩量" and vol.volume_ratio is not None: + details.append(f"成交量: 缩量 (ratio={vol.volume_ratio:.1f}x)") # Overall signal if bullish > bearish: @@ -105,9 +116,11 @@ def aggregate_signals( else: overall = "中性" - # Confidence + # Confidence: volume-confirmed signals get higher confidence max_votes = max(bullish, bearish, neutral) - if max_votes >= 4: + if vol.is_anomaly and max_votes >= 3: + confidence = "高" + elif max_votes >= 4: confidence = "高" elif max_votes >= 3: confidence = "中" diff --git a/haoinvest/analysis/trends.py b/haoinvest/analysis/trends.py new file mode 100644 index 0000000..ebdee55 --- /dev/null +++ b/haoinvest/analysis/trends.py @@ -0,0 +1,41 @@ +"""Historical financial trend analysis using multi-period eastmoney data.""" + +from ..models import MarketType + + +def financial_trends( + symbol: str, + market_type: MarketType, + periods: int = 8, +) -> list[dict]: + """Fetch multi-period financial data and return trend rows. + + Each row represents one reporting period with key metrics. + Only available for A-shares (eastmoney data source). + Returns empty list for non-A-share markets. + """ + if market_type != MarketType.A_SHARE: + return [{"message": f"Financial trends not available for {market_type.value}"}] + + from ..market.sources import eastmoney + from ..market.sources._common import bypass_proxy + + with bypass_proxy(): + rows = eastmoney.get_financial_indicators(symbol, periods=periods) + + if not rows: + return [{"message": f"No multi-period financial data for {symbol}"}] + + return [ + { + "report_date": r.get("report_date", ""), + "roe": r.get("roe"), + "revenue_growth": r.get("revenue_growth"), + "net_profit_growth": r.get("net_profit_growth"), + "gross_margin": r.get("gross_margin"), + "profit_margin": r.get("profit_margin"), + "eps": r.get("eps"), + "dividend_yield": r.get("dividend_yield"), + } + for r in rows + ] diff --git a/tests/test_analysis_registry.py b/tests/test_analysis_registry.py index 5e12159..70e4bfa 100644 --- a/tests/test_analysis_registry.py +++ b/tests/test_analysis_registry.py @@ -73,7 +73,7 @@ def test_all_modules_have_required_fields(self): assert isinstance(mod.default_lookback_days, int) def test_module_count(self): - assert len(MODULES) == 7 + assert len(MODULES) == 8 def test_module_names(self): expected = { @@ -82,6 +82,7 @@ def test_module_names(self): "risk", "volume", "signals", + "trends", "peer", "checklist", } diff --git a/tests/test_analysis_signals.py b/tests/test_analysis_signals.py index 87ad1e3..470527d 100644 --- a/tests/test_analysis_signals.py +++ b/tests/test_analysis_signals.py @@ -50,7 +50,9 @@ def test_uptrend_ma_bullish(self, db): result = aggregate_signals(db, "UP", MarketType.A_SHARE) # MA is trend-following and should be bullish assert any("多头排列" in d for d in result.details) - assert result.bullish_count + result.bearish_count + result.neutral_count == 4 + # 4 base indicators + optional volume confirmation vote + total = result.bullish_count + result.bearish_count + result.neutral_count + assert total >= 4 def test_downtrend_ma_bearish(self, db): """In a downtrend, MA trend should be bearish.""" @@ -73,11 +75,11 @@ def test_details_populated(self, db): assert len(result.details) >= 4 # at least MA, MACD, RSI, Bollinger def test_vote_counts_sum(self, db): - """Bullish + bearish + neutral should equal total indicators (4).""" + """Bullish + bearish + neutral should be 4 (base) or 5 (with volume confirmation).""" _seed_trend(db, "TEST", daily_pct=0.005, days=60) result = aggregate_signals(db, "TEST", MarketType.A_SHARE) total = result.bullish_count + result.bearish_count + result.neutral_count - assert total == 4 + assert total in (4, 5) def test_confidence_reflects_agreement(self, db): """Confidence should reflect degree of indicator agreement.""" From d80d43825ec15d3366d1e9f1c35c5d58600cfb05 Mon Sep 17 00:00:00 2001 From: Shuhao Qing Date: Wed, 8 Apr 2026 20:21:45 +0800 Subject: [PATCH 6/6] fix: address code review findings from PR #15 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add json.loads exception handling in thesis CLI (friendly error message) - Fix config.py docstring (data/ → .haoinvest/) - Add THESIS_REVIEW AlertType instead of reusing GAIN_REVIEW - Unify get_financial_indicators return type to always list[dict] - Use bisect for O(log n) percentile calculation in peer comparison - Add _project_root() fallback warning log - Add screen_stocks PE exclusion note in docstring - Remove redundant datetime import in alerts.py - Add tests: thesis CRUD (7 tests), thesis review alerts (3 tests) Co-Authored-By: Claude Opus 4.6 --- haoinvest/analysis/peer.py | 4 +- haoinvest/cli/portfolio.py | 12 +- haoinvest/config.py | 7 +- haoinvest/guardrails/alerts.py | 5 +- haoinvest/market/ashare_provider.py | 3 +- haoinvest/market/sources/eastmoney.py | 23 ++-- haoinvest/models.py | 1 + tests/test_db_thesis.py | 118 ++++++++++++++++++ tests/test_guardrails_thesis_alerts.py | 94 ++++++++++++++ tests/test_market/test_provider_contract.py | 2 +- .../test_sources/test_eastmoney.py | 20 +-- 11 files changed, 260 insertions(+), 29 deletions(-) create mode 100644 tests/test_db_thesis.py create mode 100644 tests/test_guardrails_thesis_alerts.py diff --git a/haoinvest/analysis/peer.py b/haoinvest/analysis/peer.py index c02eb08..0d51abf 100644 --- a/haoinvest/analysis/peer.py +++ b/haoinvest/analysis/peer.py @@ -97,8 +97,10 @@ def _percentile(value: float | None, sorted_vals: list[float]) -> str | None: Returns a string like "32%" meaning cheaper than 68% of peers. """ + import bisect + if value is None or value <= 0 or not sorted_vals: return None - count_below = sum(1 for v in sorted_vals if v < value) + count_below = bisect.bisect_left(sorted_vals, value) pctl = round(count_below / len(sorted_vals) * 100) return f"{pctl}%" diff --git a/haoinvest/cli/portfolio.py b/haoinvest/cli/portfolio.py index 1bf1d8b..3a0a5e0 100644 --- a/haoinvest/cli/portfolio.py +++ b/haoinvest/cli/portfolio.py @@ -243,7 +243,17 @@ def thesis_add( ) -> None: """记录投资逻辑 — record why you bought a stock.""" entry_date = date.fromisoformat(entry_date_str) if entry_date_str else date.today() - key_assumptions = json.loads(assumptions) if assumptions else [] + if assumptions: + try: + key_assumptions = json.loads(assumptions) + except json.JSONDecodeError: + error_output( + "Invalid JSON for --assumptions. Expected format: " + '\'["assumption1", "assumption2"]\'' + ) + raise typer.Exit(1) + else: + key_assumptions = [] thesis = InvestmentThesis( symbol=symbol, diff --git a/haoinvest/config.py b/haoinvest/config.py index 4e80c5d..9d91dfb 100644 --- a/haoinvest/config.py +++ b/haoinvest/config.py @@ -11,13 +11,18 @@ def _project_root() -> Path: if (current / "pyproject.toml").exists(): return current current = current.parent + import logging + + logging.getLogger(__name__).warning( + "Could not find pyproject.toml; falling back to parent of package directory" + ) return Path(__file__).resolve().parent.parent def get_data_dir() -> Path: """Return the data directory, creating it if needed. - Default: /data/ + Default: /.haoinvest/ Override: HAOINVEST_DATA_DIR environment variable """ data_dir = Path( diff --git a/haoinvest/guardrails/alerts.py b/haoinvest/guardrails/alerts.py index 409eb66..a3ccc1a 100644 --- a/haoinvest/guardrails/alerts.py +++ b/haoinvest/guardrails/alerts.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from datetime import date, timedelta +from datetime import date, datetime, timedelta from ..config import ZERO_THRESHOLD from ..db import Database @@ -107,7 +107,6 @@ def scan_alerts( def _scan_thesis_review_alerts(db: Database) -> list[PositionAlert]: """Check for active theses that are overdue for review.""" - from datetime import datetime theses = db.get_theses(status=ThesisStatus.ACTIVE) alerts: list[PositionAlert] = [] @@ -123,7 +122,7 @@ def _scan_thesis_review_alerts(db: Database) -> list[PositionAlert]: alerts.append( PositionAlert( symbol=thesis.symbol, - alert_type=AlertType.GAIN_REVIEW, + alert_type=AlertType.THESIS_REVIEW, current_pnl_pct=0, threshold_pct=0, original_thesis=thesis.thesis_summary, diff --git a/haoinvest/market/ashare_provider.py b/haoinvest/market/ashare_provider.py index ceb12c4..3980371 100644 --- a/haoinvest/market/ashare_provider.py +++ b/haoinvest/market/ashare_provider.py @@ -58,7 +58,8 @@ def get_basic_info(self, symbol: str) -> BasicInfo: with bypass_proxy(): info = eastmoney.get_basic_info(symbol) valuation = tencent.get_valuation(symbol) - fin = eastmoney.get_financial_indicators(symbol) + fin_list = eastmoney.get_financial_indicators(symbol) + fin = fin_list[0] if fin_list else {} return BasicInfo( name=info.name, diff --git a/haoinvest/market/sources/eastmoney.py b/haoinvest/market/sources/eastmoney.py index 076486f..26c0e8f 100644 --- a/haoinvest/market/sources/eastmoney.py +++ b/haoinvest/market/sources/eastmoney.py @@ -40,32 +40,28 @@ def get_basic_info(symbol: str) -> BasicInfo: ) -def get_financial_indicators(symbol: str, periods: int = 1) -> dict | list[dict]: +def get_financial_indicators(symbol: str, periods: int = 1) -> list[dict]: """Fetch financial indicators from eastmoney datacenter API. Uses RPT_LICO_FN_CPD (业绩报表). - Returns a dict of optional fields for BasicInfo enrichment (periods=1), - or a list of dicts for multi-period analysis (periods>1). - Gracefully returns empty dict/list on any failure. + Returns a list of dicts (one per reporting period) for BasicInfo enrichment + or multi-period analysis. Callers use ``result[0]`` for the latest period. + Gracefully returns empty list on any failure. """ try: body = _fetch_financial_data(symbol, page_size=periods) if not body.get("success") or not body.get("result"): - return [] if periods > 1 else {} + return [] data = body["result"].get("data") if not data: - return [] if periods > 1 else {} + return [] - results = [_parse_financial_row(row) for row in data] - - if periods > 1: - return results - return results[0] if results else {} + return [_parse_financial_row(row) for row in data] except Exception as e: logger.debug("Eastmoney financial indicators failed for %s: %s", symbol, e) - return [] if periods > 1 else {} + return [] def _parse_financial_row(row: dict) -> dict: @@ -152,6 +148,9 @@ def screen_stocks( Returns dict with 'total' count and 'data' list of matching stocks. Each stock has: symbol, name, price, change_pct, pe, pb, roe, market_cap, dividend_yield. + + Note: loss-making companies (PE < 0) are only excluded automatically when + pe_max is set without pe_min. Other filter combinations may still include them. """ # Build filter string: each condition is (FIELD>VALUE) or (FIELD None: + thesis = InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="消费升级长期受益", + key_assumptions=["ROE > 20%", "毛利率稳定"], + ) + thesis_id = db.add_thesis(thesis) + assert thesis_id > 0 + + result = db.get_thesis(thesis_id) + assert result is not None + assert result.symbol == "600519" + assert result.entry_price == 1500.0 + assert result.thesis_summary == "消费升级长期受益" + assert result.key_assumptions == ["ROE > 20%", "毛利率稳定"] + assert result.status == ThesisStatus.ACTIVE + + def test_get_theses_filter_by_symbol(self, db: Database) -> None: + db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="thesis A", + ) + ) + db.add_thesis( + InvestmentThesis( + symbol="000858", + entry_date=date(2026, 1, 1), + entry_price=200.0, + thesis_summary="thesis B", + ) + ) + results = db.get_theses(symbol="600519") + assert len(results) == 1 + assert results[0].symbol == "600519" + + def test_get_theses_filter_by_status(self, db: Database) -> None: + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="thesis A", + ) + ) + db.update_thesis_status(tid, ThesisStatus.INVALIDATED, reason="ROE dropped") + + active = db.get_theses(status=ThesisStatus.ACTIVE) + assert len(active) == 0 + + invalidated = db.get_theses(status=ThesisStatus.INVALIDATED) + assert len(invalidated) == 1 + assert invalidated[0].invalidation_reason == "ROE dropped" + + def test_update_thesis_status(self, db: Database) -> None: + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="test", + ) + ) + db.update_thesis_status(tid, ThesisStatus.REALIZED, reason="达到目标价") + + result = db.get_thesis(tid) + assert result is not None + assert result.status == ThesisStatus.REALIZED + assert result.invalidation_reason == "达到目标价" + + def test_mark_thesis_reviewed(self, db: Database) -> None: + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="test", + ) + ) + result_before = db.get_thesis(tid) + assert result_before is not None + assert result_before.last_reviewed_at is None + + db.mark_thesis_reviewed(tid) + + result_after = db.get_thesis(tid) + assert result_after is not None + assert result_after.last_reviewed_at is not None + + def test_get_nonexistent_thesis(self, db: Database) -> None: + assert db.get_thesis(9999) is None + + def test_key_assumptions_empty_list(self, db: Database) -> None: + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="test", + key_assumptions=[], + ) + ) + result = db.get_thesis(tid) + assert result is not None + assert result.key_assumptions == [] diff --git a/tests/test_guardrails_thesis_alerts.py b/tests/test_guardrails_thesis_alerts.py new file mode 100644 index 0000000..06d34cd --- /dev/null +++ b/tests/test_guardrails_thesis_alerts.py @@ -0,0 +1,94 @@ +"""Tests for thesis review alerts in guardrails.""" + +from datetime import date, datetime, timedelta + +from haoinvest.db import Database +from haoinvest.guardrails.alerts import scan_alerts +from haoinvest.models import ( + AlertType, + InvestmentThesis, + MarketType, + Position, + ThesisStatus, +) + + +def _add_position(db: Database, symbol: str, qty: float, avg_cost: float) -> None: + db.upsert_position( + Position( + symbol=symbol, + market_type=MarketType.A_SHARE, + cached_quantity=qty, + cached_avg_cost=avg_cost, + ) + ) + + +class TestThesisReviewAlerts: + def test_overdue_thesis_triggers_alert(self, db: Database) -> None: + """A thesis past its review interval should trigger THESIS_REVIEW alert.""" + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="消费升级", + review_interval_days=30, + ) + ) + # Backdate created_at to 40 days ago + db.conn.execute( + "UPDATE investment_theses SET created_at = ? WHERE id = ?", + ((datetime.now() - timedelta(days=40)).isoformat(), tid), + ) + db.conn.commit() + + # Need a position for scan_alerts to run, but thesis alerts are independent + prices: dict[tuple[str, MarketType], float] = {} + alerts = scan_alerts(db, prices) + thesis_alerts = [a for a in alerts if a.alert_type == AlertType.THESIS_REVIEW] + assert len(thesis_alerts) == 1 + assert "未审查" in thesis_alerts[0].message + assert thesis_alerts[0].symbol == "600519" + + def test_recently_reviewed_no_alert(self, db: Database) -> None: + """A recently reviewed thesis should not trigger an alert.""" + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="消费升级", + review_interval_days=30, + ) + ) + db.mark_thesis_reviewed(tid) + + prices: dict[tuple[str, MarketType], float] = {} + alerts = scan_alerts(db, prices) + thesis_alerts = [a for a in alerts if a.alert_type == AlertType.THESIS_REVIEW] + assert len(thesis_alerts) == 0 + + def test_invalidated_thesis_no_alert(self, db: Database) -> None: + """Invalidated theses should not trigger review alerts.""" + tid = db.add_thesis( + InvestmentThesis( + symbol="600519", + entry_date=date(2026, 1, 1), + entry_price=1500.0, + thesis_summary="消费升级", + review_interval_days=1, + ) + ) + db.update_thesis_status(tid, ThesisStatus.INVALIDATED, reason="thesis broken") + # Backdate to ensure overdue + db.conn.execute( + "UPDATE investment_theses SET created_at = ? WHERE id = ?", + ((datetime.now() - timedelta(days=10)).isoformat(), tid), + ) + db.conn.commit() + + prices: dict[tuple[str, MarketType], float] = {} + alerts = scan_alerts(db, prices) + thesis_alerts = [a for a in alerts if a.alert_type == AlertType.THESIS_REVIEW] + assert len(thesis_alerts) == 0 diff --git a/tests/test_market/test_provider_contract.py b/tests/test_market/test_provider_contract.py index c7c8567..9d4c002 100644 --- a/tests/test_market/test_provider_contract.py +++ b/tests/test_market/test_provider_contract.py @@ -90,7 +90,7 @@ def test_get_basic_info(self, mock_em_info, mock_tencent_val, mock_em_fin): "pb_ratio": 10.2, "total_market_cap": 2100000000000, } - mock_em_fin.return_value = {"roe": 24.64, "gross_margin": 91.29} + mock_em_fin.return_value = [{"roe": 24.64, "gross_margin": 91.29}] provider = AShareProvider() info = provider.get_basic_info("600519") diff --git a/tests/test_market/test_sources/test_eastmoney.py b/tests/test_market/test_sources/test_eastmoney.py index 0e0eadc..4f02e92 100644 --- a/tests/test_market/test_sources/test_eastmoney.py +++ b/tests/test_market/test_sources/test_eastmoney.py @@ -39,10 +39,11 @@ def test_successful_response(self, mock_get): result = get_financial_indicators("600519") - assert result["roe"] == 24.64 - assert result["gross_margin"] == 91.29 + assert len(result) == 1 + assert result[0]["roe"] == 24.64 + assert result[0]["gross_margin"] == 91.29 assert ( - result["profit_margin"] == 49.37 + result[0]["profit_margin"] == 49.37 ) # 64626746712.18 / 130903889634.88 * 100 mock_get.assert_called_once() @@ -50,7 +51,7 @@ def test_successful_response(self, mock_get): def test_api_failure_returns_empty(self, mock_get): mock_get.side_effect = Exception("Connection error") result = get_financial_indicators("600519") - assert result == {} + assert result == [] @patch("haoinvest.market.sources.eastmoney.requests.get") def test_empty_result_returns_empty(self, mock_get): @@ -58,7 +59,7 @@ def test_empty_result_returns_empty(self, mock_get): {"success": True, "result": {"data": [], "count": 0}} ) result = get_financial_indicators("600519") - assert result == {} + assert result == [] @patch("haoinvest.market.sources.eastmoney.requests.get") def test_unsuccessful_response(self, mock_get): @@ -66,7 +67,7 @@ def test_unsuccessful_response(self, mock_get): {"success": False, "result": None, "code": 9501} ) result = get_financial_indicators("600519") - assert result == {} + assert result == [] @patch("haoinvest.market.sources.eastmoney.requests.get") def test_missing_revenue_skips_profit_margin(self, mock_get): @@ -86,9 +87,10 @@ def test_missing_revenue_skips_profit_margin(self, mock_get): } ) result = get_financial_indicators("600519") - assert result["roe"] == 15.0 - assert result["gross_margin"] == 60.0 - assert "profit_margin" not in result + assert len(result) == 1 + assert result[0]["roe"] == 15.0 + assert result[0]["gross_margin"] == 60.0 + assert "profit_margin" not in result[0] class TestGetBasicInfo: