diff --git a/.claude/skills/haoinvest/SKILL.md b/.claude/skills/haoinvest/SKILL.md index b18face..0cd46ec 100644 --- a/.claude/skills/haoinvest/SKILL.md +++ b/.claude/skills/haoinvest/SKILL.md @@ -6,42 +6,134 @@ user_invocable: true # /haoinvest — Investment Management -All-in-one investment management via CLI. Run commands with `uv run haoinvest `. - -## Market Type Auto-Detection - -The CLI auto-detects market type from symbol format: -- **6-digit number** (600519, 000001) → A-share -- **Contains `_USDT`** or known crypto (BTC, ETH, SOL) → Crypto -- **Otherwise** → US market -- Override with `--market-type a_share|crypto|us` +All-in-one investment management via CLI + Claude Code agent. CLI does data + computation; you (the agent) do interpretation + recommendations. + +## Agent Workflows + +### Workflow 1: "帮我分析 XXX" — Analyze a stock + +1. Run comprehensive report: + ```bash + uv run haoinvest analyze report + ``` +2. For A-shares, also run peer comparison: + ```bash + uv run haoinvest analyze peer + ``` +3. Interpret ALL sections in Chinese: + - 估值: Is it cheap or expensive? Compare PE/PB to peers. + - 财务健康: Is the company profitable and growing? + - 风险: How volatile is it? What's the worst drawdown? + - 技术面: What's the current trend? + - Checklist: What's the buy-readiness score? +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 + +1. Check current portfolio: + ```bash + uv run haoinvest portfolio list + ``` +2. Identify concentration: which sectors are overweight? +3. Scan sector rankings: + ```bash + uv run haoinvest market sector-list + ``` +4. 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 + +### Workflow 3: "我想买 XXX" — Buy decision + +1. Run comprehensive report with checklist: + ```bash + uv run haoinvest analyze report + ``` +2. Explain the buy-readiness score dimension by dimension +3. If score is low, explain which dimensions are concerning +4. Compare with peers: + ```bash + uv run haoinvest analyze peer + ``` +5. Always remind: **这不是投资建议,最终决定需要你自己判断** + +### Workflow 4: "对比 A 和 B" — Compare stocks + +1. Batch fundamental comparison: + ```bash + uv run haoinvest analyze fundamental , --verbose + ``` +2. Batch technical comparison: + ```bash + uv run haoinvest analyze technical , + ``` +3. Summarize: who's better on what dimension, and overall recommendation + +### Workflow 5: "定期体检" — Portfolio checkup + +1. Portfolio holdings + P&L: + ```bash + uv run haoinvest portfolio list + uv run haoinvest portfolio returns + ``` +2. Risk assessment: + ```bash + uv run haoinvest analyze risk + ``` +3. For each holding with poor risk metrics, run a quick report +4. Check allocation via: + ```bash + uv run haoinvest strategy optimize + ``` +5. Suggest rebalancing if needed + +### Workflow 6: "情绪复盘" — Decision review + +1. Review journal patterns: + ```bash + uv run haoinvest journal review --days 30 + ``` +2. Analyze: which emotions led to good/bad decisions? +3. For entries needing retrospective, help add reflections +4. Gently suggest behavioral improvements ## Command Reference ### Market Data ```bash -uv run haoinvest market quote # Price + basic info -uv run haoinvest market history [--start] [--end] # OHLCV bars (default: 30 days) +uv run haoinvest market quote # Price + info (batch: comma-separated) +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 ``` -### Portfolio +### Analysis ```bash -uv run haoinvest portfolio list # All holdings -uv run haoinvest portfolio add-trade [--fee] [--tax] [--date] [--note] -uv run haoinvest portfolio returns [--symbol ] # P&L (all or single) +uv run haoinvest analyze report # Full report + buy-readiness checklist +uv run haoinvest analyze fundamental [--verbose] # Valuation + financial health (batch OK) +uv run haoinvest analyze technical # MA/MACD/RSI/BB (batch OK) +uv run haoinvest analyze peer # Same-sector peer comparison (A-shares) +uv run haoinvest analyze risk [--symbol ] # Volatility, Sharpe, drawdown +uv run haoinvest analyze correlation # Correlation matrix +uv run haoinvest analyze volume # Volume anomaly detection +uv run haoinvest analyze signals # Aggregated technical signal ``` -Actions: `buy`, `sell`, `dividend`, `split`, `transfer_in`, `transfer_out` -### Analysis +### Portfolio ```bash -uv run haoinvest analyze fundamental # PE/PB + valuation assessment -uv run haoinvest analyze risk [--symbol ] [--start] [--end] # Volatility, Sharpe, drawdown -uv run haoinvest analyze correlation # Correlation matrix +uv run haoinvest portfolio list # All holdings +uv run haoinvest portfolio add-trade [--fee] [--tax] [--date] [--note] +uv run haoinvest portfolio returns [--symbol ] # P&L ``` +Actions: `buy`, `sell`, `dividend`, `split`, `transfer_in`, `transfer_out` ### Strategy ```bash -uv run haoinvest strategy optimize [--method equal_weight|risk_parity|min_volatility|max_sharpe] [--symbols ] +uv run haoinvest strategy optimize [--method equal_weight|risk_parity|min_volatility|max_sharpe] uv run haoinvest strategy rebalance --target '{"600519": 0.5, "BTC_USDT": 0.5}' ``` @@ -52,33 +144,38 @@ uv run haoinvest journal list [--symbol ] [--limit ] uv run haoinvest journal review [--entry-id ] [--days ] ``` -## Global Options +## Market Type Auto-Detection -All commands support `--json` flag for structured JSON output. +- **6-digit number** (600519, 000001) → A-share +- **Contains `_USDT`** or known crypto (BTC, ETH, SOL) → Crypto +- **Otherwise** → US market +- Override with `--market-type a_share|crypto|us` ## Output Format -- Default: **Key-Value** text (single records) or **TSV** (tables/lists) +- Default: **Key-Value** (single records) or **TSV** (tables) - `--json`: Full JSON output -- **Prefer TSV (default) over `--json` for list commands** (`portfolio list`, `journal list`, `market history`, `strategy rebalance`). TSV is more compact and easier for LLMs to parse. Use `--json` only when you need nested data or programmatic processing. -- You (Claude) should interpret the data and respond in **Chinese**, explaining concepts in beginner-friendly terms +- **Prefer TSV for list commands** — more compact for LLM parsing +- All commands support `--json` ## Crypto Special Handling For crypto prices, **prefer Crypto.com MCP tools** when available: -- `mcp__claude_ai_Crypto_com__get_ticker` — single ticker -- `mcp__claude_ai_Crypto_com__get_tickers` — multiple tickers +- `mcp__claude_ai_Crypto_com__get_ticker` / `get_tickers` -Fall back to CLI `haoinvest market quote BTC_USDT` if MCP is unavailable. +Fall back to CLI if MCP is unavailable. -## Sandbox Mode +## Teaching Mode -For analyzing someone else's stocks (not your portfolio), just use `haoinvest analyze` or `haoinvest market` commands — they don't touch portfolio data. Set `HAOINVEST_DATA_DIR=/tmp/haoinvest_sandbox` if you need a temp database for trades. +When explaining metrics, always: +- Include "这意味着..." explanations for beginners +- Use analogies (e.g., "PE就像买房时的租售比,越低说明回本越快") +- Start with overall assessment, then detail on request +- Progressive disclosure: don't overwhelm with all metrics at once ## Response Guidelines - Always respond in **Chinese** -- Explain what each metric means for beginners (e.g., "PE 20 意味着...") - Show appropriate precision: A-shares 2 decimals, crypto 8 decimals - For journal entries, gently ask about emotion and decision type if not specified - For strategy recommendations, explain WHY each approach is suggested diff --git a/haoinvest/analysis/fundamental.py b/haoinvest/analysis/fundamental.py index 6f102c9..0884bec 100644 --- a/haoinvest/analysis/fundamental.py +++ b/haoinvest/analysis/fundamental.py @@ -1,7 +1,13 @@ """Fundamental analysis: PE/PB/ROE and valuation assessment.""" from ..market import get_provider -from ..models import FundamentalAnalysis, MarketType, ValuationAssessment +from ..models import ( + BasicInfo, + FinancialHealthAssessment, + FundamentalAnalysis, + MarketType, + ValuationAssessment, +) def analyze_stock(symbol: str, market_type: MarketType) -> FundamentalAnalysis: @@ -15,11 +21,13 @@ def analyze_stock(symbol: str, market_type: MarketType) -> FundamentalAnalysis: pb = _safe_float(info.pb_ratio) valuation = _assess_valuation(pe, pb, market_type) + financial_health = _assess_financial_health(info) return FundamentalAnalysis( symbol=symbol, name=info.name, sector=info.sector, + industry=info.industry, market_type=market_type.value, current_price=price, currency=info.currency, @@ -27,6 +35,18 @@ def analyze_stock(symbol: str, market_type: MarketType) -> FundamentalAnalysis: pb_ratio=pb, total_market_cap=info.total_market_cap, valuation=valuation, + roe=info.roe, + roa=info.roa, + debt_to_equity=info.debt_to_equity, + revenue_growth=info.revenue_growth, + profit_margin=info.profit_margin, + gross_margin=info.gross_margin, + operating_margin=info.operating_margin, + current_ratio=info.current_ratio, + free_cash_flow=info.free_cash_flow, + operating_cash_flow=info.operating_cash_flow, + peg_ratio=info.peg_ratio, + financial_health=financial_health, ) @@ -101,6 +121,134 @@ def _assess_valuation( ) +def _assess_financial_health(info: BasicInfo) -> FinancialHealthAssessment: + """Multi-dimensional financial health assessment with Chinese labels.""" + profitability = _assess_profitability(info.roe, info.profit_margin) + growth = _assess_growth(info.revenue_growth) + leverage = _assess_leverage(info.debt_to_equity, info.current_ratio) + cash_flow = _assess_cash_flow(info.free_cash_flow, info.operating_cash_flow) + + # Overall: count how many dimensions are positive + assessments = [profitability, growth, leverage, cash_flow] + known = [a for a in assessments if a != "N/A"] + if not known: + overall = "无法评估" + else: + positive_keywords = ( + "优秀", + "良好", + "高速增长", + "稳定增长", + "保守", + "适中", + "充裕", + "正常", + ) + positive = sum(1 for a in known if any(k in a for k in positive_keywords)) + ratio = positive / len(known) + if ratio >= 0.75: + overall = "财务健康" + elif ratio >= 0.5: + overall = "财务一般" + elif ratio >= 0.25: + overall = "财务偏弱" + else: + overall = "财务风险较高" + + return FinancialHealthAssessment( + profitability=profitability, + growth=growth, + leverage=leverage, + cash_flow=cash_flow, + overall=overall, + ) + + +def _assess_profitability(roe: float | None, profit_margin: float | None) -> str: + """Assess profitability based on ROE and net profit margin.""" + if roe is None and profit_margin is None: + return "N/A" + # ROE is the primary indicator + if roe is not None: + if roe > 15: + return f"优秀 (ROE {roe:.1f}%)" + elif roe > 10: + return f"良好 (ROE {roe:.1f}%)" + elif roe > 5: + return f"一般 (ROE {roe:.1f}%)" + else: + return f"偏弱 (ROE {roe:.1f}%)" + # Fallback to profit margin + if profit_margin is not None: + if profit_margin > 20: + return f"优秀 (净利率 {profit_margin:.1f}%)" + elif profit_margin > 10: + return f"良好 (净利率 {profit_margin:.1f}%)" + elif profit_margin > 5: + return f"一般 (净利率 {profit_margin:.1f}%)" + else: + return f"偏弱 (净利率 {profit_margin:.1f}%)" + return "N/A" + + +def _assess_growth(revenue_growth: float | None) -> str: + """Assess growth based on YoY revenue growth (percentage, e.g. 15.0 = 15%).""" + if revenue_growth is None: + return "N/A" + g = revenue_growth + if g > 20: + return f"高速增长 ({g:.1f}%)" + elif g > 10: + return f"稳定增长 ({g:.1f}%)" + elif g > 0: + return f"低增长 ({g:.1f}%)" + else: + return f"负增长 ({g:.1f}%)" + + +def _assess_leverage(debt_to_equity: float | None, current_ratio: float | None) -> str: + """Assess leverage based on debt-to-equity and current ratio.""" + if debt_to_equity is None and current_ratio is None: + return "N/A" + parts = [] + if debt_to_equity is not None: + if debt_to_equity < 50: + parts.append(f"保守 (D/E {debt_to_equity:.0f}%)") + elif debt_to_equity < 100: + parts.append(f"适中 (D/E {debt_to_equity:.0f}%)") + elif debt_to_equity < 200: + parts.append(f"偏高 (D/E {debt_to_equity:.0f}%)") + else: + parts.append(f"高杠杆 (D/E {debt_to_equity:.0f}%)") + if current_ratio is not None: + if current_ratio >= 2: + parts.append(f"流动性充足 (CR {current_ratio:.1f})") + elif current_ratio >= 1: + parts.append(f"流动性正常 (CR {current_ratio:.1f})") + else: + parts.append(f"流动性紧张 (CR {current_ratio:.1f})") + return "; ".join(parts) if parts else "N/A" + + +def _assess_cash_flow( + free_cash_flow: float | None, operating_cash_flow: float | None +) -> str: + """Assess cash flow health.""" + if free_cash_flow is None and operating_cash_flow is None: + return "N/A" + if free_cash_flow is not None: + if free_cash_flow > 0: + return "充裕 (自由现金流为正)" + else: + return "紧张 (自由现金流为负)" + if operating_cash_flow is not None: + if operating_cash_flow > 0: + return "正常 (经营现金流为正)" + else: + return "紧张 (经营现金流为负)" + return "N/A" + + def _safe_float(val) -> float | None: """Convert a value to float, returning None if not possible.""" if val is None or val == "": diff --git a/haoinvest/analysis/peer.py b/haoinvest/analysis/peer.py new file mode 100644 index 0000000..0c5693a --- /dev/null +++ b/haoinvest/analysis/peer.py @@ -0,0 +1,75 @@ +"""Peer comparison — find and compare same-sector stocks.""" + +import logging + +from ..models import MarketType +from .fundamental import analyze_stock + +logger = logging.getLogger(__name__) + + +def find_peers( + symbol: str, + market_type: MarketType, + top_n: int = 10, +) -> list[dict]: + """Find same-sector peers and compare fundamental metrics. + + For A-shares: uses AKShareProvider.get_sector_constituents() to find + stocks in the same industry board, sorted by market cap. + + Returns list of dicts suitable for TSV output, with the target stock + marked in the 'is_target' field. + """ + if market_type != MarketType.A_SHARE: + return [ + {"message": f"Peer comparison not yet supported for {market_type.value}"} + ] + + from ..market.akshare_provider import AKShareProvider + + # Get target stock info to determine sector + target = analyze_stock(symbol, market_type) + sector = target.sector + if not sector: + return [{"message": f"No sector info available for {symbol}"}] + + # Get sector constituents + try: + constituents = AKShareProvider.get_sector_constituents(sector) + except Exception as e: + logger.debug("Failed to get sector constituents for %s: %s", sector, e) + return [{"message": f"Failed to get sector data for {sector}: {e}"}] + + if not constituents: + return [{"message": f"No constituents found for sector {sector}"}] + + # Sort by market cap (descending), take top N + constituents.sort(key=lambda x: x.get("total_market_cap") or 0, reverse=True) + # Filter to top_n, ensuring target is included + top_codes = set() + top_peers = [] + for c in constituents: + if len(top_peers) >= top_n: + break + top_peers.append(c) + top_codes.add(c.get("code", "")) + + # 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, + } + ) + + return rows diff --git a/haoinvest/analysis/report.py b/haoinvest/analysis/report.py index 3bb96f6..479da4f 100644 --- a/haoinvest/analysis/report.py +++ b/haoinvest/analysis/report.py @@ -3,7 +3,12 @@ from datetime import date from ..db import Database -from ..models import MarketType, StockReport +from ..models import ( + BuyReadinessChecklist, + ChecklistItem, + MarketType, + StockReport, +) from .fundamental import analyze_stock from .risk import calculate_risk_metrics from .signals import aggregate_signals @@ -43,6 +48,7 @@ def full_stock_report( symbol=fundamental.symbol, name=fundamental.name, sector=fundamental.sector, + industry=fundamental.industry, market_type=fundamental.market_type, current_price=fundamental.current_price, currency=fundamental.currency, @@ -51,6 +57,18 @@ def full_stock_report( total_market_cap=fundamental.total_market_cap, valuation=fundamental.valuation, risk_metrics=risk, + roe=fundamental.roe, + roa=fundamental.roa, + debt_to_equity=fundamental.debt_to_equity, + revenue_growth=fundamental.revenue_growth, + profit_margin=fundamental.profit_margin, + gross_margin=fundamental.gross_margin, + operating_margin=fundamental.operating_margin, + current_ratio=fundamental.current_ratio, + free_cash_flow=fundamental.free_cash_flow, + operating_cash_flow=fundamental.operating_cash_flow, + peg_ratio=fundamental.peg_ratio, + financial_health=fundamental.financial_health, ) if include_technical: @@ -62,7 +80,167 @@ def full_stock_report( db, symbol, market_type, price_start, price_end ) + # Compute buy-readiness checklist + report.checklist = _compute_checklist(report) + # Cache the result db.save_analysis(symbol, cache_key, report.model_dump()) return report + + +def _compute_checklist(report: StockReport) -> BuyReadinessChecklist: + """Score each dimension 1-5 and produce a recommendation.""" + items: list[ChecklistItem] = [] + + # 1. Valuation + val_score = _score_valuation(report.valuation.overall) + items.append( + ChecklistItem( + dimension="估值", score=val_score, assessment=report.valuation.overall + ) + ) + + # 2. Profitability + prof_score = _score_profitability(report.roe, report.profit_margin) + items.append( + ChecklistItem( + dimension="盈利能力", + score=prof_score, + assessment=report.financial_health.profitability + if report.financial_health + else "N/A", + ) + ) + + # 3. Growth + growth_score = _score_growth(report.revenue_growth) + items.append( + ChecklistItem( + dimension="成长性", + score=growth_score, + assessment=report.financial_health.growth + if report.financial_health + else "N/A", + ) + ) + + # 4. Risk + risk_score = _score_risk( + report.risk_metrics.max_drawdown_pct, report.risk_metrics.sharpe_ratio + ) + risk_text = ( + f"最大回撤 {report.risk_metrics.max_drawdown_pct:.1f}%" + if report.risk_metrics.max_drawdown_pct + else "N/A" + ) + items.append( + ChecklistItem(dimension="风险", score=risk_score, assessment=risk_text) + ) + + # 5. Technical (if available) + if report.signals: + tech_score = _score_technical( + report.signals.overall_signal, report.signals.confidence + ) + tech_text = ( + f"{report.signals.overall_signal} (置信度: {report.signals.confidence})" + ) + else: + tech_score = 3 # neutral when no data + tech_text = "无技术面数据" + items.append( + ChecklistItem(dimension="技术面", score=tech_score, assessment=tech_text) + ) + + total = sum(item.score for item in items) + max_score = len(items) * 5 + + if total >= max_score * 0.75: + recommendation = "建议关注" + elif total >= max_score * 0.5: + recommendation = "谨慎观望" + else: + recommendation = "建议回避" + + return BuyReadinessChecklist( + items=items, + total_score=total, + max_score=max_score, + recommendation=recommendation, + ) + + +def _score_valuation(overall: str) -> int: + mapping = {"偏低估": 5, "估值合理": 4, "偏高估": 2, "明显高估": 1} + return mapping.get(overall, 3) + + +def _score_profitability(roe: float | None, profit_margin: float | None) -> int: + if roe is not None: + if roe > 15: + return 5 + elif roe > 10: + return 4 + elif roe > 5: + return 3 + else: + return 2 + if profit_margin is not None: + if profit_margin > 20: + return 5 + elif profit_margin > 10: + return 4 + elif profit_margin > 5: + return 3 + else: + return 2 + return 3 # neutral when no data + + +def _score_growth(revenue_growth: float | None) -> int: + """Score growth (revenue_growth is percentage, e.g. 15.0 = 15%).""" + if revenue_growth is None: + return 3 + g = revenue_growth + if g > 20: + return 5 + elif g > 10: + return 4 + elif g > 0: + return 3 + else: + return 2 + + +def _score_risk(max_drawdown: float | None, sharpe: float | None) -> int: + if max_drawdown is None and sharpe is None: + return 3 + score = 3 + if max_drawdown is not None: + if max_drawdown > -10: + score = 5 + elif max_drawdown > -20: + score = 4 + elif max_drawdown > -30: + score = 3 + else: + score = 2 + if sharpe is not None: + if sharpe > 1.0: + score = min(score + 1, 5) + elif sharpe < 0: + score = max(score - 1, 1) + return score + + +def _score_technical(signal: str, confidence: str) -> int: + if signal == "偏多" and confidence in ("高", "中"): + return 5 + elif signal == "偏多": + return 4 + elif signal == "中性": + return 3 + elif signal == "偏空": + return 2 + return 3 diff --git a/haoinvest/cli/analyze.py b/haoinvest/cli/analyze.py index 5181e61..e1fb9ef 100644 --- a/haoinvest/cli/analyze.py +++ b/haoinvest/cli/analyze.py @@ -12,7 +12,7 @@ from ..analysis.volume import analyze_volume from ..db import Database from ..models import MarketType -from .formatters import error_output, json_output, kv_output +from .formatters import error_output, json_output, kv_output, tsv_output from .market import _detect_market_type app = typer.Typer(help="Analysis — fundamental, risk, technical, volume, signals.") @@ -41,37 +41,109 @@ def _ensure_prices_cached( @app.command() def fundamental( - symbol: str = typer.Argument(help="Stock/crypto symbol"), + symbol: str = typer.Argument( + help="Stock/crypto symbol(s), comma-separated for batch" + ), market_type: Optional[str] = typer.Option( None, "--market-type", "-m", help="Override: a_share, crypto, us" ), + verbose: bool = typer.Option( + False, "--verbose", "-v", help="Show financial health assessment" + ), use_json: bool = typer.Option(False, "--json", help="Output as JSON"), ) -> None: - """Fundamental analysis — PE/PB, valuation assessment.""" - mt = MarketType(market_type) if market_type else _detect_market_type(symbol) - try: - result = analyze_stock(symbol, mt) - except (ValueError, RuntimeError) as e: - error_output(str(e)) - raise typer.Exit(1) + """Fundamental analysis — valuation, financial health, growth metrics.""" + symbol_list = [s.strip() for s in symbol.split(",")] - if use_json: - json_output(result) - else: - kv_output( - { + if len(symbol_list) == 1: + # Single symbol — kv_output + mt = MarketType(market_type) if market_type else _detect_market_type(symbol) + try: + result = analyze_stock(symbol, mt) + except (ValueError, RuntimeError) as e: + error_output(str(e)) + raise typer.Exit(1) + + if use_json: + json_output(result) + else: + output: dict = { "Symbol": result.symbol, "Name": result.name, "Price": result.current_price, "PE(TTM)": result.pe_ratio, "PB": result.pb_ratio, "Sector": result.sector, + "Industry": result.industry, "MarketCap": result.total_market_cap, + "ROE(%)": result.roe, + "ROA(%)": result.roa, + "DebtToEquity": result.debt_to_equity, + "RevenueGrowth": result.revenue_growth, + "ProfitMargin": result.profit_margin, + "GrossMargin": result.gross_margin, + "OperatingMargin": result.operating_margin, + "CurrentRatio": result.current_ratio, + "FreeCashFlow": result.free_cash_flow, + "PEG": result.peg_ratio, "PE_Assessment": result.valuation.pe_assessment, "PB_Assessment": result.valuation.pb_assessment, - "Overall": result.valuation.overall, + "Overall_Valuation": result.valuation.overall, } - ) + if verbose: + fh = result.financial_health + output.update( + { + "Profitability": fh.profitability, + "Growth": fh.growth, + "Leverage": fh.leverage, + "CashFlow": fh.cash_flow, + "FinancialHealth": fh.overall, + } + ) + kv_output(output) + else: + # Batch — tsv comparison table + rows = [] + for s in symbol_list: + mt = MarketType(market_type) if market_type else _detect_market_type(s) + try: + r = analyze_stock(s, mt) + row: dict = { + "Symbol": r.symbol, + "Name": r.name, + "Price": r.current_price, + "PE": r.pe_ratio, + "PB": r.pb_ratio, + "ROE(%)": r.roe, + "Growth": r.revenue_growth, + "Margin": r.profit_margin, + "D/E": r.debt_to_equity, + "Valuation": r.valuation.overall, + } + if verbose: + row["Health"] = r.financial_health.overall + rows.append(row) + except (ValueError, RuntimeError) as e: + rows.append({"Symbol": s, "Name": f"ERROR: {e}"}) + if use_json: + json_output(rows) + else: + columns = [ + "Symbol", + "Name", + "Price", + "PE", + "PB", + "ROE(%)", + "Growth", + "Margin", + "D/E", + "Valuation", + ] + if verbose: + columns.append("Health") + tsv_output(rows, columns=columns) @app.command() @@ -165,7 +237,9 @@ def correlation( @app.command() def technical( - symbol: str = typer.Argument(help="Stock/crypto symbol"), + symbol: str = typer.Argument( + help="Stock/crypto symbol(s), comma-separated for batch" + ), market_type: Optional[str] = typer.Option( None, "--market-type", "-m", help="Override: a_share, crypto, us" ), @@ -182,66 +256,107 @@ def technical( ) -> None: """Technical indicators — MA, MACD, RSI, Bollinger Bands.""" db = _init_db() - mt = MarketType(market_type) if market_type else _detect_market_type(symbol) end_date = date.fromisoformat(end) if end else date.today() start_date = date.fromisoformat(start) if start else end_date - timedelta(days=365) + symbol_list = [s.strip() for s in symbol.split(",")] - _ensure_prices_cached(db, symbol, mt, start_date, end_date) - result = analyze_technical(db, symbol, mt, start_date, end_date, verbose=verbose) + if len(symbol_list) == 1: + # Single symbol — detailed kv_output (original behavior) + mt = MarketType(market_type) if market_type else _detect_market_type(symbol) + _ensure_prices_cached(db, symbol, mt, start_date, end_date) + result = analyze_technical( + db, symbol, mt, start_date, end_date, verbose=verbose + ) - if use_json: - json_output(result) + if use_json: + json_output(result) + else: + output = {"symbol": result.symbol, "date": str(result.latest_date)} + if result.message: + output["message"] = result.message + else: + ma = result.moving_averages + output.update( + { + "close": result.latest_close, + "SMA5": ma.sma_5, + "SMA10": ma.sma_10, + "SMA20": ma.sma_20, + "SMA60": ma.sma_60, + "EMA12": ma.ema_12, + "EMA26": ma.ema_26, + "MA_Trend": ma.trend, + } + ) + if verbose and ma.explanation: + output["MA_Explain"] = ma.explanation + output.update( + { + "MACD": result.macd.macd_line, + "Signal": result.macd.signal_line, + "Histogram": result.macd.histogram, + "MACD_Signal": result.macd.signal, + } + ) + if verbose and result.macd.explanation: + output["MACD_Explain"] = result.macd.explanation + output.update( + { + "RSI": result.rsi.rsi, + "RSI_Assessment": result.rsi.assessment, + } + ) + if verbose and result.rsi.explanation: + output["RSI_Explain"] = result.rsi.explanation + bb = result.bollinger + output.update( + { + "BB_Upper": bb.upper, + "BB_Middle": bb.middle, + "BB_Lower": bb.lower, + "BB_Bandwidth%": bb.bandwidth_pct, + "BB_Position": bb.position, + } + ) + if verbose and bb.explanation: + output["BB_Explain"] = bb.explanation + kv_output(output) else: - output = {"symbol": result.symbol, "date": str(result.latest_date)} - if result.message: - output["message"] = result.message + # Batch — tsv comparison table with key indicators + rows = [] + for s in symbol_list: + mt = MarketType(market_type) if market_type else _detect_market_type(s) + _ensure_prices_cached(db, s, mt, start_date, end_date) + result = analyze_technical(db, s, mt, start_date, end_date) + if result.message: + rows.append({"Symbol": s, "Trend": result.message}) + else: + rows.append( + { + "Symbol": s, + "Close": result.latest_close, + "Trend": result.moving_averages.trend, + "MACD": result.macd.signal, + "RSI": result.rsi.rsi, + "RSI_Zone": result.rsi.assessment, + "BB_Pos": result.bollinger.position, + } + ) + if use_json: + json_output(rows) else: - ma = result.moving_averages - output.update( - { - "close": result.latest_close, - "SMA5": ma.sma_5, - "SMA10": ma.sma_10, - "SMA20": ma.sma_20, - "SMA60": ma.sma_60, - "EMA12": ma.ema_12, - "EMA26": ma.ema_26, - "MA_Trend": ma.trend, - } - ) - if verbose and ma.explanation: - output["MA_Explain"] = ma.explanation - output.update( - { - "MACD": result.macd.macd_line, - "Signal": result.macd.signal_line, - "Histogram": result.macd.histogram, - "MACD_Signal": result.macd.signal, - } - ) - if verbose and result.macd.explanation: - output["MACD_Explain"] = result.macd.explanation - output.update( - { - "RSI": result.rsi.rsi, - "RSI_Assessment": result.rsi.assessment, - } - ) - if verbose and result.rsi.explanation: - output["RSI_Explain"] = result.rsi.explanation - bb = result.bollinger - output.update( - { - "BB_Upper": bb.upper, - "BB_Middle": bb.middle, - "BB_Lower": bb.lower, - "BB_Bandwidth%": bb.bandwidth_pct, - "BB_Position": bb.position, - } + tsv_output( + rows, + columns=[ + "Symbol", + "Close", + "Trend", + "MACD", + "RSI", + "RSI_Zone", + "BB_Pos", + ], ) - if verbose and bb.explanation: - output["BB_Explain"] = bb.explanation - kv_output(output) @app.command() @@ -333,3 +448,151 @@ def signals( kv_output(output) if verbose and result.explanation: print(result.explanation) + + +@app.command() +def peer( + symbol: str = typer.Argument(help="Stock symbol to find peers for"), + top_n: int = typer.Option(10, "--top", "-n", help="Number of peers to show"), + market_type: Optional[str] = typer.Option( + None, "--market-type", "-m", help="Override: a_share, crypto, us" + ), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """同行业对比 — compare a stock with its sector peers.""" + from ..analysis.peer import find_peers + + mt = MarketType(market_type) if market_type else _detect_market_type(symbol) + try: + rows = find_peers(symbol, mt, top_n=top_n) + except (ValueError, RuntimeError) as e: + error_output(str(e)) + raise typer.Exit(1) + + # Check for message-only results (errors/unsupported) + if rows and "message" in rows[0]: + print(rows[0]["message"]) + return + + if use_json: + json_output(rows) + else: + tsv_output( + rows, + columns=["Symbol", "Name", "Price", "Change%", "PE", "PB", "MarketCap"], + ) + + +@app.command() +def report( + symbol: str = typer.Argument(help="Stock/crypto symbol"), + market_type: Optional[str] = typer.Option( + None, "--market-type", "-m", help="Override: a_share, crypto, us" + ), + start: Optional[str] = typer.Option( + None, "--start", help="Start date YYYY-MM-DD (default: 1 year ago)" + ), + end: Optional[str] = typer.Option( + None, "--end", help="End date YYYY-MM-DD (default: today)" + ), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """综合分析报告 — full report with buy-readiness checklist.""" + from ..analysis.report import full_stock_report + + db = _init_db() + mt = MarketType(market_type) if market_type else _detect_market_type(symbol) + end_date = date.fromisoformat(end) if end else date.today() + start_date = date.fromisoformat(start) if start else end_date - timedelta(days=365) + + _ensure_prices_cached(db, symbol, mt, start_date, end_date) + + try: + r = full_stock_report( + db, symbol, mt, start_date, end_date, include_technical=True + ) + except (ValueError, RuntimeError) as e: + error_output(str(e)) + raise typer.Exit(1) + + if use_json: + json_output(r) + else: + # Section: Basic Info + print("=== 基本信息 ===") + kv_output( + { + "Symbol": r.symbol, + "Name": r.name, + "Price": r.current_price, + "Sector": r.sector, + "Industry": r.industry, + "MarketCap": r.total_market_cap, + } + ) + # Section: Valuation + print("\n=== 估值分析 ===") + kv_output( + { + "PE(TTM)": r.pe_ratio, + "PB": r.pb_ratio, + "PEG": r.peg_ratio, + "PE_Assessment": r.valuation.pe_assessment, + "PB_Assessment": r.valuation.pb_assessment, + "Overall": r.valuation.overall, + } + ) + # Section: Financial Health + print("\n=== 财务健康 ===") + kv_output( + { + "ROE(%)": r.roe, + "ROA(%)": r.roa, + "DebtToEquity": r.debt_to_equity, + "RevenueGrowth": r.revenue_growth, + "ProfitMargin": r.profit_margin, + "GrossMargin": r.gross_margin, + "CurrentRatio": r.current_ratio, + "FreeCashFlow": r.free_cash_flow, + } + ) + if r.financial_health: + kv_output( + { + "Profitability": r.financial_health.profitability, + "Growth": r.financial_health.growth, + "Leverage": r.financial_health.leverage, + "CashFlow": r.financial_health.cash_flow, + "FinancialHealth": r.financial_health.overall, + } + ) + # Section: Risk + print("\n=== 风险指标 ===") + rm = r.risk_metrics + kv_output( + { + "Volatility": rm.annualized_volatility, + "MaxDrawdown%": rm.max_drawdown_pct, + "Sharpe": rm.sharpe_ratio, + "Sortino": rm.sortino_ratio, + "TotalReturn%": rm.total_return_pct, + } + ) + # Section: Technical + if r.signals: + print("\n=== 技术面 ===") + kv_output( + { + "Signal": r.signals.overall_signal, + "Confidence": r.signals.confidence, + "Bullish": r.signals.bullish_count, + "Bearish": r.signals.bearish_count, + } + ) + # Section: Checklist + if r.checklist: + print("\n=== 买入准备度 ===") + for item in r.checklist.items: + print(f" {item.dimension}: {item.score}/5 — {item.assessment}") + print(f" 总分: {r.checklist.total_score}/{r.checklist.max_score}") + print(f" 建议: {r.checklist.recommendation}") diff --git a/haoinvest/cli/market.py b/haoinvest/cli/market.py index 5f12c0d..17994b0 100644 --- a/haoinvest/cli/market.py +++ b/haoinvest/cli/market.py @@ -25,39 +25,79 @@ def _detect_market_type(symbol: str) -> MarketType: @app.command() def quote( symbol: str = typer.Argument( - help="Stock/crypto symbol, e.g. 600519, BTC_USDT, AAPL" + help="Stock/crypto symbol(s), comma-separated for batch, e.g. 600519,000858" ), market_type: Optional[str] = typer.Option( None, "--market-type", "-m", help="Override: a_share, crypto, us" ), use_json: bool = typer.Option(False, "--json", help="Output as JSON"), ) -> None: - """Get current price and basic info for a symbol.""" - mt = MarketType(market_type) if market_type else _detect_market_type(symbol) - try: - provider = get_provider(mt) - price = provider.get_current_price(symbol) - info = provider.get_basic_info(symbol) - except (ValueError, RuntimeError) as e: - error_output(str(e)) - raise typer.Exit(1) + """Get current price and basic info for symbol(s).""" + symbol_list = [s.strip() for s in symbol.split(",")] - result = { - "Symbol": symbol, - "Name": info.name, - "Price": price, - "Currency": info.currency, - "Sector": info.sector, - "PE(TTM)": info.pe_ratio, - "PB": info.pb_ratio, - "MarketCap": info.total_market_cap, - "MarketType": mt.value, - } + if len(symbol_list) == 1: + # Single symbol — kv_output (original behavior) + mt = MarketType(market_type) if market_type else _detect_market_type(symbol) + try: + provider = get_provider(mt) + price = provider.get_current_price(symbol) + info = provider.get_basic_info(symbol) + except (ValueError, RuntimeError) as e: + error_output(str(e)) + raise typer.Exit(1) - if use_json: - json_output(result) + result = { + "Symbol": symbol, + "Name": info.name, + "Price": price, + "Currency": info.currency, + "Sector": info.sector, + "PE(TTM)": info.pe_ratio, + "PB": info.pb_ratio, + "MarketCap": info.total_market_cap, + "MarketType": mt.value, + } + if use_json: + json_output(result) + else: + kv_output(result) else: - kv_output(result) + # Batch — tsv_output comparison table + rows = [] + for s in symbol_list: + mt = MarketType(market_type) if market_type else _detect_market_type(s) + try: + provider = get_provider(mt) + price = provider.get_current_price(s) + info = provider.get_basic_info(s) + rows.append( + { + "Symbol": s, + "Name": info.name, + "Price": price, + "PE(TTM)": info.pe_ratio, + "PB": info.pb_ratio, + "Sector": info.sector, + "MarketCap": info.total_market_cap, + } + ) + except (ValueError, RuntimeError) as e: + rows.append({"Symbol": s, "Name": f"ERROR: {e}"}) + if use_json: + json_output(rows) + else: + tsv_output( + rows, + columns=[ + "Symbol", + "Name", + "Price", + "PE(TTM)", + "PB", + "Sector", + "MarketCap", + ], + ) @app.command() @@ -92,3 +132,63 @@ def history( tsv_output( bars, columns=["trade_date", "open", "high", "low", "close", "volume"] ) + + +@app.command("sector-list") +def sector_list( + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """行业板块排行 — list all A-share industry sectors with performance.""" + from ..market.akshare_provider import AKShareProvider + + try: + rows = AKShareProvider.get_sector_list() + except Exception as e: + error_output(str(e)) + raise typer.Exit(1) + + if use_json: + json_output(rows) + else: + tsv_output( + rows, + columns=[ + "name", + "change_pct", + "total_market_cap", + "turnover_rate", + "rise_count", + "fall_count", + ], + ) + + +@app.command("sector") +def sector( + name: str = typer.Argument(help="Sector name, e.g. 白酒, 银行, 半导体"), + use_json: bool = typer.Option(False, "--json", help="Output as JSON"), +) -> None: + """行业板块成分股 — show constituents of a specific A-share sector.""" + from ..market.akshare_provider import AKShareProvider + + try: + rows = AKShareProvider.get_sector_constituents(name) + except Exception as e: + error_output(str(e)) + raise typer.Exit(1) + + if use_json: + json_output(rows) + else: + tsv_output( + rows, + columns=[ + "code", + "name", + "price", + "change_pct", + "pe_ratio", + "pb_ratio", + "total_market_cap", + ], + ) diff --git a/haoinvest/market/akshare_provider.py b/haoinvest/market/akshare_provider.py index 77208cc..915da34 100644 --- a/haoinvest/market/akshare_provider.py +++ b/haoinvest/market/akshare_provider.py @@ -5,8 +5,10 @@ Sina/Tencent/eastmoney-emweb APIs that use different, more stable endpoints. """ +import json import logging import os +import re from contextlib import contextmanager from datetime import date from typing import Any, Callable @@ -159,6 +161,9 @@ def _akshare_basic_info(symbol: str) -> BasicInfo: pb_raw = info.get("市净率", "") cap_raw = info.get("总市值", "") + # Fetch additional financial indicators (graceful fallback) + fin = AKShareProvider._akshare_financial_indicators(symbol) + return BasicInfo( name=info.get("股票简称", ""), sector=info.get("行业", ""), @@ -167,8 +172,107 @@ def _akshare_basic_info(symbol: str) -> BasicInfo: total_market_cap=_parse_int(cap_raw), pe_ratio=_parse_float(pe_raw), pb_ratio=_parse_float(pb_raw), + **fin, + ) + + @staticmethod + def _akshare_financial_indicators(symbol: str) -> dict: + """Fetch financial health metrics from AKShare. + + Returns a dict of optional fields for BasicInfo. + Gracefully returns empty dict on any failure. + """ + try: + import akshare as ak + + with _bypass_proxy(): + df = ak.stock_financial_analysis_indicator(symbol=symbol) + if df.empty: + return {} + # Take the most recent period (first row) + latest = df.iloc[0] + return { + "roe": _parse_float(latest.get("净资产收益率(%)")), + "roa": _parse_float(latest.get("总资产报酬率(%)")), + "debt_to_equity": _parse_float(latest.get("资产负债率(%)")), + "profit_margin": _parse_float(latest.get("销售净利率(%)")), + "gross_margin": _parse_float(latest.get("销售毛利率(%)")), + "operating_margin": _parse_float(latest.get("营业利润率(%)")), + "current_ratio": _parse_float(latest.get("流动比率")), + } + except Exception as e: + logger.debug("Financial indicators failed for %s: %s", symbol, e) + return {} + + # -- Sector/Industry methods (A-share specific) -- + + @staticmethod + def get_sector_list() -> list[dict]: + """List all A-share industry boards with performance ranking. + + Returns list of dicts with keys: name, change_pct, total_market_cap, + turnover_rate, rise_count, fall_count. + """ + return _with_fallback( + primary_fn=AKShareProvider._akshare_sector_list, + fallback_fn=AKShareProvider._sina_sector_list, + label="get_sector_list()", + ) + + @staticmethod + def get_sector_constituents(sector_name: str) -> list[dict]: + """Get stocks in a specific industry board. + + Returns list of dicts with keys: code, name, price, change_pct, + pe_ratio, pb_ratio, total_market_cap. + """ + return _with_fallback( + primary_fn=lambda: AKShareProvider._akshare_sector_constituents( + sector_name + ), + fallback_fn=lambda: AKShareProvider._sina_sector_constituents(sector_name), + label=f"get_sector_constituents({sector_name})", ) + @staticmethod + def _akshare_sector_list() -> list[dict]: + import akshare as ak + + df = ak.stock_board_industry_spot_em() + rows = [] + for _, row in df.iterrows(): + rows.append( + { + "name": row.get("板块名称", ""), + "change_pct": _parse_float(row.get("涨跌幅")), + "total_market_cap": _parse_int(row.get("总市值")), + "turnover_rate": _parse_float(row.get("换手率")), + "rise_count": _parse_int(row.get("上涨家数")), + "fall_count": _parse_int(row.get("下跌家数")), + } + ) + return rows + + @staticmethod + def _akshare_sector_constituents(sector_name: str) -> list[dict]: + import akshare as ak + + df = ak.stock_board_industry_cons_em(symbol=sector_name) + rows = [] + for _, row in df.iterrows(): + rows.append( + { + "code": row.get("代码", ""), + "name": row.get("名称", ""), + "price": _parse_float(row.get("最新价")), + "change_pct": _parse_float(row.get("涨跌幅")), + "pe_ratio": _parse_float(row.get("市盈率-动态")), + "pb_ratio": _parse_float(row.get("市净率")), + "total_market_cap": _parse_int(row.get("总市值")), + } + ) + return rows + # -- Fallback methods -- @staticmethod @@ -255,6 +359,86 @@ def _emweb_basic_info(self, symbol: str) -> BasicInfo: pb_ratio=valuation.get("pb_ratio"), ) + @staticmethod + def _sina_sector_list() -> list[dict]: + """Fallback: sector list from Sina Finance API. + + Sina fields per sector: code, name, stock_count, avg_price, change, + change_pct, volume, amount, leading_code, leading_turnover, + leading_price, leading_change, leading_name + """ + data = _sina_sector_data() + rows = [] + for fields in data.values(): + if len(fields) < 6: + continue + rows.append( + { + "name": fields[1], + "change_pct": _parse_float(fields[5]), + "total_market_cap": None, + "turnover_rate": None, + "rise_count": None, + "fall_count": None, + } + ) + # Sort by change_pct descending (match eastmoney default sort) + rows.sort(key=lambda r: r["change_pct"] or 0, reverse=True) + return rows + + @staticmethod + def _sina_sector_constituents(sector_name: str) -> list[dict]: + """Fallback: sector constituents from Sina Finance API. + + Looks up the Sina node code for the given Chinese sector name, + then fetches constituent stocks from the Sina HQ API. + """ + # Find the Sina node code matching the sector name + data = _sina_sector_data() + node_code = None + for code, fields in data.items(): + if len(fields) >= 2 and fields[1] == sector_name: + node_code = code + break + if node_code is None: + raise ValueError( + f"Sector '{sector_name}' not found. " + "Use 'market sector-list' to see available sectors." + ) + + r = requests.get( + "https://vip.stock.finance.sina.com.cn/quotes_service/api/json_v2.php" + "/Market_Center.getHQNodeData", + params={ + "page": 1, + "num": 200, + "sort": "changepercent", + "asc": 0, + "node": node_code, + }, + headers={"Referer": "https://finance.sina.com.cn"}, + timeout=10, + ) + r.encoding = "gbk" + stocks = r.json() + + rows = [] + for s in stocks: + rows.append( + { + "code": s.get("code", ""), + "name": s.get("name", ""), + "price": _parse_float(s.get("trade")), + "change_pct": _parse_float(s.get("changepercent")), + "pe_ratio": _parse_float(s.get("per")), + "pb_ratio": _parse_float(s.get("pb")), + "total_market_cap": ( + int(s["mktcap"] * 10000) if s.get("mktcap") else None + ), + } + ) + return rows + @staticmethod def _tencent_valuation(symbol: str) -> dict: """Fetch PE/PB/market cap from Tencent quote API. @@ -295,6 +479,21 @@ def _tencent_valuation(symbol: str) -> dict: return result +def _sina_sector_data() -> dict[str, list[str]]: + """Fetch Sina industry board data. Returns {node_code: [fields...]}.""" + r = requests.get( + "https://vip.stock.finance.sina.com.cn/q/view/newSinaHy.php", + headers={"Referer": "https://finance.sina.com.cn"}, + timeout=10, + ) + r.encoding = "gbk" + m = re.search(r"=\s*(\{.*\})", r.text) + if not m: + raise RuntimeError("Failed to parse Sina sector response") + data = json.loads(m.group(1)) + return {k: v.split(",") for k, v in data.items()} + + def _parse_float(value: Any) -> float | None: """Parse a value to float, returning None for empty/invalid values.""" if value is None or value == "": diff --git a/haoinvest/market/us_provider.py b/haoinvest/market/us_provider.py index 950ead7..a3239e5 100644 --- a/haoinvest/market/us_provider.py +++ b/haoinvest/market/us_provider.py @@ -57,9 +57,29 @@ def get_basic_info(self, symbol: str) -> BasicInfo: return BasicInfo( name=info.get("shortName") or info.get("longName", ""), sector=info.get("sector", ""), + industry=info.get("industry", ""), currency=info.get("currency", "USD"), market_type="us", market_cap=info.get("marketCap"), pe_ratio=info.get("trailingPE"), pb_ratio=info.get("priceToBook"), + # yfinance returns ratios (0.15 = 15%); convert to percentage + roe=_ratio_to_pct(info.get("returnOnEquity")), + roa=_ratio_to_pct(info.get("returnOnAssets")), + debt_to_equity=info.get("debtToEquity"), + revenue_growth=_ratio_to_pct(info.get("revenueGrowth")), + profit_margin=_ratio_to_pct(info.get("profitMargins")), + gross_margin=_ratio_to_pct(info.get("grossMargins")), + operating_margin=_ratio_to_pct(info.get("operatingMargins")), + current_ratio=info.get("currentRatio"), + free_cash_flow=info.get("freeCashflow"), + operating_cash_flow=info.get("operatingCashflow"), + peg_ratio=info.get("trailingPegRatio"), ) + + +def _ratio_to_pct(value: float | None) -> float | None: + """Convert a ratio (0.15) to percentage (15.0). Returns None for None.""" + if value is None: + return None + return value * 100 diff --git a/haoinvest/models.py b/haoinvest/models.py index 938c8af..0a81871 100644 --- a/haoinvest/models.py +++ b/haoinvest/models.py @@ -148,6 +148,7 @@ class BasicInfo(BaseModel): name: str = "" sector: str = "" + industry: str = "" currency: str = "CNY" market_type: str = "" pe_ratio: Optional[float] = None @@ -157,6 +158,36 @@ class BasicInfo(BaseModel): total_supply: Optional[float] = Field( default=None, description="Total token supply (crypto only)" ) + # Financial health metrics + roe: Optional[float] = Field(default=None, description="Return on Equity (%)") + roa: Optional[float] = Field(default=None, description="Return on Assets (%)") + debt_to_equity: Optional[float] = Field( + default=None, description="Debt-to-Equity ratio" + ) + revenue_growth: Optional[float] = Field( + default=None, description="YoY revenue growth (%)" + ) + profit_margin: Optional[float] = Field( + default=None, description="Net profit margin (%)" + ) + gross_margin: Optional[float] = Field( + default=None, description="Gross profit margin (%)" + ) + operating_margin: Optional[float] = Field( + default=None, description="Operating margin (%)" + ) + current_ratio: Optional[float] = Field( + default=None, description="Current ratio (liquidity)" + ) + free_cash_flow: Optional[float] = Field( + default=None, description="Free cash flow in local currency" + ) + operating_cash_flow: Optional[float] = Field( + default=None, description="Operating cash flow in local currency" + ) + peg_ratio: Optional[float] = Field( + default=None, description="Price/Earnings to Growth ratio" + ) # --- Analysis models --- @@ -170,12 +201,23 @@ class ValuationAssessment(BaseModel): overall: str = "无法评估" +class FinancialHealthAssessment(BaseModel): + """Multi-dimensional financial health assessment with Chinese labels.""" + + profitability: str = "N/A" + growth: str = "N/A" + leverage: str = "N/A" + cash_flow: str = "N/A" + overall: str = "无法评估" + + class FundamentalAnalysis(BaseModel): - """Fundamental analysis result for a single stock (PE/PB, valuation).""" + """Fundamental analysis result for a single stock.""" symbol: str name: str = "" sector: str = "" + industry: str = "" market_type: str current_price: float currency: str = "CNY" @@ -183,6 +225,21 @@ class FundamentalAnalysis(BaseModel): pb_ratio: Optional[float] = None total_market_cap: Optional[int] = None valuation: ValuationAssessment = Field(default_factory=ValuationAssessment) + # Enhanced financial metrics + roe: Optional[float] = None + roa: Optional[float] = None + debt_to_equity: Optional[float] = None + revenue_growth: Optional[float] = None + profit_margin: Optional[float] = None + gross_margin: Optional[float] = None + operating_margin: Optional[float] = None + current_ratio: Optional[float] = None + free_cash_flow: Optional[float] = None + operating_cash_flow: Optional[float] = None + peg_ratio: Optional[float] = None + financial_health: FinancialHealthAssessment = Field( + default_factory=FinancialHealthAssessment + ) class RiskMetrics(BaseModel): @@ -202,12 +259,30 @@ class RiskMetrics(BaseModel): ) +class ChecklistItem(BaseModel): + """A single buy-readiness checklist item.""" + + dimension: str + score: int = Field(description="1-5 scale") + assessment: str + + +class BuyReadinessChecklist(BaseModel): + """Aggregated buy-readiness scoring across multiple dimensions.""" + + items: list[ChecklistItem] = Field(default_factory=list) + total_score: int = 0 + max_score: int = 0 + recommendation: str = "无法评估" + + class StockReport(BaseModel): """Combined fundamental analysis and risk metrics for a single stock.""" symbol: str name: str = "" sector: str = "" + industry: str = "" market_type: str current_price: float currency: str = "CNY" @@ -219,6 +294,20 @@ class StockReport(BaseModel): technical: Optional["TechnicalIndicators"] = None volume: Optional["VolumeAnalysis"] = None signals: Optional["SignalSummary"] = None + # Enhanced fields (Phase 1+5) + roe: Optional[float] = None + roa: Optional[float] = None + debt_to_equity: Optional[float] = None + revenue_growth: Optional[float] = None + profit_margin: Optional[float] = None + gross_margin: Optional[float] = None + operating_margin: Optional[float] = None + current_ratio: Optional[float] = None + free_cash_flow: Optional[float] = None + operating_cash_flow: Optional[float] = None + peg_ratio: Optional[float] = None + financial_health: Optional[FinancialHealthAssessment] = None + checklist: Optional[BuyReadinessChecklist] = None # --- Technical analysis models --- diff --git a/tests/test_analysis_fundamental.py b/tests/test_analysis_fundamental.py new file mode 100644 index 0000000..e95060a --- /dev/null +++ b/tests/test_analysis_fundamental.py @@ -0,0 +1,140 @@ +"""Tests for fundamental analysis — financial health assessment.""" + +from haoinvest.analysis.fundamental import ( + _assess_cash_flow, + _assess_financial_health, + _assess_growth, + _assess_leverage, + _assess_profitability, +) +from haoinvest.models import BasicInfo + + +class TestAssessProfitability: + def test_excellent_roe(self): + assert "优秀" in _assess_profitability(20.0, None) + + def test_good_roe(self): + assert "良好" in _assess_profitability(12.0, None) + + def test_average_roe(self): + assert "一般" in _assess_profitability(7.0, None) + + def test_weak_roe(self): + assert "偏弱" in _assess_profitability(3.0, None) + + def test_fallback_to_profit_margin(self): + result = _assess_profitability(None, 25.0) + assert "优秀" in result + assert "净利率" in result + + def test_none_returns_na(self): + assert _assess_profitability(None, None) == "N/A" + + +class TestAssessGrowth: + def test_high_growth(self): + assert "高速增长" in _assess_growth(25.0) # All values now in percentage + + def test_stable_growth(self): + assert "稳定增长" in _assess_growth(15.0) + + def test_low_growth(self): + assert "低增长" in _assess_growth(5.0) + + def test_negative_growth(self): + assert "负增长" in _assess_growth(-10.0) + + def test_none_returns_na(self): + assert _assess_growth(None) == "N/A" + + +class TestAssessLeverage: + def test_conservative(self): + assert "保守" in _assess_leverage(30.0, None) + + def test_moderate(self): + assert "适中" in _assess_leverage(80.0, None) + + def test_high_leverage(self): + assert "偏高" in _assess_leverage(150.0, None) + + def test_very_high_leverage(self): + assert "高杠杆" in _assess_leverage(250.0, None) + + def test_current_ratio_sufficient(self): + result = _assess_leverage(None, 2.5) + assert "充足" in result + + def test_current_ratio_normal(self): + result = _assess_leverage(None, 1.5) + assert "正常" in result + + def test_current_ratio_tight(self): + result = _assess_leverage(None, 0.8) + assert "紧张" in result + + def test_combined(self): + result = _assess_leverage(60.0, 1.8) + assert "适中" in result + assert "正常" in result + + def test_none_returns_na(self): + assert _assess_leverage(None, None) == "N/A" + + +class TestAssessCashFlow: + def test_positive_fcf(self): + assert "充裕" in _assess_cash_flow(1_000_000, None) + + def test_negative_fcf(self): + assert "紧张" in _assess_cash_flow(-500_000, None) + + def test_fallback_to_operating(self): + result = _assess_cash_flow(None, 2_000_000) + assert "正常" in result + + def test_negative_operating(self): + result = _assess_cash_flow(None, -100_000) + assert "紧张" in result + + def test_none_returns_na(self): + assert _assess_cash_flow(None, None) == "N/A" + + +class TestAssessFinancialHealth: + def test_healthy_stock(self): + info = BasicInfo( + roe=18.0, + profit_margin=15.0, + revenue_growth=20.0, + debt_to_equity=40.0, + current_ratio=2.0, + free_cash_flow=1_000_000, + ) + result = _assess_financial_health(info) + assert result.overall == "财务健康" + assert "优秀" in result.profitability + + def test_weak_stock(self): + info = BasicInfo( + roe=2.0, + revenue_growth=-15.0, + debt_to_equity=250.0, + free_cash_flow=-500_000, + ) + result = _assess_financial_health(info) + assert result.overall == "财务风险较高" + + def test_all_none_returns_unknown(self): + info = BasicInfo() + result = _assess_financial_health(info) + assert result.overall == "无法评估" + + def test_partial_data(self): + info = BasicInfo(roe=12.0, revenue_growth=8.0) + result = _assess_financial_health(info) + assert result.profitability != "N/A" + assert result.growth != "N/A" + assert result.leverage == "N/A" + assert result.cash_flow == "N/A" diff --git a/tests/test_analysis_peer.py b/tests/test_analysis_peer.py new file mode 100644 index 0000000..1204597 --- /dev/null +++ b/tests/test_analysis_peer.py @@ -0,0 +1,129 @@ +"""Tests for peer comparison analysis.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from haoinvest.analysis.peer import find_peers +from haoinvest.cli import app +from haoinvest.models import FundamentalAnalysis, MarketType + +runner = CliRunner() + +MOCK_FUNDAMENTAL = FundamentalAnalysis( + symbol="600519", + name="贵州茅台", + sector="白酒", + market_type="a_share", + current_price=1800.0, +) + +MOCK_CONSTITUENTS = [ + { + "code": "600519", + "name": "贵州茅台", + "price": 1800.0, + "change_pct": 1.5, + "pe_ratio": 35.2, + "pb_ratio": 12.1, + "total_market_cap": 2100000000000, + }, + { + "code": "000858", + "name": "五粮液", + "price": 150.0, + "change_pct": 0.8, + "pe_ratio": 22.5, + "pb_ratio": 5.3, + "total_market_cap": 580000000000, + }, + { + "code": "000568", + "name": "泸州老窖", + "price": 200.0, + "change_pct": -0.5, + "pe_ratio": 25.0, + "pb_ratio": 7.0, + "total_market_cap": 300000000000, + }, +] + + +class TestFindPeers: + def test_a_share_peers(self): + with ( + patch( + "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL + ), + patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_CONSTITUENTS, + ), + ): + rows = find_peers("600519", MarketType.A_SHARE, top_n=5) + assert len(rows) == 3 + assert rows[0]["Symbol"] == "600519" + assert rows[0]["is_target"] is True + assert rows[1]["Symbol"] == "000858" + + def test_us_stock_unsupported(self): + rows = find_peers("AAPL", MarketType.US) + assert "message" in rows[0] + assert "not yet supported" in rows[0]["message"] + + def test_no_sector_info(self): + mock = FundamentalAnalysis( + symbol="600519", sector="", market_type="a_share", current_price=1800.0 + ) + with patch("haoinvest.analysis.peer.analyze_stock", return_value=mock): + rows = find_peers("600519", MarketType.A_SHARE) + assert "message" in rows[0] + + def test_top_n_limit(self): + with ( + patch( + "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL + ), + patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_CONSTITUENTS, + ), + ): + rows = find_peers("600519", MarketType.A_SHARE, top_n=2) + assert len(rows) == 2 + + +class TestPeerCLI: + def test_peer_command(self): + with ( + patch( + "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL + ), + patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_CONSTITUENTS, + ), + ): + result = runner.invoke(app, ["analyze", "peer", "600519"]) + assert result.exit_code == 0 + assert "600519" in result.output + assert "贵州茅台" in result.output + + def test_peer_json(self): + with ( + patch( + "haoinvest.analysis.peer.analyze_stock", return_value=MOCK_FUNDAMENTAL + ), + patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_CONSTITUENTS, + ), + ): + result = runner.invoke(app, ["analyze", "peer", "600519", "--json"]) + assert result.exit_code == 0 + assert '"Symbol": "600519"' in result.output + + def test_peer_us_unsupported(self): + result = runner.invoke(app, ["analyze", "peer", "AAPL"]) + assert result.exit_code == 0 + assert "not yet supported" in result.output diff --git a/tests/test_analysis_report.py b/tests/test_analysis_report.py index 063c3db..cf880d8 100644 --- a/tests/test_analysis_report.py +++ b/tests/test_analysis_report.py @@ -1,17 +1,35 @@ -"""Tests for full_stock_report cache key correctness.""" +"""Tests for full_stock_report cache key correctness and checklist scoring.""" from datetime import date from unittest.mock import patch -from haoinvest.analysis.report import full_stock_report +from typer.testing import CliRunner + +from haoinvest.analysis.report import ( + _compute_checklist, + _score_growth, + _score_profitability, + _score_risk, + _score_technical, + _score_valuation, + full_stock_report, +) +from haoinvest.cli import app from haoinvest.db import Database from haoinvest.models import ( + BuyReadinessChecklist, + ChecklistItem, + FinancialHealthAssessment, FundamentalAnalysis, MarketType, RiskMetrics, + SignalSummary, + StockReport, ValuationAssessment, ) +runner = CliRunner() + def _make_fundamental(symbol: str = "AAPL") -> FundamentalAnalysis: return FundamentalAnalysis( @@ -108,3 +126,150 @@ def test_no_date_range_uses_stable_cache_key(self, db: Database): full_stock_report(db, "AAPL", MarketType.US) assert mock_fundamental.call_count == 1 + + +# --- Checklist scoring tests --- + + +class TestScoreValuation: + def test_undervalued(self): + assert _score_valuation("偏低估") == 5 + + def test_fair(self): + assert _score_valuation("估值合理") == 4 + + def test_overvalued(self): + assert _score_valuation("偏高估") == 2 + + def test_unknown(self): + assert _score_valuation("无法评估") == 3 + + +class TestScoreProfitability: + def test_excellent_roe(self): + assert _score_profitability(20.0, None) == 5 + + def test_fallback_margin(self): + assert _score_profitability(None, 25.0) == 5 + + def test_no_data(self): + assert _score_profitability(None, None) == 3 + + +class TestScoreGrowth: + def test_high(self): + assert _score_growth(25.0) == 5 + + def test_negative(self): + assert _score_growth(-10.0) == 2 + + def test_none(self): + assert _score_growth(None) == 3 + + +class TestScoreRisk: + def test_low_drawdown_high_sharpe(self): + assert _score_risk(-8.0, 1.5) == 5 + + def test_no_data(self): + assert _score_risk(None, None) == 3 + + +class TestScoreTechnical: + def test_bullish(self): + assert _score_technical("偏多", "高") == 5 + + def test_bearish(self): + assert _score_technical("偏空", "中") == 2 + + +class TestComputeChecklist: + def test_healthy_stock(self): + report = StockReport( + symbol="600519", + market_type="a_share", + current_price=1800.0, + valuation=ValuationAssessment(overall="偏低估"), + risk_metrics=RiskMetrics(max_drawdown_pct=-8.0, sharpe_ratio=1.5), + roe=20.0, + revenue_growth=25.0, + financial_health=FinancialHealthAssessment( + profitability="优秀", growth="高速增长" + ), + signals=SignalSummary( + symbol="600519", + market_type="a_share", + overall_signal="偏多", + confidence="高", + ), + ) + checklist = _compute_checklist(report) + assert checklist.total_score >= 20 + assert checklist.recommendation == "建议关注" + + def test_weak_stock(self): + report = StockReport( + symbol="999999", + market_type="a_share", + current_price=5.0, + valuation=ValuationAssessment(overall="明显高估"), + risk_metrics=RiskMetrics(max_drawdown_pct=-45.0, sharpe_ratio=-0.5), + roe=2.0, + revenue_growth=-20.0, + financial_health=FinancialHealthAssessment( + profitability="偏弱", growth="负增长" + ), + signals=SignalSummary( + symbol="999999", + market_type="a_share", + overall_signal="偏空", + confidence="高", + ), + ) + checklist = _compute_checklist(report) + assert checklist.recommendation == "建议回避" + + +class TestReportCLI: + def test_report_command(self, tmp_path, monkeypatch): + monkeypatch.setenv("HAOINVEST_DATA_DIR", str(tmp_path)) + mock_report = StockReport( + symbol="600519", + name="贵州茅台", + sector="白酒", + market_type="a_share", + current_price=1800.0, + valuation=ValuationAssessment( + pe_assessment="偏高", pb_assessment="高估", overall="偏高估" + ), + risk_metrics=RiskMetrics( + annualized_volatility=25.0, + max_drawdown_pct=-15.0, + sharpe_ratio=0.85, + ), + roe=18.0, + financial_health=FinancialHealthAssessment( + profitability="优秀", overall="财务健康" + ), + checklist=BuyReadinessChecklist( + items=[ + ChecklistItem(dimension="估值", score=4, assessment="偏高估"), + ChecklistItem(dimension="盈利能力", score=5, assessment="优秀"), + ], + total_score=9, + max_score=10, + recommendation="建议关注", + ), + ) + with ( + patch("haoinvest.cli.analyze._ensure_prices_cached"), + patch( + "haoinvest.analysis.report.full_stock_report", + return_value=mock_report, + ), + ): + result = runner.invoke(app, ["analyze", "report", "600519"]) + assert result.exit_code == 0 + assert "基本信息" in result.output + assert "估值分析" in result.output + assert "买入准备度" in result.output diff --git a/tests/test_cli/test_analyze.py b/tests/test_cli/test_analyze.py index b1ae0e7..7b8bd3f 100644 --- a/tests/test_cli/test_analyze.py +++ b/tests/test_cli/test_analyze.py @@ -84,6 +84,115 @@ def test_fundamental_json(self): assert '"symbol": "600519"' in result.output +class TestAnalyzeFundamentalBatch: + """Unit tests for batch 'haoinvest analyze fundamental A,B'.""" + + def test_fundamental_batch_two_symbols(self): + mock_results = [ + FundamentalAnalysis( + symbol="600519", + name="贵州茅台", + sector="白酒", + market_type="a_share", + current_price=1800.0, + currency="CNY", + pe_ratio=35.2, + pb_ratio=12.1, + valuation=ValuationAssessment(overall="偏高估"), + roe=25.0, + revenue_growth=15.0, + profit_margin=50.0, + debt_to_equity=30.0, + ), + FundamentalAnalysis( + symbol="000001", + name="平安银行", + sector="银行", + market_type="a_share", + current_price=12.5, + currency="CNY", + pe_ratio=5.3, + pb_ratio=0.6, + valuation=ValuationAssessment(overall="偏低估"), + roe=10.0, + revenue_growth=8.0, + profit_margin=30.0, + debt_to_equity=200.0, + ), + ] + with patch("haoinvest.cli.analyze.analyze_stock", side_effect=mock_results): + result = runner.invoke(app, ["analyze", "fundamental", "600519,000001"]) + assert result.exit_code == 0 + 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( + symbol="600519", + name="贵州茅台", + sector="白酒", + market_type="a_share", + current_price=1800.0, + currency="CNY", + valuation=ValuationAssessment(overall="偏高估"), + ) + with patch( + "haoinvest.cli.analyze.analyze_stock", + side_effect=[mock_result, ValueError("not found")], + ): + result = runner.invoke(app, ["analyze", "fundamental", "600519,999999"]) + assert result.exit_code == 0 + assert "贵州茅台" in result.output + assert "ERROR" in result.output + + +class TestAnalyzeTechnicalBatch: + """Unit tests for batch 'haoinvest analyze technical A,B'.""" + + def test_technical_batch_two_symbols(self, tmp_path, monkeypatch): + from haoinvest.models import ( + BollingerBands, + MACDResult, + MovingAverages, + RSIResult, + TechnicalIndicators, + ) + + monkeypatch.setenv("HAOINVEST_DATA_DIR", str(tmp_path)) + mock_results = [ + TechnicalIndicators( + symbol="600519", + market_type="a_share", + latest_close=1800.0, + moving_averages=MovingAverages(trend="上升趋势"), + macd=MACDResult(signal="金叉"), + rsi=RSIResult(rsi=55.0, assessment="中性"), + bollinger=BollingerBands(position="中轨附近"), + ), + TechnicalIndicators( + symbol="000001", + market_type="a_share", + latest_close=12.5, + moving_averages=MovingAverages(trend="下降趋势"), + macd=MACDResult(signal="死叉"), + rsi=RSIResult(rsi=30.0, assessment="超卖"), + bollinger=BollingerBands(position="下轨附近"), + ), + ] + with patch("haoinvest.cli.analyze._ensure_prices_cached"): + with patch( + "haoinvest.cli.analyze.analyze_technical", side_effect=mock_results + ): + result = runner.invoke(app, ["analyze", "technical", "600519,000001"]) + assert result.exit_code == 0 + assert "上升趋势" in result.output + assert "下降趋势" in result.output + assert "金叉" in result.output + assert "死叉" in result.output + + class TestAnalyzeRisk: def test_risk_no_holdings(self, tmp_path, monkeypatch): monkeypatch.setenv("HAOINVEST_DATA_DIR", str(tmp_path)) diff --git a/tests/test_cli/test_market.py b/tests/test_cli/test_market.py index 141350a..b5af427 100644 --- a/tests/test_cli/test_market.py +++ b/tests/test_cli/test_market.py @@ -144,6 +144,49 @@ def test_history_empty(self): assert "(empty)" in result.output +class TestMarketQuoteBatch: + """Unit tests for batch 'haoinvest market quote A,B'.""" + + def test_quote_batch_two_symbols(self): + mock_provider = MagicMock() + mock_provider.get_current_price.side_effect = [1800.0, 12.5] + mock_provider.get_basic_info.side_effect = [ + BasicInfo( + name="贵州茅台", + currency="CNY", + sector="白酒", + pe_ratio=35.2, + pb_ratio=12.1, + total_market_cap=2100000000000, + ), + BasicInfo( + name="平安银行", + currency="CNY", + sector="银行", + pe_ratio=5.3, + pb_ratio=0.6, + total_market_cap=240000000000, + ), + ] + with patch("haoinvest.cli.market.get_provider", return_value=mock_provider): + result = runner.invoke(app, ["market", "quote", "600519,000001"]) + assert result.exit_code == 0 + assert "贵州茅台" in result.output + assert "平安银行" in result.output + + def test_quote_batch_one_fails(self): + mock_provider = MagicMock() + mock_provider.get_current_price.side_effect = [1800.0, ValueError("not found")] + mock_provider.get_basic_info.return_value = BasicInfo( + name="贵州茅台", currency="CNY", sector="白酒", pe_ratio=35.2 + ) + with patch("haoinvest.cli.market.get_provider", return_value=mock_provider): + result = runner.invoke(app, ["market", "quote", "600519,999999"]) + assert result.exit_code == 0 + assert "贵州茅台" in result.output + assert "ERROR" in result.output + + class TestMarketIntegration: """Integration tests — real API calls.""" diff --git a/tests/test_cli/test_sector.py b/tests/test_cli/test_sector.py new file mode 100644 index 0000000..e334e8d --- /dev/null +++ b/tests/test_cli/test_sector.py @@ -0,0 +1,108 @@ +"""Tests for haoinvest market sector CLI commands.""" + +from unittest.mock import patch + +from typer.testing import CliRunner + +from haoinvest.cli import app + +runner = CliRunner() + +MOCK_SECTOR_LIST = [ + { + "name": "白酒", + "change_pct": 2.15, + "total_market_cap": 5000000000000, + "turnover_rate": 1.23, + "rise_count": 15, + "fall_count": 3, + }, + { + "name": "银行", + "change_pct": -0.32, + "total_market_cap": 8000000000000, + "turnover_rate": 0.45, + "rise_count": 10, + "fall_count": 20, + }, +] + +MOCK_SECTOR_CONSTITUENTS = [ + { + "code": "600519", + "name": "贵州茅台", + "price": 1800.0, + "change_pct": 1.5, + "pe_ratio": 35.2, + "pb_ratio": 12.1, + "total_market_cap": 2100000000000, + }, + { + "code": "000858", + "name": "五粮液", + "price": 150.0, + "change_pct": 0.8, + "pe_ratio": 22.5, + "pb_ratio": 5.3, + "total_market_cap": 580000000000, + }, +] + + +class TestSectorList: + def test_sector_list_tsv(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + return_value=MOCK_SECTOR_LIST, + ): + result = runner.invoke(app, ["market", "sector-list"]) + assert result.exit_code == 0 + assert "白酒" in result.output + assert "银行" in result.output + + def test_sector_list_json(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + return_value=MOCK_SECTOR_LIST, + ): + result = runner.invoke(app, ["market", "sector-list", "--json"]) + assert result.exit_code == 0 + assert '"name": "白酒"' in result.output + + def test_sector_list_error(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_list", + side_effect=RuntimeError("API failed"), + ): + result = runner.invoke(app, ["market", "sector-list"]) + assert result.exit_code == 1 + + +class TestSector: + def test_sector_constituents_tsv(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_SECTOR_CONSTITUENTS, + ): + result = runner.invoke(app, ["market", "sector", "白酒"]) + assert result.exit_code == 0 + assert "600519" in result.output + assert "贵州茅台" in result.output + assert "000858" in result.output + + def test_sector_constituents_json(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + return_value=MOCK_SECTOR_CONSTITUENTS, + ): + result = runner.invoke(app, ["market", "sector", "白酒", "--json"]) + assert result.exit_code == 0 + assert '"code": "600519"' in result.output + + def test_sector_not_found(self): + with patch( + "haoinvest.market.akshare_provider.AKShareProvider.get_sector_constituents", + side_effect=RuntimeError("Sector not found"), + ): + result = runner.invoke(app, ["market", "sector", "不存在的板块"]) + assert result.exit_code == 1