259 lines
9.0 KiB
Python
Executable File
259 lines
9.0 KiB
Python
Executable File
#!/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()
|