Skill: tacitlab/stockbuddy (version 1.0.8)

--- name: stockbuddy description: Multi-market stock analysis and portfolio execution assistant for CN, HK, and US equities. Provides technical + basic valuation analysis, portfolio review, account-aware position tracking, cash balances by market/currency, and execution-aware suggestions that respect lot size, odd-lot support, and trading constraints. Use when the user asks for stock analysis, portfolio analysis, buy/sell advice, watchlist management, position management, account cash tracking, rebalancing, or practical trading actions for a stock code or company name. --- # StockBuddy ## Overview StockBuddy is a stock analysis and portfolio execution support skill for A-share, Hong Kong, and US equities. It outputs quantified scores and clear action labels (Strong Buy / Buy / Hold / Sell / Strong Sell). By default, responses are **decision-first**: give the concise conclusion, score/confidence, event-adjusted second-pass suggestion, and practical order ideas before expanding into a long-form report. **Core rule: separate durable facts from derived values.** - **Persist durable facts**: share count, cost basis, account, available cash, market/currency, lot size, odd-lot support, and other user-confirmed trading constraints - **Compute in real time**: latest price, market value, position weight, unrealized P&L, executable buy/sell size, and whether partial selling is actually possible - Do **not** write latest price, position weight, or unrealized P&L back into durable storage Five core scenarios: 1. **Single-stock analysis** — analyze one stock and produce an action recommendation 2. **Batch portfolio analysis** — analyze current positions and summarize stock-level and portfolio-level status 3. **Position management** — add, update, remove, and inspect positions 4. **Account and allocation management** — track account, cash, market/currency, and execution constraints 5. **Watchlist management** — add, remove, and inspect watched stocks while storing basic stock metadata and trading rules ## Execution-Aware Advice Rules Before giving execution-ready trading advice, confirm whether the durable constraints are sufficient. **Required durable facts for execution-aware advice:** - account context - market / currency - available cash for the account - lot size - odd-lot support when relevant **If these are incomplete:** - still give a directional view when possible - label it as **directional only** or **non-execution-ready** - ask only for the missing durable facts - do not invent quantity, allocation, or partial-sell actions that may be impossible in the real market setup **Special rule for buy advice:** - If available cash is unknown, do not provide quantity, allocation, or order-size advice. - Ask for the account plus available cash first. **Special rule for sell/trim advice:** - If lot size or odd-lot support is unknown, avoid suggesting partial sells as if they are definitely executable. - If the user holds only one lot and odd-lot selling is not supported, suggest only executable actions such as hold or sell the full lot. ## Environment Setup Only install dependencies when they are actually missing, or when a script fails with a missing-package error: ```bash bash {{SKILL_DIR}}/scripts/install_deps.sh ``` Required dependencies: `numpy`, `pandas`, built-in Python `sqlite3`. No `yfinance` dependency is required; the current implementation mainly uses Tencent Finance data. ## Core Workflow ### Scenario 1: Analyze a Single Stock Trigger examples: "analyze Tencent", "can I buy this stock", "look at BYD", "analyze this ticker" **Steps:** 1. **Normalize the stock code** - Hong Kong stocks: normalize to `XXXX.HK` - A-shares: normalize to `SH600519` / `SZ000001` - US stocks: normalize to `AAPL` / `TSLA` - If the user provides only a company name, infer the market from context first; ask for confirmation only if the mapping is ambiguous 2. **Run the analysis script** ```bash python3 {{SKILL_DIR}}/scripts/analyze_stock.py <CODE> --period 6mo ``` Optional period values: `1mo` / `3mo` / `6mo` (default) / `1y` / `2y` / `5y` **Data and caching behavior**: - Raw daily K-line data, watchlist data, and portfolio data are stored in `~/.stockbuddy/stockbuddy.db` (SQLite) - Positions are linked through `watchlist_id` - Analysis results are cached separately in SQLite with a default TTL of 10 minutes - Cache cleanup runs automatically and total cached analysis rows are capped - If the user explicitly asks to "refresh data" or "reanalyze", add `--no-cache` - To clear analysis cache: `--clear-cache` 3. **Interpret and present the result** - The script returns JSON analysis data - **For default single-stock requests**, use the **default query template** in `references/output_templates.md` - The default response must include: stock basics, data-driven action recommendation (with score and confidence), important events, event-adjusted second-pass suggestion, and practical order ideas - **Default order style = balanced**. Only switch when the user explicitly asks for a conservative or aggressive version - **Only produce the full report when explicitly requested** with phrases like "full report", "detailed analysis", or "complete analysis" - **The top natural-language summary is mandatory** in both short and long versions: 2-4 sentences covering regime, main recommendation, confidence, support/risk points, and whether the stock is actionable today - **Only expand into a more open-ended explanation when the user asks for detail** such as "explain why", "show the reasoning", "how about short-term", or "what stop-loss/stop-profit should I use" - Final output must be normal Markdown, not wrapped in code fences; prefer short paragraphs, bullet points, and card-style formatting over wide tables unless the user explicitly wants a detailed report ### Scenario 2: Batch Portfolio Analysis Trigger examples: "analyze my portfolio", "look at my holdings", "how are my positions doing" Default output should still be **decision-first**: for each position, give the action label, score/confidence, important events, event-adjusted second suggestion, and a compact practical order version. Do not expand every holding into a full long report unless the user explicitly wants a detailed version. **Steps:** 1. **Check portfolio data** ```bash python3 {{SKILL_DIR}}/scripts/portfolio_manager.py list ``` Portfolio data is stored in the `positions` table in `~/.stockbuddy/stockbuddy.db`. 2. **If the portfolio is empty** → guide the user to add positions first (see Scenario 3) 3. **Run batch analysis** ```bash python3 {{SKILL_DIR}}/scripts/portfolio_manager.py analyze ``` 4. **Interpret and present the result** - Format the result using the "Portfolio Batch Analysis Report" section in `references/output_templates.md` - Output normal Markdown, not code fences - It may use standard Markdown tables mixed with lists when helpful, but keep it readable on chat surfaces - Include stock-level recommendations and portfolio-level P&L summary - Prefer **real-time computed fields** in the output: latest price, market value, unrealized P&L, position weight, and executable action constraints such as whole-lot vs odd-lot behavior - Do **not** write latest price, position weight, or unrealized P&L back into durable storage; the database should only hold stable user-confirmed facts and trading rules ### Scenario 3: Position Management Trigger examples: "add a Tencent position", "I bought 100 shares of BYD", "remove Alibaba from my holdings" | Action | Command | |------|------| | Add position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py add <CODE> --price <BUY_PRICE> --shares <SHARES> [--date <DATE>] [--note <NOTE>] [--account <ACCOUNT_NAME_OR_ID>]` | | List positions | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py list` | | Update position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py update <CODE> [--price <PRICE>] [--shares <SHARES>] [--note <NOTE>] [--account <ACCOUNT_NAME_OR_ID>]` | | Remove position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py remove <CODE>` | | List accounts | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py account-list` | | Create/update account | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py account-upsert <ACCOUNT_NAME> [--market <MARKET>] [--currency <CURRENCY>] [--cash <TOTAL_CASH>] [--available-cash <AVAILABLE_CASH>] [--note <NOTE>]` | | Set trading rule | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py rule-set <CODE> [--lot-size <LOT_SIZE>] [--tick-size <TICK_SIZE>] [--odd-lot]` | When adding a position, ensure the stock exists in the watchlist and is linked through `positions.watchlist_id -> watchlist.id`. If the user does not provide a date, default to the current date. If the user provides natural-language trade info such as "I bought 100 shares of Tencent last week at 350", extract price, share count, date, and account info where possible, then execute the appropriate command. **After the first successful position record, proactively guide the user to fill the missing durable facts needed for execution-aware advice.** Do not stop at only code / shares / cost if important constraints are still unknown. Ask for or help the user confirm these fields, in this priority order: 1. **Account context** — which account this belongs to, and its market / currency 2. **Cash** — total cash or available cash in that account 3. **Lot rule** — lot size for the stock 4. **Odd-lot support** — whether the broker supports odd-lot selling / buying 5. **Trade date** — if omitted and relevant for later review 6. **Notes** — optional thesis, time horizon, or special constraints If some fields are already known, only ask for the missing ones. Keep the follow-up compact: confirm what was captured, state what is still missing, and ask for the missing durable facts in one short message. If the user gives only partial follow-up info, update what is available and continue asking only for the remaining missing fields. Once enough execution constraints are known, stop prompting and proceed normally. ### Scenario 4: Account and Allocation Management Trigger examples: "my HK account has 3000 HKD cash", "track available cash", "record this under my US account", "how concentrated is my portfolio" **Rules:** - Keep cash, account, market, and currency as durable facts - Keep position weights, market value, and unrealized P&L as computed fields - If the user has multiple markets or currencies, treat them as separate account contexts unless the user explicitly wants cross-account aggregation - Use account information to improve practical trading advice: whether the user can afford a new lot, whether a rebalance is even possible, and whether the trade would increase concentration too much ### Scenario 5: Watchlist Management Trigger examples: "watch Tencent", "add Apple to my watchlist", "remove Moutai from watchlist" | Action | Command | |------|------| | List watchlist | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-list` | | Add watch item | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-add <CODE>` | | Remove watch item | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-remove <CODE>` | ## Analysis Methodology The scoring system combines technicals (roughly 60% weight) and basic valuation (roughly 40% weight). Final score range is approximately -10 to +10: | Score Range | Recommendation | |----------|----------| | ≥ 5 | 🟢🟢 Strong Buy | | 2 ~ 4 | 🟢 Buy | | -1 ~ 1 | 🟡 Hold / Watch | | -4 ~ -2 | 🔴 Sell | | ≤ -5 | 🔴🔴 Strong Sell | Only read `references/technical_indicators.md` when the user asks for detailed scoring logic, indicator interpretation, or when you need help calibrating a more detailed explanation. When deciding the final output format, choosing between default query vs full report, or generating practical order suggestions, prefer `references/output_templates.md`. It defines the default query template, atomic templates, full-report composition rules, and the conservative / balanced / aggressive order-price generation rules (balanced is the default). ## Important Notes - All analysis is for reference only and is **not investment advice** - The primary data source is **Tencent Finance**, which may have delays, gaps, or field limitations - Hong Kong stocks do not have the same daily price-limit structure as A-shares and therefore carry higher intraday volatility risk - Every final analysis output **must** include a risk disclaimer - Technical analysis can fail during extreme market conditions - Encourage the user to combine macro conditions, sector trends, and company fundamentals in final decision-making - Only store user-confirmed durable facts in the database; latest price, market value, unrealized P&L, and position weight should be fetched or calculated at analysis time ## Resource Files | File | Purpose | |------|------| | `scripts/analyze_stock.py` | Core analysis script for market data retrieval, technical indicators, and valuation scoring | | `scripts/portfolio_manager.py` | Portfolio/account/watchlist management and batch analysis entry point | | `scripts/install_deps.sh` | Dependency installation script | | `references/technical_indicators.md` | Detailed technical indicator and scoring reference | | `references/output_templates.md` | Output template controller: default query template, atomic templates, full-report rules, and practical order generation rules | | `references/data-source-roadmap.md` | Data-source roadmap for primary/fallback/event-layer evolution; read only when extending data sources or event coverage |

path: _meta.json
content: ownerId: kn74h7etxcb4wsgk6rb22qczzd838pqh
slug: stockbuddy
version: 1.0.7
publishedAt: 1775064382511

path: references/data-source-roadmap.md
content: # StockBuddy 数据源升级方案（Phase 1）

## 目标

在不显著增加复杂度的前提下，提升 StockBuddy 对 A 股、港股、美股的：
- 数据可用性
- 数据源容错能力
- 报告可解释性
- 后续接入事件层的扩展性

当前原则：
- 先做 **行情/基础面多源容错**
- 再做 **事件提示层**
- 暂不把新闻/社媒直接并入总评分

---

## 已实测可用的免费数据源

### 行情 / K 线 / 基础字段

1. **腾讯财经**
   - 适用：A 股、港股；美股实时快照也可用
   - 实测：quote 可用，K 线可用
   - 用途：主行情源

2. **Yahoo Finance**
   - 适用：美股
   - 实测：chart 接口可用
   - 用途：美股 K 线主源 / fallback

3. **东方财富部分行情接口**
   - 适用：A 股
   - 实测：JSON 行情接口可用
   - 用途：A 股补充行情源 / fallback

### 公告 / 事件层

1. **SEC**
   - 适用：美股
   - 实测：JSON submissions 可用
   - 用途：美股公告与硬信息源

2. **HKEX 披露网页**
   - 适用：港股
   - 实测：搜索页可访问
   - 用途：港股公告层（需后续结构化适配）

3. **巨潮资讯**
   - 适用：A 股
   - 实测：主页可访问；查询接口需单独适配
   - 用途：A 股公告层

---

## 不建议当前作为主源的来源

1. **新浪财经**
   - 实测出现 403
   - 不适合作为稳定主源

2. **雪球 / 股吧 / 其他社区舆情**
   - 可访问性和结构稳定性不如行情源
   - 更适合后续做热度/风险雷达，不适合先进入主链路

---

## Phase 1 架构

### 1. 行情主链路

#### A 股 / 港股
- 主源：腾讯财经
- A 股 fallback：东方财富
- 输出：
  - 最新价
  - 涨跌幅
  - K 线
  - PE / PB / 市值 / 52 周高低点（能取则取）

#### 美股
- 实时快照：腾讯财经可保留尝试
- K 线主源：Yahoo Finance
- fallback：后续可考虑 Finnhub / Twelve Data（若未来接受 API key）

### 2. 事件层（先只做扩展位，不进总分）

- A 股：巨潮资讯 / 交易所公告
- 港股：HKEX
- 美股：SEC

输出方式：
- 报告新增 “近期关键事件” 小节
- 默认只摘要 1-3 条
- 只做说明，不直接改变买卖评级

### 3. 社媒层（暂缓）

- 国内：雪球 / 股吧
- 海外：X / Reddit / Stocktwits

定位：
- 仅做热度、风险、情绪极端提示
- 不纳入核心评分

---

## 建议的代码改造顺序

### Step 1：行情源抽象

将当前 `analyze_stock.py` 中的数据获取逻辑拆成可替换函数层：
- `fetch_quote_primary()`
- `fetch_quote_fallback()`
- `fetch_kline_primary()`
- `fetch_kline_fallback()`

建议后续拆到独立模块，例如：
- `scripts/providers/tencent.py`
- `scripts/providers/eastmoney.py`
- `scripts/providers/yahoo.py`
- `scripts/providers/sec.py`（后续事件层）

### Step 2：统一数据模型

无论来源是什么，最终统一成内部字段：
- `price`
- `change_pct`
- `volume`
- `pe`
- `pb`
- `market_cap`
- `52w_high`
- `52w_low`
- `currency`
- `source`

并允许部分字段缺失，避免某一源缺字段时整个分析失败。

### Step 3：报告补充数据来源

在最终输出中增加轻量数据源标记，例如：
- 行情源：腾讯财经
- K线源：腾讯财经 / Yahoo Finance
- 事件源：SEC / HKEX / 巨潮（未来）

用途：
- 方便排查数据异常
- 提高报告透明度

### Step 4：事件层接入

在行情分析后，按市场类型追加事件抓取：
- A 股 -> 巨潮
- 港股 -> HKEX
- 美股 -> SEC

若事件抓取失败：
- 不影响主分析
- 仅在报告中省略事件小节或标注“暂无近期关键事件”

---

## 报告层建议

默认模板继续保持简洁，但可新增一个可选区块：

### 🧾 数据来源
- 行情：{quote_source}
- K线：{kline_source}
- 事件：{event_source 或 暂无}

### 📰 近期关键事件（后续接入）
- {事件1}
- {事件2}
- {事件3}

要求：
- 事件层不抢主体位置
- 不让报告变成资讯聚合页
- 保持 Telegram 等 IM 场景的短消息可读性

---

## Phase 1 成功标准

满足以下条件即可认为第一阶段完成：

1. A 股分析：腾讯失败时可自动尝试东方财富补位
2. 美股分析：Yahoo K 线链路保持稳定
3. 输出中可看到实际使用的数据源
4. 单一来源失败不导致整份分析直接不可用
5. 事件层接口预留清晰，但暂不强耦合评分逻辑

---

## 后续 Phase 2 / 3

### Phase 2：事件层
- 接入 SEC / HKEX / 巨潮
- 报告增加“近期关键事件”摘要

### Phase 3：舆情雷达
- 接入雪球 / X / Reddit 等热度监测
- 只做“情绪过热 / 异常传播 / 风险提示”
- 不直接进入买卖总分


path: references/output_templates.md
content: # 分析报告输出模板

## 使用规则

- 默认响应使用 **“默认查询模板”**，优先给出最重要的决策信息。
- 只有当用户明确要求 **“完整报告 / 详细报告 / 全量分析 / 完整分析详情”** 时，才按 **“完整报告组合规则”** 拼装全部原子模板。
- 所有最终输出必须是标准 Markdown 正文，不要包在代码块里。
- 优先适配 Telegram 等 IM：少用宽表格，多用短段落、项目符号和卡片式结构。
- 价格、盈亏、挂单建议按实际币种书写，不要写死为 HK$ / ¥ / $。
- 若数据缺失，显示 `N/A` / `样本不足` / `暂无`，不要伪造。

---

## 默认查询模板

> 用于用户只说“分析一下”“现在能不能买”“给我建议”“今天怎么操作”“下午怎么搞”这类默认查询。
> 目标：先返回最重要、最可执行的信息，而不是一上来铺满技术指标。
> **默认挂单风格：平衡型。** 只有当用户明确指定“保守版 / 激进版”时才切换。

## 📌 {公司名称} ({股票代码}) 快速决策卡

### 一句话结论
{用 2-4 句话写成的完整分析详情。至少包含：股票当前所处市场场景、基于数据面的主操作建议、置信度、主要支撑/风险点、是否适合今天立即操作。语气直接，像交易前 briefing。}

### 股票基本信息
- **市场 / 交易所 / 币种**：{市场} / {交易所} / {币种}
- **当前价格**：{币种符号}{价格} ({涨跌幅}%)
- **分析时间**：{时间}
- **数据周期**：{周期}

### 数据面主建议
- **操作建议**：{建议图标} {操作建议}
- **动作类型**：{动作类型}
- **综合评分**：{评分}
- **置信度**：{置信度等级} ({置信度分数})
- **市场场景**：{市场场景}
- **核心信号**：
  - {核心信号1}
  - {核心信号2}
  - {核心信号3}

### 重要事件
- **事件基调**：{事件整体基调，如“偏利多 / 中性 / 偏利空 / 事件扰动有限”}
- **重点事件**：
  - {事件1}
  - {事件2}
  - {事件3}

### 事件加持后的二次分析建议
- **二次判断**：{结合事件后，对原始数据面建议做二次修正后的结论。明确说明“维持 / 小幅上修 / 小幅下修 / 暂不修正”以及原因。}
- **执行提示**：{说明今天/本周更适合追涨、低吸、观望、减仓、防守中的哪一种。}

### 挂单实操建议
- **买入挂单**：{建议价格；若不建议买入则写“不建议主动挂买单”}
- **卖出 / 止盈挂单**：{建议价格}
- **防守 / 止损挂单**：{建议价格}
- **实操版**：{按“买/卖/防守”给出一句最简洁的可执行指令，例如：`买 55.9 / 卖 57.8 / 防守 54.9`}

> ⚠️ 以上分析仅供参考，不构成投资建议。投资有风险，入市需谨慎。

---

## 原子模板

> 以下模板用于按需拼装。默认查询不要全量输出；只有在用户明确要完整报告时才组合。

### ATOM-01｜顶部完整分析详情
{用 2-4 句话给出完整分析详情：当前市场场景、主操作建议、置信度、主要支撑/风险点、是否建议立刻操作。默认完整报告和默认查询模板都必须包含这一段。}

### ATOM-02｜股票基本信息
- **市场 / 交易所 / 币种**：{市场} / {交易所} / {币种}
- **当前价格**：{币种符号}{价格} ({涨跌幅}%)
- **分析时间**：{时间}
- **数据周期**：{周期}

### ATOM-03｜数据来源
### 🧾 数据来源
- **行情**：{行情源}
- **K线**：{K线源}
- **新闻**：{新闻源}
- **事件**：{事件源}
- **舆情雷达**：{舆情源}

### ATOM-04｜数据面操作建议
### {建议图标} 操作建议：{操作建议}
- **动作类型**：{动作类型}
- **综合评分**：{评分}
- **置信度**：{置信度等级} ({置信度分数})
- **市场场景**：{市场场景}
- **核心信号**：
  - {核心信号1}
  - {核心信号2}
  - {核心信号3}
  - {核心信号4}
  - {核心信号5}
  - {核心信号6}

### ATOM-05｜事件与新闻摘要
### 📰 重要事件与舆情
- **事件基调**：{事件整体基调}
- **新闻情绪**：{新闻情绪}
- **新闻热度**：{新闻热度}
- **舆情雷达**：{舆情等级}
- **重点事件**：
  - {事件1}
  - {事件2}
  - {事件3}
- **主要新闻样本**：
  - {新闻1}
  - {新闻2}
  - {新闻3}

### ATOM-06｜事件加持后的二次分析建议
### 🔁 二次分析建议
- **原始结论**：{原始数据面建议}
- **事件修正**：{维持 / 小幅上修 / 小幅下修 / 暂不修正}
- **修正后建议**：{修正后的建议}
- **修正原因**：{事件/新闻为何改变或不改变策略}
- **执行窗口**：{今天 / 未来1-3日 / 未来1-2周 的操作节奏建议}

### ATOM-07｜挂单实操建议
### 🎯 挂单实操建议
- **挂单风格**：{保守型 / 平衡型 / 激进型；默认平衡型}
- **买入挂单**：{建议价格；若无则写“不建议主动挂买单”}
- **加仓挂单**：{建议价格；若无则写“暂无”}
- **卖出 / 止盈挂单**：{建议价格}
- **防守 / 止损挂单**：{建议价格}
- **最简执行版**：{如：`买 55.9 / 卖 57.8 / 防守 54.9`}
- **说明**：{这些价格对应的支撑/压力/确认逻辑，以及为何匹配当前挂单风格}

### ATOM-08｜多层评分
### 📈 多层评分
- **趋势层**：{趋势层} —— {趋势层解读}
- **动量层**：{动量层} —— {动量层解读}
- **风险层**：{风险层} —— {风险层解读}
- **估值层**：{估值层} —— {估值层解读}
- **相对强弱**：{相对强弱} —— {相对强弱解读}
- **量价结构**：{量价结构} —— {量价结构解读}

### ATOM-09｜技术面细节
### 📉 技术面细节
- **均线趋势**：{均线排列}
  - 信号：{均线信号}
- **MACD**：DIF:{DIF} / DEA:{DEA} / MACD:{MACD}
  - 信号：{MACD信号}
- **RSI**：RSI6:{RSI6} / RSI12:{RSI12} / RSI24:{RSI24}
  - 信号：{RSI信号}
- **KDJ**：K:{K} / D:{D} / J:{J}
  - 信号：{KDJ信号}
- **布林带**：上:{上轨} / 中:{中轨} / 下:{下轨}
  - 信号：{布林带信号}
- **成交量**：量比 {量比}
  - 信号：{成交量信号}

### ATOM-10｜基本面概况
### 📋 基本面概况
- **市盈率 (PE)**：{PE}
- **市净率 (PB)**：{PB}
- **市值**：{市值}
- **52周高点**：{52周高点}
- **52周低点**：{52周低点}
- **52周位置**：{52周位置}
- **基本面判断**：{基本面判断}

### ATOM-11｜历史验证
### 🧪 历史验证
- **相似样本数**：{样本数}
- **5日平均收益 / 胜率**：{5日平均收益}% / {5日胜率}%
- **10日平均收益 / 胜率**：{10日平均收益}% / {10日胜率}%
- **20日平均收益 / 胜率**：{20日平均收益}% / {20日胜率}%
- **回撤代理**：{回撤代理}%

### ATOM-12｜总结与免责
### 💡 分析总结
{2-4 句话的自然语言总结，至少包含：当前市场场景、操作建议、置信度、主要支撑/风险点。若历史验证样本不足，要明确提醒。可在 1 句话内补充新闻/舆情/事件仅作辅助，不直接决定评级。}

> ⚠️ 以上分析仅供参考，不构成投资建议。投资有风险，入市需谨慎。

---

## 完整报告组合规则

> 用户明确要求“完整报告 / 详细分析 / 全量报告 / 完整分析详情”时，按以下顺序拼接。

## 📊 {公司名称} ({股票代码}) 分析报告

1. `ATOM-01｜顶部完整分析详情`
2. `ATOM-02｜股票基本信息`
3. `ATOM-03｜数据来源`
4. `ATOM-04｜数据面操作建议`
5. `ATOM-05｜事件与新闻摘要`
6. `ATOM-06｜事件加持后的二次分析建议`
7. `ATOM-07｜挂单实操建议`
8. `ATOM-08｜多层评分`
9. `ATOM-09｜技术面细节`
10. `ATOM-10｜基本面概况`
11. `ATOM-11｜历史验证`
12. `ATOM-12｜总结与免责`

---

## 持仓批量分析报告

> 按以下 Markdown 结构直接输出最终报告，不要再包一层代码块。
> 优先适配 Telegram 等 IM：避免大表格，按卡片式分段输出。

## 📊 持仓分析报告

- **分析时间**：{时间}
- **持仓数量**：{数量}只

### 💰 总览
- **总成本**：{总成本}
- **总市值**：{总市值}
- **总盈亏**：{盈亏} ({盈亏比例}%)

---

### 各持仓分析

{对每只股票输出简要分析卡片，格式如下：}

#### {序号}. {公司名称} ({股票代码}) — {操作建议图标} {操作建议}
- **市场 / 币种**：{市场} / {币种}
- **动作类型 / 场景**：{动作类型} / {市场场景}
- **当前价 / 买入价**：{当前价} / {买入价}
- **持仓数量 / 盈亏**：{数量}股 / {盈亏} ({盈亏比例}%)
- **综合评分 / 置信度**：{评分} / {置信度等级} ({置信度分数})
- **重要事件**：{1-2条关键事件}
- **二次建议**：{结合事件后的简短操作建议}
- **挂单实操**：{最简执行版，如：`买 55.9 / 卖 57.8 / 防守 54.9`}
- **核心信号**：{1-3条最重要的信号}

---

### 💡 持仓总结
{综合所有持仓的建议，明确指出：}
- 建议加仓的股票及理由
- 建议减仓/卖出的股票及理由
- 建议继续持有/观察的股票及理由
- 如不同市场混合持仓，指出币种和市场风险差异

> ⚠️ 以上分析仅供参考，不构成投资建议。投资有风险，入市需谨慎。

---

## 挂单价格生成规范

### 一、挂单风格

#### 1) 保守型
- 目标：优先控制回撤，降低误触发和追单概率。
- 买入挂单：仅在强支撑位、回踩确认位或明显低风险位置给出；弱势票可直接不给买点。
- 卖出挂单：优先给反弹减仓位或保守止盈位，价格更贴近当前压力区。
- 防守挂单：收得更紧，优先放在关键支撑位/逻辑失效位下方。

#### 2) 平衡型（默认）
- 目标：兼顾胜率、盈亏比和执行性。
- 买入挂单：允许给出 1 个主买点 + 1 个更低承接点。
- 卖出挂单：允许给出 1 个主止盈位 + 1 个更高延伸位（若趋势支持）。
- 防守挂单：放在短线逻辑失效位下方，不宜过紧也不宜过松。

#### 3) 激进型
- 目标：优先捕捉短线波动和强势延续。
- 买入挂单：允许更靠近突破回踩位、浅回调位，甚至在强势确认后给追击型回踩买点。
- 卖出挂单：允许给更高的扩展止盈位。
- 防守挂单：可略放宽，但必须保留明确的趋势失效位。

### 二、挂单类型
- **买入挂单**：抄底买、回踩确认买、突破回踩买。
- **卖出挂单**：反弹减仓卖、压力位止盈卖、分批止盈卖。
- **防守挂单**：跌破关键位止损、趋势失效保护卖、盈利回撤保护单。

### 三、价格生成来源

#### 买入挂单来源
优先从以下结构取值：
- MA5 / MA10 / MA20
- 布林中轨 / 下轨
- 整数关口
- 当日回踩低点附近
- 前高突破后的回踩确认位

#### 卖出挂单来源
优先从以下结构取值：
- 短期压力位
- 布林上轨
- 前高附近
- 整数关口
- 成本上方、且盈亏比合适的位置

#### 防守挂单来源
优先从以下结构取值：
- 关键支撑位下方
- MA10 / MA20 下破确认位
- 前低下方
- 整数关口下方
- 短线逻辑失效位

### 四、市场状态到挂单策略映射

#### 偏强
- 保守型：回踩买 + 近端止盈 + 紧防守
- 平衡型：回踩买 + 冲高卖 + 防守保护
- 激进型：浅回踩买/突破回踩买 + 更高止盈 + 趋势防守

#### 震荡
- 保守型：区间下沿才考虑买，上沿先减，防守贴近区间下界
- 平衡型：区间低吸 + 区间高抛 + 严格防守
- 激进型：允许更靠近中枢做来回，但必须明确失效位

#### 偏弱
- 保守型：默认不给主动买单，只给防守卖和反弹减仓卖
- 平衡型：通常不给主动买单；只有极强支撑位才给轻仓低吸参考
- 激进型：允许给超跌反抽位，但必须明确标注高风险，并同步给出更紧的防守位

### 五、事件修正层
- **利多但盘面未确认**：不主动上调买点；可小幅上修卖点预期；防守不放松。
- **利多且盘面确认**：买点可略上移，卖点可上修，防守位可维持原结构。
- **利空扰动**：买点下移或取消，防守点收紧，卖点更偏反弹减仓。
- **事件中性**：维持技术面原建议，不额外修正。

### 六、价格修正规则
- 避免机械挂在整数位，优先给更贴近真实委托行为的价格。
- 港股常用修正示例：
  - `56.0` → `55.9` 或 `55.8`
  - `58.0` → `57.8` 或 `57.9`
  - `80.0` → `79.8`
- 修正后仍需保持原始支撑/压力逻辑，不要为了避开整数位而偏离结构。

### 七、输出要求
- 默认输出 **平衡型** 挂单建议。
- 若用户明确说“保守版挂单”或“激进版挂单”，才切换风格。
- 对弱势票，允许明确写：`不建议主动挂买单`。
- 对强势票，允许给出：主买点、次买点、主止盈位、扩展止盈位、防守位。
- 最终必须输出一句 **最简执行版**，格式尽量固定：`买 X / 卖 Y / 防守 Z`。

## 模板使用说明

- 所有 `{占位符}` 根据脚本返回的 JSON 数据填充。
- `{行情源}` / `{K线源}` / `{新闻源}` / `{事件源}` / `{舆情源}` 使用 `data_sources` 中的实际来源；若某层尚未接入，填 `暂无`。
- `{新闻情绪}` / `{新闻热度}` / `{新闻1-3}` 来自 `news` 字段；若新闻抓取失败或为空，分别填 `暂无` / `低` / `暂无相关新闻`。
- `{舆情等级}` 来自 `buzz.level`；当前为新闻驱动的热度雷达，不是社交媒体实时讨论量。
- `{事件1-3}` 来自 `events.items`；美股优先使用 SEC 事件，其他市场先从新闻标题中提取关键事件。
- `{事件整体基调}` 需结合事件类别、新闻情绪和事件新鲜度人工归纳，不要机械照抄标题。
- `{修正后建议}` 必须明确表达：事件是否改变原始数据面建议；若事件影响弱，也要明确写“维持原建议”。
- `{挂单实操建议}` 要基于支撑位、压力位、均线、整数关口和当日波动结构来落具体价格；如果盘面偏弱，允许明确写“不建议主动挂买单”。
- 默认查询优先输出“默认查询模板”；除非用户明确要求完整报告，否则不要自动展开全部技术面细节。
- 最终输出必须是标准 Markdown 正文，不要放进 ``` 代码块。
- 优先使用短段落、项目符号、卡片式结构；除非用户明确要求，否则尽量不要使用宽表格。
- Telegram 等 IM 场景下，优先保证手机端可读性，避免一行承载过多字段。
- 操作建议图标映射：🟢🟢 强烈买入 / 🟢 买入 / 🟡 持有 / 🔴 卖出 / 🔴🔴 强烈卖出。
- 单股报告优先使用 `recommendation.action`、`recommendation.action_type`、`recommendation.confidence`、`recommendation.regime`、`recommendation.layer_scores`、`signal_validation`、`events`、`news` 来填充。
- 若某项历史验证不存在或样本不足，显示为 `样本不足`，不要伪造数值。
- 若某项基本面数据缺失，显示为 `N/A`。
- 分析总结部分使用自然语言，避免机械堆砌指标；要把“为什么是这个评级”说清楚。
- 对于用户问“今天怎么搞 / 下午如何操作 / 给挂单价格”，优先使用默认查询模板，并确保输出包含“挂单实操建议”。


path: references/technical_indicators.md
content: # 港股技术指标参考手册

## 支持的技术指标

### 1. 移动平均线 (MA)

| 均线 | 含义 | 用途 |
|------|------|------|
| MA5 | 5日均线（周线） | 短期趋势 |
| MA10 | 10日均线（半月线） | 短期趋势确认 |
| MA20 | 20日均线（月线） | 中短期趋势 |
| MA60 | 60日均线（季线） | 中期趋势 |
| MA120 | 120日均线（半年线） | 中长期趋势 |
| MA250 | 250日均线（年线） | 长期趋势/牛熊分界 |

**判读方法**:
- 多头排列（短期MA > 长期MA）→ 上升趋势
- 空头排列（短期MA < 长期MA）→ 下降趋势
- 价格站上/跌破重要均线 → 趋势转折信号

### 2. MACD (指数平滑异同移动平均线)

**参数**: DIF(12,26), DEA(9), MACD柱状图

| 信号 | 条件 | 建议 |
|------|------|------|
| 金叉 | DIF上穿DEA | 买入信号 |
| 死叉 | DIF下穿DEA | 卖出信号 |
| 零轴上方金叉 | DIF>0且金叉 | 强烈买入 |
| 零轴下方死叉 | DIF<0且死叉 | 强烈卖出 |
| 顶背离 | 价格新高但MACD未新高 | 见顶风险 |
| 底背离 | 价格新低但MACD未新低 | 见底信号 |

### 3. RSI (相对强弱指数)

**参数**: RSI6（短期）、RSI12（中期）、RSI24（长期）

| 区间 | 状态 | 建议 |
|------|------|------|
| > 80 | 严重超买 | 强烈卖出信号 |
| 70-80 | 超买 | 注意风险，准备减仓 |
| 30-70 | 正常 | 中性 |
| 20-30 | 超卖 | 关注买入机会 |
| < 20 | 严重超卖 | 强烈买入信号 |

### 4. KDJ (随机指标)

**参数**: K(9,3,3), D(3), J=3K-2D

| 信号 | 条件 | 建议 |
|------|------|------|
| K金叉D | K值上穿D值 | 买入信号 |
| K死叉D | K值下穿D值 | 卖出信号 |
| J > 100 | J值过高 | 超买区域 |
| J < 0 | J值过低 | 超卖区域 |
| 低位金叉 | K<20时金叉 | 强烈买入 |
| 高位死叉 | K>80时死叉 | 强烈卖出 |

### 5. 布林带 (Bollinger Bands)

**参数**: 中轨=MA20, 上/下轨=中轨±2倍标准差

| 信号 | 条件 | 建议 |
|------|------|------|
| 突破上轨 | 价格 > 上轨 | 超买/强势突破 |
| 突破下轨 | 价格 < 下轨 | 超卖/弱势突破 |
| 带宽收窄 | 上下轨收窄 | 即将变盘 |
| 中轨上方 | 价格在中轨以上 | 偏强运行 |
| 中轨下方 | 价格在中轨以下 | 偏弱运行 |

### 6. 成交量分析

| 信号 | 条件 | 建议 |
|------|------|------|
| 放量上涨 | 量比>2且价涨 | 强势上攻 |
| 放量下跌 | 量比>2且价跌 | 恐慌出逃 |
| 缩量上涨 | 量比<0.5且价涨 | 动力不足 |
| 缩量下跌 | 量比<0.5且价跌 | 抛压减轻 |

## 综合评分体系

最终评分范围约 -10 到 +10，映射为：

| 评分区间 | 操作建议 |
|----------|----------|
| ≥ 5 | 🟢🟢 强烈买入 |
| 2 ~ 4 | 🟢 买入 |
| -1 ~ 1 | 🟡 持有/观望 |
| -4 ~ -2 | 🔴 卖出 |
| ≤ -5 | 🔴🔴 强烈卖出 |

## 重要提醒

- 技术分析仅供参考，不构成投资建议
- 港股交易时间: 9:30-12:00, 13:00-16:00 (港交所时间)
- 港股没有涨跌停限制，波动可能较大
- 部分港股流动性不足，需注意成交量
- 建议结合基本面和市场环境综合判断


path: scripts/analyze_stock.py
content: #!/usr/bin/env python3
"""
港股分析脚本 - 使用腾讯财经数据源进行技术面+基本面分析

用法:
    python3 analyze_stock.py <股票代码> [--period <周期>] [--output <输出文件>]

示例:
    python3 analyze_stock.py 0700.HK
    python3 analyze_stock.py 0700.HK --period 6mo --output report.json
    python3 analyze_stock.py 9988.HK --period 1y
"""

import sys
import json
import argparse
import time
import html
import urllib.request
import urllib.error
import urllib.parse
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from pathlib import Path

try:
    from db import (
        ANALYSIS_CACHE_TTL_SECONDS,
        AUX_CACHE_TTL_SECONDS,
        clear_analysis_cache,
        get_cached_analysis,
        get_cached_aux,
        get_kline_df,
        get_latest_kline_date,
        init_db,
        set_cached_analysis,
        set_cached_aux,
        upsert_kline_df,
        upsert_watchlist_item,
    )
except ImportError:
    sys.path.insert(0, str(Path(__file__).resolve().parent))
    from db import (
        ANALYSIS_CACHE_TTL_SECONDS,
        AUX_CACHE_TTL_SECONDS,
        clear_analysis_cache,
        get_cached_analysis,
        get_cached_aux,
        get_kline_df,
        get_latest_kline_date,
        init_db,
        set_cached_analysis,
        set_cached_aux,
        upsert_kline_df,
        upsert_watchlist_item,
    )

try:
    import numpy as np
except ImportError:
    print("ERROR: numpy 未安装。请运行: pip3 install numpy", file=sys.stderr)
    sys.exit(1)

try:
    import pandas as pd
except ImportError:
    print("ERROR: pandas 未安装。请运行: pip3 install pandas", file=sys.stderr)
    sys.exit(1)


# ─────────────────────────────────────────────
#  缓存与重试机制
# ─────────────────────────────────────────────

MAX_RETRIES = 3
RETRY_BASE_DELAY = 2
ANALYSIS_CACHE_TTL = ANALYSIS_CACHE_TTL_SECONDS


# ─────────────────────────────────────────────
#  腾讯财经数据获取
# ─────────────────────────────────────────────


def normalize_stock_code(code: str) -> dict:
    """标准化股票代码，支持港股/A股/美股。"""
    raw = code.strip().upper()

    if raw.endswith('.HK'):
        digits = raw[:-3].lstrip('0') or '0'
        return {
            'market': 'HK',
            'code': digits.zfill(4) + '.HK',
            'tencent_symbol': 'hk' + digits.zfill(5),
            'exchange': 'HKEX',
        }

    if raw.startswith(('SH', 'SZ')) and len(raw) == 8 and raw[2:].isdigit():
        market = raw[:2]
        return {
            'market': market,
            'code': raw,
            'tencent_symbol': raw.lower(),
            'exchange': 'SSE' if market == 'SH' else 'SZSE',
        }

    if raw.endswith('.US'):
        symbol = raw[:-3]
        return {
            'market': 'US',
            'code': symbol,
            'tencent_symbol': 'us' + symbol,
            'exchange': 'US',
        }

    if raw.startswith('US.'):
        symbol = raw[3:]
        return {
            'market': 'US',
            'code': symbol,
            'tencent_symbol': 'us' + symbol,
            'exchange': 'US',
        }

    if raw.isdigit():
        if len(raw) <= 5:
            digits = raw.lstrip('0') or '0'
            return {
                'market': 'HK',
                'code': digits.zfill(4) + '.HK',
                'tencent_symbol': 'hk' + digits.zfill(5),
                'exchange': 'HKEX',
            }
        if len(raw) == 6:
            market = 'SH' if raw.startswith(('5', '6', '9')) else 'SZ'
            return {
                'market': market,
                'code': market + raw,
                'tencent_symbol': (market + raw).lower(),
                'exchange': 'SSE' if market == 'SH' else 'SZSE',
            }

    symbol = raw.replace('.', '').replace('-', '')
    return {
        'market': 'US',
        'code': symbol,
        'tencent_symbol': 'us' + symbol,
        'exchange': 'US',
    }


def fetch_tencent_quote(code: str) -> dict:
    """获取腾讯财经实时行情"""
    stock = normalize_stock_code(code)
    symbol = stock['tencent_symbol']
    url = f"http://qt.gtimg.cn/q={symbol}"

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                'Referer': 'https://gu.qq.com/',
            })
            with urllib.request.urlopen(req, timeout=10) as response:
                data = response.read().decode('gb2312', errors='ignore')
                return _parse_tencent_quote(data, symbol, stock)
        except urllib.error.URLError as e:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                raise Exception(f"获取实时行情失败: {e}")
    return {}


def fetch_eastmoney_quote(code: str) -> dict:
    """获取东方财富实时行情（当前仅作为 A 股 fallback）。"""
    stock = normalize_stock_code(code)
    if stock['market'] not in ('SH', 'SZ') or len(stock['code']) != 8:
        return {}

    raw_code = stock['code'][2:]
    secid_prefix = '1' if stock['market'] == 'SH' else '0'
    fields = 'f43,f44,f45,f46,f57,f58,f60,f116,f164,f167,f168,f169,f170'
    url = f"https://push2.eastmoney.com/api/qt/stock/get?secid={secid_prefix}.{raw_code}&fields={fields}"

    def scaled(value, digits=100):
        if value in (None, ''):
            return None
        try:
            return round(float(value) / digits, 2)
        except (TypeError, ValueError):
            return None

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0',
                'Referer': 'https://quote.eastmoney.com/',
            })
            with urllib.request.urlopen(req, timeout=10) as response:
                payload = json.loads(response.read().decode('utf-8'))
                data = payload.get('data') or {}
                price = scaled(data.get('f43'))
                if not price:
                    return {}
                change_amount = scaled(data.get('f169'))
                change_pct = scaled(data.get('f170'))
                return {
                    'name': data.get('f58') or stock['code'],
                    'code': stock['code'],
                    'market': stock['market'],
                    'exchange': stock.get('exchange'),
                    'tencent_symbol': stock['tencent_symbol'],
                    'price': price,
                    'prev_close': scaled(data.get('f60')),
                    'open': scaled(data.get('f46')),
                    'volume': None,
                    'high': scaled(data.get('f44')),
                    'low': scaled(data.get('f45')),
                    'change_amount': change_amount,
                    'change_pct': change_pct,
                    'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
                    'currency': 'CNY',
                    'pe': scaled(data.get('f164')),
                    'pb': scaled(data.get('f167')),
                    'market_cap': data.get('f116'),
                    '52w_high': None,
                    '52w_low': None,
                    'raw_code': data.get('f57') or raw_code,
                    'quote_source': 'eastmoney',
                }
        except (urllib.error.URLError, json.JSONDecodeError) as e:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                raise Exception(f"获取东方财富行情失败: {e}")
    return {}


def fetch_quote_with_fallback(code: str) -> dict:
    """先取主源；A 股主源失败时自动尝试东方财富。"""
    stock = normalize_stock_code(code)
    errors = []

    try:
        quote = fetch_tencent_quote(code)
        if quote and quote.get('price'):
            quote.setdefault('quote_source', 'tencent')
            return quote
    except Exception as e:
        errors.append(f"tencent={e}")

    if stock['market'] in ('SH', 'SZ'):
        try:
            quote = fetch_eastmoney_quote(code)
            if quote and quote.get('price'):
                return quote
        except Exception as e:
            errors.append(f"eastmoney={e}")

    if errors:
        raise Exception('; '.join(errors))
    return {}


def _parse_tencent_quote(data: str, symbol: str, stock: dict) -> dict:
    """解析腾讯财经实时行情响应"""
    var_name = f"v_{symbol}"
    for line in data.strip().split(";"):
        line = line.strip()
        if not line or var_name not in line:
            continue
        # 提取引号内的内容
        parts = line.split('"')
        if len(parts) < 2:
            continue
        values = parts[1].split("~")
        if len(values) < 35:  # 至少需要35个字段
            continue
        
        def safe_float(idx: int, default: float = 0.0) -> float:
            try:
                return float(values[idx]) if values[idx] else default
            except (ValueError, IndexError):
                return default
        
        def safe_str(idx: int, default: str = "") -> str:
            return values[idx] if idx < len(values) else default
        
        # 字段映射 (根据腾讯财经API实际数据)
        # 0:市场 1:名称 2:代码 3:现价 4:昨收 5:今开 6:成交量
        # 30:时间戳 31:涨跌额 32:涨跌幅 33:最高 34:最低
        # 39:市盈率 47:市净率 37:总市值 48:52周高 49:52周低
        market = stock['market']
        currency = 'HKD' if market == 'HK' else ('CNY' if market in ('SH', 'SZ') else safe_str(35, 'USD') or 'USD')
        pb_idx = 47 if market in ('HK', 'US') else 46
        market_cap_idx = 37 if market == 'HK' else (57 if market in ('SH', 'SZ') else 44)
        high_52_idx = 48 if market in ('HK', 'US') else 41
        low_52_idx = 49 if market in ('HK', 'US') else 42
        return {
            'name': values[1],
            'code': stock['code'],
            'market': market,
            'exchange': stock.get('exchange'),
            'tencent_symbol': symbol,
            'price': safe_float(3),
            'prev_close': safe_float(4),
            'open': safe_float(5),
            'volume': safe_float(6),
            'high': safe_float(33),
            'low': safe_float(34),
            'change_amount': safe_float(31),
            'change_pct': safe_float(32),
            'timestamp': safe_str(30),
            'currency': currency,
            'pe': safe_float(39) if len(values) > 39 else None,
            'pb': safe_float(pb_idx) if len(values) > pb_idx else None,
            'market_cap': safe_str(market_cap_idx),
            '52w_high': safe_float(high_52_idx) if len(values) > high_52_idx else None,
            '52w_low': safe_float(low_52_idx) if len(values) > low_52_idx else None,
            'raw_code': safe_str(2),
        }
    return {}


def fetch_tencent_kline(code: str, days: int = 120) -> pd.DataFrame:
    """获取腾讯财经K线数据"""
    stock = normalize_stock_code(code)
    symbol = stock['tencent_symbol']
    url = f"https://web.ifzq.gtimg.cn/appstock/app/fqkline/get?param={symbol},day,,,{days},qfq"

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
                'Referer': 'https://gu.qq.com/',
            })
            with urllib.request.urlopen(req, timeout=15) as response:
                data = json.loads(response.read().decode('utf-8'))
                return _parse_tencent_kline(data, symbol)
        except (urllib.error.URLError, json.JSONDecodeError) as e:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                raise Exception(f"获取K线数据失败: {e}")
    return pd.DataFrame()


def _parse_tencent_kline(data: dict, symbol: str) -> pd.DataFrame:
    """解析腾讯财经K线数据"""
    if data.get('code') != 0 or not data.get('data') or symbol not in data['data']:
        return pd.DataFrame()

    symbol_data = data['data'][symbol]
    day_data = symbol_data.get('day') or symbol_data.get('qfqday') or symbol_data.get('hfqday') or []
    if not day_data:
        return pd.DataFrame()

    records = []
    for item in day_data:
        if len(item) >= 6:
            records.append({
                'Date': item[0],
                'Open': float(item[1]),
                'Close': float(item[2]),
                'Low': float(item[3]),
                'High': float(item[4]),
                'Volume': float(item[5]),
            })

    df = pd.DataFrame(records)
    if not df.empty:
        df['Date'] = pd.to_datetime(df['Date'])
        df.set_index('Date', inplace=True)
    return df


def fetch_eastmoney_kline(code: str, days: int = 120) -> pd.DataFrame:
    """获取东方财富日线（当前仅作为 A 股 fallback）。"""
    stock = normalize_stock_code(code)
    if stock['market'] not in ('SH', 'SZ') or len(stock['code']) != 8:
        return pd.DataFrame()

    raw_code = stock['code'][2:]
    secid_prefix = '1' if stock['market'] == 'SH' else '0'
    end = datetime.now().strftime('%Y%m%d')
    # 多抓一些，避免交易日折算不足
    start = (datetime.now() - timedelta(days=max(days * 2, 180))).strftime('%Y%m%d')
    url = (
        "https://push2his.eastmoney.com/api/qt/stock/kline/get"
        f"?secid={secid_prefix}.{raw_code}"
        "&fields1=f1,f2,f3,f4,f5,f6"
        "&fields2=f51,f52,f53,f54,f55,f56,f57,f58"
        "&klt=101&fqt=1"
        f"&beg={start}&end={end}"
    )

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={
                'User-Agent': 'Mozilla/5.0',
                'Referer': 'https://quote.eastmoney.com/',
            })
            with urllib.request.urlopen(req, timeout=20) as response:
                payload = json.loads(response.read().decode('utf-8'))
                klines = ((payload.get('data') or {}).get('klines')) or []
                if not klines:
                    return pd.DataFrame()
                records = []
                for item in klines:
                    parts = item.split(',')
                    if len(parts) < 6:
                        continue
                    records.append({
                        'Date': parts[0],
                        'Open': float(parts[1]),
                        'Close': float(parts[2]),
                        'High': float(parts[3]),
                        'Low': float(parts[4]),
                        'Volume': float(parts[5]),
                    })
                df = pd.DataFrame(records)
                if not df.empty:
                    df['Date'] = pd.to_datetime(df['Date'])
                    df.set_index('Date', inplace=True)
                return df
        except (urllib.error.URLError, json.JSONDecodeError, ValueError) as e:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                raise Exception(f"获取东方财富K线失败: {e}")
    return pd.DataFrame()


def fetch_us_kline_yahoo(symbol: str, period: str = '6mo') -> pd.DataFrame:
    range_map = {
        '1mo': '1mo',
        '3mo': '3mo',
        '6mo': '6mo',
        '1y': '1y',
        '2y': '2y',
        '5y': '5y',
    }
    url = f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range={range_map.get(period, '6mo')}&interval=1d&includePrePost=false"

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req, timeout=20) as response:
                data = json.loads(response.read().decode('utf-8'))

            result = data.get('chart', {}).get('result', [])
            if not result:
                return pd.DataFrame()
            result = result[0]
            timestamps = result.get('timestamp') or []
            quote = (result.get('indicators', {}).get('quote') or [{}])[0]
            opens = quote.get('open') or []
            highs = quote.get('high') or []
            lows = quote.get('low') or []
            closes = quote.get('close') or []
            volumes = quote.get('volume') or []

            records = []
            for i, ts in enumerate(timestamps):
                if i >= len(opens) or opens[i] is None or closes[i] is None or highs[i] is None or lows[i] is None:
                    continue
                records.append({
                    'Date': datetime.fromtimestamp(ts).strftime('%Y-%m-%d'),
                    'Open': float(opens[i]),
                    'Close': float(closes[i]),
                    'Low': float(lows[i]),
                    'High': float(highs[i]),
                    'Volume': float(volumes[i] or 0),
                })

            df = pd.DataFrame(records)
            if not df.empty:
                df['Date'] = pd.to_datetime(df['Date'])
                df.set_index('Date', inplace=True)
            return df
        except (urllib.error.URLError, json.JSONDecodeError, ValueError) as e:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                raise Exception(f"获取 Yahoo K线失败: {e}")

    return pd.DataFrame()


POSITIVE_NEWS_KEYWORDS = [
    'beat', 'surge', 'upgrade', 'record', 'growth', 'profit', 'win', 'breakthrough', 'bullish',
    '超预期', '增长', '中标', '利好', '创新高', '回购', '增持', '扭亏', '突破', '上调'
]
NEGATIVE_NEWS_KEYWORDS = [
    'miss', 'lawsuit', 'probe', 'fraud', 'downgrade', 'slump', 'warning', 'decline', 'loss', 'risk',
    '诉讼', '调查', '减持', '亏损', '预警', '下滑', '利空', '处罚', '暴跌', '风险'
]


def build_news_query(code: str, quote: dict) -> tuple[str, str]:
    stock = normalize_stock_code(code)
    name = (quote.get('name') or stock['code']).strip()
    if stock['market'] in ('SH', 'SZ'):
        return f'{name} {stock["code"][2:]}', 'zh'
    if stock['market'] == 'HK':
        return f'{name} {stock["code"].replace(".HK", "")}', 'zh'
    return f'{name} {stock["code"]}', 'en'


def score_news_title(title: str) -> int:
    lower = title.lower()
    score = 0
    for kw in POSITIVE_NEWS_KEYWORDS:
        if kw.lower() in lower:
            score += 1
    for kw in NEGATIVE_NEWS_KEYWORDS:
        if kw.lower() in lower:
            score -= 1
    return score


def summarize_news_sentiment(items: list[dict]) -> dict:
    if not items:
        return {
            'label': '暂无',
            'heat': '低',
            'score': 0,
            'positive': 0,
            'negative': 0,
            'neutral': 0,
        }

    pos = neg = neu = 0
    total_score = 0
    for item in items:
        score = score_news_title(item.get('title', ''))
        total_score += score
        if score > 0:
            pos += 1
        elif score < 0:
            neg += 1
        else:
            neu += 1

    if total_score >= 2:
        label = '偏正面'
    elif total_score <= -2:
        label = '偏负面'
    else:
        label = '中性'

    count = len(items)
    if count >= 10:
        heat = '高'
    elif count >= 5:
        heat = '中'
    else:
        heat = '低'

    return {
        'label': label,
        'heat': heat,
        'score': total_score,
        'positive': pos,
        'negative': neg,
        'neutral': neu,
    }


def fetch_google_news_rss(code: str, quote: dict, limit: int = 5) -> dict:
    query, lang = build_news_query(code, quote)
    if lang == 'zh':
        params = f'q={urllib.parse.quote(query)}&hl=zh-CN&gl=CN&ceid=CN:zh-Hans'
    else:
        params = f'q={urllib.parse.quote(query)}&hl=en-US&gl=US&ceid=US:en'
    url = f'https://news.google.com/rss/search?{params}'

    for attempt in range(MAX_RETRIES):
        try:
            req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
            with urllib.request.urlopen(req, timeout=20) as response:
                xml_text = response.read().decode('utf-8', 'ignore')
            root = ET.fromstring(xml_text)
            channel = root.find('channel')
            rss_items = channel.findall('item') if channel is not None else []
            items = []
            for node in rss_items[:limit]:
                title = html.unescape((node.findtext('title') or '').strip())
                link = (node.findtext('link') or '').strip()
                pub_date = (node.findtext('pubDate') or '').strip()
                source = html.unescape((node.findtext('source') or '').strip())
                if not title:
                    continue
                items.append({
                    'title': title,
                    'link': link,
                    'published_at': pub_date,
                    'source': source or 'Google News',
                })
            sentiment = summarize_news_sentiment(items)
            return {
                'query': query,
                'source': 'google-news-rss',
                'items': items,
                'sentiment': sentiment,
            }
        except Exception:
            if attempt < MAX_RETRIES - 1:
                time.sleep(RETRY_BASE_DELAY * (attempt + 1))
            else:
                return {
                    'query': query,
                    'source': 'google-news-rss',
                    'items': [],
                    'sentiment': summarize_news_sentiment([]),
                }

    return {
        'query': query,
        'source': 'google-news-rss',
        'items': [],
        'sentiment': summarize_news_sentiment([]),
    }


SEC_TICKER_MAP_CACHE = None
EVENT_KEYWORDS = {
    '业绩': ['财报', '业绩', '盈利', '营收', '季报', '年报', 'earnings', 'revenue', 'profit'],
    '订单/中标': ['中标', '订单', '签约', '合作', 'contract', 'deal'],
    '资本动作': ['回购', '增持', '减持', '融资', '定增', 'buyback', 'offering'],
    '监管/风险': ['调查', '诉讼', '处罚', '风险', '警告', 'probe', 'lawsuit', 'fraud', 'warning'],
    '产品/技术': ['新品', '发布', '突破', '芯片', 'ai', 'launch', 'breakthrough'],
}


def classify_event_title(title: str) -> str:
    lower = title.lower()
    for label, keywords in EVENT_KEYWORDS.items():
        if any(kw.lower() in lower for kw in keywords):
            return label
    return '市场动态'


def derive_events_from_news(news: dict, limit: int = 3) -> dict:
    items = []
    for item in (news.get('items') or [])[:limit]:
        items.append({
            'title': item.get('title', ''),
            'category': classify_event_title(item.get('title', '')),
            'source': item.get('source', 'Google News'),
            'published_at': item.get('published_at', ''),
            'link': item.get('link', ''),
        })
    return {
        'source': 'news-derived',
        'items': items,
    }


def fetch_sec_ticker_map() -> dict:
    global SEC_TICKER_MAP_CACHE
    if SEC_TICKER_MAP_CACHE is not None:
        return SEC_TICKER_MAP_CACHE
    req = urllib.request.Request(
        'https://www.sec.gov/files/company_tickers.json',
        headers={'User-Agent': 'Mozilla/5.0 [REDACTED_SECRET]'}
    )
    with urllib.request.urlopen(req, timeout=20) as response:
        payload = json.loads(response.read().decode('utf-8'))
    mapping = {}
    for _, item in payload.items():
        ticker = (item.get('ticker') or '').upper()
        cik = str(item.get('cik_str') or '').strip()
        if ticker and cik:
            mapping[ticker] = cik.zfill(10)
    SEC_TICKER_MAP_CACHE = mapping
    return mapping


def fetch_sec_events(code: str, quote: dict, limit: int = 3) -> dict:
    stock = normalize_stock_code(code)
    if stock['market'] != 'US':
        return {'source': 'sec', 'items': []}

    ticker = stock['code'].upper()
    try:
        mapping = fetch_sec_ticker_map()
        cik = mapping.get(ticker)
        if not cik:
            return {'source': 'sec', 'items': []}
        req = urllib.request.Request(
            f'https://data.sec.gov/submissions/CIK{cik}.json',
            headers={'User-Agent': 'Mozilla/5.0 [REDACTED_SECRET]'}
        )
        with urllib.request.urlopen(req, timeout=20) as response:
            payload = json.loads(response.read().decode('utf-8'))
        recent = ((payload.get('filings') or {}).get('recent')) or {}
        forms = recent.get('form') or []
        dates = recent.get('filingDate') or []
        accessions = recent.get('accessionNumber') or []
        primary_docs = recent.get('primaryDocument') or []
        items = []
        for i in range(min(len(forms), len(dates), len(accessions), len(primary_docs), limit)):
            form = forms[i]
            filing_date = dates[i]
            acc = accessions[i].replace('-', '')
            doc = primary_docs[i]
            link = f'https://www.sec.gov/Archives/edgar/data/{int(cik)}/{acc}/{doc}' if doc else ''
            items.append({
                'title': f'SEC {form} filed on {filing_date}',
                'category': 'SEC申报',
                'source': 'SEC',
                'published_at': filing_date,
                'link': link,
            })
        return {'source': 'sec', 'items': items}
    except Exception:
        return {'source': 'sec', 'items': []}


def build_buzz_radar(news: dict, events: dict) -> dict:
    sentiment = news.get('sentiment') or {}
    news_heat = sentiment.get('heat', '低')
    total_events = len(events.get('items') or [])
    score = 0
    if news_heat == '高':
        score += 2
    elif news_heat == '中':
        score += 1
    if sentiment.get('label') in ('偏正面', '偏负面'):
        score += 1
    if total_events >= 3:
        score += 1

    if score >= 4:
        level = '过热'
    elif score >= 2:
        level = '升温'
    else:
        level = '正常'

    return {
        'level': level,
        'news_heat': news_heat,
        'event_count': total_events,
        'sentiment': sentiment.get('label', '暂无'),
        'source': 'news-derived-buzz',
    }


def build_event_layer(code: str, quote: dict, news: dict) -> dict:
    stock = normalize_stock_code(code)
    if stock['market'] == 'US':
        sec_events = fetch_sec_events(code, quote)
        if sec_events.get('items'):
            return sec_events
    return derive_events_from_news(news)


def get_or_refresh_aux_layers(code: str, quote: dict, refresh: bool = False) -> dict:
    news = None if refresh else get_cached_aux(code, 'news')
    if not news:
        news = fetch_google_news_rss(code, quote)
        set_cached_aux(code, 'news', news, ttl_seconds=AUX_CACHE_TTL_SECONDS)

    events = None if refresh else get_cached_aux(code, 'events')
    if not events:
        events = build_event_layer(code, quote, news)
        set_cached_aux(code, 'events', events, ttl_seconds=AUX_CACHE_TTL_SECONDS)

    buzz = None if refresh else get_cached_aux(code, 'buzz')
    if not buzz:
        buzz = build_buzz_radar(news, events)
        set_cached_aux(code, 'buzz', buzz, ttl_seconds=AUX_CACHE_TTL_SECONDS)

    return {
        'news': news,
        'events': events,
        'buzz': buzz,
    }


def period_to_days(period: str) -> int:
    """将周期字符串转换为天数"""
    mapping = {
        "1mo": 30,
        "3mo": 90,
        "6mo": 180,
        "1y": 250,
        "2y": 500,
        "5y": 1250,
    }
    return mapping.get(period, 180)


def min_kline_points(required_days: int) -> int:
    return 20 if required_days <= 30 else 30


def refresh_kline_cache(code: str, required_days: int, period: str = '6mo') -> tuple[pd.DataFrame, str]:
    """使用 SQLite 保存日线数据，并按需增量刷新。返回 (hist, source)。"""
    stock = normalize_stock_code(code)
    buffer_days = 30
    latest_date = get_latest_kline_date(code)
    fetch_days = max(required_days + buffer_days, 60)
    source_used = 'tencent'

    if latest_date:
        latest_dt = datetime.strptime(latest_date, "%Y-%m-%d")
        missing_days = max((datetime.now() - latest_dt).days, 0)
        if missing_days <= 2:
            fetch_days = min(fetch_days, 60)
        else:
            fetch_days = max(missing_days + buffer_days, 60)

    fetched = fetch_tencent_kline(code, fetch_days)
    if stock['market'] in ('SH', 'SZ') and len(fetched) <= 2:
        fetched = fetch_eastmoney_kline(code, fetch_days)
        source_used = 'eastmoney' if not fetched.empty else source_used
    elif stock['market'] == 'US' and len(fetched) <= 2:
        fetched = fetch_us_kline_yahoo(stock['code'], period)
        source_used = 'yahoo' if not fetched.empty else source_used

    if not fetched.empty:
        upsert_kline_df(code, fetched, source=source_used)

    hist = get_kline_df(code, required_days + buffer_days)
    if len(hist) < min_kline_points(required_days):
        fallback = fetch_tencent_kline(code, required_days + buffer_days)
        fallback_source = 'tencent'
        if stock['market'] in ('SH', 'SZ') and len(fallback) <= 2:
            fallback = fetch_eastmoney_kline(code, required_days + buffer_days)
            fallback_source = 'eastmoney' if not fallback.empty else fallback_source
        elif stock['market'] == 'US' and len(fallback) <= 2:
            fallback = fetch_us_kline_yahoo(stock['code'], period)
            fallback_source = 'yahoo' if not fallback.empty else fallback_source
        if not fallback.empty:
            upsert_kline_df(code, fallback, source=fallback_source)
            hist = get_kline_df(code, required_days + buffer_days)
            source_used = fallback_source
    return hist, source_used


# ─────────────────────────────────────────────
#  技术指标计算 (保持不变)
# ─────────────────────────────────────────────

def calc_ma(close: pd.Series, windows: list[int] = None) -> dict:
    """计算多周期移动平均线"""
    if windows is None:
        windows = [5, 10, 20, 60, 120, 250]
    result = {}
    for w in windows:
        if len(close) >= w:
            ma = close.rolling(window=w).mean()
            result[f"MA{w}"] = round(ma.iloc[-1], 3)
    return result


def calc_ema(close: pd.Series, span: int) -> pd.Series:
    """计算指数移动平均线"""
    return close.ewm(span=span, adjust=False).mean()


def calc_macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> dict:
    """计算MACD指标"""
    ema_fast = calc_ema(close, fast)
    ema_slow = calc_ema(close, slow)
    dif = ema_fast - ema_slow
    dea = dif.ewm(span=signal, adjust=False).mean()
    macd_hist = 2 * (dif - dea)

    return {
        "DIF": round(dif.iloc[-1], 4),
        "DEA": round(dea.iloc[-1], 4),
        "MACD": round(macd_hist.iloc[-1], 4),
        "signal": _macd_signal(dif, dea, macd_hist),
    }


def _macd_signal(dif: pd.Series, dea: pd.Series, macd_hist: pd.Series) -> str:
    """MACD信号判断"""
    if len(dif) < 3:
        return "中性"
    if dif.iloc[-1] > dea.iloc[-1] and dif.iloc[-2] <= dea.iloc[-2]:
        return "金叉-买入信号"
    if dif.iloc[-1] < dea.iloc[-1] and dif.iloc[-2] >= dea.iloc[-2]:
        return "死叉-卖出信号"
    if dif.iloc[-1] > 0 and dea.iloc[-1] > 0:
        if macd_hist.iloc[-1] > macd_hist.iloc[-2]:
            return "多头增强"
        return "多头区域"
    if dif.iloc[-1] < 0 and dea.iloc[-1] < 0:
        if macd_hist.iloc[-1] < macd_hist.iloc[-2]:
            return "空头增强"
        return "空头区域"
    return "中性"


def calc_rsi(close: pd.Series, periods: list[int] = None) -> dict:
    """计算RSI指标"""
    if periods is None:
        periods = [6, 12, 24]
    result = {}
    delta = close.diff()
    for p in periods:
        if len(close) < p + 1:
            continue
        gain = delta.clip(lower=0).rolling(window=p).mean()
        loss = (-delta.clip(upper=0)).rolling(window=p).mean()
        rs = gain / loss.replace(0, np.nan)
        rsi = 100 - (100 / (1 + rs))
        val = round(rsi.iloc[-1], 2)
        result[f"RSI{p}"] = val
    rsi_main = result.get("RSI12", result.get("RSI6", 50))
    if rsi_main > 80:
        result["signal"] = "严重超买-卖出信号"
    elif rsi_main > 70:
        result["signal"] = "超买-注意风险"
    elif rsi_main < 20:
        result["signal"] = "严重超卖-买入信号"
    elif rsi_main < 30:
        result["signal"] = "超卖-关注买入"
    else:
        result["signal"] = "中性"
    return result


def calc_kdj(high: pd.Series, low: pd.Series, close: pd.Series, n: int = 9) -> dict:
    """计算KDJ指标"""
    if len(close) < n:
        return {"K": 50, "D": 50, "J": 50, "signal": "数据不足"}
    lowest_low = low.rolling(window=n).min()
    highest_high = high.rolling(window=n).max()
    rsv = (close - lowest_low) / (highest_high - lowest_low).replace(0, np.nan) * 100

    k = pd.Series(index=close.index, dtype=float)
    d = pd.Series(index=close.index, dtype=float)
    k.iloc[n - 1] = 50
    d.iloc[n - 1] = 50
    for i in range(n, len(close)):
        k.iloc[i] = 2 / 3 * k.iloc[i - 1] + 1 / 3 * rsv.iloc[i]
        d.iloc[i] = 2 / 3 * d.iloc[i - 1] + 1 / 3 * k.iloc[i]
    j = 3 * k - 2 * d

    k_val = round(k.iloc[-1], 2)
    d_val = round(d.iloc[-1], 2)
    j_val = round(j.iloc[-1], 2)

    signal = "中性"
    if k_val > d_val and k.iloc[-2] <= d.iloc[-2]:
        signal = "金叉-买入信号"
    elif k_val < d_val and k.iloc[-2] >= d.iloc[-2]:
        signal = "死叉-卖出信号"
    elif j_val > 100:
        signal = "超买区域"
    elif j_val < 0:
        signal = "超卖区域"

    return {"K": k_val, "D": d_val, "J": j_val, "signal": signal}


def calc_bollinger(close: pd.Series, window: int = 20, num_std: float = 2) -> dict:
    """计算布林带"""
    if len(close) < window:
        return {"signal": "数据不足"}
    ma = close.rolling(window=window).mean()
    std = close.rolling(window=window).std()
    upper = ma + num_std * std
    lower = ma - num_std * std

    current = close.iloc[-1]
    upper_val = round(upper.iloc[-1], 3)
    lower_val = round(lower.iloc[-1], 3)
    mid_val = round(ma.iloc[-1], 3)
    bandwidth = round((upper_val - lower_val) / mid_val * 100, 2)

    signal = "中性"
    if current > upper_val:
        signal = "突破上轨-超买"
    elif current < lower_val:
        signal = "突破下轨-超卖"
    elif current > mid_val:
        signal = "中轨上方-偏强"
    else:
        signal = "中轨下方-偏弱"

    return {
        "upper": upper_val,
        "middle": mid_val,
        "lower": lower_val,
        "bandwidth_pct": bandwidth,
        "signal": signal,
    }


def calc_volume_analysis(volume: pd.Series, close: pd.Series) -> dict:
    """成交量分析"""
    if len(volume) < 20:
        return {"signal": "数据不足"}
    avg_5 = volume.rolling(5).mean().iloc[-1]
    avg_20 = volume.rolling(20).mean().iloc[-1]
    current = volume.iloc[-1]
    vol_ratio = round(current / avg_5, 2) if avg_5 > 0 else 0
    price_change = close.iloc[-1] - close.iloc[-2]

    signal = "中性"
    if vol_ratio > 2 and price_change > 0:
        signal = "放量上涨-强势"
    elif vol_ratio > 2 and price_change < 0:
        signal = "放量下跌-弱势"
    elif vol_ratio < 0.5 and price_change > 0:
        signal = "缩量上涨-动力不足"
    elif vol_ratio < 0.5 and price_change < 0:
        signal = "缩量下跌-抛压减轻"

    return {
        "current_volume": int(current),
        "avg_5d_volume": int(avg_5),
        "avg_20d_volume": int(avg_20),
        "volume_ratio": vol_ratio,
        "signal": signal,
    }


def calc_ma_trend(close: pd.Series) -> dict:
    """均线趋势分析"""
    mas = calc_ma(close, [5, 10, 20, 60])
    current = close.iloc[-1]

    above_count = sum(1 for v in mas.values() if current > v)
    total = len(mas)

    if above_count == total and total > 0:
        signal = "多头排列-强势"
    elif above_count == 0:
        signal = "空头排列-弱势"
    elif above_count >= total * 0.7:
        signal = "偏多"
    elif above_count <= total * 0.3:
        signal = "偏空"
    else:
        signal = "震荡"

    return {**mas, "trend_signal": signal, "price_above_ma_count": f"{above_count}/{total}"}


# ─────────────────────────────────────────────
#  基本面分析 (基于腾讯数据)
# ─────────────────────────────────────────────

def get_fundamentals(quote: dict) -> dict:
    """基于实时行情数据的基本面分析"""
    result = {}

    pe = quote.get('pe')
    pb = quote.get('pb')
    result['PE'] = round(pe, 2) if pe else None
    result['PB'] = round(pb, 2) if pb else None
    result['PS'] = None
    result['market_cap'] = quote.get('market_cap', '')
    result['52w_high'] = quote.get('52w_high')
    result['52w_low'] = quote.get('52w_low')
    result['company_name'] = quote.get('name', '未知')
    result['sector'] = quote.get('market', '未知市场')
    result['industry'] = quote.get('exchange') or quote.get('market', '未知')
    result['currency'] = quote.get('currency', 'N/A')
    result['market'] = quote.get('market', 'N/A')
    result['fundamental_signal'] = _fundamental_signal(result)
    return result


def _fundamental_signal(data: dict) -> str:
    """基本面信号判断 (简化版)"""
    score = 0
    reasons = []

    pe = data.get("PE")
    if pe is not None and pe > 0:
        if pe < 15:
            score += 2
            reasons.append(f"PE低估值({pe})")
        elif pe < 25:
            score += 1
            reasons.append(f"PE合理({pe})")
        elif pe > 40:
            score -= 1
            reasons.append(f"PE偏高({pe})")

    pb = data.get("PB")
    if pb is not None:
        if pb < 1:
            score += 1
            reasons.append(f"PB破净({pb})")
        elif pb > 5:
            score -= 1
            reasons.append(f"PB偏高({pb})")

    if score >= 3:
        signal = "基本面优秀"
    elif score >= 1:
        signal = "基本面良好"
    elif score >= 0:
        signal = "基本面一般"
    else:
        signal = "基本面较差"

    return f"{signal} ({'; '.join(reasons[:3])})" if reasons else signal


# ─────────────────────────────────────────────
#  综合评分与建议
# ─────────────────────────────────────────────

MARKET_PROFILES = {
    "HK": {"technical": 0.62, "fundamental": 0.38, "risk_penalty": 1.0},
    "SH": {"technical": 0.58, "fundamental": 0.42, "risk_penalty": 0.9},
    "SZ": {"technical": 0.60, "fundamental": 0.40, "risk_penalty": 1.0},
    "US": {"technical": 0.55, "fundamental": 0.45, "risk_penalty": 0.85},
}


def clamp(value: float, low: float, high: float) -> float:
    return max(low, min(high, value))


def detect_market_regime(hist: pd.DataFrame, technical: dict, quote: dict) -> dict:
    close = hist["Close"]
    ma20 = close.rolling(20).mean().iloc[-1] if len(close) >= 20 else close.iloc[-1]
    ma60 = close.rolling(60).mean().iloc[-1] if len(close) >= 60 else ma20
    current = close.iloc[-1]
    rsi12 = technical.get("rsi", {}).get("RSI12", technical.get("rsi", {}).get("RSI6", 50))
    high_52w = quote.get("52w_high")
    low_52w = quote.get("52w_low")
    pos_52w = None
    if high_52w and low_52w and high_52w != low_52w:
        pos_52w = (current - low_52w) / (high_52w - low_52w)

    if current > ma20 > ma60 and rsi12 >= 55:
        regime = "趋势延续"
    elif rsi12 <= 35 and technical.get("kdj", {}).get("J", 50) < 20:
        regime = "超跌反弹"
    elif pos_52w is not None and pos_52w > 0.85 and rsi12 >= 68:
        regime = "高位风险"
    elif abs(current / ma20 - 1) < 0.03 and 40 <= rsi12 <= 60:
        regime = "区间震荡"
    else:
        regime = "估值修复/等待确认"

    return {"regime": regime, "position_52w": round(pos_52w, 4) if pos_52w is not None else None}


def compute_layer_scores(hist: pd.DataFrame, technical: dict, fundamental: dict, quote: dict) -> dict:
    close = hist["Close"]
    current = close.iloc[-1]
    ret_5 = (current / close.iloc[-6] - 1) if len(close) > 5 else 0
    ret_20 = (current / close.iloc[-21] - 1) if len(close) > 20 else ret_5
    ma = technical.get("ma_trend", {})
    above = ma.get("price_above_ma_count", "0/1").split("/")
    above_ratio = (int(above[0]) / max(int(above[1]), 1)) if len(above) == 2 else 0
    macd_sig = technical.get("macd", {}).get("signal", "")
    rsi = technical.get("rsi", {}).get("RSI12", technical.get("rsi", {}).get("RSI6", 50))
    kdj_j = technical.get("kdj", {}).get("J", 50)
    volume_ratio = technical.get("volume", {}).get("volume_ratio", 1)
    boll_sig = technical.get("bollinger", {}).get("signal", "")
    pe = fundamental.get("PE")
    pb = fundamental.get("PB")
    high_52w = fundamental.get("52w_high")
    low_52w = fundamental.get("52w_low")
    pos_52w = 0.5
    if high_52w and low_52w and high_52w != low_52w:
        pos_52w = clamp((quote.get("price", current) - low_52w) / (high_52w - low_52w), 0, 1)

    trend = (ret_20 * 100 * 0.6) + (above_ratio - 0.5) * 8
    if "多头" in macd_sig or "金叉" in macd_sig:
        trend += 1.5
    elif "空头" in macd_sig or "死叉" in macd_sig:
        trend -= 1.5

    momentum = ret_5 * 100 * 0.8
    momentum += 1.2 if volume_ratio > 1.5 and ret_5 > 0 else 0
    momentum -= 1.2 if volume_ratio > 1.5 and ret_5 < 0 else 0
    momentum += 0.8 if "金叉" in technical.get("kdj", {}).get("signal", "") else 0
    momentum -= 0.8 if "死叉" in technical.get("kdj", {}).get("signal", "") else 0

    risk = 0.0
    if rsi > 75:
        risk -= 2.2
    elif rsi < 28:
        risk += 1.0
    if kdj_j > 100:
        risk -= 1.2
    elif kdj_j < 0:
        risk += 0.8
    if pos_52w > 0.88:
        risk -= 1.2
    elif pos_52w < 0.18:
        risk += 0.8
    if "突破上轨" in boll_sig:
        risk -= 0.8
    elif "突破下轨" in boll_sig:
        risk += 0.6

    valuation = 0.0
    if pe is not None:
        if 0 < pe < 15:
            valuation += 2.0
        elif pe < 25:
            valuation += 1.0
        elif pe > 40:
            valuation -= 1.5
    if pb is not None:
        if 0 < pb < 1:
            valuation += 1.0
        elif pb > 6:
            valuation -= 1.0

    relative_strength = clamp(ret_20 * 100 / 4, -3, 3)
    volume_structure = clamp((volume_ratio - 1.0) * 2, -2.5, 2.5)

    return {
        "trend": round(clamp(trend, -5, 5), 2),
        "momentum": round(clamp(momentum, -5, 5), 2),
        "risk": round(clamp(risk, -5, 5), 2),
        "valuation": round(clamp(valuation, -5, 5), 2),
        "relative_strength": round(relative_strength, 2),
        "volume_structure": round(volume_structure, 2),
    }


def evaluate_signal_quality(layer_scores: dict) -> dict:
    positives = sum(1 for v in layer_scores.values() if v > 0.8)
    negatives = sum(1 for v in layer_scores.values() if v < -0.8)
    dispersion = max(layer_scores.values()) - min(layer_scores.values())
    agreement = abs(positives - negatives)
    confidence = 40 + agreement * 8 - min(dispersion * 2.5, 18)
    confidence = int(clamp(confidence, 18, 92))
    if confidence >= 72:
        level = "高"
    elif confidence >= 55:
        level = "中"
    else:
        level = "低"
    return {"score": confidence, "level": level, "positives": positives, "negatives": negatives}


def backtest_current_signal(hist: pd.DataFrame, period: str) -> dict:
    horizons = [5, 10, 20]
    closes = hist["Close"].reset_index(drop=True)
    if len(closes) < 45:
        return {"samples": 0, "message": "历史样本不足"}
    current_ret20 = (closes.iloc[-1] / closes.iloc[-21] - 1) if len(closes) > 20 else 0
    current_ret5 = (closes.iloc[-1] / closes.iloc[-6] - 1) if len(closes) > 5 else 0
    matched = []
    for i in range(25, len(closes) - 20):
        r20 = closes.iloc[i] / closes.iloc[i-20] - 1
        r5 = closes.iloc[i] / closes.iloc[i-5] - 1
        if abs(r20 - current_ret20) < 0.06 and abs(r5 - current_ret5) < 0.04:
            matched.append(i)
    if len(matched) < 5:
        return {"samples": len(matched), "message": "相似信号样本不足"}

    perf = {"samples": len(matched)}
    all_forward = []
    for h in horizons:
        vals = []
        for i in matched:
            if i + h < len(closes):
                vals.append(closes.iloc[i + h] / closes.iloc[i] - 1)
        if vals:
            perf[f"forward_{h}d_avg_pct"] = round(sum(vals) / len(vals) * 100, 2)
            perf[f"forward_{h}d_win_rate"] = round(sum(1 for x in vals if x > 0) / len(vals) * 100, 2)
            all_forward.extend(vals)
    if all_forward:
        perf["max_drawdown_proxy_pct"] = round(min(all_forward) * 100, 2)
    perf["period"] = period
    return perf


def decide_action_type(regime: str, total_score: float, confidence: dict) -> tuple[str, str]:
    if total_score >= 4.5 and confidence["score"] >= 70:
        return "强烈买入", "趋势型买入" if regime == "趋势延续" else "高置信度买入"
    if total_score >= 2:
        if regime == "超跌反弹":
            return "买入", "超跌博弈型买入"
        return "买入", "趋势跟随型买入"
    if total_score <= -4.5 and confidence["score"] >= 70:
        return "强烈卖出", "风险规避型卖出"
    if total_score <= -2:
        return "卖出", "止盈/止损型卖出"
    return "持有/观望", "等待确认"


def generate_recommendation(technical: dict, fundamental: dict, current_price: float, hist: pd.DataFrame, quote: dict) -> dict:
    market = quote.get("market", "HK")
    profile = MARKET_PROFILES.get(market, MARKET_PROFILES["HK"])
    regime = detect_market_regime(hist, technical, quote)
    layer_scores = compute_layer_scores(hist, technical, fundamental, quote)
    confidence = evaluate_signal_quality(layer_scores)

    technical_bucket = (
        layer_scores["trend"] * 0.35 +
        layer_scores["momentum"] * 0.25 +
        layer_scores["relative_strength"] * 0.20 +
        layer_scores["volume_structure"] * 0.20
    )
    fundamental_bucket = layer_scores["valuation"]
    risk_bucket = layer_scores["risk"] * profile["risk_penalty"]
    total_score = technical_bucket * profile["technical"] + fundamental_bucket * profile["fundamental"] + risk_bucket
    total_score = round(clamp(total_score, -8, 8), 2)

    action, action_type = decide_action_type(regime["regime"], total_score, confidence)
    icon_map = {"强烈买入": "🟢🟢", "买入": "🟢", "持有/观望": "🟡", "卖出": "🔴", "强烈卖出": "🔴🔴"}
    en_map = {"强烈买入": "STRONG_BUY", "买入": "BUY", "持有/观望": "HOLD", "卖出": "SELL", "强烈卖出": "STRONG_SELL"}
    icon = icon_map[action]

    key_signals = [
        f"市场场景: {regime['regime']}",
        f"趋势层: {layer_scores['trend']}",
        f"动量层: {layer_scores['momentum']}",
        f"风险层: {layer_scores['risk']}",
        f"估值层: {layer_scores['valuation']}",
        f"置信度: {confidence['level']}({confidence['score']})",
    ]

    return {
        "action": action,
        "action_en": en_map[action],
        "action_type": action_type,
        "score": total_score,
        "icon": icon,
        "market_profile": market,
        "regime": regime,
        "layer_scores": layer_scores,
        "confidence": confidence,
        "key_signals": key_signals,
        "summary": f"{icon} {action} / {action_type} (综合评分: {total_score})",
    }


# ─────────────────────────────────────────────
#  主流程
# ─────────────────────────────────────────────

def analyze_stock(code: str, period: str = "6mo", use_cache: bool = True) -> dict:
    """对单只股票进行完整分析"""
    init_db()
    stock = normalize_stock_code(code)
    full_code = stock['code']

    if use_cache:
        cached = get_cached_analysis(full_code, period)
        if cached:
            aux = get_or_refresh_aux_layers(full_code, cached.get('fundamental', {}) | {'name': cached.get('fundamental', {}).get('company_name', '')}, refresh=False)
            cached['news'] = aux['news']
            cached['events'] = aux['events']
            cached['buzz'] = aux['buzz']
            cached.setdefault('data_sources', {})
            cached['data_sources']['news'] = aux['news'].get('source', 'google-news-rss')
            cached['data_sources']['event'] = aux['events'].get('source', 'news-derived')
            cached['data_sources']['buzz'] = aux['buzz'].get('source', 'news-derived-buzz')
            print(f"📦 使用缓存数据 ({full_code})，分析缓存有效期 {ANALYSIS_CACHE_TTL}s，辅助层缓存有效期 {AUX_CACHE_TTL_SECONDS}s", file=sys.stderr)
            return cached

    result = {"code": full_code, "market": stock['market'], "analysis_time": datetime.now().isoformat(), "error": None}

    try:
        quote = fetch_quote_with_fallback(full_code)
        if not quote or not quote.get("price"):
            result["error"] = f"无法获取 {full_code} 的实时行情"
            return result

        result["data_sources"] = {
            "quote": quote.get("quote_source", "tencent"),
            "kline": None,
            "news": None,
            "event": None,
            "buzz": None,
        }

        upsert_watchlist_item(
            code=full_code,
            market=quote.get('market', stock['market']),
            tencent_symbol=quote.get('tencent_symbol', stock['tencent_symbol']),
            name=quote.get('name'),
            exchange=quote.get('exchange', stock.get('exchange')),
            currency=quote.get('currency'),
            last_price=quote.get('price'),
            pe=quote.get('pe'),
            pb=quote.get('pb'),
            market_cap=quote.get('market_cap'),
            week52_high=quote.get('52w_high'),
            week52_low=quote.get('52w_low'),
            quote_time=quote.get('timestamp'),
            meta=quote,
        )

        current_price = quote["price"]
        result["current_price"] = current_price
        result["price_date"] = quote.get("timestamp", "")
        result["price_change"] = quote.get("change_amount")
        result["price_change_pct"] = quote.get("change_pct")

        days = period_to_days(period)
        hist, kline_source = refresh_kline_cache(full_code, days, period)
        result["data_sources"]["kline"] = kline_source
        if hist.empty or len(hist) < min_kline_points(days):
            result["error"] = f"无法获取 {full_code} 的历史K线数据 (仅获得 {len(hist)} 条)"
            return result

        result["data_points"] = len(hist)
        close = hist["Close"]
        high = hist["High"]
        low = hist["Low"]
        volume = hist["Volume"]

        technical = {
            "ma_trend": calc_ma_trend(close),
            "macd": calc_macd(close),
            "rsi": calc_rsi(close),
            "kdj": calc_kdj(high, low, close),
            "bollinger": calc_bollinger(close),
            "volume": calc_volume_analysis(volume, close),
        }
        result["technical"] = technical

        fundamental = get_fundamentals(quote)
        result["fundamental"] = fundamental
        result["recommendation"] = generate_recommendation(technical, fundamental, current_price, hist, quote)
        result["signal_validation"] = backtest_current_signal(hist, period)

        aux = get_or_refresh_aux_layers(full_code, quote, refresh=not use_cache)
        result["news"] = aux["news"]
        result["events"] = aux["events"]
        result["buzz"] = aux["buzz"]
        result["data_sources"]["news"] = aux["news"].get("source", "google-news-rss")
        result["data_sources"]["event"] = aux["events"].get("source", "news-derived")
        result["data_sources"]["buzz"] = aux["buzz"].get("source", "news-derived-buzz")

        if result.get("error") is None:
            set_cached_analysis(full_code, period, result)

    except Exception as e:
        result["error"] = f"分析过程出错: {str(e)}"

    return result


def main():
    parser = argparse.ArgumentParser(description="多市场股票分析工具 (腾讯财经/Yahoo 数据源)")
    parser.add_argument("code", help="股票代码，如 0700.HK / 600519 / SH600519 / AAPL")
    parser.add_argument("--period", default="6mo", help="数据周期 (1mo/3mo/6mo/1y/2y/5y)")
    parser.add_argument("--output", help="输出JSON文件路径")
    parser.add_argument("--no-cache", action="store_true", help="跳过缓存，强制重新请求数据")
    parser.add_argument("--clear-cache", action="store_true", help="清除所有缓存后退出")
    args = parser.parse_args()

    if args.clear_cache:
        cleared = clear_analysis_cache()
        if cleared:
            print(f"✅ 已清除 {cleared} 条分析缓存")
        else:
            print("ℹ️ 无缓存可清除")
        return

    result = analyze_stock(args.code, args.period, use_cache=not args.no_cache)
    output = json.dumps(result, ensure_ascii=False, indent=2, default=str)
    if args.output:
        with open(args.output, "w", encoding="utf-8") as f:
            f.write(output)
        print(f"分析结果已保存至 {args.output}")
    else:
        print(output)


if __name__ == "__main__":
    main()


path: scripts/db.py
content: #!/usr/bin/env python3
"""
StockBuddy SQLite 数据层
"""

from __future__ import annotations

import json
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import pandas as pd

DATA_DIR = Path.home() / ".stockbuddy"
DB_PATH = DATA_DIR / "stockbuddy.db"
ANALYSIS_CACHE_TTL_SECONDS = 600
ANALYSIS_CACHE_MAX_ROWS = 1000
AUX_CACHE_TTL_SECONDS = 1800
AUX_CACHE_MAX_ROWS = 2000


def _utc_now_iso() -> str:
    return datetime.utcnow().replace(microsecond=0).isoformat()


def ensure_data_dir() -> None:
    DATA_DIR.mkdir(parents=True, exist_ok=True)


def get_connection() -> sqlite3.Connection:
    ensure_data_dir()
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA journal_mode=WAL")
    conn.execute("PRAGMA foreign_keys=ON")
    conn.execute("PRAGMA synchronous=NORMAL")
    return conn


def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]:
    try:
        rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
        return [row[1] for row in rows]
    except sqlite3.OperationalError:
        return []


def _ensure_column(conn: sqlite3.Connection, table: str, column: str, ddl: str) -> None:
    columns = _table_columns(conn, table)
    if columns and column not in columns:
        conn.execute(f"ALTER TABLE {table} ADD COLUMN {ddl}")


def _migrate_schema(conn: sqlite3.Connection) -> None:
    positions_cols = _table_columns(conn, "positions")
    if positions_cols and "watchlist_id" not in positions_cols:
        conn.execute("DROP TABLE positions")
        positions_cols = []

    if positions_cols:
        _ensure_column(conn, "positions", "account_id", "account_id INTEGER")


def init_db() -> None:
    with get_connection() as conn:
        conn.executescript(
            """
            CREATE TABLE IF NOT EXISTS watchlist (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                code TEXT NOT NULL UNIQUE,
                market TEXT NOT NULL,
                tencent_symbol TEXT NOT NULL,
                name TEXT,
                exchange TEXT,
                currency TEXT,
                last_price REAL,
                pe REAL,
                pb REAL,
                market_cap TEXT,
                week52_high REAL,
                week52_low REAL,
                quote_time TEXT,
                is_watched INTEGER NOT NULL DEFAULT 0,
                meta_json TEXT,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_watchlist_market ON watchlist (market, code);
            CREATE INDEX IF NOT EXISTS idx_watchlist_is_watched ON watchlist (is_watched, updated_at DESC);

            CREATE TABLE IF NOT EXISTS accounts (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                name TEXT NOT NULL UNIQUE,
                market TEXT,
                currency TEXT,
                cash_balance REAL NOT NULL DEFAULT 0,
                available_cash REAL,
                note TEXT DEFAULT '',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_accounts_market_currency
            ON accounts (market, currency);

            CREATE TABLE IF NOT EXISTS stock_rules (
                code TEXT PRIMARY KEY,
                lot_size INTEGER,
                tick_size REAL,
                allows_odd_lot INTEGER NOT NULL DEFAULT 0,
                source TEXT DEFAULT 'manual',
                updated_at TEXT NOT NULL,
                FOREIGN KEY (code) REFERENCES watchlist(code) ON DELETE CASCADE
            );

            CREATE TABLE IF NOT EXISTS kline_daily (
                code TEXT NOT NULL,
                trade_date TEXT NOT NULL,
                open REAL NOT NULL,
                high REAL NOT NULL,
                low REAL NOT NULL,
                close REAL NOT NULL,
                volume REAL NOT NULL,
                adj_type TEXT NOT NULL DEFAULT 'qfq',
                source TEXT NOT NULL DEFAULT 'tencent',
                updated_at TEXT NOT NULL,
                PRIMARY KEY (code, trade_date, adj_type)
            );

            CREATE INDEX IF NOT EXISTS idx_kline_daily_code_date
            ON kline_daily (code, trade_date DESC);

            CREATE TABLE IF NOT EXISTS positions (
                watchlist_id INTEGER PRIMARY KEY,
                account_id INTEGER,
                buy_price REAL NOT NULL,
                shares INTEGER NOT NULL,
                buy_date TEXT,
                note TEXT DEFAULT '',
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                FOREIGN KEY (watchlist_id) REFERENCES watchlist(id) ON DELETE CASCADE,
                FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE SET NULL
            );

            CREATE TABLE IF NOT EXISTS analysis_cache (
                cache_key TEXT PRIMARY KEY,
                code TEXT NOT NULL,
                period TEXT NOT NULL,
                result_json TEXT NOT NULL,
                expires_at TEXT NOT NULL,
                created_at TEXT NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_analysis_cache_expires_at
            ON analysis_cache (expires_at);

            CREATE INDEX IF NOT EXISTS idx_analysis_cache_code_period
            ON analysis_cache (code, period, created_at DESC);

            CREATE TABLE IF NOT EXISTS aux_cache (
                cache_key TEXT PRIMARY KEY,
                code TEXT NOT NULL,
                category TEXT NOT NULL,
                result_json TEXT NOT NULL,
                expires_at TEXT NOT NULL,
                created_at TEXT NOT NULL
            );

            CREATE INDEX IF NOT EXISTS idx_aux_cache_expires_at
            ON aux_cache (expires_at);

            CREATE INDEX IF NOT EXISTS idx_aux_cache_code_category
            ON aux_cache (code, category, created_at DESC);
            """
        )
        _migrate_schema(conn)
        conn.commit()


def cleanup_analysis_cache(conn: sqlite3.Connection | None = None) -> None:
    own_conn = conn is None
    conn = conn or get_connection()
    try:
        now = _utc_now_iso()
        conn.execute("DELETE FROM analysis_cache WHERE expires_at <= ?", (now,))
        overflow = conn.execute(
            "SELECT COUNT(*) AS cnt FROM analysis_cache"
        ).fetchone()["cnt"] - ANALYSIS_CACHE_MAX_ROWS
        if overflow > 0:
            conn.execute(
                """
                DELETE FROM analysis_cache
                WHERE cache_key IN (
                    SELECT cache_key
                    FROM analysis_cache
                    ORDER BY created_at ASC
                    LIMIT ?
                )
                """,
                (overflow,),
            )
        conn.commit()
    finally:
        if own_conn:
            conn.close()


def clear_analysis_cache() -> int:
    init_db()
    with get_connection() as conn:
        count = conn.execute("SELECT COUNT(*) AS cnt FROM analysis_cache").fetchone()["cnt"]
        conn.execute("DELETE FROM analysis_cache")
        conn.commit()
        return count


def cleanup_aux_cache(conn: sqlite3.Connection | None = None) -> None:
    own_conn = conn is None
    conn = conn or get_connection()
    try:
        now = _utc_now_iso()
        conn.execute("DELETE FROM aux_cache WHERE expires_at <= ?", (now,))
        overflow = conn.execute(
            "SELECT COUNT(*) AS cnt FROM aux_cache"
        ).fetchone()["cnt"] - AUX_CACHE_MAX_ROWS
        if overflow > 0:
            conn.execute(
                """
                DELETE FROM aux_cache
                WHERE cache_key IN (
                    SELECT cache_key
                    FROM aux_cache
                    ORDER BY created_at ASC
                    LIMIT ?
                )
                """,
                (overflow,),
            )
        conn.commit()
    finally:
        if own_conn:
            conn.close()


def clear_aux_cache() -> int:
    init_db()
    with get_connection() as conn:
        count = conn.execute("SELECT COUNT(*) AS cnt FROM aux_cache").fetchone()["cnt"]
        conn.execute("DELETE FROM aux_cache")
        conn.commit()
        return count


def get_cached_aux(code: str, category: str) -> dict | None:
    init_db()
    with get_connection() as conn:
        cleanup_aux_cache(conn)
        cache_key = f"{code}:{category}"
        row = conn.execute(
            """
            SELECT result_json
            FROM aux_cache
            WHERE cache_key = ? AND expires_at > ?
            """,
            (cache_key, _utc_now_iso()),
        ).fetchone()
        if not row:
            return None
        result = json.loads(row["result_json"])
        result["_from_cache"] = True
        return result


def set_cached_aux(code: str, category: str, result: dict, ttl_seconds: int = AUX_CACHE_TTL_SECONDS) -> None:
    init_db()
    now = _utc_now_iso()
    expires_at = (datetime.utcnow() + timedelta(seconds=ttl_seconds)).replace(microsecond=0).isoformat()
    cache_key = f"{code}:{category}"
    with get_connection() as conn:
        cleanup_aux_cache(conn)
        conn.execute(
            """
            INSERT INTO aux_cache (cache_key, code, category, result_json, expires_at, created_at)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(cache_key) DO UPDATE SET
                result_json = excluded.result_json,
                expires_at = excluded.expires_at,
                created_at = excluded.created_at
            """,
            (cache_key, code, category, json.dumps(result, ensure_ascii=False), expires_at, now),
        )
        conn.commit()


def get_cached_analysis(code: str, period: str) -> dict | None:
    init_db()
    with get_connection() as conn:
        cleanup_analysis_cache(conn)
        cache_key = f"{code}:{period}"
        row = conn.execute(
            """
            SELECT result_json
            FROM analysis_cache
            WHERE cache_key = ? AND expires_at > ?
            """,
            (cache_key, _utc_now_iso()),
        ).fetchone()
        if not row:
            return None
        result = json.loads(row["result_json"])
        result["_from_cache"] = True
        return result


def set_cached_analysis(code: str, period: str, result: dict) -> None:
    init_db()
    now = _utc_now_iso()
    expires_at = (datetime.utcnow() + timedelta(seconds=ANALYSIS_CACHE_TTL_SECONDS)).replace(microsecond=0).isoformat()
    cache_key = f"{code}:{period}"
    with get_connection() as conn:
        cleanup_analysis_cache(conn)
        conn.execute(
            """
            INSERT INTO analysis_cache (cache_key, code, period, result_json, expires_at, created_at)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(cache_key) DO UPDATE SET
                result_json = excluded.result_json,
                expires_at = excluded.expires_at,
                created_at = excluded.created_at
            """,
            (cache_key, code, period, json.dumps(result, ensure_ascii=False), expires_at, now),
        )
        conn.commit()


def upsert_watchlist_item(
    *,
    code: str,
    market: str,
    tencent_symbol: str,
    name: str | None = None,
    exchange: str | None = None,
    currency: str | None = None,
    last_price: float | None = None,
    pe: float | None = None,
    pb: float | None = None,
    market_cap: str | None = None,
    week52_high: float | None = None,
    week52_low: float | None = None,
    quote_time: str | None = None,
    is_watched: bool | None = None,
    meta: dict | None = None,
) -> dict:
    init_db()
    now = _utc_now_iso()
    with get_connection() as conn:
        existing = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
        created_at = existing["created_at"] if existing else now
        current_is_watched = existing["is_watched"] if existing else 0
        conn.execute(
            """
            INSERT INTO watchlist (
                code, market, tencent_symbol, name, exchange, currency, last_price,
                pe, pb, market_cap, week52_high, week52_low, quote_time, is_watched,
                meta_json, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(code) DO UPDATE SET
                market = excluded.market,
                tencent_symbol = excluded.tencent_symbol,
                name = COALESCE(excluded.name, watchlist.name),
                exchange = COALESCE(excluded.exchange, watchlist.exchange),
                currency = COALESCE(excluded.currency, watchlist.currency),
                last_price = COALESCE(excluded.last_price, watchlist.last_price),
                pe = COALESCE(excluded.pe, watchlist.pe),
                pb = COALESCE(excluded.pb, watchlist.pb),
                market_cap = COALESCE(excluded.market_cap, watchlist.market_cap),
                week52_high = COALESCE(excluded.week52_high, watchlist.week52_high),
                week52_low = COALESCE(excluded.week52_low, watchlist.week52_low),
                quote_time = COALESCE(excluded.quote_time, watchlist.quote_time),
                is_watched = excluded.is_watched,
                meta_json = COALESCE(excluded.meta_json, watchlist.meta_json),
                updated_at = excluded.updated_at
            """,
            (
                code,
                market,
                tencent_symbol,
                name,
                exchange,
                currency,
                last_price,
                pe,
                pb,
                market_cap,
                week52_high,
                week52_low,
                quote_time,
                int(current_is_watched if is_watched is None else is_watched),
                json.dumps(meta, ensure_ascii=False) if meta else None,
                created_at,
                now,
            ),
        )
        conn.commit()
        row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
    return dict(row)


def get_watchlist_item(code: str) -> dict | None:
    init_db()
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
    return dict(row) if row else None


def list_watchlist(only_watched: bool = False) -> list[dict]:
    init_db()
    sql = "SELECT * FROM watchlist"
    params = ()
    if only_watched:
        sql += " WHERE is_watched = 1"
    sql += " ORDER BY updated_at DESC, code ASC"
    with get_connection() as conn:
        rows = conn.execute(sql, params).fetchall()
    return [dict(row) for row in rows]


def set_watch_status(code: str, watched: bool) -> dict | None:
    init_db()
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
        if not row:
            return None
        conn.execute(
            "UPDATE watchlist SET is_watched = ?, updated_at = ? WHERE code = ?",
            (int(watched), _utc_now_iso(), code),
        )
        conn.commit()
        row = conn.execute("SELECT * FROM watchlist WHERE code = ?", (code,)).fetchone()
    return dict(row) if row else None


def list_accounts() -> list[dict]:
    init_db()
    with get_connection() as conn:
        rows = conn.execute(
            """
            SELECT id, name, market, currency, cash_balance, available_cash, note, created_at, updated_at
            FROM accounts
            ORDER BY market IS NULL, market, currency IS NULL, currency, name
            """
        ).fetchall()
    return [dict(row) for row in rows]


def get_account(identifier: int | str) -> dict | None:
    init_db()
    with get_connection() as conn:
        if isinstance(identifier, int) or (isinstance(identifier, str) and identifier.isdigit()):
            row = conn.execute("SELECT * FROM accounts WHERE id = ?", (int(identifier),)).fetchone()
        else:
            row = conn.execute("SELECT * FROM accounts WHERE name = ?", (identifier,)).fetchone()
    return dict(row) if row else None


def upsert_account(
    *,
    name: str,
    market: str | None = None,
    currency: str | None = None,
    cash_balance: float | None = None,
    available_cash: float | None = None,
    note: str = "",
) -> dict:
    init_db()
    now = _utc_now_iso()
    with get_connection() as conn:
        existing = conn.execute("SELECT * FROM accounts WHERE name = ?", (name,)).fetchone()
        created_at = existing["created_at"] if existing else now
        conn.execute(
            """
            INSERT INTO accounts (name, market, currency, cash_balance, available_cash, note, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(name) DO UPDATE SET
                market = COALESCE(excluded.market, accounts.market),
                currency = COALESCE(excluded.currency, accounts.currency),
                cash_balance = COALESCE(excluded.cash_balance, accounts.cash_balance),
                available_cash = COALESCE(excluded.available_cash, accounts.available_cash),
                note = CASE WHEN excluded.note = '' THEN accounts.note ELSE excluded.note END,
                updated_at = excluded.updated_at
            """,
            (
                name,
                market,
                currency,
                0 if cash_balance is None else cash_balance,
                available_cash,
                note,
                created_at,
                now,
            ),
        )
        conn.commit()
        row = conn.execute("SELECT * FROM accounts WHERE name = ?", (name,)).fetchone()
    return dict(row)


def upsert_stock_rule(
    *,
    code: str,
    lot_size: int | None = None,
    tick_size: float | None = None,
    allows_odd_lot: bool = False,
    source: str = "manual",
) -> dict:
    init_db()
    now = _utc_now_iso()
    with get_connection() as conn:
        conn.execute(
            """
            INSERT INTO stock_rules (code, lot_size, tick_size, allows_odd_lot, source, updated_at)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(code) DO UPDATE SET
                lot_size = COALESCE(excluded.lot_size, stock_rules.lot_size),
                tick_size = COALESCE(excluded.tick_size, stock_rules.tick_size),
                allows_odd_lot = excluded.allows_odd_lot,
                source = excluded.source,
                updated_at = excluded.updated_at
            """,
            (code, lot_size, tick_size, int(allows_odd_lot), source, now),
        )
        conn.commit()
        row = conn.execute("SELECT * FROM stock_rules WHERE code = ?", (code,)).fetchone()
    return dict(row)


def get_stock_rule(code: str) -> dict | None:
    init_db()
    with get_connection() as conn:
        row = conn.execute("SELECT * FROM stock_rules WHERE code = ?", (code,)).fetchone()
    return dict(row) if row else None


def get_latest_kline_date(code: str, adj_type: str = "qfq") -> str | None:
    init_db()
    with get_connection() as conn:
        row = conn.execute(
            "SELECT MAX(trade_date) AS latest_date FROM kline_daily WHERE code = ? AND adj_type = ?",
            (code, adj_type),
        ).fetchone()
        return row["latest_date"] if row and row["latest_date"] else None


def upsert_kline_df(code: str, df, adj_type: str = "qfq", source: str = "tencent") -> int:
    import pandas as pd

    if df.empty:
        return 0
    init_db()
    now = _utc_now_iso()
    records = []
    for idx, row in df.sort_index().iterrows():
        trade_date = pd.Timestamp(idx).strftime("%Y-%m-%d")
        records.append(
            (
                code,
                trade_date,
                float(row["Open"]),
                float(row["High"]),
                float(row["Low"]),
                float(row["Close"]),
                float(row["Volume"]),
                adj_type,
                source,
                now,
            )
        )
    with get_connection() as conn:
        conn.executemany(
            """
            INSERT INTO kline_daily (
                code, trade_date, open, high, low, close, volume, adj_type, source, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(code, trade_date, adj_type) DO UPDATE SET
                open = excluded.open,
                high = excluded.high,
                low = excluded.low,
                close = excluded.close,
                volume = excluded.volume,
                source = excluded.source,
                updated_at = excluded.updated_at
            """,
            records,
        )
        conn.commit()
    return len(records)


def get_kline_df(code: str, limit: int, adj_type: str = "qfq"):
    import pandas as pd

    init_db()
    with get_connection() as conn:
        rows = conn.execute(
            """
            SELECT trade_date, open, high, low, close, volume
            FROM kline_daily
            WHERE code = ? AND adj_type = ?
            ORDER BY trade_date DESC
            LIMIT ?
            """,
            (code, adj_type, limit),
        ).fetchall()
    if not rows:
        return pd.DataFrame()
    records = [
        {
            "Date": row["trade_date"],
            "Open": row["open"],
            "High": row["high"],
            "Low": row["low"],
            "Close": row["close"],
            "Volume": row["volume"],
        }
        for row in reversed(rows)
    ]
    df = pd.DataFrame(records)
    df["Date"] = pd.to_datetime(df["Date"])
    df.set_index("Date", inplace=True)
    return df


def list_positions() -> list[dict]:
    init_db()
    with get_connection() as conn:
        rows = conn.execute(
            """
            SELECT
                p.watchlist_id,
                p.account_id,
                a.name AS account_name,
                a.market AS account_market,
                a.currency AS account_currency,
                a.cash_balance AS account_cash_balance,
                a.available_cash AS account_available_cash,
                w.code,
                w.market,
                w.name,
                w.currency,
                p.buy_price,
                p.shares,
                p.buy_date,
                p.note,
                p.created_at AS added_at,
                p.updated_at,
                sr.lot_size,
                sr.tick_size,
                sr.allows_odd_lot,
                sr.source AS lot_rule_source
            FROM positions p
            JOIN watchlist w ON w.id = p.watchlist_id
            LEFT JOIN accounts a ON a.id = p.account_id
            LEFT JOIN stock_rules sr ON sr.code = w.code
            ORDER BY w.code ASC
            """
        ).fetchall()
    return [dict(row) for row in rows]


def get_position(code: str) -> dict | None:
    init_db()
    with get_connection() as conn:
        row = conn.execute(
            """
            SELECT
                p.watchlist_id,
                p.account_id,
                a.name AS account_name,
                a.market AS account_market,
                a.currency AS account_currency,
                a.cash_balance AS account_cash_balance,
                a.available_cash AS account_available_cash,
                w.code,
                w.market,
                w.name,
                w.currency,
                p.buy_price,
                p.shares,
                p.buy_date,
                p.note,
                p.created_at AS added_at,
                p.updated_at,
                sr.lot_size,
                sr.tick_size,
                sr.allows_odd_lot,
                sr.source AS lot_rule_source
            FROM positions p
            JOIN watchlist w ON w.id = p.watchlist_id
            LEFT JOIN accounts a ON a.id = p.account_id
            LEFT JOIN stock_rules sr ON sr.code = w.code
            WHERE w.code = ?
            """,
            (code,),
        ).fetchone()
    return dict(row) if row else None


def upsert_position(
    *,
    code: str,
    market: str,
    tencent_symbol: str,
    buy_price: float,
    shares: int,
    buy_date: str | None,
    note: str = "",
    account_id: int | None = None,
    name: str | None = None,
    currency: str | None = None,
    meta: dict | None = None,
) -> dict:
    init_db()
    watch = upsert_watchlist_item(
        code=code,
        market=market,
        tencent_symbol=tencent_symbol,
        name=name,
        currency=currency,
        is_watched=True,
        meta=meta,
    )
    now = _utc_now_iso()
    with get_connection() as conn:
        existing = conn.execute(
            "SELECT created_at, account_id FROM positions WHERE watchlist_id = ?", (watch["id"],)
        ).fetchone()
        created_at = existing["created_at"] if existing else now
        account_id_value = account_id if account_id is not None else (existing["account_id"] if existing else None)
        conn.execute(
            """
            INSERT INTO positions (watchlist_id, account_id, buy_price, shares, buy_date, note, created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(watchlist_id) DO UPDATE SET
                account_id = excluded.account_id,
                buy_price = excluded.buy_price,
                shares = excluded.shares,
                buy_date = excluded.buy_date,
                note = excluded.note,
                updated_at = excluded.updated_at
            """,
            (watch["id"], account_id_value, buy_price, shares, buy_date, note, created_at, now),
        )
        conn.commit()
    return get_position(code)


def remove_position(code: str) -> bool:
    init_db()
    with get_connection() as conn:
        row = conn.execute("SELECT id FROM watchlist WHERE code = ?", (code,)).fetchone()
        if not row:
            return False
        cur = conn.execute("DELETE FROM positions WHERE watchlist_id = ?", (row["id"],))
        conn.commit()
        return cur.rowcount > 0


def update_position_fields(
    code: str,
    price: float | None = None,
    shares: int | None = None,
    note: str | None = None,
    account_id: int | None = None,
) -> dict | None:
    current = get_position(code)
    if not current:
        return None
    watch = get_watchlist_item(code)
    return upsert_position(
        code=code,
        market=watch["market"],
        tencent_symbol=watch["tencent_symbol"],
        buy_price=price if price is not None else current["buy_price"],
        shares=shares if shares is not None else current["shares"],
        buy_date=current.get("buy_date"),
        note=note if note is not None else current.get("note", ""),
        account_id=account_id if account_id is not None else current.get("account_id"),
        name=watch.get("name"),
        currency=watch.get("currency"),
        meta=json.loads(watch["meta_json"]) if watch.get("meta_json") else None,
    )


path: scripts/install_deps.sh
content: #!/bin/bash
# 安装港股分析工具所需的 Python 依赖
# 用法: bash install_deps.sh

echo "正在安装港股分析工具依赖..."

# 检查是否已安装
python3 -c "import numpy; import pandas; print('依赖已安装')" 2>/dev/null
if [ $? -eq 0 ]; then
    echo "✅ 所有依赖已就绪"
    exit 0
fi

# 尝试安装 (兼容 PEP 668 限制)
pip3 install numpy pandas --quiet 2>/dev/null
if [ $? -ne 0 ]; then
    echo "尝试使用 --break-system-packages 安装..."
    pip3 install --break-system-packages numpy pandas --quiet 2>/dev/null
fi

if [ $? -ne 0 ]; then
    echo "尝试使用 --user 安装..."
    pip3 install --user numpy pandas --quiet 2>/dev/null
fi

# 最终验证
python3 -c "import numpy; import pandas" 2>/dev/null
if [ $? -eq 0 ]; then
    echo "✅ 依赖安装成功"
    echo "已安装: numpy, pandas"
else
    echo "❌ 安装失败，请手动运行以下命令之一:"
    echo "   pip3 install numpy pandas"
    echo "   pip3 install --break-system-packages numpy pandas"
    echo "   pip3 install --user numpy pandas"
    exit 1
fi


path: scripts/portfolio_manager.py
content: #!/usr/bin/env python3
"""
多市场股票持仓/关注池管理工具 - 基于 SQLite 管理关注股票、持仓并批量分析。

用法:
    python3 portfolio_manager.py list
    python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>] [--account <账户名或ID>]
    python3 portfolio_manager.py remove <代码>
    python3 portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>] [--account <账户名或ID>]
    python3 portfolio_manager.py analyze [--output <输出文件>]
    python3 portfolio_manager.py account-list
    python3 portfolio_manager.py account-upsert <账户名> [--market <市场>] [--currency <币种>] [--cash <总现金>] [--available-cash <可用现金>] [--note <备注>]
    python3 portfolio_manager.py rule-set <代码> [--lot-size <每手股数>] [--tick-size <最小价位>] [--odd-lot]
    python3 portfolio_manager.py watch-list
    python3 portfolio_manager.py watch-add <代码>
    python3 portfolio_manager.py watch-remove <代码>

数据默认保存在: ~/.stockbuddy/stockbuddy.db
"""

import sys
import json
import argparse
import os
import time
from collections import defaultdict
from datetime import datetime

try:
    from db import (
        DB_PATH,
        get_account,
        get_watchlist_item,
        init_db,
        list_accounts as db_list_accounts,
        list_positions as db_list_positions,
        list_watchlist as db_list_watchlist,
        remove_position as db_remove_position,
        set_watch_status,
        update_position_fields,
        upsert_account,
        upsert_position,
        upsert_stock_rule,
        upsert_watchlist_item,
    )
    from analyze_stock import fetch_tencent_quote, normalize_stock_code, analyze_stock
except ImportError:
    script_dir = os.path.dirname(os.path.abspath(__file__))
    sys.path.insert(0, script_dir)
    from db import (
        DB_PATH,
        get_account,
        get_watchlist_item,
        init_db,
        list_accounts as db_list_accounts,
        list_positions as db_list_positions,
        list_watchlist as db_list_watchlist,
        remove_position as db_remove_position,
        set_watch_status,
        update_position_fields,
        upsert_account,
        upsert_position,
        upsert_stock_rule,
        upsert_watchlist_item,
    )
    from analyze_stock import fetch_tencent_quote, normalize_stock_code, analyze_stock


def normalize_code(code: str) -> str:
    return normalize_stock_code(code)["code"]


def resolve_account(account_ref: str | None):
    if not account_ref:
        return None
    account = get_account(account_ref)
    if not account:
        raise ValueError(f"账户不存在: {account_ref}")
    return account


def ensure_watch_item(code: str, watched: bool = False) -> dict:
    stock = normalize_stock_code(code)
    quote = fetch_tencent_quote(stock["code"])
    name = quote.get("name") if quote else None
    return upsert_watchlist_item(
        code=stock["code"],
        market=stock["market"],
        tencent_symbol=stock["tencent_symbol"],
        name=name,
        exchange=quote.get("exchange", stock.get("exchange")) if quote else stock.get("exchange"),
        currency=quote.get("currency") if quote else None,
        last_price=quote.get("price") if quote else None,
        pe=quote.get("pe") if quote else None,
        pb=quote.get("pb") if quote else None,
        market_cap=quote.get("market_cap") if quote else None,
        week52_high=quote.get("52w_high") if quote else None,
        week52_low=quote.get("52w_low") if quote else None,
        quote_time=quote.get("timestamp") if quote else None,
        is_watched=watched,
        meta=quote or stock,
    )


def derive_execution_constraints(position: dict, current_price: float | None = None) -> dict:
    shares = int(position.get("shares") or 0)
    lot_size = position.get("lot_size")
    allows_odd_lot = bool(position.get("allows_odd_lot") or False)
    if lot_size is None or lot_size <= 0:
        whole_lots = None
        remainder = None
        can_partial_sell = None
        sellable_min_unit = 1 if allows_odd_lot else None
    else:
        whole_lots = shares // lot_size
        remainder = shares % lot_size
        can_partial_sell = allows_odd_lot or whole_lots >= 2 or remainder > 0
        sellable_min_unit = 1 if allows_odd_lot else lot_size

    estimated_cash_if_sell_all = round(shares * current_price, 2) if current_price is not None else None
    return {
        "lot_size": lot_size,
        "allows_odd_lot": allows_odd_lot,
        "sellable_min_unit": sellable_min_unit,
        "whole_lots": whole_lots,
        "odd_lot_remainder": remainder,
        "can_partial_sell": can_partial_sell,
        "estimated_cash_if_sell_all": estimated_cash_if_sell_all,
    }


def derive_position_snapshot(position: dict, analysis: dict) -> dict:
    current_price = analysis.get("current_price")
    buy_price = position.get("buy_price")
    shares = int(position.get("shares") or 0)
    cost = round((buy_price or 0) * shares, 2)
    market_value = round((current_price or 0) * shares, 2) if current_price is not None else None
    pnl = round((current_price - buy_price) * shares, 2) if current_price is not None and buy_price is not None else None
    pnl_pct = round((current_price - buy_price) / buy_price * 100, 2) if current_price is not None and buy_price not in (None, 0) else None
    execution = derive_execution_constraints(position, current_price)
    return {
        "buy_price": buy_price,
        "shares": shares,
        "buy_date": position.get("buy_date"),
        "cost": cost,
        "market_value": market_value,
        "pnl": pnl,
        "pnl_pct": pnl_pct,
        "note": position.get("note", ""),
        "currency": position.get("currency"),
        "market": position.get("market"),
        "account": {
            "id": position.get("account_id"),
            "name": position.get("account_name"),
            "market": position.get("account_market"),
            "currency": position.get("account_currency"),
            "cash_balance": position.get("account_cash_balance"),
            "available_cash": position.get("account_available_cash"),
        },
        "execution_constraints": execution,
    }


# ─────────────────────────────────────────────
#  持仓管理
# ─────────────────────────────────────────────

def list_positions():
    init_db()
    positions = db_list_positions()
    if not positions:
        print(json.dumps({"message": "持仓为空", "positions": []}, ensure_ascii=False, indent=2))
        return
    print(json.dumps({
        "total_positions": len(positions),
        "positions": positions,
        "portfolio_db": str(DB_PATH),
        "updated_at": datetime.now().isoformat(),
    }, ensure_ascii=False, indent=2))


def add_position(code: str, price: float, shares: int, date: str = None, note: str = "", account_ref: str = None):
    init_db()
    normalized = normalize_stock_code(code)
    existing = next((p for p in db_list_positions() if p["code"] == normalized["code"]), None)
    if existing:
        print(json.dumps({"error": f"{normalized['code']} 已在持仓中，请使用 update 命令更新"}, ensure_ascii=False))
        return

    account = resolve_account(account_ref)
    watch = ensure_watch_item(normalized["code"], watched=True)
    position = upsert_position(
        code=normalized["code"],
        market=normalized["market"],
        tencent_symbol=normalized["tencent_symbol"],
        buy_price=price,
        shares=shares,
        buy_date=date or datetime.now().strftime("%Y-%m-%d"),
        note=note,
        account_id=account.get("id") if account else None,
        name=watch.get("name"),
        currency=watch.get("currency"),
        meta=json.loads(watch["meta_json"]) if watch.get("meta_json") else None,
    )
    print(json.dumps({"message": f"已添加 {normalized['code']}", "position": position}, ensure_ascii=False, indent=2))


def remove_position(code: str):
    init_db()
    normalized_code = normalize_code(code)
    removed = db_remove_position(normalized_code)
    if not removed:
        print(json.dumps({"error": f"{normalized_code} 不在持仓中"}, ensure_ascii=False))
        return
    print(json.dumps({"message": f"已移除 {normalized_code}"}, ensure_ascii=False, indent=2))


def update_position(code: str, price: float = None, shares: int = None, note: str = None, account_ref: str = None):
    init_db()
    normalized_code = normalize_code(code)
    account = resolve_account(account_ref) if account_ref else None
    position = update_position_fields(normalized_code, price=price, shares=shares, note=note, account_id=account.get("id") if account else None)
    if not position:
        print(json.dumps({"error": f"{normalized_code} 不在持仓中"}, ensure_ascii=False))
        return
    print(json.dumps({"message": f"已更新 {normalized_code}", "position": position}, ensure_ascii=False, indent=2))


# ─────────────────────────────────────────────
#  账户与交易规则管理
# ─────────────────────────────────────────────

def list_accounts():
    init_db()
    accounts = db_list_accounts()
    print(json.dumps({
        "total_accounts": len(accounts),
        "accounts": accounts,
        "portfolio_db": str(DB_PATH),
        "updated_at": datetime.now().isoformat(),
    }, ensure_ascii=False, indent=2))


def save_account(name: str, market: str = None, currency: str = None, cash: float = None, available_cash: float = None, note: str = ""):
    init_db()
    account = upsert_account(
        name=name,
        market=market,
        currency=currency,
        cash_balance=cash,
        available_cash=available_cash,
        note=note,
    )
    print(json.dumps({"message": f"已保存账户 {name}", "account": account}, ensure_ascii=False, indent=2))


def set_rule(code: str, lot_size: int = None, tick_size: float = None, odd_lot: bool = False):
    init_db()
    normalized_code = normalize_code(code)
    ensure_watch_item(normalized_code, watched=False)
    rule = upsert_stock_rule(code=normalized_code, lot_size=lot_size, tick_size=tick_size, allows_odd_lot=odd_lot)
    print(json.dumps({"message": f"已设置 {normalized_code} 的交易规则", "rule": rule}, ensure_ascii=False, indent=2))


# ─────────────────────────────────────────────
#  关注池管理
# ─────────────────────────────────────────────

def list_watchlist():
    init_db()
    items = db_list_watchlist(only_watched=True)
    print(json.dumps({
        "total_watchlist": len(items),
        "watchlist": items,
        "portfolio_db": str(DB_PATH),
        "updated_at": datetime.now().isoformat(),
    }, ensure_ascii=False, indent=2))


def add_watch(code: str):
    init_db()
    watch = ensure_watch_item(code, watched=True)
    print(json.dumps({"message": f"已关注 {watch['code']}", "watch": watch}, ensure_ascii=False, indent=2))


def remove_watch(code: str):
    init_db()
    normalized_code = normalize_code(code)
    watch = set_watch_status(normalized_code, False)
    if not watch:
        print(json.dumps({"error": f"{normalized_code} 不在关注池中"}, ensure_ascii=False))
        return
    print(json.dumps({"message": f"已取消关注 {normalized_code}", "watch": watch}, ensure_ascii=False, indent=2))


# ─────────────────────────────────────────────
#  批量分析
# ─────────────────────────────────────────────

def analyze_portfolio(output_file: str = None):
    init_db()
    positions = db_list_positions()
    if not positions:
        print(json.dumps({"message": "持仓为空，无法分析"}, ensure_ascii=False, indent=2))
        return

    results = []
    account_totals = defaultdict(lambda: {"cost": 0.0, "market_value": 0.0})
    market_currency_totals = defaultdict(lambda: {"cost": 0.0, "market_value": 0.0})

    for i, pos in enumerate(positions):
        code = pos["code"]
        print(f"正在分析 {code} ({i+1}/{len(positions)})...", file=sys.stderr)
        analysis = analyze_stock(code)
        analysis["portfolio_info"] = derive_position_snapshot(pos, analysis)
        results.append(analysis)

        snapshot = analysis["portfolio_info"]
        market_value = snapshot.get("market_value") or 0.0
        cost = snapshot.get("cost") or 0.0

        account_name = snapshot.get("account", {}).get("name") or "未分配账户"
        account_totals[account_name]["cost"] += cost
        account_totals[account_name]["market_value"] += market_value

        mc_key = f"{snapshot.get('market') or 'UNKNOWN'}:{snapshot.get('currency') or 'UNKNOWN'}"
        market_currency_totals[mc_key]["cost"] += cost
        market_currency_totals[mc_key]["market_value"] += market_value

        if i < len(positions) - 1 and not analysis.get("_from_cache"):
            time.sleep(2)

    total_cost = sum(r.get("portfolio_info", {}).get("cost", 0) or 0 for r in results)
    total_value = sum(r.get("portfolio_info", {}).get("market_value", 0) or 0 for r in results)
    total_pnl = total_value - total_cost

    for analysis in results:
        snapshot = analysis.get("portfolio_info", {})
        market_value = snapshot.get("market_value") or 0.0
        account = snapshot.get("account") or {}
        account_name = account.get("name") or "未分配账户"
        account_total_value = account_totals[account_name]["market_value"]
        snapshot[[REDACTED_SECRET]] = round(market_value / total_value * 100, 2) if total_value > 0 else None
        snapshot["position_weight_of_account_pct"] = round(market_value / account_total_value * 100, 2) if account_total_value > 0 else None

    summary = {
        "analysis_time": datetime.now().isoformat(),
        "total_positions": len(results),
        "total_cost": round(total_cost, 2),
        "total_market_value": round(total_value, 2),
        "total_pnl": round(total_pnl, 2),
        "total_pnl_pct": round(total_pnl / total_cost * 100, 2) if total_cost > 0 else 0,
        "accounts": [
            {
                "name": name,
                "total_cost": round(v["cost"], 2),
                "total_market_value": round(v["market_value"], 2),
                "total_pnl": round(v["market_value"] - v["cost"], 2),
            }
            for name, v in sorted(account_totals.items())
        ],
        "market_currency_breakdown": [
            {
                "market_currency": key,
                "total_cost": round(v["cost"], 2),
                "total_market_value": round(v["market_value"], 2),
                "total_pnl": round(v["market_value"] - v["cost"], 2),
            }
            for key, v in sorted(market_currency_totals.items())
        ],
        "positions": results,
    }

    output = json.dumps(summary, ensure_ascii=False, indent=2, default=str)

    if output_file:
        with open(output_file, "w", encoding="utf-8") as f:
            f.write(output)
        print(f"分析结果已保存至 {output_file}", file=sys.stderr)

    print(output)


def main():
    parser = argparse.ArgumentParser(description="多市场股票持仓/关注池管理工具")
    subparsers = parser.add_subparsers(dest="command", help="子命令")

    subparsers.add_parser("list", help="列出所有持仓")
    subparsers.add_parser("account-list", help="列出账户")

    add_parser = subparsers.add_parser("add", help="添加持仓")
    add_parser.add_argument("code", help="股票代码")
    add_parser.add_argument("--price", type=float, required=True, help="买入价格")
    add_parser.add_argument("--shares", type=int, required=True, help="持有数量")
    add_parser.add_argument("--date", help="买入日期 (YYYY-MM-DD)")
    add_parser.add_argument("--note", default="", help="备注")
    add_parser.add_argument("--account", help="账户名或账户ID")

    rm_parser = subparsers.add_parser("remove", help="移除持仓")
    rm_parser.add_argument("code", help="股票代码")

    up_parser = subparsers.add_parser("update", help="更新持仓")
    up_parser.add_argument("code", help="股票代码")
    up_parser.add_argument("--price", type=float, help="买入价格")
    up_parser.add_argument("--shares", type=int, help="持有数量")
    up_parser.add_argument("--note", help="备注")
    up_parser.add_argument("--account", help="账户名或账户ID")

    analyze_parser = subparsers.add_parser("analyze", help="批量分析持仓")
    analyze_parser.add_argument("--output", help="输出JSON文件")

    account_parser = subparsers.add_parser("account-upsert", help="新增或更新账户")
    account_parser.add_argument("name", help="账户名")
    account_parser.add_argument("--market", help="市场，例如 HK/CN/US")
    account_parser.add_argument("--currency", help="币种，例如 HKD/CNY/USD")
    account_parser.add_argument("--cash", type=float, help="账户总现金")
    account_parser.add_argument("--available-cash", type=float, help="账户可用现金")
    account_parser.add_argument("--note", default="", help="备注")

    rule_parser = subparsers.add_parser("rule-set", help="设置股票交易规则")
    rule_parser.add_argument("code", help="股票代码")
    rule_parser.add_argument("--lot-size", type=int, help="一手股数")
    rule_parser.add_argument("--tick-size", type=float, help="最小价位变动")
    rule_parser.add_argument("--odd-lot", action="store_true", help="允许碎股")

    subparsers.add_parser("watch-list", help="列出关注池")
    watch_add_parser = subparsers.add_parser("watch-add", help="添加关注股票")
    watch_add_parser.add_argument("code", help="股票代码")
    watch_remove_parser = subparsers.add_parser("watch-remove", help="取消关注股票")
    watch_remove_parser.add_argument("code", help="股票代码")

    args = parser.parse_args()

    try:
        if args.command == "list":
            list_positions()
        elif args.command == "add":
            add_position(args.code, args.price, args.shares, args.date, args.note, args.account)
        elif args.command == "remove":
            remove_position(args.code)
        elif args.command == "update":
            update_position(args.code, args.price, args.shares, args.note, args.account)
        elif args.command == "analyze":
            analyze_portfolio(args.output)
        elif args.command == "account-list":
            list_accounts()
        elif args.command == "account-upsert":
            save_account(args.name, args.market, args.currency, args.cash, args.available_cash, args.note)
        elif args.command == "rule-set":
            set_rule(args.code, args.lot_size, args.tick_size, args.odd_lot)
        elif args.command == "watch-list":
            list_watchlist()
        elif args.command == "watch-add":
            add_watch(args.code)
        elif args.command == "watch-remove":
            remove_watch(args.code)
        else:
            parser.print_help()
    except ValueError as e:
        print(json.dumps({"error": str(e)}, ensure_ascii=False, indent=2))
        sys.exit(1)


if __name__ == "__main__":
    main()


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