Files
stockbuddy/scripts/portfolio_manager.py

259 lines
9.0 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
港股持仓管理工具 - 管理持仓列表并批量分析。
用法:
python3 portfolio_manager.py list
python3 portfolio_manager.py add <代码> --price <买入价> --shares <数量> [--date <日期>] [--note <备注>]
python3 portfolio_manager.py remove <代码>
python3 portfolio_manager.py update <代码> [--price <价格>] [--shares <数量>] [--note <备注>]
python3 portfolio_manager.py analyze [--output <输出文件>]
持仓文件默认保存在: ~/.stockbuddy/portfolio.json
"""
import sys
import json
import argparse
import os
import time
from datetime import datetime
from pathlib import Path
DATA_DIR = Path.home() / ".stockbuddy"
PORTFOLIO_PATH = DATA_DIR / "portfolio.json"
LEGACY_PORTFOLIO_PATH = Path.home() / ".hk_stock_portfolio.json"
def load_portfolio() -> dict:
"""加载持仓数据"""
if not PORTFOLIO_PATH.exists() and LEGACY_PORTFOLIO_PATH.exists():
DATA_DIR.mkdir(parents=True, exist_ok=True)
PORTFOLIO_PATH.write_text(LEGACY_PORTFOLIO_PATH.read_text(encoding="utf-8"), encoding="utf-8")
if not PORTFOLIO_PATH.exists():
return {"positions": [], "updated_at": None}
with open(PORTFOLIO_PATH, "r", encoding="utf-8") as f:
return json.load(f)
def save_portfolio(data: dict):
"""保存持仓数据"""
data["updated_at"] = datetime.now().isoformat()
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(PORTFOLIO_PATH, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
def normalize_code(code: str) -> str:
"""标准化港股代码"""
code = code.strip().upper()
if not code.endswith(".HK"):
digits = code.lstrip("0")
if digits.isdigit():
code = code.zfill(4) + ".HK"
return code
def list_positions():
"""列出所有持仓"""
portfolio = load_portfolio()
positions = portfolio.get("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_file": str(PORTFOLIO_PATH),
"updated_at": portfolio.get("updated_at"),
}, ensure_ascii=False, indent=2))
def add_position(code: str, price: float, shares: int, date: str = None, note: str = ""):
"""添加持仓"""
code = normalize_code(code)
portfolio = load_portfolio()
positions = portfolio.get("positions", [])
# 检查是否已存在
for pos in positions:
if pos["code"] == code:
print(json.dumps({"error": f"{code} 已在持仓中,请使用 update 命令更新"}, ensure_ascii=False))
return
position = {
"code": code,
"buy_price": price,
"shares": shares,
"buy_date": date or datetime.now().strftime("%Y-%m-%d"),
"note": note,
"added_at": datetime.now().isoformat(),
}
positions.append(position)
portfolio["positions"] = positions
save_portfolio(portfolio)
print(json.dumps({"message": f"已添加 {code}", "position": position}, ensure_ascii=False, indent=2))
def remove_position(code: str):
"""移除持仓"""
code = normalize_code(code)
portfolio = load_portfolio()
positions = portfolio.get("positions", [])
new_positions = [p for p in positions if p["code"] != code]
if len(new_positions) == len(positions):
print(json.dumps({"error": f"{code} 不在持仓中"}, ensure_ascii=False))
return
portfolio["positions"] = new_positions
save_portfolio(portfolio)
print(json.dumps({"message": f"已移除 {code}"}, ensure_ascii=False, indent=2))
def update_position(code: str, price: float = None, shares: int = None, note: str = None):
"""更新持仓信息"""
code = normalize_code(code)
portfolio = load_portfolio()
positions = portfolio.get("positions", [])
found = False
for pos in positions:
if pos["code"] == code:
if price is not None:
pos["buy_price"] = price
if shares is not None:
pos["shares"] = shares
if note is not None:
pos["note"] = note
pos["updated_at"] = datetime.now().isoformat()
found = True
print(json.dumps({"message": f"已更新 {code}", "position": pos}, ensure_ascii=False, indent=2))
break
if not found:
print(json.dumps({"error": f"{code} 不在持仓中"}, ensure_ascii=False))
return
portfolio["positions"] = positions
save_portfolio(portfolio)
def analyze_portfolio(output_file: str = None):
"""批量分析所有持仓"""
# 延迟导入避免未安装yfinance时也能管理持仓
try:
from analyze_stock import analyze_stock
except ImportError:
# 尝试从同目录导入
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from analyze_stock import analyze_stock
portfolio = load_portfolio()
positions = portfolio.get("positions", [])
if not positions:
print(json.dumps({"message": "持仓为空,无法分析"}, ensure_ascii=False, indent=2))
return
results = []
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", ""),
}
results.append(analysis)
# 批量请求间隔:避免连续请求触发限频(最后一只不需要等待)
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_pnl = total_value - total_cost
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,
"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="子命令")
# list
subparsers.add_parser("list", help="列出所有持仓")
# add
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="备注")
# remove
rm_parser = subparsers.add_parser("remove", help="移除持仓")
rm_parser.add_argument("code", help="股票代码")
# update
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="备注")
# analyze
analyze_parser = subparsers.add_parser("analyze", help="批量分析持仓")
analyze_parser.add_argument("--output", help="输出JSON文件")
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)
else:
parser.print_help()
if __name__ == "__main__":
main()