Skill: qcrcherry/tzzb-analyzer (version 2.4.0)

--- name: tzzb-analyzer description: 同花顺投资账本持仓分析工具。自动从同花顺投资账本读取持仓、自选股、交易记录，结合市场行情生成深度分析报告，支持板块分布、风险监控和阈值报警。 version: 2.0.0 homepage: https://tzzb.10jqka.com.cn tags: ["portfolio", "analyzer", "stock", "tzzb", "chinese-market"] commands: - /tzzb analyze - 生成持仓分析报告 - /tzzb positions - 获取当前持仓数据 - /tzzb watchlist - 获取自选股列表 - /tzzb trades - 获取近期交易记录 - /tzzb monitor - 运行持仓风险监控 - /tzzb status - 检查 Chrome 连接状态 metadata: {"clawdbot":{"emoji":"📊","requires":{"bins":["python","uv"],"env":["CHROME_DEBUG_URL"]}}} --- # 同花顺投资账本持仓分析器 自动从同花顺投资账本读取持仓数据，结合市场行情生成深度分析报告，支持板块分布、风险监控和阈值报警。 ## 安装 ### 1. 安装依赖 ```bash # 安装 Chromium 浏览器（Playwright） playwright install chromium ``` ### 2. 配置（可选） ```bash # Chrome 远程调试端口（默认: http://127.0.0.1:9222） export CHROME_DEBUG_URL=http://127.0.0.1:9222 ``` ## 快速开始 ### 生成持仓分析报告 ```bash python -m uv run scripts/analyze.py analyze ``` ### 生成定时报告（早盘/午盘/收盘，新闻由 MCP web_search 注入） ```bash # 早盘 python -m uv run scripts/report.py morning --news '[{"title":"...","snippet":"..."}]' # 午盘 python -m uv run scripts/report.py midday --news '[{"title":"...","snippet":"..."}]' # 收盘 python -m uv run scripts/report.py close --news '[{"title":"...","snippet":"..."}]' ``` > 新闻数据通过 `--news` 参数（JSON 数组）注入，格式：`{"title":"标题","snippet":"摘要"}`。 > 定时任务中，agent 先调用 `web_search` 获取新闻，再调用 report.py 时传入。 ### 查看各模块 ```bash python -m uv run scripts/analyze.py positions # 持仓数据 python -m uv run scripts/analyze.py watchlist # 自选股 python -m uv run scripts/analyze.py trades # 交易记录 python -m uv run scripts/analyze.py status # 连接状态 ``` ### 运行风险监控 ```bash python -m uv run scripts/monitor.py # 检查并报警 python -m uv run scripts/monitor.py --dry-run # 仅预览不写状态 ``` ## 配置文件 配置文件位于 `memory/` 目录，首次运行自动创建： ### memory/positions_config.json ```json { "report": { "show_sector_detail": true, "show_risk_alerts": true, "show_suggestions": true, "price_source": "tzzb+yahoo", "sort_by": "profit_pct" } } ``` ### memory/monitor_config.json ```json { "enabled": true, "alerts": [ {"name": "单股仓位过重", "condition": "position_rate_above", "threshold": 30, "message": "...", "cooldown_hours": 24}, {"name": "亏损超20%", "condition": "profit_rate_below", "threshold": -20, "message": "...", "cooldown_hours": 24}, {"name": "持仓超1年", "condition": "hold_days_above", "threshold": 365, "message": "...", "cooldown_hours": 168}, {"name": "总仓位过高", "condition": "total_position_above", "threshold": 80, "message": "...", "cooldown_hours": 24} ] } ``` 支持的监控条件： - `position_rate_above` - 单股仓位占比超过 X% - `profit_rate_below` - 单股亏损率超过 X% - `hold_days_above` - 持仓天数超过 X 天 - `total_position_above` - 总仓位超过 X% ## 分析报告内容 1. **账户概览** - 总资产、持仓市值、可用资金、仓位、整体盈亏 2. **持仓明细** - 持仓量、成本、现价、市值、盈亏、收益率、持股天数 3. **板块分布** - 按行业/概念分类的持仓市值 4. **风险提示** - 仓位过重、连续亏损、持仓过长等 5. **优化建议** - 减仓、换股、仓位管理建议 ## 定时任务 ```bash # 每天收盘后自动分析 openclaw cron add --name "持仓分析" \ --cron "0 16 * * 1-5" --tz "Asia/Shanghai" \ --message "运行 python -m uv run scripts/analyze.py analyze" # 每天 16:05 风险监控 openclaw cron add --name "持仓监控" \ --cron "5 16 * * 1-5" --tz "Asia/Shanghai" \ --message "运行 python -m uv run scripts/monitor.py" ``` ## 数据来源 - 持仓/自选股/交易：同花顺投资账本（Playwright CDP 提取） - 行情数据：Yahoo Finance ## 注意事项 1. Chrome 远程调试端口由 Playwright 自动管理（自动启动/连接） 2. 分析建议仅供参考，不构成投资建议 3. 监控状态每日重置（跟随交易日） 4. 数据缓存位于 `data/` 目录（可手动清理）

path: data/analysis_report.json
content: {
  "ok": true,
  "report": {
    "overview": {
      "total_asset": 10007.98,
      "total_value": 1052.0,
      "money_remain": 8955.98,
      "position_rate": 10.51,
      "total_profit": 7.98,
      "profit_rate": 0.77,
      "stock_count": 1
    },
    "positions": [
      {
        "stock_code": "300098",
        "stock_name": "高新兴",
        "ticker": "300098.SS",
        "hold_count": 200,
        "cost_price": 5.21,
        "current_price": 5.26,
        "market_value": 1052.0,
        "hold_profit": 7.98,
        "profit_pct": 0.96,
        "hold_days": 2,
        "today_change_pct": 0,
        "position_rate": 1.0,
        "sector": "综合"
      }
    ],
    "sector_distribution": [
      {
        "sector": "综合",
        "value": 1052.0,
        "pct": 100.0
      }
    ],
    "risk_alerts": [],
    "suggestions": [
      "本月最佳：高新兴（+1.0%）最差：高新兴（+1.0%）"
    ],
    "summary": "总资产 10,007.98 元，持仓市值 1,052.00 元（10.5% 仓位），可用资金 8,955.98 元，持有 1 只股票。整体盈利 7.98 元（+0.77%）。",
    "generated_at": "2026-03-25 21:07"
  }
}

path: data/positions.json
content: {
  "ok": true,
  "data": {
    "overview": {
      "total_asset": 10007.98,
      "total_value": 1052.0,
      "money_remain": 8955.98,
      "total_liability": 0.0,
      "position_rate": 0.1051
    },
    "items": [
      {
        "stock_code": "300098",
        "stock_name": "高新兴",
        "market": "1",
        "hold_count": 200,
        "cost_price": 5.21,
        "current_price": 5.26,
        "market_value": 1052.0,
        "hold_profit": 7.98,
        "hold_rate": 0.0077,
        "position_rate": 1.0,
        "hold_days": 2
      }
    ]
  }
}

path: data/report_close_20260325.md
content: # 📉 收盘复盘  03/25

---
## 🎯 今日结论（最重要）
🔵 **高新兴**（300098）**持有**
  小幅盈利，设定成本价为红线，跌破即卖出

## 📊 账户总览
| 项目 | 数值 |
|------|------|
| 总资产 | 10,010 元 |
| 持仓市值 | 1,052 元 |
| 可用资金 | 8,958 元 |
| 仓位 | 11% （轻仓） |
| 浮盈亏 | +10 元 |

## 💹 今日交易复盘
### 高新兴（300098）
- 买入 100 股 / 卖出 0 股 / 剩余 100 股
- 买入总额 526 元（含费 5.01 元）
- **已实现盈亏：+0 元**
- 当前价 5.26 / 平均成本 5.31
- **浮动盈亏：-5 元（-0.9%）**
> 🟡 小幅浮亏，继续持有观察
> ⚠️ 手续费率偏高（0.952%），建议比较券商费率

#### 📊 今日交易汇总
- 已实现盈亏合计：**+0 元**
- 浮动盈亏合计：**-5 元**
- 当日综合盈亏：**-5 元**

## 📋 持仓逐一点评
### 🔵 高新兴（300098）**持有**
```
  [◆··········] 止损 5.2
  [·◆·········] 成本 5.21
  [··········◆] 现价 5.26
```
| 项目 | 数值 |
|------|------|
| 现价 | 5.26 元 |
| 成本 | 5.21 元 |
| 浮盈亏 | +10 元（+1.0%） |
| 持股 | 2 天 |
| 止损价 | 5.20 元（跌破执行） |

**理由：小幅盈利，设定成本价为红线，跌破即卖出**

## ✅ 交易纪律自检（今晚填写）
- 今日买入 1 笔，是否符合计划？  □ 是  □ 否
- 是否有冲动交易（上头买/恐慌卖）？  □ 是  □ 否
- 仓位是否符合计划？  □ 是  □ 否

## 🎯 次日操作计划（含具体价格）
- **高新兴**（300098）**持有**
  止损设成本价 5.21，跌破即卖出（保住利润）

---
⚠️ **免责声明**：以上为个人分析，不构成投资建议。股市有风险，盈亏自负。

path: data/report_morning_20260325.md
content: # 📈 早盘操作指导 03/25 21:43

---
## 📊 账户概况
| 项目 | 数值 |
|------|------|
| 总资产 | 10,008 元 |
| 持仓市值 | 1,052 元 |
| 可用资金 | 8,956 元 |
| 仓位 | 10.5% |
| 持仓数量 | 1 只 |

## 💹 今日交易记录
### 高新兴（300098）
- 买入 100 股 / 卖出 0 股 / 剩余 100 股
- 买入总额 526 元（含费 0.00 元）
- **已实现盈亏：+0 元**
- 当前价 5.26 / 平均成本 5.26
- **浮动盈亏：+0 元（+0.0%）**
> 🟡 小幅浮亏，继续持有观察

#### 📊 今日交易汇总
- 已实现盈亏合计：**+0 元**
- 浮动盈亏合计：**+0 元**
- 当日综合盈亏：**+0 元**

## 🌍 隔夜消息面
暂无最新消息，请关注盘中动态。

## 🔮 今日大盘预判
当前仓位较轻，建议以观察为主，谨慎开仓，不盲目追高。

## 🎯 持仓个股操作建议
### 高新兴（300098） 🟢微涨
- 现价 5.26 / 成本 5.21 / 浮盈 +8 元（+0.8%）
> ✅ 正常持有，关注开盘半小时走势

---
⚠️ **免责声明**：以上分析仅供参考，不构成任何投资建议。股市有风险，投资需谨慎，盈亏自负。

path: memory/monitor_config.json
content: {
  "enabled": true,
  "alerts": [
    {
      "name": "单股仓位过重",
      "condition": "position_rate_above",
      "threshold": 30,
      "message": "单股仓位超过30%，建议适当减仓",
      "cooldown_hours": 24
    },
    {
      "name": "亏损超过20%",
      "condition": "profit_rate_below",
      "threshold": -20,
      "message": "亏损超过20%，注意止损风险",
      "cooldown_hours": 24
    },
    {
      "name": "持仓超1年",
      "condition": "hold_days_above",
      "threshold": 365,
      "message": "持仓超过1年，建议重新评估",
      "cooldown_hours": 168
    },
    {
      "name": "总仓位过高",
      "condition": "total_position_above",
      "threshold": 80,
      "message": "总仓位超过80%，建议预留现金应对波动",
      "cooldown_hours": 24
    },
    {
      "name": "单日亏损过大",
      "condition": "daily_loss_above",
      "threshold": 5,
      "message": "持仓股单日亏损超5%，注意市场风险",
      "cooldown_hours": 12
    }
  ]
}


path: memory/monitor_state.json

path: memory/positions_config.json
content: {
  "report": {
    "show_sector_detail": true,
    "show_risk_alerts": true,
    "show_suggestions": true,
    "price_source": "tzzb+yahoo",
    "sort_by": "profit_pct"
  },
  "watchlist": {
    "auto_sync": false,
    "save_cache": true,
    "cache_max_age_hours": 24
  }
}


path: pyproject.toml
content: [project]
name = "tzzb-analyzer"
version = "2.1.0"
description = "同花顺投资账本持仓分析器"
requires-python = ">=3.10"
dependencies = [
    "playwright>=1.40.0",
    "python-dotenv>=1.0.0",
]

[project.optional-dependencies]
dev = []

[tool.uv]


path: scripts/analyze.py
content: #!/usr/bin/env python3
"""
同花顺投资账本持仓分析器 v2.0
用法:
    python analyze.py analyze     - 生成持仓分析报告
    python analyze.py positions   - 获取持仓数据
    python analyze.py watchlist  - 获取自选股
    python analyze.py trades     - 获取交易记录
    python analyze.py status     - 检查 Chrome 连接
"""

import sys
import json
import os
import argparse
from pathlib import Path
from datetime import datetime

# ============ 路径配置 ============
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = Path(__file__).parent
MEMORY_DIR = SKILL_DIR / "memory"
DATA_DIR = SKILL_DIR / "data"
MEMORY_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)

# 内置 tzzb_parser 源码路径
TZzb_PARSER_DIR = SCRIPTS_DIR / "tzzb_parser"
if str(TZzb_PARSER_DIR) not in sys.path:
    sys.path.insert(0, str(TZzb_PARSER_DIR))

# ============ 工具函数 ============
def _to_float(v, default=0.0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return default


def _to_int(v, default=0):
    try:
        return int(v)
    except (TypeError, ValueError):
        return default


def _yahoo_ticker(code: str, market: str) -> str:
    """同花顺代码 -> Yahoo Finance 代码"""
    code = str(code).strip()
    market = str(market).strip()
    if market in ("1", "sh", "ss", "sse"):
        return f"{code}.SS"
    elif market in ("0", "2", "sz", "szse"):
        return f"{code}.SZ"
    return f"{code}.SZ" if not code.startswith("6") else f"{code}.SS"


def _config_path(name: str) -> Path:
    return MEMORY_DIR / name


def _load_json(path: Path, default=None):
    if path.exists():
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return default if default is not None else {}


# ============ 数据获取（一次 CDP 会话）============
def _fetch_all_via_cdp():
    """一次性从同花顺获取所有数据，共用一次 Chrome 会话"""
    from main import fetch_watchlist, fetch_positions, fetch_trade_records
    from cookie_extractor import cdp_session

    result = {}
    exit_code = 0
    with cdp_session() as (context, page):
        result["watchlist"] = fetch_watchlist(context=context, page=page)
        if not result["watchlist"].get("ok"):
            exit_code = 1
        result["positions"] = fetch_positions(context=context, page=page)
        if not result["positions"].get("ok"):
            exit_code = 1
        result["trades"] = fetch_trade_records(context=context, page=page)
        if not result["trades"].get("ok"):
            exit_code = 1
    return result, exit_code


def fetch_positions_raw():
    from main import fetch_positions
    from cookie_extractor import cdp_session
    with cdp_session() as (context, page):
        return fetch_positions(context=context, page=page)


def check_status():
    try:
        import urllib.request
        resp = urllib.request.urlopen("http://127.0.0.1:9222/json", timeout=5)
        tabs = json.loads(resp.read())
        return {"ok": True, "tabs": len(tabs), "details": tabs}
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ============ 行情获取 ============
def fetch_yahoo_quotes(tickers: list[str]) -> dict[str, dict]:
    """预留接口，暂未使用（tzzb 已提供现价，无需额外获取）"""
    return {}


# ============ 板块识别 ============
def _identify_sector(stock_name: str, stock_code: str) -> str:
    """根据股票名称识别所属行业板块"""
    name = stock_name
    code = str(stock_code)

    sector_map = {
        "医药": ["恒瑞", "药明", "迈瑞", "乐普", "联影", "迪安", "贝瑞", "华大基因", "创新医疗",
                 "东诚药业", "沃华医药", "仁和药业", "万邦德", "华特达因", "西藏药业", "药捷安康",
                 "王子新材", "联影医疗", "乐普医疗"],
        "农牧渔": ["牧原", "圣农", "立华", "天康", "大北农", "新希望", "民和", "隆平高科", "万向德农",
                   "双塔食品", "智慧农业", "国投丰乐", "桂发祥"],
        "新能源/光伏": ["阳光电源", "隆基绿能", "晶澳科技", "特变电工", "TCL中环", "科士达", "巨星科技",
                       "永鼎股份"],
        "半导体/芯片": ["兆易创新", "通富微电", "士兰微", "康强电子", "上海贝岭", "博通集成", "睿能科技"],
        "消费电子": ["立讯精密", "歌尔股份", "漫步者", "京东方A", "TCL科技", "欧菲光", "海信视像",
                    "安洁科技", "华映科技", "凯盛新能", "彩虹股份", "三力士", "劲嘉股份"],
        "AI/软件": ["科大讯飞", "中国软件", "电科数字", "大智慧", "东方财富", "漫步者", "共达电声",
                   "奥拓电子", "分众传媒", "人民网", "国脉文化"],
        "通信": ["中兴通讯", "海能达", "通宇通讯", "三维通信", "东信和平", "闻泰科技", "华胜天成"],
        "新能源汽车": ["比亚迪", "长安汽车", "北汽蓝谷", "宇通客车", "中通客车", "香山股份", "科达利",
                      "杰克科技", "小康股份"],
        "金融": ["招商银行", "平安银行", "中信证券", "华泰证券", "中信建投", "天风证券", "中原证券",
                "华鑫股份", "越秀资本", "太平洋", "湘财股份", "华创云信"],
        "房地产/基建": ["万科A", "滨江集团", "荣盛发展", "合肥城建", "浙江交科", "宁波港", "上港集团",
                       "西部建设", "中储股份", "浙江建投", "沙河股份"],
        "军工/卫星": ["中国卫星", "中国卫通", "中船科技", "中国长城", "大唐电信", "特力A", "全柴动力"],
        "稀土/有色": ["中国稀土", "中稀有色", "宝钛股份", "贵研铂业", "四川金顶", "丰元股份", "雄韬股份",
                     "露笑科技", "三维化学", "联化科技", "密尔克卫", "华友钴业", "国风新材", "新奥股份"],
        "电力/能源": ["中国核电", "西昌电力", "绿色动力", "江苏新能", "宝丰能源"],
        "医药ETF": ["医疗ETF", "医药ETF", "恒生医药", "创新药ETF"],
        "港股科技": ["恒生科技", "小米", "阿里健康", "微创医疗", "阿里巴巴", "腾讯"],
        "其他": [],
    }

    for sector, keywords in sector_map.items():
        for kw in keywords:
            if kw in name:
                return sector
    return "综合"


# ============ 持仓分析 ============
def analyze_positions(positions_data: dict, config: dict = None) -> dict:
    """生成持仓分析报告"""
    config = config or {}
    data = positions_data.get("data", {})
    overview = data.get("overview", {})
    items = data.get("items", [])

    if not items:
        return {
            "ok": True,
            "report": {
                "overview": _build_overview(overview, []),
                "positions": [],
                "sector_distribution": [],
                "risk_alerts": ["当前无持仓"],
                "suggestions": ["暂无持仓，建议观望"],
                "summary": "当前无持仓",
                "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
            },
        }

    # 获取实时行情
    tickers = []
    for pos in items:
        code = pos.get("stock_code", "")
        market = pos.get("market", "")
        if code:
            tickers.append(_yahoo_ticker(code, market))

    quotes = fetch_yahoo_quotes(tickers) if tickers else {}

    # 解析每只持仓
    analyzed = []
    total_cost = 0.0
    total_value = 0.0
    total_profit = 0.0
    sector_value = {}

    for pos in items:
        code = str(pos.get("stock_code", ""))
        market = str(pos.get("market", ""))
        ticker = _yahoo_ticker(code, market)
        quote = quotes.get(ticker, {})

        cost_price = _to_float(pos.get("cost_price"))
        hold_count = _to_int(pos.get("hold_count"))
        market_value = _to_float(pos.get("market_value"))
        hold_profit = _to_float(pos.get("hold_profit"))
        position_rate = _to_float(pos.get("position_rate"))
        hold_days = _to_int(pos.get("hold_days"))
        current_price = _to_float(pos.get("current_price"))

        # 用 Yahoo 行情补充
        if quote.get("price") and quote["price"] > 0:
            current_price = quote["price"]
            market_value = current_price * hold_count
            if cost_price:
                hold_profit = (current_price - cost_price) * hold_count

        profit_pct = (current_price - cost_price) / cost_price * 100 if cost_price else 0
        cost_total = cost_price * hold_count
        total_cost += cost_total
        total_value += market_value
        total_profit += hold_profit

        sector = _identify_sector(pos.get("stock_name", ""), code)
        if sector not in sector_value:
            sector_value[sector] = 0.0
        sector_value[sector] += market_value

        analyzed.append({
            "stock_code": code,
            "stock_name": pos.get("stock_name", "?"),
            "ticker": ticker,
            "hold_count": hold_count,
            "cost_price": round(cost_price, 3),
            "current_price": round(current_price, 3),
            "market_value": round(market_value, 2),
            "hold_profit": round(hold_profit, 2),
            "profit_pct": round(profit_pct, 2),
            "hold_days": hold_days,
            "today_change_pct": 0,
            "position_rate": round(position_rate, 2),
            "sector": sector,
        })

    # 排序
    sort_by = config.get("report", {}).get("sort_by", "profit_pct")
    if sort_by == "market_value":
        analyzed.sort(key=lambda x: x["market_value"], reverse=True)
    else:
        analyzed.sort(key=lambda x: x["profit_pct"])

    total_asset = _to_float(overview.get("total_asset"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate_pct = (total_value / total_asset * 100) if total_asset else 0

    # 板块分布
    sector_dist = [
        {"sector": s, "value": round(v, 2), "pct": round(v / total_value * 100, 1) if total_value else 0}
        for s, v in sorted(sector_value.items(), key=lambda x: -x[1])
    ]

    # 风险提示
    risk_alerts = []
    for p in analyzed:
        if p["position_rate"] > 30:
            risk_alerts.append(
                f"⚠️ {p['stock_name']}（{p['stock_code']}）仓位过重，占总资产 {p['position_rate']:.1f}%"
            )
        if p["profit_pct"] < -20:
            risk_alerts.append(
                f"🔴 {p['stock_name']}（{p['stock_code']}）亏损达 {p['profit_pct']:.1f}%，注意止损"
            )
        if p["hold_days"] > 365:
            risk_alerts.append(
                f"📅 {p['stock_name']}（{p['stock_code']}）持仓 {p['hold_days']} 天，建议重新评估"
            )

    # 优化建议
    suggestions = []
    if position_rate_pct > 80:
        suggestions.append(f"当前仓位较重（{position_rate_pct:.1f}%），建议预留现金应对波动")
    loss_count = sum(1 for p in analyzed if p["hold_profit"] < 0)
    if loss_count > len(analyzed) * 0.5 and len(analyzed) > 1:
        suggestions.append(f"亏损股占比过高（{loss_count}/{len(analyzed)}），建议精简弱势股")
    long_hold = sum(1 for p in analyzed if p["hold_days"] > 180)
    if long_hold > 0:
        suggestions.append(f"存在 {long_hold} 只持仓超半年的股票，注意定期检视")
    if len(analyzed) >= 3:
        max_pos = max(analyzed, key=lambda x: x["position_rate"])
        if max_pos["position_rate"] > 40:
            suggestions.append(f"最大持仓 {max_pos['stock_name']} 占比 {max_pos['position_rate']:.1f}%，集中度高")

    worst = min(analyzed, key=lambda x: x["profit_pct"])
    best = max(analyzed, key=lambda x: x["profit_pct"])
    suggestions.append(
        f"本月最佳：{best['stock_name']}（{best['profit_pct']:+.1f}%）最差：{worst['stock_name']}（{worst['profit_pct']:+.1f}%）"
    )

    profit_sign_str = "盈利" if total_profit >= 0 else "亏损"
    profit_rate_fmt = total_profit/total_cost*100 if total_cost else 0
    summary = (
        f"总资产 {total_asset:,.2f} 元，持仓市值 {total_value:,.2f} 元（{position_rate_pct:.1f}% 仓位），"
        f"可用资金 {money_remain:,.2f} 元，持有 {len(analyzed)} 只股票。"
        f"整体{profit_sign_str} {abs(total_profit):,.2f} 元（{profit_rate_fmt:+.2f}%）。"
    )

    report = {
        "overview": {
            "total_asset": round(total_asset, 2),
            "total_value": round(total_value, 2),
            "money_remain": round(money_remain, 2),
            "position_rate": round(position_rate_pct, 2),
            "total_profit": round(total_profit, 2),
            "profit_rate": round((total_profit / total_cost * 100) if total_cost else 0, 2),
            "stock_count": len(analyzed),
        },
        "positions": analyzed,
        "sector_distribution": sector_dist,
        "risk_alerts": risk_alerts,
        "suggestions": suggestions,
        "summary": summary,
        "generated_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
    }
    return {"ok": True, "report": report}


def _build_overview(overview: dict, items: list) -> dict:
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate = _to_float(overview.get("position_rate")) * 100 if overview.get("position_rate") else 0
    return {
        "total_asset": round(total_asset, 2),
        "total_value": round(total_value, 2),
        "money_remain": round(money_remain, 2),
        "position_rate": round(position_rate, 2),
        "total_profit": 0,
        "profit_rate": 0,
        "stock_count": len(items),
    }


# ============ 报告格式化 ============
def format_report(report: dict) -> str:
    lines = []
    ov = report.get("overview", {})

    lines.append("=" * 52)
    lines.append(f"   同花顺持仓分析报告  {report.get('generated_at', '')}")
    lines.append("=" * 52)

    lines.append("\n📊 账户概览")
    lines.append(f"   总资产    {ov.get('total_asset', 0):>12,.2f}  元")
    lines.append(f"   持仓市值  {ov.get('total_value', 0):>12,.2f}  元")
    lines.append(f"   可用资金  {ov.get('money_remain', 0):>12,.2f}  元")
    lines.append(f"   仓位水平  {ov.get('position_rate', 0):>12.1f}  %")
    profit = ov.get("total_profit", 0)
    profit_rate = ov.get("profit_rate", 0)
    p_sign = "+" if profit >= 0 else ""
    lines.append(f"   整体盈亏  {p_sign}{profit:>11,.2f}  元  ({p_sign}{profit_rate:.2f}%)")
    lines.append(f"   股票数量  {ov.get('stock_count', 0):>12}  只")

    positions = report.get("positions", [])
    if positions:
        lines.append("\n📋 持仓明细（按收益率排序）")
        hdr = f"{'名称':<8} {'代码':<8} {'持仓':>6} {'成本':>8} {'现价':>8} {'市值':>10} {'盈亏':>10} {'收益率':>8} {'持股天':>6}"
        lines.append(hdr)
        lines.append("-" * len(hdr))
        for p in positions:
            name = p.get("stock_name", "?")[:8]
            code = p.get("stock_code", "")
            cnt = p.get("hold_count", 0)
            cost = p.get("cost_price", 0)
            price = p.get("current_price", 0)
            mv = p.get("market_value", 0)
            pf = p.get("hold_profit", 0)
            pct = p.get("profit_pct", 0)
            days = p.get("hold_days", 0)
            s_profit = "+" if pf >= 0 else ""
            s_pct = "+" if pct >= 0 else ""
            lines.append(
                f"{name:<8} {code:<8} {cnt:>6} {cost:>8.2f} {price:>8.2f} "
                f"{mv:>10,.0f} {s_profit}{abs(pf):>9,.0f} {s_pct}{abs(pct):>5.1f}%  {days:>5}"
            )

    sectors = report.get("sector_distribution", [])
    if sectors:
        lines.append("\n🏭 板块分布")
        for s in sectors:
            lines.append(f"   {s['sector']:<14} {s['value']:>10,.0f} 元  {s['pct']:>5.1f}%")

    alerts = report.get("risk_alerts", [])
    if alerts:
        lines.append("\n⚠️ 风险提示")
        for a in alerts:
            lines.append(f"   {a}")

    suggestions = report.get("suggestions", [])
    if suggestions:
        lines.append("\n💡 优化建议")
        for s in suggestions:
            lines.append(f"   • {s}")

    lines.append(f"\n📝 {report.get('summary', '')}")
    lines.append("\n" + "=" * 52)
    lines.append("   仅供参考，不构成投资建议")
    lines.append("=" * 52)
    return "\n".join(lines)


# ============ 主函数 ============
def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")

    parser = argparse.ArgumentParser(description="同花顺投资账本持仓分析器 v2.0")
    parser.add_argument("cmd", choices=["status", "positions", "watchlist", "trades", "analyze", "monitor"],
                        help="命令")
    args = parser.parse_args()

    if args.cmd == "status":
        result = check_status()
        print(json.dumps(result, ensure_ascii=False, indent=2))
        return

    if args.cmd == "positions":
        print("正在连接同花顺投资账本...", flush=True)
        raw = fetch_positions_raw()
        out = {"type": "positions", "positions": raw}
        print(json.dumps(out, ensure_ascii=False, indent=2))
        (DATA_DIR / "positions.json").write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
        return

    if args.cmd == "watchlist":
        from main import fetch_watchlist
        from cookie_extractor import cdp_session
        print("正在连接同花顺投资账本...", flush=True)
        with cdp_session() as (context, page):
            raw = fetch_watchlist(context=context, page=page)
        out = {"type": "watchlist", "watchlist": raw}
        print(json.dumps(out, ensure_ascii=False, indent=2))
        return

    if args.cmd == "trades":
        from main import fetch_trade_records
        from cookie_extractor import cdp_session
        print("正在连接同花顺投资账本...", flush=True)
        with cdp_session() as (context, page):
            raw = fetch_trade_records(context=context, page=page)
        out = {"type": "trades", "trades": raw}
        print(json.dumps(out, ensure_ascii=False, indent=2))
        return

    if args.cmd == "monitor":
        from scripts.monitor import run_monitor
        run_monitor()
        return

    if args.cmd == "analyze":
        print("正在连接同花顺投资账本...", flush=True)
        raw, exit_code = _fetch_all_via_cdp()
        if exit_code != 0:
            print("获取持仓失败，请确认已登录同花顺投资账本", file=sys.stderr)
            sys.exit(1)

        positions = raw.get("positions", {})
        if not positions.get("ok"):
            print(f"获取持仓失败: {positions.get('error')}", file=sys.stderr)
            sys.exit(1)

        print("正在获取市场行情并分析...", flush=True)
        config = _load_json(_config_path("positions_config.json"))
        analysis = analyze_positions(positions, config)
        report = analysis["report"]

        # 保存
        (DATA_DIR / "analysis_report.json").write_text(
            json.dumps(analysis, ensure_ascii=False, indent=2), encoding="utf-8"
        )
        (DATA_DIR / "positions.json").write_text(
            json.dumps(raw.get("positions", {}), ensure_ascii=False, indent=2), encoding="utf-8"
        )

        print(format_report(report))


if __name__ == "__main__":
    main()


path: scripts/monitor.py
content: #!/usr/bin/env python3
"""
同花顺持仓监控脚本
根据 monitor_config.json 中的阈值配置，检查持仓风险项并输出报警信息

用法:
    python monitor.py              # 运行一次监控检查
    python monitor.py --dry-run   # 仅显示会触发但不复写状态
"""

import sys
import json
import os
import argparse
from pathlib import Path
from datetime import datetime, timedelta

# ============ 路径配置 ============
SKILL_DIR = Path(__file__).parent.parent
MEMORY_DIR = SKILL_DIR / "memory"
DATA_DIR = SKILL_DIR / "data"

# ============ 工具函数 ============
def _to_float(v, default=0.0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return default


def _to_int(v, default=0):
    try:
        return int(v)
    except (TypeError, ValueError):
        return default


def _load_json(path: Path, default=None):
    if path.exists():
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return default if default is not None else {}


def _save_json(path: Path, data: dict):
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)


# ============ 加载数据 ============
def load_positions():
    """从缓存加载持仓数据"""
    cache = DATA_DIR / "positions.json"
    if not cache.exists():
        return None
    try:
        data = json.loads(cache.read_text(encoding="utf-8"))
        # 尝试新版格式
        if "positions" in data:
            return data["positions"]
        return data.get("positions")
    except Exception:
        return None


def load_monitor_state() -> dict:
    return _load_json(MEMORY_DIR / "monitor_state.json", {})


def save_monitor_state(state: dict):
    _save_json(MEMORY_DIR / "monitor_state.json", state)


# ============ 监控检查 ============
def check_alerts(positions_data: dict, monitor_config: dict, state: dict, dry_run: bool = False) -> list[dict]:
    """
    检查所有监控条件，返回触发的告警列表
    """
    alerts_triggered = []

    if not positions_data.get("ok"):
        return [{"level": "error", "name": "数据获取失败", "message": positions_data.get("error", "未知错误")}]

    data = positions_data.get("data", {})
    overview = data.get("overview", {})
    items = data.get("items", [])

    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    total_position_rate = (total_value / total_asset * 100) if total_asset else 0

    # 当前时间
    now = datetime.now()
    today_str = now.strftime("%Y-%m-%d")

    monitor_alerts = monitor_config.get("alerts", [])

    for alert_cfg in monitor_alerts:
        alert_name = alert_cfg["name"]
        condition = alert_cfg["condition"]
        threshold = alert_cfg.get("threshold", 0)
        message_template = alert_cfg.get("message", "")
        cooldown_hours = alert_cfg.get("cooldown_hours", 24)

        # 检查冷却期
        if not dry_run:
            if alert_name in state:
                last_triggered = state[alert_name].get("last_triggered")
                if last_triggered:
                    last_time = datetime.fromisoformat(last_triggered)
                    if now - last_time < timedelta(hours=cooldown_hours):
                        continue  # 在冷却期内

        triggered = False
        details = []

        if condition == "position_rate_above":
            # 单股仓位超过 X%
            for pos in items:
                rate = _to_float(pos.get("position_rate"))
                if rate > threshold:
                    triggered = True
                    details.append(f"{pos.get('stock_name','')}({pos.get('stock_code','')}) 仓位 {rate:.1f}%")

        elif condition == "profit_rate_below":
            # 单股亏损率超过 X%（成本为基准）
            for pos in items:
                cost = _to_float(pos.get("cost_price"))
                current = _to_float(pos.get("current_price"))
                if cost > 0:
                    rate = (current - cost) / cost * 100
                    if rate < threshold:
                        triggered = True
                        details.append(
                            f"{pos.get('stock_name','')}({pos.get('stock_code','')}) 收益率 {rate:.1f}%"
                        )

        elif condition == "hold_days_above":
            # 持仓天数超过 X 天
            for pos in items:
                days = _to_int(pos.get("hold_days"))
                if days > threshold:
                    triggered = True
                    details.append(f"{pos.get('stock_name','')}({pos.get('stock_code','')}) 持仓 {days} 天")

        elif condition == "total_position_above":
            # 总仓位超过 X%
            if total_position_rate > threshold:
                triggered = True
                details.append(f"总仓位 {total_position_rate:.1f}%（阈值 {threshold}%）")

        elif condition == "daily_loss_above":
            # 单日亏损超过 X%（需要今日行情，暂用持仓盈亏代替）
            for pos in items:
                hold_profit = _to_float(pos.get("hold_profit"))
                mv = _to_float(pos.get("market_value"))
                if mv > 0:
                    # 用持仓盈亏率估算
                    cost_total = _to_float(pos.get("cost_price")) * _to_int(pos.get("hold_count"))
                    if cost_total > 0:
                        rate = hold_profit / cost_total * 100
                        if rate < -threshold:
                            triggered = True
                            details.append(
                                f"{pos.get('stock_name','')}({pos.get('stock_code','')}) 亏损 {rate:.1f}%"
                            )

        if triggered:
            msg = message_template
            if details:
                msg += "\n   " + " | ".join(details[:5])  # 最多显示5条详情
            alert_record = {
                "name": alert_name,
                "message": msg,
                "triggered_at": now.isoformat(),
                "details": details,
                "level": "warning",
            }
            alerts_triggered.append(alert_record)

            # 更新状态
            if not dry_run:
                state[alert_name] = {
                    "last_triggered": now.isoformat(),
                    "trigger_count": state.get(alert_name, {}).get("trigger_count", 0) + 1,
                    "last_message": msg,
                }

    return alerts_triggered


# ============ 报告格式化 ============
def format_alerts(alerts: list[dict], positions_data: dict = None) -> str:
    lines = []
    now = datetime.now().strftime("%Y-%m-%d %H:%M")

    lines.append("=" * 52)
    lines.append(f"   持仓监控报告  {now}")
    lines.append("=" * 52)

    if positions_data and positions_data.get("ok"):
        data = positions_data.get("data", {})
        overview = data.get("overview", {})
        items = data.get("items", [])
        total_asset = _to_float(overview.get("total_asset"))
        total_value = _to_float(overview.get("total_value"))
        position_rate = (total_value / total_asset * 100) if total_asset else 0
        lines.append(f"\n📊 当前持仓：{len(items)} 只  总资产 {total_asset:,.2f} 元  仓位 {position_rate:.1f}%")

    if not alerts:
        lines.append("\n✅ 暂无触发阈值的风险项，持仓状态正常。")
    else:
        lines.append(f"\n⚠️ 共触发 {len(alerts)} 项风险监控：")
        for i, alert in enumerate(alerts, 1):
            level_icon = "🔴" if alert.get("level") == "error" else "⚠️"
            lines.append(f"\n{level_icon} [{i}] {alert['name']}")
            lines.append(f"   {alert['message']}")
            if alert.get("triggered_at"):
                lines.append(f"   触发时间：{alert['triggered_at'][:16]}")

    lines.append("\n" + "=" * 52)
    lines.append("  仅供参考，不构成投资建议")
    lines.append("=" * 52)
    return "\n".join(lines)


# ============ 主入口 ============
def run_monitor(dry_run: bool = False):
    """执行监控检查"""
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")

    monitor_config = _load_json(MEMORY_DIR / "monitor_config.json", {"enabled": True, "alerts": []})
    if not monitor_config.get("enabled", True):
        print("监控未启用（monitor_config.json 中 enabled=false）")
        return

    print("正在加载持仓数据...", flush=True)
    positions = load_positions()
    if not positions or not positions.get("ok"):
        print("无法加载持仓数据，请先运行：python -m uv run scripts/analyze.py positions")
        print("(或 python -m uv run scripts/analyze.py analyze 会自动获取并缓存持仓数据)")
        sys.exit(1)

    state = {} if dry_run else load_monitor_state()

    print("正在检查监控阈值...", flush=True)
    alerts = check_alerts(positions, monitor_config, state, dry_run=dry_run)

    if not dry_run:
        save_monitor_state(state)

    report = format_alerts(alerts, positions)
    print(report)


def main():
    parser = argparse.ArgumentParser(description="同花顺持仓监控")
    parser.add_argument("--dry-run", action="store_true", help="仅显示会触发的告警，不写入状态")
    args = parser.parse_args()
    run_monitor(dry_run=args.dry_run)


if __name__ == "__main__":
    main()


path: scripts/report.py
content: #!/usr/bin/env python3
"""
同花顺持仓 - 定时报告生成器 v2.0
支持早盘、午盘、收盘三种报告模式，自动获取持仓+交易数据，整合免费新闻源，输出 Markdown 并推送飞书

用法:
    python report.py morning   # 早盘报告（9:25前）
    python report.py midday    # 午盘报告（12:55前）
    python report.py close    # 收盘复盘（17:00后）
    python report.py --feishu  # 输出飞书富文本格式
"""

import sys
import json
import os
import subprocess
import argparse
from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict


# ============ 路径配置 ============
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = Path(__file__).parent
TZzb_PARSER_DIR = SCRIPTS_DIR / "tzzb_parser"
if str(TZzb_PARSER_DIR) not in sys.path:
    sys.path.insert(0, str(TZzb_PARSER_DIR))

MEMORY_DIR = SKILL_DIR / "memory"
DATA_DIR = SKILL_DIR / "data"
MEMORY_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)

# ============ 工具函数 ============
def _to_float(v, default=0.0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return default

def _to_int(v, default=0):
    try:
        return int(v)
    except (TypeError, ValueError):
        return default

def _load_json(path: Path, default=None):
    if path.exists():
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return default if default is not None else {}

def _run_cmd(cmd: list, timeout=30) -> str:
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return result.stdout.strip()
    except Exception:
        return ""


# ============ 数据获取（一次 CDP 会话）============
def get_all_tzzb():
    """
    一次性从同花顺获取持仓 + 交易记录，共用一次 Chrome 会话
    返回 (positions_data, trades_data)
    """
    from main import fetch_positions, fetch_trade_records
    from cookie_extractor import cdp_session

    positions = {}
    trades = {}
    try:
        with cdp_session() as (context, page):
            positions = fetch_positions(context=context, page=page)
            trades = fetch_trade_records(context=context, page=page)
    except Exception as e:
        positions = {"ok": False, "error": str(e)}
        trades = {"ok": False, "error": str(e)}
    return positions, trades


def get_tzzb_positions() -> dict:
    from main import fetch_positions
    from cookie_extractor import cdp_session
    try:
        with cdp_session() as (context, page):
            return fetch_positions(context=context, page=page)
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ============ 新闻获取（东方财富 RSS，无需 API Key）============
def fetch_news_from_mcp(queries: list[str], max_results: int = 5) -> list[dict]:
    """
    新闻获取接口。
    新闻数据由调用方通过 --news 参数传入（JSON 格式）。
    本函数仅作参数验证，返回空列表（实际数据由 main() 从 --news 解析）。
    """
    return []  # 实际数据从 CLI --news 参数注入，详见 main() 中的 json.loads(news_raw)
    """
    通过东方财富 RSS 获取财经新闻，无需 API Key
    用内置 urllib + re，正则提取 title/link/description
    """
    import re
    import ssl
    import urllib.request

    all_news = []
    rss_urls = [
        "https://feed.eastmoney.com/moneyflow.html",
        "https://news.eastmoney.com/rss.xml",
    ]
    req_headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept": "*/*",
    }

    for rss_url in rss_urls:
        try:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            req = urllib.request.Request(rss_url, headers=req_headers)
            with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
                raw = resp.read().decode("utf-8-sig", errors="ignore")

            # 用正则提取标题（支持 CDATA 和普通标签）
            titles = re.findall(
                r'<title[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|https?://[^\s<]*)</title>',
                raw, re.IGNORECASE
            )
            # 去掉 feed 名称（第一个 title 通常是 feed 名）
            titles = [t.strip() for t in titles if t.strip()]
            titles = titles[1:] if len(titles) > 1 else titles

            # 提取 link
            links = re.findall(
                r'<link[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|https?://[^\s<]+)</link>',
                raw, re.IGNORECASE
            )
            links = [l.strip() for l in links if l.strip().startswith("http")]

            # 提取 description
            descs_raw = re.findall(
                r'<description[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|([^<]*)<)', raw, re.IGNORECASE
            )
            descs = [re.sub(r"<[^>]+>", "", (d[0] or d[1] or "")).strip() for d in descs_raw]

            for i, title in enumerate(titles[:30]):
                if not title or len(title) < 4:
                    continue
                summary = descs[i][:100] if i < len(descs) else ""
                link = links[i] if i < len(links) else ""
                for q in queries:
                    if q.lower() in title.lower() or q.lower() in summary.lower():
                        all_news.append({"title": title, "summary": summary, "link": link})
                        break
        except Exception:
            pass

    # 去重
    seen = set()
    deduped = []
    for n in all_news:
        key = n["title"][:30]
        if key not in seen:
            seen.add(key)
            deduped.append(n)
    return deduped[:15]


# ============ 数据解析 ============
def parse_positions(positions_data: dict) -> tuple[dict, list]:
    if not positions_data.get("ok"):
        return {}, []
    data = positions_data.get("data", {})
    return data.get("overview", {}), data.get("items", [])


def parse_trades(trades_data: dict) -> list:
    if not trades_data.get("ok"):
        return []
    return trades_data.get("data", {}).get("items", [])


def get_today_trades(trades: list) -> list:
    today_str = datetime.now().strftime("%Y-%m-%d")
    return [t for t in trades if t.get("trade_date", "").startswith(today_str)]


def get_recent_trades(trades: list, days: int = 5) -> list:
    """获取最近N天的交易"""
    cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
    return [t for t in trades if t.get("trade_date", "") >= cutoff]


# ============ 交易复盘核心算法 ============
def aggregate_trades(trades: list) -> dict:
    """
    按股票聚合交易，计算加权平均成本和真实盈亏
    返回: {stock_code: {"name":, "buy_records":[], "sell_records":[],
                        "avg_cost": float, "total_buy_qty": int, "total_sell_qty": int,
                        "remaining_qty": int, "realized_pl": float}}
    """
    stocks = defaultdict(lambda: {
        "name": "", "buy_records": [], "sell_records": [],
        "avg_cost": 0.0, "total_buy_qty": 0, "total_sell_qty": 0,
        "remaining_qty": 0, "realized_pl": 0.0
    })

    for t in trades:
        code = t.get("stock_code", "")
        name = t.get("stock_name", "")
        trade_type = t.get("trade_type", "")
        price = _to_float(t.get("price"))
        qty = _to_int(t.get("quantity"))
        fee = _to_float(t.get("fee"))
        date = t.get("trade_date", "")

        record = {"date": date, "price": price, "qty": qty, "fee": fee}

        if "买" in trade_type:
            stocks[code]["name"] = name
            stocks[code]["buy_records"].append(record)
            stocks[code]["total_buy_qty"] += qty
        elif "卖" in trade_type:
            stocks[code]["name"] = name
            stocks[code]["sell_records"].append(record)
            stocks[code]["total_sell_qty"] += qty

    # 计算剩余持仓和已实现盈亏
    for code, s in stocks.items():
        remaining = s["total_buy_qty"] - s["total_sell_qty"]
        s["remaining_qty"] = max(0, remaining)

        # 已实现盈亏（按FIFO匹配买卖）
        buy_fifo = list(reversed(s["buy_records"]))  # 最近的先卖
        sell_fifo = list(reversed(s["sell_records"]))
        realized = 0.0
        buy_pool = []  # (price, qty)

        for rec in buy_fifo:
            buy_pool.append((rec["price"], rec["qty"], rec["fee"]))

        for sell in sell_fifo:
            sell_qty = sell["qty"]
            sell_price = sell["price"]
            while sell_qty > 0 and buy_pool:
                bp_price, bp_qty, bp_fee = buy_pool.pop(0)
                matched = min(sell_qty, bp_qty)
                realized += (sell_price - bp_price) * matched
                sell_qty -= matched
                if bp_qty > matched:
                    buy_pool.insert(0, (bp_price, bp_qty - matched, 0))
            # 手续费
            realized -= sell.get("fee", 0)

        s["realized_pl"] = realized

        # 加权平均成本（剩余持仓）
        if s["remaining_qty"] > 0:
            total_cost = sum(r["price"] * r["qty"] + r["fee"] for r in s["buy_records"])
            s["avg_cost"] = total_cost / s["total_buy_qty"]  # 按买入总量均摊

    return stocks


def review_aggregated_trades(stocks_trades: dict, current_prices: dict) -> str:
    """
    对聚合后的交易数据进行复盘点评
    current_prices: {stock_code: current_price}
    """
    if not stocks_trades:
        return "今日无交易记录。"

    lines = []
    total_realized = 0.0
    total_unrealized = 0.0

    for code, s in sorted(stocks_trades.items(), key=lambda x: x[1]["realized_pl"], reverse=True):
        name = s["name"] or code
        remaining = s["remaining_qty"]
        avg_cost = s["avg_cost"]
        current = current_prices.get(code, 0)
        realized = s["realized_pl"]

        # 已买卖总览
        buy_total = sum(r["price"] * r["qty"] for r in s["buy_records"])
        sell_total = sum(r["price"] * r["qty"] for r in s["sell_records"])
        buy_fee = sum(r["fee"] for r in s["buy_records"])
        sell_fee = sum(r["fee"] for r in s["sell_records"])

        lines.append(f"### {name}（{code}）")
        lines.append(f"- 买入 {s['total_buy_qty']} 股 / 卖出 {s['total_sell_qty']} 股 / 剩余 {remaining} 股")
        lines.append(f"- 买入总额 {buy_total:,.0f} 元（含费 {buy_fee:.2f} 元）")
        if s["sell_records"]:
            lines.append(f"- 卖出总额 {sell_total:,.0f} 元（含费 {sell_fee:.2f} 元）")
        lines.append(f"- **已实现盈亏：{realized:+,.0f} 元**")

        # 剩余持仓点评
        if remaining > 0 and current > 0:
            unrealized = (current - avg_cost) * remaining
            total_unrealized += unrealized
            unrealized_pct = (current - avg_cost) / avg_cost * 100 if avg_cost else 0
            lines.append(f"- 当前价 {current:.2f} / 平均成本 {avg_cost:.2f}")
            lines.append(f"- **浮动盈亏：{unrealized:+,.0f} 元（{unrealized_pct:+.1f}%）**")

            if unrealized > 0:
                lines.append(f"> 🟢 买入后上涨，{'注意分批止盈' if unrealized_pct > 10 else '可继续持有'}")
            elif unrealized < -buy_total * 0.05:
                lines.append(f"> 🔴 浮亏超5%，注意止损纪律，跌破成本价7%坚决执行")
            else:
                lines.append(f"> 🟡 小幅浮亏，继续持有观察")
        elif remaining > 0 and current == 0:
            lines.append(f"> ⚠️ 剩余 {remaining} 股暂无行情，按买入成本持有")
        else:
            lines.append(f"> 📗 已全部卖出，{'盈利了结' if realized > 0 else '亏损出场'}")

        # 手续费评价
        total_fee = buy_fee + sell_fee
        if total_fee > 0 and buy_total > 0:
            fee_rate = total_fee / buy_total
            if fee_rate > 0.003:
                lines.append(f"> ⚠️ 手续费率偏高（{fee_rate:.3%}），建议比较券商费率")

        total_realized += realized
        lines.append("")

    # 汇总
    lines.append("#### 📊 今日交易汇总")
    lines.append(f"- 已实现盈亏合计：**{total_realized:+,.0f} 元**")
    lines.append(f"- 浮动盈亏合计：**{total_unrealized:+,.0f} 元**")
    lines.append(f"- 当日综合盈亏：**{total_realized + total_unrealized:+,.0f} 元**")

    return "\n".join(lines)


# ============ 报告生成 ============
def gen_profit_tag(pct: float) -> str:
    if pct > 10:
        return "🔴强势"
    elif pct > 3:
        return "🟢上涨"
    elif pct > 0:
        return "🟢微涨"
    elif pct > -5:
        return "🟡微亏"
    elif pct > -15:
        return "🟠亏损"
    else:
        return "🔴深套"


# ========== 报告辅助函数 ==========

def _verdict_emoji(action: str) -> str:
    return {"持有": "🔵", "加仓": "🟢", "减仓": "🟠", "止损": "🔴"}.get(action, "⚪")


def _stock_verdict(cost: float, current: float, hold_days: int, profit_rate: float):
    """
    返回 (动作结论, 理由, 止损价)
    决策逻辑：
      亏损 > 15%  → 止损（严格执行）
      亏损 8%~15% → 止损（不等了）
      亏损 3%~8%  → 减仓（跌破成本8%止损）
      亏损 0~3%   → 持有（等反弹）
      盈利 0~8%   → 持有（跌破成本卖）
      盈利 8~15%  → 持有（允许回撤）
      盈利 > 15%  → 减仓（分批止盈）
    """
    if profit_rate < -15:
        action = "止损"
        reason = "亏损已超15%，进入深套区，止损是唯一纪律，次日低开果断执行"
        stop_loss = current * 0.97
    elif profit_rate < -8:
        action = "止损"
        reason = f"亏损{profit_rate:.1f}%，建议现价清仓，不再等待"
        stop_loss = current * 0.97
    elif profit_rate < -3:
        action = "减仓"
        reason = f"亏损{profit_rate:.1f}%，建议减半仓，剩余设定止损在成本价"
        stop_loss = cost * 0.97
    elif profit_rate < 0:
        action = "持有"
        reason = "微亏，不急于操作，等待明日反弹出现"
        stop_loss = cost * 0.97
    elif profit_rate < 8:
        action = "持有"
        reason = "小幅盈利，设定成本价为红线，跌破即卖出"
        stop_loss = cost * 0.999  # 极靠近成本，视觉上用相同价位表示"成本线即止损线"
    elif profit_rate < 15:
        action = "持有"
        reason = "盈利可观，继续持有让利润奔跑，设定回撤止盈"
        stop_loss = current * 0.93
    else:
        action = "减仓"
        reason = f"盈利{profit_rate:.1f}%，建议分批止盈，先卖一半保留利润"
        stop_loss = current * 0.90

    if hold_days > 180 and action in ("持有",):
        reason += "（长线持仓，注意定期检视基本面是否有变化）"

    return action, reason, stop_loss


def _cost_visual(cost: float, current: float, stop_loss: float) -> str:
    """
    ASCII 成本可视化示例：
         4.89止损
         5.21成本
         5.26现价
    """
    labels = sorted([
        (round(stop_loss, 2), f"止损 {round(stop_loss, 2)}"),
        (round(cost, 2),      f"成本 {round(cost, 2)}"),
        (round(current, 2),   f"现价 {round(current, 2)}"),
    ])
    mn = labels[0][0]
    mx = labels[-1][0]
    rng = mx - mn if mx != mn else 0.01

    parts = []
    for price, label in labels:
        pos = int((price - mn) / rng * 10) if rng > 0 else 5
        bar = "·" * pos + "◆" + "·" * (10 - pos)
        parts.append(f"  [{bar}] {label}")

    return "\n".join(parts)


def _verdict_summary(overview: dict, items: list):
    """返回 (总体结论, 仓位说明)"""
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0
    total_profit = sum(_to_float(p.get("hold_profit", 0)) for p in items)

    if not items:
        verdict = "空仓观望"
        msg = "无持仓"
    elif position_rate > 80:
        verdict = "减仓控制风险"
        msg = f"仓位过重({position_rate:.0f}%)"
    elif position_rate < 20:
        verdict = "轻仓布局"
        msg = f"仓位较轻({position_rate:.0f}%)"
    else:
        verdict = "仓位适中"
        msg = f"仓位正常({position_rate:.0f}%)"

    profit_msg = f"整体{'盈利' if total_profit >= 0 else '亏损'}{abs(total_profit):.0f}元"
    return f"{verdict} | {profit_msg}", msg


# ========== 早盘报告 ==========
def build_morning_report(overview: dict, items: list, today_trades: list, news: list) -> str:
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    money_remain = _to_float(overview.get("money_remain"))
    total_profit = sum(_to_float(p.get("hold_profit", 0)) for p in items)
    verdict, pos_msg = _verdict_summary(overview, items)

    lines = []
    lines.append(f"# 📈 早盘操作指导  {now.strftime('%m/%d %H:%M')}")
    lines.append("")
    lines.append("---")
    lines.append("## 📊 账户状态")
    lines.append(f"| 项目 | 数值 |")
    lines.append(f"|------|------|")
    lines.append(f"| 总资产 | {total_asset:,.0f} 元 |")
    lines.append(f"| 可用资金 | {money_remain:,.0f} 元 |")
    lines.append(f"| 浮盈亏 | {total_profit:+,.0f} 元 |")
    lines.append(f"| {pos_msg} |")
    lines.append("")

    if today_trades:
        lines.append("## 💹 今日交易")
        stocks = aggregate_trades(today_trades)
        cp = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, cp))
        lines.append("")
    else:
        lines.append("## 💹 今日交易")
        lines.append("暂无交易记录。")
        lines.append("")

    if news:
        lines.append("## 🌍 隔夜消息面")
        for n in news[:4]:
            lines.append(f"- **{n.get('title', '')}**")
            if n.get('snippet'):
                lines.append(f"  {n['snippet'][:60]}")
        lines.append("")

    total_value = _to_float(overview.get("total_value"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0
    lines.append("## 🔮 今日大盘预判")
    if position_rate < 20:
        lines.append("**结论：轻仓布局** — 可用资金充裕，等待盘中买点出现，不追高。")
    elif position_rate > 75:
        lines.append("**结论：逢高减仓** — 仓位偏重，若大盘冲高可适当减至七成。")
    else:
        lines.append("**结论：持仓观望** — 仓位适中，不盲目加减仓，等待信号。")
    lines.append("")

    if items:
        lines.append("## 🎯 持仓操作建议")
        for pos in sorted(items, key=lambda x: _to_float(x.get("hold_profit", 0))):
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            hold_days = _to_int(pos.get("hold_days"))
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            action, reason, stop_loss = _stock_verdict(cost, current, hold_days, profit_rate)
            emoji = _verdict_emoji(action)

            lines.append(f"### {emoji} {name}（{code}）**{action}**")
            lines.append("```")
            lines.append(_cost_visual(cost, current, stop_loss))
            lines.append("```")
            lines.append(f"今日：{current:.2f}元  成本：{cost:.2f}元  浮盈：{profit:+,.0f}元（{profit_rate:+.1f}%）")
            lines.append(f"**建议：{reason}**")
            if stop_loss > 0:
                lines.append(f"止损价：{stop_loss:.2f}元（跌破即执行）")
            lines.append("")
    else:
        lines.append("## 🎯 操作建议")
        lines.append("空仓，建议继续观望。")
        lines.append("")

    lines.append("---")
    lines.append("⚠️ **免责声明**：以上为个人分析，不构成投资建议。股市有风险，盈亏自负。")
    return "\n".join(lines)


# ========== 午盘报告 ==========
def build_midday_report(overview: dict, items: list, today_trades: list, news: list) -> str:
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0
    total_profit = sum(_to_float(p.get("hold_profit", 0)) for p in items)

    lines = []
    lines.append(f"# 🕐 午盘操作指导  {now.strftime('%m/%d %H:%M')}")
    lines.append("")
    lines.append("---")
    lines.append("## 📊 上午盘面状态")
    lines.append(f"| 项目 | 数值 |")
    lines.append(f"|------|------|")
    lines.append(f"| 浮盈亏 | {total_profit:+,.0f} 元 |")
    lines.append(f"| 仓位 | {position_rate:.0f}% |")
    lines.append(f"| 持仓 | {len(items)} 只 |")
    lines.append("")

    if today_trades:
        lines.append("## 💹 今日交易")
        stocks = aggregate_trades(today_trades)
        cp = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, cp))
        lines.append("")
    else:
        lines.append("## 💹 今日交易")
        lines.append("暂无交易。")
        lines.append("")

    if news:
        lines.append("## 📰 盘中重要消息")
        for n in news[:3]:
            lines.append(f"- **{n.get('title', '')}**")
        lines.append("")

    lines.append("## 🎯 下午操作结论")
    if items:
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            hold_days = _to_int(pos.get("hold_days"))
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            action, reason, stop_loss = _stock_verdict(cost, current, hold_days, profit_rate)
            emoji = _verdict_emoji(action)

            lines.append(f"{emoji} **{name}**（{code}）**{action}**")
            lines.append(f"  现价 {current:.2f} / 成本 {cost:.2f} / {profit:+,.0f}元（{profit_rate:+.1f}%）")
            lines.append(f"  {reason}")
            if stop_loss > 0:
                lines.append(f"  止损价 {stop_loss:.2f}元，跌破严格执行")
            lines.append("")
    else:
        lines.append("空仓，下午继续保持观望。")

    if position_rate > 75:
        lines.append("")
        lines.append("⚠️ **仓位提醒**：当前仓位偏重，建议尾盘前适当减仓。")

    lines.append("---")
    lines.append("⚠️ **免责声明**：以上为个人分析，不构成投资建议。股市有风险，盈亏自负。")
    return "\n".join(lines)


# ========== 收盘报告 ==========
def build_close_report(overview: dict, items: list, today_trades: list, all_trades: list) -> str:
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0
    total_profit = sum(_to_float(p.get("hold_profit", 0)) for p in items)
    verdict, pos_msg = _verdict_summary(overview, items)

    lines = []
    lines.append(f"# 📉 收盘复盘  {now.strftime('%m/%d')}")
    lines.append("")
    lines.append("---")

    lines.append("## 🎯 今日结论（最重要）")
    if items:
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            hold_days = _to_int(pos.get("hold_days"))
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            action, reason, stop_loss = _stock_verdict(cost, current, hold_days, profit_rate)
            emoji = _verdict_emoji(action)

            lines.append(f"{emoji} **{name}**（{code}）**{action}**")
            lines.append(f"  {reason}")
    else:
        lines.append("空仓观望，无操作。")
    lines.append("")

    lines.append("## 📊 账户总览")
    lines.append(f"| 项目 | 数值 |")
    lines.append(f"|------|------|")
    lines.append(f"| 总资产 | {total_asset:,.0f} 元 |")
    lines.append(f"| 持仓市值 | {total_value:,.0f} 元 |")
    lines.append(f"| 可用资金 | {money_remain:,.0f} 元 |")
    lines.append(f"| 仓位 | {position_rate:.0f}% {'（正常）' if 20 <= position_rate <= 75 else '（轻仓）' if position_rate < 20 else '（重仓）'} |")
    lines.append(f"| 浮盈亏 | {total_profit:+,.0f} 元 |")
    lines.append("")

    if today_trades:
        lines.append("## 💹 今日交易复盘")
        stocks = aggregate_trades(today_trades)
        cp = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, cp))
        lines.append("")
    else:
        lines.append("## 💹 今日交易")
        lines.append("无操作，观望为主。")
        lines.append("")

    if items:
        lines.append("## 📋 持仓逐一点评")
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            hold_days = _to_int(pos.get("hold_days"))
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            action, reason, stop_loss = _stock_verdict(cost, current, hold_days, profit_rate)
            emoji = _verdict_emoji(action)

            lines.append(f"### {emoji} {name}（{code}）**{action}**")
            lines.append("```")
            lines.append(_cost_visual(cost, current, stop_loss))
            lines.append("```")
            lines.append(f"| 项目 | 数值 |")
            lines.append(f"|------|------|")
            lines.append(f"| 现价 | {current:.2f} 元 |")
            lines.append(f"| 成本 | {cost:.2f} 元 |")
            lines.append(f"| 浮盈亏 | {profit:+,.0f} 元（{profit_rate:+.1f}%） |")
            lines.append(f"| 持股 | {hold_days} 天 |")
            lines.append(f"| 止损价 | {stop_loss:.2f} 元（跌破执行） |")
            lines.append("")
            lines.append(f"**理由：{reason}**")
            lines.append("")
    else:
        lines.append("空仓。")
        lines.append("")

    buy_count = sum(1 for t in today_trades if "买" in t.get("trade_type", ""))
    sell_count = sum(1 for t in today_trades if "卖" in t.get("trade_type", ""))

    lines.append("## ✅ 交易纪律自检（今晚填写）")
    if buy_count > 0:
        lines.append(f"- 今日买入 {buy_count} 笔，是否符合计划？  □ 是  □ 否")
    if sell_count > 0:
        lines.append(f"- 今日卖出 {sell_count} 笔，原因？  □ 止盈  □ 止损  □ 其他：_____")
    lines.append("- 是否有冲动交易（上头买/恐慌卖）？  □ 是  □ 否")
    lines.append("- 仓位是否符合计划？  □ 是  □ 否")
    lines.append("")

    lines.append("## 🎯 次日操作计划（含具体价格）")
    if items:
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            if profit_rate > 15:
                target = current * 0.95
                lines.append(f"- **{name}**（{code}）**减仓**")
                lines.append(f"  现价 {current:.2f}，明日跌破 {target:.2f}（-5%）减半仓止盈")
                lines.append(f"  若继续涨至 {current * 1.05:.2f}，再卖剩1/3")
            elif profit_rate > 0:
                lines.append(f"- **{name}**（{code}）**持有**")
                lines.append(f"  止损设成本价 {cost:.2f}，跌破即卖出（保住利润）")
            else:
                key_level = cost * 0.97
                lines.append(f"- **{name}**（{code}）**止损/减仓**")
                lines.append(f"  关注 {key_level:.2f} 元支撑（成本{cost:.2f}的97%）")
                lines.append(f"  跌破 {key_level:.2f} 减仓一半；跌破 {cost * 0.95:.2f} 清仓止损")
    else:
        lines.append("空仓，建议明日继续观望。")

    lines.append("")
    lines.append("---")
    lines.append("⚠️ **免责声明**：以上为个人分析，不构成投资建议。股市有风险，盈亏自负。")
    return "\n".join(lines)


# ============ 飞书富文本格式 ============
def format_feishu_card(markdown: str) -> dict:
    """
    将 Markdown 报告转换为飞书 Interactive 消息卡片
    """
    import re
    # 简化处理：拆分为多个文本段落
    sections = []
    current_text = ""

    for line in markdown.split("\n"):
        if line.startswith("# ") and not current_text.strip():
            current_text = line.replace("# ", "**").replace("**", "") + "\n"
        elif line.startswith("## "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = "**" + line.replace("## ", "") + "**\n"
        elif line.startswith("### "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = line.replace("### ", "**") + "**\n"
        elif line.startswith("> "):
            current_text += line.replace("> ", "") + "\n"
        elif line.startswith("#### "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = line.replace("#### ", "") + "\n"
        elif line.startswith("- "):
            current_text += "• " + line[2:] + "\n"
        elif line.startswith("⚠️ ") or line.startswith("🔴 ") or line.startswith("🟢 ") or line.startswith("📗 "):
            current_text += line + "\n"
        elif line.startswith("|") and " | " in line:
            # 表格简化处理
            parts = [p.strip().strip("|") for p in line.split("|")]
            if all(p.replace("-", "").replace(":", "").strip() == "" for p in parts):
                continue  # 分隔线
            current_text += "  ".join(p for p in parts if p) + "\n"
        elif line.strip() == "---":
            continue
        else:
            current_text += line + "\n"

    if current_text.strip():
        sections.append({"tag": "markdown", "content": current_text.strip()})

    return {
        "msg_type": "interactive",
        "card": {
            "header": {
                "title": {"tag": "plain_text", "content": "📊 持仓报告"},
                "template": "blue"
            },
            "elements": sections
        }
    }


# ============ 主入口 ============
def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")

    parser = argparse.ArgumentParser(description="同花顺持仓定时报告生成器 v2.0")
    parser.add_argument("mode", choices=["morning", "midday", "close"], nargs="?",
                        help="报告模式: morning(早盘) / midday(午盘) / close(收盘)")
    parser.add_argument("--positions-only", action="store_true",
                        help="仅获取持仓数据")
    parser.add_argument("--feishu", action="store_true",
                        help="输出飞书富文本格式")
    parser.add_argument("--news", default=None,
                        help="新闻数据（JSON 数组格式），由调用方通过 MCP web_search 获取后传入")
    parser.add_argument("--queries", default="美股,A股政策,大宗商品,人民币",
                        help="新闻搜索关键词，逗号分隔（仅在 --news 未提供时使用）")
    args = parser.parse_args()
    if not args.mode and not args.positions_only:
        parser.error("mode is required unless --positions-only is set")

    # 解析新闻数据（由外部 MCP 工具获取后通过 --news 注入）
    news = []
    if args.news:
        try:
            news = json.loads(args.news)
        except Exception:
            print("警告: --news 参数解析失败，将跳过新闻", file=sys.stderr)

    # 统一获取持仓+交易（一次 CDP 会话）
    print(f"[{args.mode}] 正在连接同花顺...", flush=True)
    positions, trades = get_all_tzzb()
    if not positions.get("ok"):
        print(f"获取持仓失败: {positions.get('error')}", file=sys.stderr)
        sys.exit(1)

    overview, items = parse_positions(positions)
    all_trades = parse_trades(trades)
    today_trades = get_today_trades(all_trades) if all_trades else []

    if args.positions_only:
        print(json.dumps({"ok": True, "overview": overview, "items": items,
                          "today_trades": today_trades}, ensure_ascii=False, indent=2))
        return

    if args.mode == "morning":
        report = build_morning_report(overview, items, today_trades, news)
    elif args.mode == "midday":
        report = build_midday_report(overview, items, today_trades, news)
    else:
        report = build_close_report(overview, items, today_trades, all_trades)

    # 保存报告
    report_path = DATA_DIR / f"report_{args.mode}_{datetime.now().strftime('%Y%m%d')}.md"
    report_path.write_text(report, encoding="utf-8")
    print(f"报告已保存: {report_path}")

    if args.feishu:
        feishu_data = format_feishu_card(report)
        feishu_path = DATA_DIR / f"report_{args.mode}_{datetime.now().strftime('%Y%m%d')}_feishu.json"
        feishu_path.write_text(json.dumps(feishu_data, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"飞书格式已保存: {feishu_path}")
    else:
        print()
        print(report)


if __name__ == "__main__":
    main()


path: scripts/report_backup_v2.3.py
content: #!/usr/bin/env python3
"""
同花顺持仓 - 定时报告生成器 v2.0
支持早盘、午盘、收盘三种报告模式，自动获取持仓+交易数据，整合免费新闻源，输出 Markdown 并推送飞书

用法:
    python report.py morning   # 早盘报告（9:25前）
    python report.py midday    # 午盘报告（12:55前）
    python report.py close    # 收盘复盘（17:00后）
    python report.py --feishu  # 输出飞书富文本格式
"""

import sys
import json
import os
import subprocess
import argparse
from pathlib import Path
from datetime import datetime, timedelta
from collections import defaultdict


# ============ 路径配置 ============
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = Path(__file__).parent
TZzb_PARSER_DIR = SCRIPTS_DIR / "tzzb_parser"
if str(TZzb_PARSER_DIR) not in sys.path:
    sys.path.insert(0, str(TZzb_PARSER_DIR))

MEMORY_DIR = SKILL_DIR / "memory"
DATA_DIR = SKILL_DIR / "data"
MEMORY_DIR.mkdir(exist_ok=True)
DATA_DIR.mkdir(exist_ok=True)

# ============ 工具函数 ============
def _to_float(v, default=0.0):
    try:
        return float(v)
    except (TypeError, ValueError):
        return default

def _to_int(v, default=0):
    try:
        return int(v)
    except (TypeError, ValueError):
        return default

def _load_json(path: Path, default=None):
    if path.exists():
        try:
            with open(path, encoding="utf-8") as f:
                return json.load(f)
        except Exception:
            pass
    return default if default is not None else {}

def _run_cmd(cmd: list, timeout=30) -> str:
    try:
        result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return result.stdout.strip()
    except Exception:
        return ""


# ============ 数据获取（一次 CDP 会话）============
def get_all_tzzb():
    """
    一次性从同花顺获取持仓 + 交易记录，共用一次 Chrome 会话
    返回 (positions_data, trades_data)
    """
    from main import fetch_positions, fetch_trade_records
    from cookie_extractor import cdp_session

    positions = {}
    trades = {}
    try:
        with cdp_session() as (context, page):
            positions = fetch_positions(context=context, page=page)
            trades = fetch_trade_records(context=context, page=page)
    except Exception as e:
        positions = {"ok": False, "error": str(e)}
        trades = {"ok": False, "error": str(e)}
    return positions, trades


def get_tzzb_positions() -> dict:
    from main import fetch_positions
    from cookie_extractor import cdp_session
    try:
        with cdp_session() as (context, page):
            return fetch_positions(context=context, page=page)
    except Exception as e:
        return {"ok": False, "error": str(e)}


# ============ 新闻获取（东方财富 RSS，无需 API Key）============
def fetch_news_from_mcp(queries: list[str], max_results: int = 5) -> list[dict]:
    """
    新闻获取接口。
    新闻数据由调用方通过 --news 参数传入（JSON 格式）。
    本函数仅作参数验证，返回空列表（实际数据由 main() 从 --news 解析）。
    """
    return []  # 实际数据从 CLI --news 参数注入，详见 main() 中的 json.loads(news_raw)
    """
    通过东方财富 RSS 获取财经新闻，无需 API Key
    用内置 urllib + re，正则提取 title/link/description
    """
    import re
    import ssl
    import urllib.request

    all_news = []
    rss_urls = [
        "https://feed.eastmoney.com/moneyflow.html",
        "https://news.eastmoney.com/rss.xml",
    ]
    req_headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Accept": "*/*",
    }

    for rss_url in rss_urls:
        try:
            ctx = ssl.create_default_context()
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
            req = urllib.request.Request(rss_url, headers=req_headers)
            with urllib.request.urlopen(req, timeout=10, context=ctx) as resp:
                raw = resp.read().decode("utf-8-sig", errors="ignore")

            # 用正则提取标题（支持 CDATA 和普通标签）
            titles = re.findall(
                r'<title[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|https?://[^\s<]*)</title>',
                raw, re.IGNORECASE
            )
            # 去掉 feed 名称（第一个 title 通常是 feed 名）
            titles = [t.strip() for t in titles if t.strip()]
            titles = titles[1:] if len(titles) > 1 else titles

            # 提取 link
            links = re.findall(
                r'<link[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|https?://[^\s<]+)</link>',
                raw, re.IGNORECASE
            )
            links = [l.strip() for l in links if l.strip().startswith("http")]

            # 提取 description
            descs_raw = re.findall(
                r'<description[^>]*>(?:<!\[CDATA\[([^\]]*)\]\]>|([^<]*)<)', raw, re.IGNORECASE
            )
            descs = [re.sub(r"<[^>]+>", "", (d[0] or d[1] or "")).strip() for d in descs_raw]

            for i, title in enumerate(titles[:30]):
                if not title or len(title) < 4:
                    continue
                summary = descs[i][:100] if i < len(descs) else ""
                link = links[i] if i < len(links) else ""
                for q in queries:
                    if q.lower() in title.lower() or q.lower() in summary.lower():
                        all_news.append({"title": title, "summary": summary, "link": link})
                        break
        except Exception:
            pass

    # 去重
    seen = set()
    deduped = []
    for n in all_news:
        key = n["title"][:30]
        if key not in seen:
            seen.add(key)
            deduped.append(n)
    return deduped[:15]


# ============ 数据解析 ============
def parse_positions(positions_data: dict) -> tuple[dict, list]:
    if not positions_data.get("ok"):
        return {}, []
    data = positions_data.get("data", {})
    return data.get("overview", {}), data.get("items", [])


def parse_trades(trades_data: dict) -> list:
    if not trades_data.get("ok"):
        return []
    return trades_data.get("data", {}).get("items", [])


def get_today_trades(trades: list) -> list:
    today_str = datetime.now().strftime("%Y-%m-%d")
    return [t for t in trades if t.get("trade_date", "").startswith(today_str)]


def get_recent_trades(trades: list, days: int = 5) -> list:
    """获取最近N天的交易"""
    cutoff = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
    return [t for t in trades if t.get("trade_date", "") >= cutoff]


# ============ 交易复盘核心算法 ============
def aggregate_trades(trades: list) -> dict:
    """
    按股票聚合交易，计算加权平均成本和真实盈亏
    返回: {stock_code: {"name":, "buy_records":[], "sell_records":[],
                        "avg_cost": float, "total_buy_qty": int, "total_sell_qty": int,
                        "remaining_qty": int, "realized_pl": float}}
    """
    stocks = defaultdict(lambda: {
        "name": "", "buy_records": [], "sell_records": [],
        "avg_cost": 0.0, "total_buy_qty": 0, "total_sell_qty": 0,
        "remaining_qty": 0, "realized_pl": 0.0
    })

    for t in trades:
        code = t.get("stock_code", "")
        name = t.get("stock_name", "")
        trade_type = t.get("trade_type", "")
        price = _to_float(t.get("price"))
        qty = _to_int(t.get("quantity"))
        fee = _to_float(t.get("fee"))
        date = t.get("trade_date", "")

        record = {"date": date, "price": price, "qty": qty, "fee": fee}

        if "买" in trade_type:
            stocks[code]["name"] = name
            stocks[code]["buy_records"].append(record)
            stocks[code]["total_buy_qty"] += qty
        elif "卖" in trade_type:
            stocks[code]["name"] = name
            stocks[code]["sell_records"].append(record)
            stocks[code]["total_sell_qty"] += qty

    # 计算剩余持仓和已实现盈亏
    for code, s in stocks.items():
        remaining = s["total_buy_qty"] - s["total_sell_qty"]
        s["remaining_qty"] = max(0, remaining)

        # 已实现盈亏（按FIFO匹配买卖）
        buy_fifo = list(reversed(s["buy_records"]))  # 最近的先卖
        sell_fifo = list(reversed(s["sell_records"]))
        realized = 0.0
        buy_pool = []  # (price, qty)

        for rec in buy_fifo:
            buy_pool.append((rec["price"], rec["qty"], rec["fee"]))

        for sell in sell_fifo:
            sell_qty = sell["qty"]
            sell_price = sell["price"]
            while sell_qty > 0 and buy_pool:
                bp_price, bp_qty, bp_fee = buy_pool.pop(0)
                matched = min(sell_qty, bp_qty)
                realized += (sell_price - bp_price) * matched
                sell_qty -= matched
                if bp_qty > matched:
                    buy_pool.insert(0, (bp_price, bp_qty - matched, 0))
            # 手续费
            realized -= sell.get("fee", 0)

        s["realized_pl"] = realized

        # 加权平均成本（剩余持仓）
        if s["remaining_qty"] > 0:
            total_cost = sum(r["price"] * r["qty"] + r["fee"] for r in s["buy_records"])
            s["avg_cost"] = total_cost / s["total_buy_qty"]  # 按买入总量均摊

    return stocks


def review_aggregated_trades(stocks_trades: dict, current_prices: dict) -> str:
    """
    对聚合后的交易数据进行复盘点评
    current_prices: {stock_code: current_price}
    """
    if not stocks_trades:
        return "今日无交易记录。"

    lines = []
    total_realized = 0.0
    total_unrealized = 0.0

    for code, s in sorted(stocks_trades.items(), key=lambda x: x[1]["realized_pl"], reverse=True):
        name = s["name"] or code
        remaining = s["remaining_qty"]
        avg_cost = s["avg_cost"]
        current = current_prices.get(code, 0)
        realized = s["realized_pl"]

        # 已买卖总览
        buy_total = sum(r["price"] * r["qty"] for r in s["buy_records"])
        sell_total = sum(r["price"] * r["qty"] for r in s["sell_records"])
        buy_fee = sum(r["fee"] for r in s["buy_records"])
        sell_fee = sum(r["fee"] for r in s["sell_records"])

        lines.append(f"### {name}（{code}）")
        lines.append(f"- 买入 {s['total_buy_qty']} 股 / 卖出 {s['total_sell_qty']} 股 / 剩余 {remaining} 股")
        lines.append(f"- 买入总额 {buy_total:,.0f} 元（含费 {buy_fee:.2f} 元）")
        if s["sell_records"]:
            lines.append(f"- 卖出总额 {sell_total:,.0f} 元（含费 {sell_fee:.2f} 元）")
        lines.append(f"- **已实现盈亏：{realized:+,.0f} 元**")

        # 剩余持仓点评
        if remaining > 0 and current > 0:
            unrealized = (current - avg_cost) * remaining
            total_unrealized += unrealized
            unrealized_pct = (current - avg_cost) / avg_cost * 100 if avg_cost else 0
            lines.append(f"- 当前价 {current:.2f} / 平均成本 {avg_cost:.2f}")
            lines.append(f"- **浮动盈亏：{unrealized:+,.0f} 元（{unrealized_pct:+.1f}%）**")

            if unrealized > 0:
                lines.append(f"> 🟢 买入后上涨，{'注意分批止盈' if unrealized_pct > 10 else '可继续持有'}")
            elif unrealized < -buy_total * 0.05:
                lines.append(f"> 🔴 浮亏超5%，注意止损纪律，跌破成本价7%坚决执行")
            else:
                lines.append(f"> 🟡 小幅浮亏，继续持有观察")
        elif remaining > 0 and current == 0:
            lines.append(f"> ⚠️ 剩余 {remaining} 股暂无行情，按买入成本持有")
        else:
            lines.append(f"> 📗 已全部卖出，{'盈利了结' if realized > 0 else '亏损出场'}")

        # 手续费评价
        total_fee = buy_fee + sell_fee
        if total_fee > 0 and buy_total > 0:
            fee_rate = total_fee / buy_total
            if fee_rate > 0.003:
                lines.append(f"> ⚠️ 手续费率偏高（{fee_rate:.3%}），建议比较券商费率")

        total_realized += realized
        lines.append("")

    # 汇总
    lines.append("#### 📊 今日交易汇总")
    lines.append(f"- 已实现盈亏合计：**{total_realized:+,.0f} 元**")
    lines.append(f"- 浮动盈亏合计：**{total_unrealized:+,.0f} 元**")
    lines.append(f"- 当日综合盈亏：**{total_realized + total_unrealized:+,.0f} 元**")

    return "\n".join(lines)


# ============ 报告生成 ============
def gen_profit_tag(pct: float) -> str:
    if pct > 10:
        return "🔴强势"
    elif pct > 3:
        return "🟢上涨"
    elif pct > 0:
        return "🟢微涨"
    elif pct > -5:
        return "🟡微亏"
    elif pct > -15:
        return "🟠亏损"
    else:
        return "🔴深套"


def build_morning_report(overview: dict, items: list, today_trades: list, news: list) -> str:
    """早盘报告（含当日交易信息）"""
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0

    lines = []
    lines.append(f"# 📈 早盘操作指导 {now.strftime('%m/%d %H:%M')}\n")
    lines.append("---")
    lines.append("## 📊 账户概况")
    lines.append(f"| 项目 | 数值 |")
    lines.append(f"|------|------|")
    lines.append(f"| 总资产 | {total_asset:,.0f} 元 |")
    lines.append(f"| 持仓市值 | {total_value:,.0f} 元 |")
    lines.append(f"| 可用资金 | {money_remain:,.0f} 元 |")
    lines.append(f"| 仓位 | {position_rate:.1f}% |")
    lines.append(f"| 持仓数量 | {len(items)} 只 |")
    lines.append("")

    # 今日交易（如果有）
    if today_trades:
        lines.append("## 💹 今日交易记录")
        stocks = aggregate_trades(today_trades)
        current_prices = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, current_prices))
        lines.append("")
    else:
        lines.append("## 💹 今日交易")
        lines.append("今日暂无交易记录。\n")

    # 隔夜消息
    lines.append("## 🌍 隔夜消息面")
    if news:
        for n in news[:5]:
            lines.append(f"- **{n.get('title', '')}**")
            if n.get("summary"):
                lines.append(f"  {n['summary'][:60]}...")
    else:
        lines.append("暂无最新消息，请关注盘中动态。")
    lines.append("")

    # 大盘预判
    lines.append("## 🔮 今日大盘预判")
    if position_rate < 20:
        lines.append("当前仓位较轻，建议以观察为主，谨慎开仓，不盲目追高。")
    elif position_rate < 60:
        lines.append("仓位适中，可参与但控制节奏，重点关注持仓股动向。")
    else:
        lines.append("⚠️ 仓位较重，注意逢高适当减仓，控制风险。")
    lines.append("")

    # 个股建议
    if items:
        lines.append("## 🎯 持仓个股操作建议")
        for pos in sorted(items, key=lambda x: _to_float(x.get("hold_profit", 0)), reverse=True):
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            hold_days = _to_int(pos.get("hold_days"))

            tag = gen_profit_tag(profit_rate)
            lines.append(f"### {name}（{code}） {tag}")
            lines.append(f"- 现价 {current:.2f} / 成本 {cost:.2f} / 浮盈 {profit:+,.0f} 元（{profit_rate:+.1f}%）")

            if profit_rate < -5:
                lines.append(f"> ⚠️ 亏损较大，设定止损线，跌破成本7%坚决止损")
            elif profit_rate > 15:
                lines.append(f"> 🔴 涨幅较大，可分批止盈")
            elif hold_days > 180:
                lines.append(f"> 📅 长线持仓，定期检视基本面")
            else:
                lines.append(f"> ✅ 正常持有，关注开盘半小时走势")
            lines.append("")
    else:
        lines.append("## 🎯 操作建议")
        lines.append("当前无持仓，建议继续观望。\n")

    lines.append("---")
    lines.append("⚠️ **免责声明**：以上分析仅供参考，不构成任何投资建议。股市有风险，投资需谨慎，盈亏自负。")

    return "\n".join(lines)


def build_midday_report(overview: dict, items: list, today_trades: list, news: list) -> str:
    """午盘报告"""
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0

    lines = []
    lines.append(f"# 🕐 午盘操作指导 {now.strftime('%m/%d %H:%M')}\n")
    lines.append("---")

    lines.append("## 📊 上午行情回顾")
    if items:
        total_profit = sum(_to_float(p.get("hold_profit", 0)) for p in items)
        lines.append(f"持仓整体浮盈：{total_profit:+,.0f} 元  |  仓位 {position_rate:.1f}%")
    lines.append("")

    # 今日交易
    if today_trades:
        lines.append("## 💹 今日交易记录")
        stocks = aggregate_trades(today_trades)
        current_prices = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, current_prices))
        lines.append("")
    else:
        lines.append("## 💹 今日交易")
        lines.append("今日暂无交易记录。\n")

    # 盘中消息
    lines.append("## 📰 盘中重要消息")
    if news:
        for n in news[:4]:
            lines.append(f"- **{n.get('title', '')}**")
    else:
        lines.append("暂无最新消息。")
    lines.append("")

    # 下午建议
    lines.append("## 🎯 下午操作建议")
    if items:
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            if profit_rate < -3:
                lines.append(f"- **{name}**（{code}）：亏损扩大，午后低走考虑止损")
            elif profit_rate > 10:
                lines.append(f"- **{name}**（{code}）：盈利可观，可分批止盈")
            else:
                lines.append(f"- **{name}**（{code}）：维持持有，等待机会")
    else:
        lines.append("无持仓，午后继续保持观望。")

    if position_rate > 75:
        lines.append("")
        lines.append("⚠️ **仓位提醒**：仓位较重，午后冲高适当减仓至七成以下。")

    lines.append("---")
    lines.append("⚠️ **免责声明**：以上分析仅供参考，不构成任何投资建议。股市有风险，投资需谨慎，盈亏自负。")

    return "\n".join(lines)


def build_close_report(overview: dict, items: list, today_trades: list, all_trades: list) -> str:
    """收盘复盘报告"""
    now = datetime.now()
    total_asset = _to_float(overview.get("total_asset"))
    total_value = _to_float(overview.get("total_value"))
    money_remain = _to_float(overview.get("money_remain"))
    position_rate = (total_value / total_asset * 100) if total_asset else 0
    total_unrealized = sum(_to_float(p.get("hold_profit", 0)) for p in items)

    lines = []
    lines.append(f"# 📉 收盘复盘与次日指导 {now.strftime('%m/%d')}\n")
    lines.append("---")

    # 账户日终概况
    lines.append("## 📊 账户日终概况")
    lines.append(f"| 项目 | 数值 |")
    lines.append(f"|------|------|")
    lines.append(f"| 总资产 | {total_asset:,.0f} 元 |")
    lines.append(f"| 持仓市值 | {total_value:,.0f} 元 |")
    lines.append(f"| 可用资金 | {money_remain:,.0f} 元 |")
    lines.append(f"| 仓位 | {position_rate:.1f}% |")
    lines.append(f"| 持仓盈亏 | {total_unrealized:+,.0f} 元 |")
    lines.append(f"| 持仓数量 | {len(items)} 只 |")
    lines.append("")

    # 今日交易复盘（核心新增）
    lines.append("## 💹 今日交易复盘")
    if today_trades:
        stocks = aggregate_trades(today_trades)
        current_prices = {p.get("stock_code"): _to_float(p.get("current_price")) for p in items}
        lines.append(review_aggregated_trades(stocks, current_prices))
    else:
        lines.append("今日无交易操作。")
    lines.append("")

    # 持仓逐一点评
    if items:
        lines.append("## 📋 持仓逐一点评")
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            profit = _to_float(pos.get("hold_profit"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100
            hold_days = _to_int(pos.get("hold_days"))
            position_rate_item = _to_float(pos.get("position_rate", 0))

            tag = gen_profit_tag(profit_rate)
            lines.append(f"### {name}（{code}） {tag}")
            lines.append(f"- 现价 **{current:.2f}** / 成本 {cost:.2f} / 浮盈 {profit:+,.0f} 元（{profit_rate:+.1f}%）")
            lines.append(f"- 持股 **{hold_days}** 天 / 仓位 {position_rate_item:.1f}%")

            if profit_rate > 10:
                lines.append(f"> 🔴 涨幅较大，注意高位回调，可分批止盈保护利润")
            elif profit_rate > 0:
                lines.append(f"> 🟢 盈利状态，设定动态止盈位")
            elif profit_rate > -5:
                lines.append(f"> 🟡 小幅亏损，耐心等待反弹")
            elif profit_rate > -15:
                lines.append(f"> 🟠 亏损较大，跌破支撑位严格执行止损")
            else:
                lines.append(f"> 🔴 亏损严重，**次日低开即考虑止损**")

            if hold_days > 365:
                lines.append(f"> 📅 持仓超1年，重新评估持有逻辑是否成立")

            lines.append("")
    else:
        lines.append("当前无持仓。\n")

    # 交易纪律自检
    lines.append("## ✅ 交易纪律自检")
    buy_count = sum(1 for t in today_trades if "买" in t.get("trade_type", ""))
    sell_count = sum(1 for t in today_trades if "卖" in t.get("trade_type", ""))

    if buy_count > 3:
        lines.append(f"- ⚠️ 买入 {buy_count} 笔，频率偏高，警惕冲动交易")
    elif buy_count > 0:
        lines.append(f"- ✅ 买入 {buy_count} 笔，操作节奏正常")
    else:
        lines.append("- 📝 今日无买入，观望为主")

    if sell_count > 0:
        lines.append(f"- 📤 卖出 {sell_count} 笔，请确认卖出理由清晰")
    lines.append("- 是否执行了止损计划？  □ 是  □ 否")
    lines.append("- 是否追涨杀跌？  □ 是  □ 否")
    lines.append("- 仓位是否符合计划？  □ 是  □ 否")
    lines.append("")

    # 风险提示
    risk_lines = []
    for pos in items:
        if _to_float(pos.get("position_rate", 0)) > 30:
            risk_lines.append(f"⚠️ {pos.get('stock_name')} 仓位过重（{_to_float(pos.get('position_rate', 0)):.1f}%）")
    if any(_to_float(p.get("hold_rate", 0)) * 100 < -15 for p in items):
        risk_lines.append("🔴 存在大幅亏损持仓，请严格执行止损")
    if position_rate > 80:
        risk_lines.append(f"⚠️ 总仓位过重（{position_rate:.1f}%），建议预留现金")

    if risk_lines:
        lines.append("## ⚠️ 风险提示")
        for r in risk_lines:
            lines.append(f"- {r}")
        lines.append("")

    # 次日操作计划
    lines.append("## 🎯 次日操作计划")
    if items:
        for pos in items:
            name = pos.get("stock_name", "?")
            code = pos.get("stock_code", "")
            cost = _to_float(pos.get("cost_price"))
            current = _to_float(pos.get("current_price"))
            profit_rate = _to_float(pos.get("hold_rate", 0)) * 100

            stop_loss = current * 0.93  # 7% 止损
            if profit_rate > 10:
                # 设定止盈位：高于成本15%部分分批卖
                target = cost * 1.15
                lines.append(f"- **{name}**（{code}）：持有，止盈位 {target:.2f}（+15%），跌破 {stop_loss:.2f} 止盈")
            elif profit_rate > 0:
                lines.append(f"- **{name}**（{code}）：持有，止损位 {stop_loss:.2f}（-7%）")
            else:
                stop_loss_key = max(cost * 0.95, current * 0.97)  # 5%或3%
                lines.append(f"- **{name}**（{code}）：关注 {stop_loss_key:.2f} 支撑，跌破坚决止损")
    else:
        lines.append("无持仓，建议明日继续观望，等待市场明朗。")

    lines.append("")
    lines.append("---")
    lines.append("⚠️ **免责声明**：以上分析仅供参考，不构成任何投资建议。股市有风险，投资需谨慎，盈亏自负。")

    return "\n".join(lines)


# ============ 飞书富文本格式 ============
def format_feishu_card(markdown: str) -> dict:
    """
    将 Markdown 报告转换为飞书 Interactive 消息卡片
    """
    import re
    # 简化处理：拆分为多个文本段落
    sections = []
    current_text = ""

    for line in markdown.split("\n"):
        if line.startswith("# ") and not current_text.strip():
            current_text = line.replace("# ", "**").replace("**", "") + "\n"
        elif line.startswith("## "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = "**" + line.replace("## ", "") + "**\n"
        elif line.startswith("### "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = line.replace("### ", "**") + "**\n"
        elif line.startswith("> "):
            current_text += line.replace("> ", "") + "\n"
        elif line.startswith("#### "):
            if current_text.strip():
                sections.append({"tag": "markdown", "content": current_text.strip()})
            current_text = line.replace("#### ", "") + "\n"
        elif line.startswith("- "):
            current_text += "• " + line[2:] + "\n"
        elif line.startswith("⚠️ ") or line.startswith("🔴 ") or line.startswith("🟢 ") or line.startswith("📗 "):
            current_text += line + "\n"
        elif line.startswith("|") and " | " in line:
            # 表格简化处理
            parts = [p.strip().strip("|") for p in line.split("|")]
            if all(p.replace("-", "").replace(":", "").strip() == "" for p in parts):
                continue  # 分隔线
            current_text += "  ".join(p for p in parts if p) + "\n"
        elif line.strip() == "---":
            continue
        else:
            current_text += line + "\n"

    if current_text.strip():
        sections.append({"tag": "markdown", "content": current_text.strip()})

    return {
        "msg_type": "interactive",
        "card": {
            "header": {
                "title": {"tag": "plain_text", "content": "📊 持仓报告"},
                "template": "blue"
            },
            "elements": sections
        }
    }


# ============ 主入口 ============
def main():
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")

    parser = argparse.ArgumentParser(description="同花顺持仓定时报告生成器 v2.0")
    parser.add_argument("mode", choices=["morning", "midday", "close"], nargs="?",
                        help="报告模式: morning(早盘) / midday(午盘) / close(收盘)")
    parser.add_argument("--positions-only", action="store_true",
                        help="仅获取持仓数据")
    parser.add_argument("--feishu", action="store_true",
                        help="输出飞书富文本格式")
    parser.add_argument("--news", default=None,
                        help="新闻数据（JSON 数组格式），由调用方通过 MCP web_search 获取后传入")
    parser.add_argument("--queries", default="美股,A股政策,大宗商品,人民币",
                        help="新闻搜索关键词，逗号分隔（仅在 --news 未提供时使用）")
    args = parser.parse_args()
    if not args.mode and not args.positions_only:
        parser.error("mode is required unless --positions-only is set")

    # 解析新闻数据（由外部 MCP 工具获取后通过 --news 注入）
    news = []
    if args.news:
        try:
            news = json.loads(args.news)
        except Exception:
            print("警告: --news 参数解析失败，将跳过新闻", file=sys.stderr)

    # 统一获取持仓+交易（一次 CDP 会话）
    print(f"[{args.mode}] 正在连接同花顺...", flush=True)
    positions, trades = get_all_tzzb()
    if not positions.get("ok"):
        print(f"获取持仓失败: {positions.get('error')}", file=sys.stderr)
        sys.exit(1)

    overview, items = parse_positions(positions)
    all_trades = parse_trades(trades)
    today_trades = get_today_trades(all_trades) if all_trades else []

    if args.positions_only:
        print(json.dumps({"ok": True, "overview": overview, "items": items,
                          "today_trades": today_trades}, ensure_ascii=False, indent=2))
        return

    if args.mode == "morning":
        report = build_morning_report(overview, items, today_trades, news)
    elif args.mode == "midday":
        report = build_midday_report(overview, items, today_trades, news)
    else:
        report = build_close_report(overview, items, today_trades, all_trades)

    # 保存报告
    report_path = DATA_DIR / f"report_{args.mode}_{datetime.now().strftime('%Y%m%d')}.md"
    report_path.write_text(report, encoding="utf-8")
    print(f"报告已保存: {report_path}")

    if args.feishu:
        feishu_data = format_feishu_card(report)
        feishu_path = DATA_DIR / f"report_{args.mode}_{datetime.now().strftime('%Y%m%d')}_feishu.json"
        feishu_path.write_text(json.dumps(feishu_data, ensure_ascii=False, indent=2), encoding="utf-8")
        print(f"飞书格式已保存: {feishu_path}")
    else:
        print()
        print(report)


if __name__ == "__main__":
    main()


path: scripts/tzzb_parser/cookie_extractor.py
content: import os
import sys
import time
import subprocess
import re
import json
import urllib.request
import urllib.error
from pathlib import Path
from urllib.parse import urlparse
from contextlib import contextmanager
from dotenv import load_dotenv

os.environ.setdefault("NODE_NO_WARNINGS", "1")
os.environ.setdefault("NODE_OPTIONS", "--no-deprecation")

from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError

load_dotenv()

CHROME_DEBUG_URL = os.getenv("CHROME_DEBUG_URL", "http://127.0.0.1:9222")
TARGET_URL = "https://tzzb.10jqka.com.cn/"
COOKIE_DOMAINS = [".10jqka.com.cn", ".10jqka.com"]

def _cdp_base_url() -> str:
    return CHROME_DEBUG_URL.rstrip("/")


def _cdp_version_url() -> str:
    return f"{_cdp_base_url()}/json/version"


def _is_cdp_running(timeout_s: float = 2.0) -> bool:
    try:
        with urllib.request.urlopen(_cdp_version_url(), timeout=timeout_s) as resp:
            raw = resp.read()
        data = json.loads(raw.decode("utf-8", errors="ignore") or "{}")
        return bool(data.get("webSocketDebuggerUrl") or data.get("Browser"))
    except Exception:
        return False


def _parse_debug_port(debug_url: str) -> int:
    parsed = urlparse(debug_url)
    if parsed.port:
        return int(parsed.port)
    if parsed.scheme in ("http", "https") and parsed.netloc and ":" in parsed.netloc:
        return int(parsed.netloc.rsplit(":", 1)[-1])
    return 9222


def _default_user_data_dir() -> str:
    env_dir = os.getenv("CHROME_USER_DATA_DIR", "").strip()
    if env_dir:
        return str(Path(env_dir).expanduser())
    base = Path((os.getenv("TEMP") or os.getenv("TMP") or "")).expanduser()
    if str(base).strip():
        return str(base / "tzzb_parser_chrome")
    return str(Path.cwd() / "tzzb_parser_chrome")


def _find_chrome_executable() -> str:
    env_path = os.getenv("CHROME_PATH", "").strip()
    if env_path and Path(env_path).exists():
        return env_path

    candidates: list[str] = []
    if sys.platform.startswith("win"):
        candidates.extend(
            [
                os.path.join(os.getenv("PROGRAMFILES", r"C:\Program Files"), "Google", "Chrome", "Application", "chrome.exe"),
                os.path.join(os.getenv("PROGRAMFILES(X86)", r"C:\Program Files (x86)"), "Google", "Chrome", "Application", "chrome.exe"),
                os.path.join(os.getenv("LOCALAPPDATA", ""), "Google", "Chrome", "Application", "chrome.exe"),
            ]
        )
    elif sys.platform == "darwin":
        candidates.append("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
    else:
        candidates.extend(["google-chrome", "google-chrome-stable", "chromium", "chromium-browser"])

    for p in candidates:
        if Path(p).exists() or p in ("google-chrome", "google-chrome-stable", "chromium", "chromium-browser"):
            return p
    return "chrome"


def _start_chrome_remote_debug() -> tuple[subprocess.Popen | None, str]:
    exe = _find_chrome_executable()
    port = _parse_debug_port(CHROME_DEBUG_URL)
    user_data_dir = _default_user_data_dir()
    Path(user_data_dir).mkdir(parents=True, exist_ok=True)

    parsed = urlparse(CHROME_DEBUG_URL)
    host = (parsed.hostname or "").lower()
    if host not in ("127.0.0.1", "localhost", ""):
        return None, f"debug url host is not local: {host}"

    if sys.platform.startswith("win") and os.getenv("CHROME_KILL_EXISTING", "").strip() in ("1", "true", "yes"):
        subprocess.run(["taskkill", "/F", "/IM", "chrome.exe"], capture_output=True)
        time.sleep(1.5)

    args = [
        exe,
        f"--remote-debugging-port={port}",
        f"--user-data-dir={user_data_dir}",
        "--remote-allow-origins=*",
        "--no-first-run",
        "--no-default-browser-check",
        "about:blank",
    ]

    try:
        if sys.platform.startswith("win"):
            creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
            proc = subprocess.Popen(
                args,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                close_fds=True,
                creationflags=creationflags,
            )
        else:
            proc = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, close_fds=True)

        time.sleep(0.5)
        if proc.poll() is not None:
            return None, f"chrome exited early (code={proc.returncode})"

        deadline = time.time() + 15.0
        while time.time() < deadline:
            if _is_cdp_running(timeout_s=2.0):
                return proc, ""
            if proc.poll() is not None:
                return None, f"chrome exited early (code={proc.returncode})"
            time.sleep(1.0)

        return None, "cdp did not become ready in 15s"
    except Exception as e:
        return None, str(e)


def _cdp_guide(error: Exception) -> str:
    return (
        f"无法连接到 Chrome 远程调试地址 {CHROME_DEBUG_URL}。"
        "程序会尝试自动启动 Chrome 并连接该地址；若自动启动失败，可手动以远程调试模式启动 Chrome 后重试。"
        "若你使用了不同端口/地址，请设置环境变量 CHROME_DEBUG_URL。"
        "也可设置 CHROME_PATH / CHROME_USER_DATA_DIR 来辅助自动启动。"
        f" 原始错误：{error}"
    )


def _connect_browser(p):
    try:
        return p.chromium.connect_over_cdp(CHROME_DEBUG_URL)
    except Exception as e:
        if _is_cdp_running(timeout_s=2.0):
            try:
                return p.chromium.connect_over_cdp(CHROME_DEBUG_URL)
            except Exception:
                pass

        proc, start_err = _start_chrome_remote_debug()
        if proc:
            deadline = time.time() + 20.0
            while time.time() < deadline:
                try:
                    return p.chromium.connect_over_cdp(CHROME_DEBUG_URL)
                except Exception:
                    if proc.poll() is not None:
                        break
                    time.sleep(0.4)
        if start_err:
            raise RuntimeError(f"{_cdp_guide(e)} 自动启动失败：{start_err}")
        raise RuntimeError(f"{_cdp_guide(e)} 已尝试自动启动 Chrome 但仍无法连接")


def _get_automation_page(context):
    try:
        pages = list(context.pages)
    except Exception:
        pages = []

    if len(pages) == 1:
        url = ""
        try:
            url = pages[0].url or ""
        except Exception:
            url = ""
        if url == "about:blank":
            return pages[0]

    return context.new_page()


@contextmanager
def cdp_session():
    with sync_playwright() as p:
        browser = _connect_browser(p)
        context = browser.contexts[0] if browser.contexts else browser.new_context()
        page = _get_automation_page(context)
        try:
            yield context, page
        finally:
            try:
                page.close()
            except Exception:
                pass


def _click_first(locator) -> bool:
    try:
        target = locator.first
        target.scroll_into_view_if_needed(timeout=2000)
        target.click(timeout=8000)
        return True
    except Exception:
        return False


def _open_trade_records_tab(page) -> None:
    candidates = [
        page.get_by_role("tab", name=re.compile(r"(交易记录|成交记录)")),
        page.get_by_text("交易记录", exact=True),
        page.get_by_text("成交记录", exact=True),
        page.locator("text=交易记录"),
        page.locator("text=成交记录"),
    ]
    for locator in candidates:
        if _click_first(locator):
            return

    try:
        clicked = page.evaluate(
            """
            () => {
              const targets = new Set(["交易记录", "成交记录"]);
              const els = document.querySelectorAll("*");
              for (const el of els) {
                const t = (el.innerText || "").trim();
                if (targets.has(t) && el.offsetParent !== null) {
                  el.click();
                  return true;
                }
              }
              return false;
            }
            """
        )
        if clicked:
            return
    except Exception:
        pass
    raise RuntimeError("未找到“交易记录/成交记录”入口，请确认已进入账户详情页并处于登录态")


def _login_guide() -> str:
    return (
        "未检测到登录态：请在已开启远程调试的 Chrome 中打开并登录 "
        "https://tzzb.10jqka.com.cn/ ，然后重试。若使用 --user-data-dir，"
        "请确保登录发生在该目录对应的 Chrome 中。"
    )


def _get_userid(context, page) -> str:
    urls = [TARGET_URL, f"{TARGET_URL}pc/index.html"]
    try:
        cookies = context.cookies(urls)
    except Exception:
        cookies = context.cookies()

    for cookie in cookies:
        if cookie.get("name") == "userid":
            return cookie.get("value", "")

    page.goto(f"{TARGET_URL}pc/index.html", wait_until="networkidle")
    page.wait_for_timeout(1500)

    try:
        cookies = context.cookies(urls)
    except Exception:
        cookies = context.cookies()

    for cookie in cookies:
        if cookie.get("name") == "userid":
            return cookie.get("value", "")

    return ""


def get_userid(context, page) -> str:
    return _get_userid(context, page)


def _get_stock_watchlist_in_session(context, page) -> dict:
    page.goto(TARGET_URL, wait_until="networkidle")
    page.wait_for_timeout(3000)

    userid = _get_userid(context, page)

    if not userid:
        return {"error_code": "-1", "error_msg": _login_guide()}

    return page.evaluate(
        """
        async function(userid) {
            function safeJson(text) {
                try {
                    return JSON.parse(text);
                } catch (e) {
                    return {
                        error_code: "-1",
                        error_msg: String(text || "").slice(0, 500)
                    };
                }
            }

            const formData = new URLSearchParams();
            formData.append('terminal', '1');
            formData.append('version', '0.0.0');
            formData.append('userid', userid);
            formData.append('user_id', userid);
            formData.append('sort_rule', '');
            formData.append('sort_order', '1');

            const response = await fetch('/caishen_httpserver/tzzb/caishen_fund/pc/optional/v1/sort_list', {
                method: 'POST',
                body: formData,
                credentials: 'include',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                    'Referer': 'https://tzzb.10jqka.com.cn/',
                    'X-Requested-With': 'XMLHttpRequest'
                }
            });
            const text = await response.text();
            const json = safeJson(text);
            if (typeof json === "object" && json !== null && !("status" in json)) {
                json.status = response.status;
            }
            return json;
        }
        """,
        userid,
    )


def extract_cookies() -> dict[str, str]:
    """通过 Playwright CDP 连接 Chrome，提取认证 Cookie"""
    cookies_dict: dict[str, str] = {}

    with sync_playwright() as p:
        browser = _connect_browser(p)
        context = browser.contexts[0] if browser.contexts else browser.new_context()
        page = _get_automation_page(context)

        page.goto(TARGET_URL, wait_until="networkidle")
        page.wait_for_timeout(2000)

        urls = [TARGET_URL, f"{TARGET_URL}pc/index.html"]
        try:
            all_cookies = context.cookies(urls)
        except Exception:
            all_cookies = context.cookies()
        for cookie in all_cookies:
            domain = cookie.get("domain", "")
            if any(d in domain for d in COOKIE_DOMAINS):
                cookies_dict[cookie["name"]] = cookie["value"]

        page.close()

    return cookies_dict


def get_stock_watchlist_via_browser(context=None, page=None) -> dict:
    """通过浏览器上下文调用自选股 API，返回原始响应 dict"""
    if context is not None and page is not None:
        return _get_stock_watchlist_in_session(context, page)

    try:
        with cdp_session() as (ctx, pg):
            return _get_stock_watchlist_in_session(ctx, pg)
    except RuntimeError as e:
        return {"error_code": "-1", "error_msg": str(e)}


def _get_stock_positions_in_session(context, page) -> dict:
    page.goto(TARGET_URL, wait_until="networkidle")
    page.wait_for_timeout(3000)

    userid = _get_userid(context, page)

    if not userid:
        return {"error_code": "-1", "error_msg": _login_guide()}

    account_result = page.evaluate(
        """
        async function(userid) {
            function safeJson(text) {
                try {
                    return JSON.parse(text);
                } catch (e) {
                    return {
                        error_code: "-1",
                        error_msg: String(text || "").slice(0, 500)
                    };
                }
            }

            const formData = new URLSearchParams();
            formData.append("terminal", "1");
            formData.append("version", "0.0.0");
            formData.append("userid", userid);
            formData.append("user_id", userid);

            const response = await fetch("/caishen_httpserver/tzzb/caishen_fund/pc/account/v1/account_list", {
                method: "POST",
                body: formData,
                credentials: "include",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Referer": "https://tzzb.10jqka.com.cn/pc/index.html",
                    "X-Requested-With": "XMLHttpRequest"
                }
            });
            const text = await response.text();
            const json = safeJson(text);
            if (typeof json === "object" && json !== null && !("status" in json)) {
                json.status = response.status;
            }
            return json;
        }
        """,
        userid,
    )

    fund_key = ""
    common_accounts = account_result.get("ex_data", {}).get("common", [])
    if common_accounts:
        fund_key = common_accounts[0].get("fund_key", "")

    if not fund_key:
        return {"error_code": "-1", "error_msg": "missing fund_key"}

    return page.evaluate(
        """
        async function(args) {
            function safeJson(text) {
                try {
                    return JSON.parse(text);
                } catch (e) {
                    return {
                        error_code: "-1",
                        error_msg: String(text || "").slice(0, 500)
                    };
                }
            }

            const formData = new URLSearchParams();
            formData.append("terminal", "1");
            formData.append("version", "0.0.0");
            formData.append("userid", args.userid);
            formData.append("user_id", args.userid);
            formData.append("manual_id", "");
            formData.append("fund_key", args.fund_key);
            formData.append("rzrq_fund_key", "");

            const response = await fetch("/caishen_httpserver/tzzb/caishen_fund/pc/asset/v1/stock_position", {
                method: "POST",
                body: formData,
                credentials: "include",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Referer": "https://tzzb.10jqka.com.cn/pc/index.html",
                    "X-Requested-With": "XMLHttpRequest"
                }
            });
            const text = await response.text();
            const json = safeJson(text);
            if (typeof json === "object" && json !== null && !("status" in json)) {
                json.status = response.status;
            }
            return json;
        }
        """,
        {"userid": userid, "fund_key": fund_key},
    )


def get_stock_positions_via_browser(context=None, page=None) -> dict:
    """通过浏览器上下文调用持仓 API，返回原始响应 dict"""
    if context is not None and page is not None:
        return _get_stock_positions_in_session(context, page)

    try:
        with cdp_session() as (ctx, pg):
            return _get_stock_positions_in_session(ctx, pg)
    except RuntimeError as e:
        return {"error_code": "-1", "error_msg": str(e)}


def _get_trade_records_in_session(context, page) -> list[dict]:
    records: list[dict] = []

    page.goto(TARGET_URL, wait_until="networkidle")
    page.wait_for_timeout(3000)

    userid = _get_userid(context, page)

    if not userid:
        return records

    account_result = page.evaluate(
        """
        async function(userid) {
            function safeJson(text) {
                try {
                    return JSON.parse(text);
                } catch (e) {
                    return {
                        error_code: "-1",
                        error_msg: String(text || "").slice(0, 500)
                    };
                }
            }

            const formData = new URLSearchParams();
            formData.append("terminal", "1");
            formData.append("version", "0.0.0");
            formData.append("userid", userid);
            formData.append("user_id", userid);

            const response = await fetch("/caishen_httpserver/tzzb/caishen_fund/pc/account/v1/account_list", {
                method: "POST",
                body: formData,
                credentials: "include",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                    "Referer": "https://tzzb.10jqka.com.cn/pc/index.html",
                    "X-Requested-With": "XMLHttpRequest"
                }
            });
            const text = await response.text();
            const json = safeJson(text);
            if (typeof json === "object" && json !== null && !("status" in json)) {
                json.status = response.status;
            }
            return json;
        }
        """,
        userid,
    )

    account_key = ""
    stock_accounts = account_result.get("ex_data", {}).get("stock", [])
    if stock_accounts:
        account_key = stock_accounts[0].get("fund_key", "")

    if not account_key:
        common_accounts = account_result.get("ex_data", {}).get("common", [])
        if common_accounts:
            account_key = common_accounts[0].get("fund_key", "")

    if not account_key:
        return records

    page.goto(f"https://tzzb.10jqka.com.cn/pc/index.html#/myAccount/a/{account_key}", wait_until="networkidle")
    page.wait_for_timeout(2000)

    try:
        _open_trade_records_tab(page)
    except PlaywrightTimeoutError as e:
        raise RuntimeError(f"打开交易记录失败：{e}")

    page.wait_for_timeout(1500)

    result = page.evaluate(
        """
        () => {
            const rows = document.querySelectorAll("table tr");
            const data = [];
            rows.forEach(row => {
                const text = row.innerText;
                if (text && !text.includes("成交日期") && text.length > 10) {
                    const parts = text.split("\\n").map(p => p.trim()).filter(p => p);
                    if (parts.length >= 8) {
                        data.push(parts);
                    }
                }
            });
            return data;
        }
        """
    )

    for row in result:
        if len(row) >= 8:
            records.append(
                {
                    "trade_date": row[0],
                    "stock_code": row[1],
                    "stock_name": row[2],
                    "trade_type": row[3],
                    "price": row[4],
                    "quantity": row[5],
                    "amount": row[6],
                    "fee": row[7],
                    "note": row[8] if len(row) > 8 else "",
                }
            )

    return records


def get_trade_records_via_browser(context=None, page=None) -> list[dict]:
    """通过 Playwright CDP 连接 Chrome，从 DOM 提取交易记录"""
    if context is not None and page is not None:
        return _get_trade_records_in_session(context, page)

    with cdp_session() as (ctx, pg):
        return _get_trade_records_in_session(ctx, pg)


path: scripts/tzzb_parser/main.py
content: import sys
import json
from cookie_extractor import (
    cdp_session,
    extract_cookies,
    get_userid,
    get_stock_watchlist_via_browser,
    get_stock_positions_via_browser,
    get_trade_records_via_browser,
)

def _looks_like_not_logged_in(error_msg: str) -> bool:
    msg = (error_msg or "").lower()
    return ("未检测到登录态" in error_msg) or ("missing userid" in msg)


def _to_float(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return 0.0


def _to_int(v):
    try:
        return int(v)
    except (TypeError, ValueError):
        return 0


def parse_stock_list(data: dict) -> list[dict]:
    """从 API 响应中解析自选股列表"""
    try:
        ex_data = data.get("ex_data", {})
        stock_list = ex_data.get("list", [])
        stocks = []
        for item in stock_list:
            # type: "1" = 股票, "2" = 基金等
            item_type = str(item.get("type", ""))
            if item_type not in ("1", "stock", ""):
                continue
            stocks.append(
                {
                    "stock_code": item.get("code", ""),
                    "stock_name": item.get("name", ""),
                    "market": item.get("hq_market", "") or item.get("market", ""),
                    "account_type": "stock" if item_type == "1" else item_type,
                }
            )
        return stocks
    except (KeyError, TypeError) as e:
        return []


def parse_positions(data: dict) -> tuple[list[dict], dict]:
    """解析持仓数据，返回 (持仓列表, 账户概览)"""
    ex = data.get("ex_data", {})
    positions = []
    for item in ex.get("position", []) or []:
        positions.append(
            {
                "stock_code": str(item.get("code", "")),
                "stock_name": item.get("name", ""),
                "market": str(item.get("market", "")),
                "hold_count": _to_int(item.get("count", 0)),
                "cost_price": _to_float(item.get("cost", 0)),
                "current_price": _to_float(item.get("price", 0)),
                "market_value": _to_float(item.get("value", 0)),
                "hold_profit": _to_float(item.get("hold_profit", 0)),
                "hold_rate": _to_float(item.get("hold_rate", 0)),
                "position_rate": _to_float(item.get("position_rate", 0)),
                "hold_days": _to_int(item.get("hold_days", 0)),
            }
        )
    overview = {
        "total_asset": _to_float(ex.get("total_asset", 0) or 0),
        "total_value": _to_float(ex.get("total_value", 0) or 0),
        "money_remain": _to_float(ex.get("money_remain", 0) or 0),
        "total_liability": _to_float(ex.get("total_liability", 0) or 0),
        "position_rate": _to_float(ex.get("position_rate", 0) or 0),
    }
    return positions, overview


def parse_trade_records(raw_records: list[dict]) -> list[dict]:
    """从 DOM 提取的原始数据解析交易记录"""
    records = []
    for item in raw_records:
        if not item.get("stock_code"):
            continue
        records.append(
            {
                "trade_date": item.get("trade_date", ""),
                "stock_code": item.get("stock_code", ""),
                "stock_name": item.get("stock_name", ""),
                "trade_type": item.get("trade_type", ""),
                "price": _to_float(item.get("price") or 0),
                "quantity": _to_int(item.get("quantity") or 0),
                "amount": _to_float(item.get("amount") or 0),
                "fee": _to_float(item.get("fee") or 0),
                "note": item.get("note", ""),
            }
        )
    return records


def _ok(data: dict) -> dict:
    return {"ok": True, "data": data}


def _err(error_code: str, error_msg: str) -> dict:
    return {"ok": False, "error": {"error_code": str(error_code), "error_msg": error_msg or ""}}


def fetch_watchlist(context=None, page=None) -> dict:
    data = get_stock_watchlist_via_browser(context=context, page=page)
    if data.get("error_code") != "0":
        error_msg = data.get("error_msg") or ""
        msg = error_msg
        if _looks_like_not_logged_in(error_msg):
            msg = f"{error_msg} | 请先在已开启远程调试的 Chrome 中登录 https://tzzb.10jqka.com.cn/ 后重试"
        return _err(data.get("error_code", "-1"), msg)
    stocks = parse_stock_list(data)
    return _ok({"items": stocks})


def fetch_positions(context=None, page=None) -> dict:
    data = get_stock_positions_via_browser(context=context, page=page)
    if data.get("error_code") != "0":
        error_msg = data.get("error_msg") or ""
        msg = error_msg
        if _looks_like_not_logged_in(error_msg):
            msg = f"{error_msg} | 请先在已开启远程调试的 Chrome 中登录 https://tzzb.10jqka.com.cn/ 后重试"
        return _err(data.get("error_code", "-1"), msg)
    positions, overview = parse_positions(data)
    return _ok({"overview": overview, "items": positions})


def fetch_trade_records(context=None, page=None) -> dict:
    try:
        raw_records = get_trade_records_via_browser(context=context, page=page)
    except RuntimeError as e:
        return _err("-1", str(e))
    if not raw_records:
        if context is not None and page is not None:
            userid = get_userid(context, page)
            if not userid:
                return _err("-1", "未检测到登录态，请先在 Chrome 中登录 https://tzzb.10jqka.com.cn/ 后重试")
            return _ok({"items": []})

        try:
            cookies = extract_cookies()
        except RuntimeError as e:
            return _err("-1", str(e))

        if not cookies.get("userid"):
            return _err("-1", "未检测到登录态，请先在 Chrome 中登录 https://tzzb.10jqka.com.cn/ 后重试")

        return _ok({"items": []})

    records = parse_trade_records(raw_records)
    return _ok({"items": records})


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description="同花顺投资账本数据解析")
    parser.add_argument("--type", "-t", choices=["all", "watchlist", "positions", "trades"],
                        default="all", help="数据类型：all=全部, watchlist=自选股, positions=持仓, trades=交易记录")
    args = parser.parse_args()

    output: dict = {"type": args.type}
    exit_code = 0

    if args.type == "all":
        with cdp_session() as (context, page):
            output["watchlist"] = fetch_watchlist(context=context, page=page)
            if not output["watchlist"].get("ok"):
                exit_code = 1

            output["positions"] = fetch_positions(context=context, page=page)
            if not output["positions"].get("ok"):
                exit_code = 1

            output["trades"] = fetch_trade_records(context=context, page=page)
            if not output["trades"].get("ok"):
                exit_code = 1
    else:
        if args.type == "watchlist":
            output["watchlist"] = fetch_watchlist()
            if not output["watchlist"].get("ok"):
                exit_code = 1
        if args.type == "positions":
            output["positions"] = fetch_positions()
            if not output["positions"].get("ok"):
                exit_code = 1
        if args.type == "trades":
            output["trades"] = fetch_trade_records()
            if not output["trades"].get("ok"):
                exit_code = 1

    print(json.dumps(output, ensure_ascii=False))
    raise SystemExit(exit_code)


path: scripts/tzzb_parser/requirements.txt
content: playwright
python-dotenv


path: scripts/tzzb_parser/__init__.py
content: """同花顺账本数据解析器"""


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)