From 5d2482c9ba9cfab37ab6aebb81ccef87866ccc42 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 2 Apr 2026 01:26:10 +0800 Subject: [PATCH] Add account-aware execution constraints --- SKILL.md | 203 +++++++++++++++------------ scripts/db.py | 190 +++++++++++++++++++++++-- scripts/portfolio_manager.py | 262 ++++++++++++++++++++++++++++------- 3 files changed, 514 insertions(+), 141 deletions(-) diff --git a/SKILL.md b/SKILL.md index 6182040..1a3c016 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,143 +1,176 @@ --- name: stockbuddy -description: 多市场股票分析助手,提供 A 股、港股、美股的技术面和基础估值分析,给出买入/卖出操作建议。支持单只股票查询分析、持仓批量分析、关注股票管理和持仓管理。当用户提到股票分析、持仓分析、关注股票、买入建议、卖出建议,或直接提供股票代码 / 股票名称请求分析时触发此技能。 +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) +# StockBuddy -## 概述 +## Overview -A 股、港股、美股的技术面与基础估值综合分析工具,输出量化评分和明确操作建议(强烈买入/买入/持有/卖出/强烈卖出)。默认以**决策优先**的方式返回结果:先给简明结论、评分/置信度、事件修正后的二次建议与挂单实操建议;只有在用户明确要求时才展开完整长报告。 +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. -四大核心场景: -1. **单只股票分析** — 对指定股票进行完整技术面+基本面分析,给出操作建议 -2. **持仓批量分析** — 对用户所有持仓股票批量分析,给出各股操作建议和整体盈亏统计 -3. **持仓管理** — 增删改查持仓记录 -4. **关注池管理** — 增删改查关注股票,并记录股票基本信息 +**Design principle: separate durable facts from real-time derived values.** +- **Persist durable facts**: share count, cost basis, account, cash balance, 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 as durable truth in the database; compute them during analysis to avoid stale data and hallucination drift -## 环境准备 +Five core scenarios: +1. **Single-stock analysis** — Analyze one stock with technical + basic valuation signals and produce an action recommendation +2. **Batch portfolio analysis** — Analyze all current positions and summarize both stock-level actions and overall portfolio status +3. **Position management** — Add, update, remove, and inspect positions +4. **Account and allocation management** — Track account, cash, market/currency, and use them to compute position weights and execution constraints +5. **Watchlist management** — Add, remove, and inspect watched stocks while storing basic stock metadata and trading rules -仅在依赖缺失,或脚本运行时报缺包错误时,再执行安装脚本: +## 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 ``` -所需依赖:`numpy`、`pandas`、Python 内置 `sqlite3`(无需 yfinance,已改用腾讯财经数据源) +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. **识别股票代码** - - 港股:标准化为 `XXXX.HK` - - A 股:标准化为 `SH600519` / `SZ000001` - - 美股:标准化为 `AAPL` / `TSLA` - - 用户提供中文名称时,可先根据上下文判断市场;无法唯一匹配时再向用户确认 +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. **执行分析脚本** +2. **Run the analysis script** ```bash - python3 {{SKILL_DIR}}/scripts/analyze_stock.py <代码> --period 6mo + python3 {{SKILL_DIR}}/scripts/analyze_stock.py --period 6mo ``` - 可选周期参数:`1mo` / `3mo` / `6mo`(默认)/ `1y` / `2y` / `5y` + Optional period values: `1mo` / `3mo` / `6mo` (default) / `1y` / `2y` / `5y` - **数据与缓存机制**:原始日线 K 线、关注池、持仓数据统一保存在 `~/.stockbuddy/stockbuddy.db`(SQLite)。持仓记录通过 `watchlist_id` 关联关注股票主键。分析结果单独写入 SQLite 缓存表,默认 TTL 为 10 分钟,写入时自动清理过期缓存,并将总缓存条数控制在 1000 条以内。若用户明确要求"刷新数据"或"重新分析",加 `--no-cache` 参数强制刷新。清除分析缓存:`--clear-cache`。 + **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. **解读并呈现结果** - - 脚本输出 JSON 格式分析数据 - - **默认单股分析优先使用** `references/output_templates.md` 中的 **“默认查询模板”**:先返回最重要的决策信息,必须包含:股票基本信息、基于数据面的操作建议(含评分与置信度)、重要事件、事件加持后的二次分析建议,以及最终的挂单实操建议 - - **挂单风格默认走平衡型**:若用户未特别指定,挂单价格按 `references/output_templates.md` 中的“挂单价格生成规范”采用平衡型;只有当用户明确要求“保守版”或“激进版”时才切换 - - **完整报告仅在用户明确要求时输出**:当用户说"完整报告"、"详细分析"、"完整分析详情"、"全量报告"等,再按同文件中的 **“完整报告组合规则”**,用多个原子模板拼装完整报告 - - **顶部完整分析详情为必选项**:无论默认查询还是完整报告,都必须先给出 2-4 句话的自然语言完整分析详情,概括市场场景、主建议、置信度、支撑/风险点,以及是否适合立刻操作 - - **仅当用户明确追问细节时**(如"展开讲讲"、"为什么是这个评级"、"短线怎么看"、"止盈止损怎么设"、"详细分析"),才切换为更自然的开放式解读,围绕用户追问点展开说明 - - 最终结果直接输出为标准 Markdown 正文,不要包在代码块里;默认优先短段落、项目符号和卡片式结构,除非用户明确要求,否则不要自动展开过多宽表格 +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. -**步骤:** +**Execution realism matters more than surface correctness.** When producing portfolio advice, always consider account cash, market/currency, lot size, odd-lot support, and current share count. If the user holds only one lot and odd-lot selling is not supported, do **not** suggest "trim a little" or "sell half". Instead, output only truly executable actions such as "hold" or "sell the full lot". -1. **检查持仓数据** +**Steps:** + +1. **Check portfolio data** ```bash python3 {{SKILL_DIR}}/scripts/portfolio_manager.py list ``` - 持仓数据保存在 `~/.stockbuddy/stockbuddy.db` 的 `positions` 表。 + Portfolio data is stored in the `positions` table in `~/.stockbuddy/stockbuddy.db`. -2. **持仓为空时** → 引导用户添加持仓(参见场景三的添加操作) +2. **If the portfolio is empty** → guide the user to add positions first (see Scenario 3) -3. **执行批量分析** +3. **Run batch analysis** ```bash python3 {{SKILL_DIR}}/scripts/portfolio_manager.py analyze ``` -4. **解读并呈现结果** - - 按 `references/output_templates.md` 中"持仓批量分析报告"模板呈现 - - 直接输出为标准 Markdown 正文,不要包在代码块里;可使用规范 Markdown 表格与列表混合呈现,保证不同平台可读性 - - 包含每只股票的操作建议和整体盈亏汇总 +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 -触发示例:"添加腾讯持仓"、"我买了 100 股比亚迪"、"删除阿里持仓" +Trigger examples: "add a Tencent position", "I bought 100 shares of BYD", "remove Alibaba from my holdings" -| 操作 | 命令 | +| Action | Command | |------|------| -| 添加 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>]` | -| 查看 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py list` | -| 更新 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>]` | -| 移除 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py remove <代码>` | +| Add position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py add --price --shares [--date ] [--note ] [--account ]` | +| List positions | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py list` | +| Update position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py update [--price ] [--shares ] [--note ] [--account ]` | +| Remove position | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py remove ` | +| List accounts | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py account-list` | +| Create/update account | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py account-upsert [--market ] [--currency ] [--cash ] [--available-cash ] [--note ]` | +| Set trading rule | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py rule-set [--lot-size ] [--tick-size ] [--odd-lot]` | -添加持仓时会自动确保该股票存在于关注池,并通过 `positions.watchlist_id -> watchlist.id` 关联。若用户未提供日期,默认使用当天日期。若用户提供了自然语言信息(如"我上周花 350 买了 100 股腾讯"),提取价格、数量、日期等参数后执行命令。 +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. -### 场景四:关注池管理 +### 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 | |------|------| -| 查看关注池 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-list` | -| 添加关注 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-add <代码>` | -| 取消关注 | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-remove <代码>` | +| List watchlist | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-list` | +| Add watch item | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-add ` | +| Remove watch item | `python3 {{SKILL_DIR}}/scripts/portfolio_manager.py watch-remove ` | +## Analysis Methodology -## 分析方法论 +The scoring system combines technicals (roughly 60% weight) and basic valuation (roughly 40% weight). Final score range is approximately -10 to +10: -综合评分体系覆盖技术面(约 60% 权重)和基本面(约 40% 权重),最终评分范围约 -10 到 +10: - -| 评分区间 | 操作建议 | +| Score Range | Recommendation | |----------|----------| -| ≥ 5 | 🟢🟢 强烈买入 | -| 2 ~ 4 | 🟢 买入 | -| -1 ~ 1 | 🟡 持有/观望 | -| -4 ~ -2 | 🔴 卖出 | -| ≤ -5 | 🔴🔴 强烈卖出 | +| ≥ 5 | 🟢🟢 Strong Buy | +| 2 ~ 4 | 🟢 Buy | +| -1 ~ 1 | 🟡 Hold / Watch | +| -4 ~ -2 | 🔴 Sell | +| ≤ -5 | 🔴🔴 Strong Sell | -仅当用户要求解释评分逻辑、技术指标含义,或你需要校准开放式解读时,再读取 `references/technical_indicators.md`。 +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. -当需要组织最终输出格式、决定默认查询 vs 完整报告、或生成挂单实操建议时,优先读取 `references/output_templates.md`;其中已经定义了默认查询模板、原子模板、完整报告组合规则,以及保守型/平衡型/激进型挂单价格生成规范(默认平衡型)。 +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 +- If key execution constraints are missing — such as account data, cash, lot size, or odd-lot support — explicitly say the output is only a **directional recommendation**, not a complete execution-ready trading plan +- 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` | 核心分析脚本,获取数据并计算技术指标和基本面评分 | -| `scripts/portfolio_manager.py` | 持仓管理脚本,支持增删改查和批量分析 | -| `scripts/install_deps.sh` | Python 依赖安装脚本 | -| `references/technical_indicators.md` | 技术指标详解和评分标准 | -| `references/output_templates.md` | 分析输出模板总控:默认查询模板、原子模板、完整报告组合规则、挂单价格生成规范 | -| `references/data-source-roadmap.md` | 数据源升级路线图:主源 / fallback / 事件层规划;仅在需要扩展数据源或接入事件信息时读取 | +| `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 | diff --git a/scripts/db.py b/scripts/db.py index cb71c68..4488275 100644 --- a/scripts/db.py +++ b/scripts/db.py @@ -48,15 +48,24 @@ def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: 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: - _migrate_schema(conn) conn.executescript( """ CREATE TABLE IF NOT EXISTS watchlist ( @@ -83,6 +92,31 @@ def init_db() -> None: 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, @@ -102,13 +136,15 @@ def init_db() -> None: 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 (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 ( @@ -142,6 +178,7 @@ def init_db() -> None: ON aux_cache (code, category, created_at DESC); """ ) + _migrate_schema(conn) conn.commit() @@ -408,6 +445,107 @@ def set_watch_status(code: str, watched: bool) -> dict | None: 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: @@ -504,6 +642,12 @@ def list_positions() -> list[dict]: """ 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, @@ -513,9 +657,15 @@ def list_positions() -> list[dict]: p.buy_date, p.note, p.created_at AS added_at, - p.updated_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() @@ -529,6 +679,12 @@ def get_position(code: str) -> dict | None: """ 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, @@ -538,9 +694,15 @@ def get_position(code: str) -> dict | None: p.buy_date, p.note, p.created_at AS added_at, - p.updated_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,), @@ -557,6 +719,7 @@ def upsert_position( 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, @@ -574,21 +737,23 @@ def upsert_position( now = _utc_now_iso() with get_connection() as conn: existing = conn.execute( - "SELECT created_at FROM positions WHERE watchlist_id = ?", (watch["id"],) + "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, buy_price, shares, buy_date, note, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + 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"], buy_price, shares, buy_date, note, created_at, now), + (watch["id"], account_id_value, buy_price, shares, buy_date, note, created_at, now), ) conn.commit() return get_position(code) @@ -605,7 +770,13 @@ def remove_position(code: str) -> bool: return cur.rowcount > 0 -def update_position_fields(code: str, price: float | None = None, shares: int | None = None, note: str | None = None) -> dict | None: +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 @@ -618,6 +789,7 @@ def update_position_fields(code: str, price: float | None = None, shares: int | 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, diff --git a/scripts/portfolio_manager.py b/scripts/portfolio_manager.py index f7731a0..cfb4905 100755 --- a/scripts/portfolio_manager.py +++ b/scripts/portfolio_manager.py @@ -4,10 +4,13 @@ 用法: python3 portfolio_manager.py list - python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>] + python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>] [--account <账户名或ID>] python3 portfolio_manager.py remove <代码> - python3 portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>] + 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 <代码> @@ -20,19 +23,24 @@ 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 @@ -41,14 +49,18 @@ except ImportError: 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 @@ -58,6 +70,15 @@ 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"]) @@ -81,6 +102,65 @@ def ensure_watch_item(code: str, watched: bool = False) -> dict: ) +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, + } + + # ───────────────────────────────────────────── # 持仓管理 # ───────────────────────────────────────────── @@ -99,7 +179,7 @@ def list_positions(): }, ensure_ascii=False, indent=2)) -def add_position(code: str, price: float, shares: int, date: str = None, note: str = ""): +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) @@ -107,6 +187,7 @@ def add_position(code: str, price: float, shares: int, date: str = None, note: s 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"], @@ -116,6 +197,7 @@ def add_position(code: str, price: float, shares: int, date: str = None, note: s 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, @@ -133,16 +215,53 @@ def remove_position(code: str): 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): +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) - position = update_position_fields(normalized_code, price=price, shares=shares, note=note) + 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)) + + # ───────────────────────────────────────────── # 关注池管理 # ───────────────────────────────────────────── @@ -186,40 +305,44 @@ def analyze_portfolio(output_file: str = None): 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) - - if analysis.get("current_price") and pos.get("buy_price"): - current = analysis["current_price"] - buy = pos["buy_price"] - shares = pos.get("shares", 0) - pnl = (current - buy) * shares - pnl_pct = (current - buy) / buy * 100 - - analysis["portfolio_info"] = { - "buy_price": buy, - "shares": shares, - "buy_date": pos.get("buy_date"), - "cost": round(buy * shares, 2), - "market_value": round(current * shares, 2), - "pnl": round(pnl, 2), - "pnl_pct": round(pnl_pct, 2), - "note": pos.get("note", ""), - "currency": pos.get("currency"), - "market": pos.get("market"), - } - + 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) for r in results) - total_value = sum(r.get("portfolio_info", {}).get("market_value", 0) for r in results) + 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["position_weight_of_portfolio_pct"] = 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), @@ -227,6 +350,24 @@ def analyze_portfolio(output_file: str = None): "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, } @@ -245,6 +386,7 @@ def main(): 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="股票代码") @@ -252,6 +394,7 @@ def main(): 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="股票代码") @@ -261,11 +404,26 @@ def main(): 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文件") - watch_list_parser = subparsers.add_parser("watch-list", help="列出关注池") + 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="取消关注股票") @@ -273,24 +431,34 @@ def main(): args = parser.parse_args() - if args.command == "list": - list_positions() - elif args.command == "add": - add_position(args.code, args.price, args.shares, args.date, args.note) - elif args.command == "remove": - remove_position(args.code) - elif args.command == "update": - update_position(args.code, args.price, args.shares, args.note) - elif args.command == "analyze": - analyze_portfolio(args.output) - 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() + 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__":