Skip to content
91 changes: 83 additions & 8 deletions .claude/skills/haoinvest/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,85 @@ All-in-one investment management via CLI + Claude Code agent. CLI does data + co
5. Run reports on 2-3 candidates from underrepresented sectors
6. Explain WHY each candidate diversifies the portfolio

### Workflow 3: "我想买 XXX" — Buy decision
### Workflow 3: "我想买/卖 XXX" — Pre-Trade Review (5维度审查)

1. Run comprehensive report with checklist:
**触发条件**: 用户表达买入/卖出意图时。

1. Run comprehensive report + guardrails pre-trade data (2 calls):
```bash
uv run haoinvest analyze report <symbol>
uv run haoinvest analyze report <symbol> --json
uv run haoinvest guardrails pre-trade-data <symbol> <buy/sell> <qty> -m <type> --json
```
2. Explain the buy-readiness score dimension by dimension
3. If score is low, explain which dimensions are concerning
4. Compare with peers:
If user hasn't specified quantity, ask first. If price not known, the command auto-fetches.

2. **5维度审查** — interpret each dimension:

| 维度 | 数据来源 | Go | Caution | Stop |
|------|---------|-----|---------|------|
| 估值 | report.checklist.recommendation | "建议关注" | "谨慎观望" | "建议回避" |
| 仓位 | pre-trade-data.simulated_violations (max_single_position_pct) | 无违规 | 有 warning | 有 critical |
| 行业均衡 | pre-trade-data.simulated_violations (max_sector_pct) | 无违规 | 有违规 | — |
| 信号 | report.signals.overall_signal | "偏多" | "中性" | "偏空" |
| 情绪 | 语言检测 + pre-trade-data | 无风险信号 | 轻微信号 | 强信号 |

3. **综合判定**:
- 0-1 个 Caution → **执行 (Go)**: "各维度基本通过,可以考虑执行"
- 2-3 个 Caution → **谨慎 (Caution)**: "有几个维度需要注意,建议再想想"
- 任何 Stop → **停止 (Stop)**: "建议暂缓,先解决以下问题..."

4. **隐式情绪检测** (见 Workflow 3b)

5. If proceeding, suggest recording journal:
```bash
uv run haoinvest analyze peer <symbol>
uv run haoinvest journal add "<决策理由>" --decision buy --emotion <detected> --symbols <symbol>
```
5. Always remind: **这不是投资建议,最终决定需要你自己判断**

6. Always remind: **这不是投资建议,最终决定需要你自己判断**

### Workflow 3b: 隐式情绪检测 (每次交易讨论时自动执行)

**不要直接问用户"你现在什么情绪"** — 人在情绪中往往察觉不到。

**语言信号检测**:
| 情绪 | 关键词/模式 |
|------|-----------|
| FOMO | "赶紧买"、"不能再等了"、"错过就没了"、"别人都买了"、"马上" |
| GREEDY | "全仓冲"、"必涨"、"加杠杆"、"all in"、"翻倍" |
| FEARFUL | "撑不住了"、"割了吧"、"快跑"、"受不了了"、"止损" |

**数据信号检测** (from pre-trade-data):
- `recent_price_change.one_month_pct > 20%` + 买入意图 → 可能追涨
- `recent_price_change.one_month_pct < -15%` + 卖出意图 → 可能杀跌
- `current_alerts` 中有 `rapid_change` → 加强警惕
- `emotion_stats` 中该情绪的 `profitable_pct < 40%` → 历史表现不佳

**检测到风险信号时**:
- 温和提醒(不指责):"注意,这只股票最近一个月涨了28%。现在买入可能受到追涨情绪影响。"
- 引用历史数据:"过去5次类似情况下的交易,只有20%盈利。"
- 建议:"考虑等待24小时冷静后再决策。或者先做一下基本面分析,看看当前价格是否合理。"

**Journal 记录时建议情绪标签**: 根据检测到的信号建议标签,让用户确认或修正。

### Workflow 3c: 止盈/止损建议 (alerts 触发时)

当 `hao guardrails alerts --json` 返回报警时:

**gain_review 触发 (浮盈超过阈值)**:
1. 提醒:"你持有的 XXX 浮盈已达 Y%,超过了 Z% 的审查阈值。"
2. 回顾原始 thesis: "你当初买入的理由是:{original_thesis}"
3. 引导思考:
- thesis 是否仍然成立?公司基本面有变化吗?
- 当前估值还合理吗?运行 `analyze report` 看看
- 如果 thesis 不变且估值合理 → 可继续持有
- 如果估值已偏高 → 建议考虑分批止盈(卖出 20-30% 锁定利润)

**loss_review 触发 (浮亏超过阈值)**:
1. 提醒:"你持有的 XXX 浮亏已达 Y%。"
2. 回顾原始 thesis
3. 引导思考:
- thesis 是否已被打破?(行业变化、公司暴雷、逻辑失效)
- 如果 thesis 打破 → 建议果断止损,"不要让沉没成本影响判断"
- 如果 thesis 未变,仅市场波动 → 建议耐心持有,考虑是否低位加仓

### Workflow 4: "对比 A 和 B" — Compare stocks

Expand Down Expand Up @@ -144,6 +210,15 @@ uv run haoinvest journal list [--symbol <sym>] [--limit <n>]
uv run haoinvest journal review [--entry-id <id>] [--days <n>]
```

### Guardrails
```bash
uv run haoinvest guardrails health-check [--cash <amt>] [--json] # Check portfolio against rules
uv run haoinvest guardrails alerts [--json] # Scan all positions for threshold violations
uv run haoinvest guardrails config [--set KEY=VALUE] [--json] # View/set guardrail configuration
uv run haoinvest guardrails pre-trade-data <sym> <buy/sell> <qty> [-m <type>] [--price] [--cash] [--json] # Agent pre-trade data (aggregated)
```
Default rules (configurable): single position ≤15%, sector ≤35%, max 8 positions, cash reserve ≥10%, gain review +30%, loss review -10%, rapid change ±10%/week.

## Market Type Auto-Detection

- **6-digit number** (600519, 000001) → A-share
Expand Down
3 changes: 2 additions & 1 deletion haoinvest/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import typer

from . import analyze, journal, market, portfolio, strategy
from . import analyze, guardrails, journal, market, portfolio, strategy

app = typer.Typer(
name="haoinvest",
Expand All @@ -15,3 +15,4 @@
app.add_typer(analyze.app, name="analyze")
app.add_typer(strategy.app, name="strategy")
app.add_typer(journal.app, name="journal")
app.add_typer(guardrails.app, name="guardrails")
181 changes: 181 additions & 0 deletions haoinvest/cli/guardrails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""CLI commands for investment guardrails."""

from typing import Optional

import typer

from ..db import Database
from ..market import get_provider
from ..models import MarketType
from .formatters import error_output, json_output, kv_output
from .market import _detect_market_type

app = typer.Typer(help="Guardrails — position rules, alerts, trade review.")


def _init_db() -> Database:
db = Database()
db.init_schema()
return db


def _fetch_current_prices(db: Database) -> dict[tuple[str, MarketType], float]:
"""Fetch current prices for all holdings."""
positions = db.get_positions(include_zero=False)
prices: dict[tuple[str, MarketType], float] = {}
for pos in positions:
try:
provider = get_provider(pos.market_type)
prices[(pos.symbol, pos.market_type)] = provider.get_current_price(
pos.symbol
)
except Exception as e:
error_output(f"Failed to get price for {pos.symbol}: {e}")
return prices


@app.command("health-check")
def health_check_cmd(
cash: float = typer.Option(0.0, "--cash", help="Current cash balance"),
use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""Check current portfolio against guardrail rules."""
from ..guardrails.rules import health_check

db = _init_db()
prices = _fetch_current_prices(db)
result = health_check(db, prices, cash_balance=cash)

if use_json:
json_output(result)
else:
if result.passed:
print(f"✓ {result.summary}")
else:
print(f"✗ {result.summary}")
for v in result.violations:
severity_icon = "⚠" if v.severity.value == "warning" else "✗"
print(f" {severity_icon} [{v.rule_name}] {v.message}")


@app.command("alerts")
def alerts_cmd(
use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""Scan all positions for threshold violations."""
from ..guardrails.alerts import scan_alerts

db = _init_db()
prices = _fetch_current_prices(db)
alerts = scan_alerts(db, prices)

if use_json:
json_output(alerts)
else:
if not alerts:
print("✓ 没有触发报警的持仓")
return
for a in alerts:
icon = {"gain_review": "📈", "loss_review": "📉", "rapid_change": "⚡"}.get(
a.alert_type.value, "!"
)
print(f" {icon} {a.message}")
if a.holding_days is not None:
print(f" 持有天数: {a.holding_days}")
if a.original_thesis:
print(f" 原始买入理由: {a.original_thesis[:80]}")


@app.command("config")
def config_cmd(
set_value: Optional[str] = typer.Option(
None, "--set", help="Set config: KEY=VALUE"
),
use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""View or set guardrail configuration."""
from ..guardrails.rules import load_config

db = _init_db()

if set_value:
if "=" not in set_value:
error_output("Format: --set KEY=VALUE")
raise typer.Exit(1)
key, value = set_value.split("=", 1)
db.set_guardrails_config(key.strip(), value.strip())
print(f"✓ {key.strip()} = {value.strip()}")
return

config = load_config(db)
if use_json:
json_output(config)
else:
kv_output(config)


@app.command("pre-trade-data")
def pre_trade_data_cmd(
symbol: str = typer.Argument(help="Stock/crypto symbol"),
action: str = typer.Argument(help="buy or sell"),
quantity: float = typer.Argument(help="Number of units"),
market_type: Optional[str] = typer.Option(
None, "--market-type", "-m", help="Override: a_share, crypto, us, hk"
),
price: Optional[float] = typer.Option(
None, "--price", "-p", help="Price per unit (default: current market price)"
),
cash: float = typer.Option(0.0, "--cash", help="Current cash balance"),
use_json: bool = typer.Option(False, "--json", help="Output as JSON"),
) -> None:
"""Collect all data for agent pre-trade review (single call)."""
from ..guardrails.pre_trade_data import collect_pre_trade_data

mt = MarketType(market_type) if market_type else _detect_market_type(symbol)
db = _init_db()

# Get current price if not specified
trade_price = price
if trade_price is None:
try:
provider = get_provider(mt)
trade_price = provider.get_current_price(symbol)
except Exception as e:
error_output(f"Failed to get price for {symbol}: {e}")
raise typer.Exit(1)

prices = _fetch_current_prices(db)
prices[(symbol, mt)] = trade_price

result = collect_pre_trade_data(
db, symbol, mt, action, quantity, trade_price, prices, cash_balance=cash
)

if use_json:
json_output(result)
else:
# Text summary for human readers
print(f"Pre-Trade Data: {action.upper()} {quantity} x {symbol} @ {trade_price}")
print()
if result.simulated_violations:
print("⚠ 规则违规:")
for v in result.simulated_violations:
print(f" - {v.message}")
else:
print("✓ 无规则违规")
print()
if result.current_position:
cp = result.current_position
print(
f"当前持仓: {cp.quantity} 股, 均价 {cp.avg_cost}, 浮盈 {cp.unrealized_pnl_pct}%"
)
else:
print("当前无持仓")
if result.recent_price_change.one_week_pct is not None:
print(
f"近期走势: 1周 {result.recent_price_change.one_week_pct:+.1f}%", end=""
)
if result.recent_price_change.one_month_pct is not None:
print(f", 1月 {result.recent_price_change.one_month_pct:+.1f}%")
else:
print()
21 changes: 21 additions & 0 deletions haoinvest/cli/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ def add_trade(
)

db = _init_db()

# Advisory guardrail check before trade
try:
from ..guardrails.rules import validate_trade as _validate

_prices: dict[tuple[str, MarketType], float] = {}
for pos in db.get_positions(include_zero=False):
try:
_provider = get_provider(pos.market_type)
_prices[(pos.symbol, pos.market_type)] = _provider.get_current_price(
pos.symbol
)
except Exception:
pass
_prices[(symbol, mt)] = price
_violations = _validate(db, symbol, mt, action, quantity, price, _prices)
for _v in _violations:
print(f" ⚠ {_v.message}", file=__import__("sys").stderr)
except Exception:
pass # guardrails are advisory, never block

pm = PortfolioManager(db)
txn_id = pm.add_trade(txn)

Expand Down
14 changes: 14 additions & 0 deletions haoinvest/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,17 @@ def get_db_path() -> Path:
}

ZERO_THRESHOLD = 1e-10 # Use abs(quantity) < ZERO_THRESHOLD instead of == 0

# Guardrails defaults (conservative for beginners)
GUARDRAILS_DEFAULTS = {
"max_single_position_pct": 15.0,
"max_sector_pct": 35.0,
"max_total_positions": 8,
"min_cash_reserve_pct": 10.0,
"gain_review_threshold": 30.0,
"loss_review_threshold": -10.0,
"rapid_change_threshold": 10.0,
}

# Sector info cache TTL (7 days — sectors don't change often)
SECTOR_CACHE_TTL = 7 * 24 * 3600
Loading
Loading