#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Stock & Crypto Portfolio Tracker / 주식 및 암호화폐 포트폴리오 트래커 # 기능 / Features: # - 주식 및 암호화폐 가격 추적 / Track stocks and crypto prices # - 포트폴리오 P&L 계산 / Calculate portfolio P&L # - 시장 지수 비교 / Compare against market indices # - 투자 권고사항 생성 / Generate investment recommendations # - 일일/주간 리포트 / Daily/weekly reports """ import os import json import datetime from dataclasses import dataclass, field, asdict from typing import List, Dict, Optional, Tuple from enum import Enum import requests # Try import yfinance for real market data try: import yfinance as yf YFINANCE_AVAILABLE = True except ImportError: YFINANCE_AVAILABLE = False print("⚠️ yfinance not installed. Run: pip install yfinance") # Configuration DATA_DIR = os.environ.get('PORTFOLIO_DATA_DIR', '/tmp/portfolio') os.makedirs(DATA_DIR, exist_ok=True) PORTFOLIO_FILE = os.path.join(DATA_DIR, 'portfolio.json') PRICES_FILE = os.path.join(DATA_DIR, 'prices.json') PERFORMANCE_FILE = os.path.join(DATA_DIR, 'performance.json') # Market API (placeholder - would use real API) YAHOO_API = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}" KRX_API = "https://api.ksicore.net/v1/stock/quote" class AssetType(Enum): STOCK = "stock" CRYPTO = "crypto" ETF = "etf" @dataclass class Position: symbol: str asset_type: str quantity: float avg_cost: float entry_date: str = "" notes: str = "" @dataclass class PriceData: symbol: str current_price: float change_percent: float high_52w: float = 0 low_52w: float = 0 volume: float = 0 updated_at: str = "" @dataclass class PortfolioSummary: total_value: float total_cost: float total_pnl: float total_pnl_percent: float positions: List[Dict] top_performer: Optional[Dict] worst_performer: Optional[Dict] market_comparison: Dict = field(default_factory=dict) class StockTracker: """Stock & Crypto Portfolio Tracker""" def __init__(self): self.positions = self._load_positions() self.prices = self._load_prices() # ============== 데이터 관리 / Data Management ============== def _load_positions(self) -> Dict[str, Position]: if os.path.exists(PORTFOLIO_FILE): with open(PORTFOLIO_FILE, 'r') as f: data = json.load(f) return {k: Position(**v) for k, v in data.items()} return {} def _save_positions(self): with open(PORTFOLIO_FILE, 'w') as f: json.dump({k: asdict(v) for k, v in self.positions.items()}, f, indent=2) def _load_prices(self) -> Dict[str, PriceData]: if os.path.exists(PRICES_FILE): with open(PRICES_FILE, 'r') as f: return {k: PriceData(**v) for k, v in json.load(f).items()} return {} def _save_prices(self): with open(PRICES_FILE, 'w') as f: json.dump({k: asdict(v) for k, v in self.prices.items()}, f, indent=2) # ============== 포트폴리오 관리 / Portfolio Management ============== def add_position(self, symbol: str, asset_type: str, quantity: float, avg_cost: float, entry_date: str = "", notes: str = "") -> bool: """Add a new position to portfolio""" try: key = f"{asset_type}_{symbol.upper()}" self.positions[key] = Position( symbol=symbol.upper(), asset_type=asset_type, quantity=quantity, avg_cost=avg_cost, entry_date=entry_date or datetime.datetime.now().strftime('%Y-%m-%d'), notes=notes ) self._save_positions() return True except Exception as e: print(f"Error adding position: {e}") return False def remove_position(self, symbol: str, asset_type: str) -> bool: """Remove a position from portfolio""" key = f"{asset_type}_{symbol.upper()}" if key in self.positions: del self.positions[key] self._save_positions() return True return False def get_positions(self) -> List[Position]: return list(self.positions.values()) # ============== 가격 가져오기 / Price Fetching ============== def fetch_price(self, symbol: str) -> Optional[PriceData]: """Fetch current price for a symbol using yfinance""" if YFINANCE_AVAILABLE: try: # Add .KS for Korean stocks, normal for others ticker = yf.Ticker(symbol) info = ticker.info current_price = info.get('currentPrice', info.get('regularMarketPrice', 0)) change_percent = info.get('regularMarketChangePercent', 0) * 100 high_52w = info.get('fiftyTwoWeekHigh', 0) low_52w = info.get('fiftyTwoWeekLow', 0) volume = info.get('volume', 0) return PriceData( symbol=symbol, current_price=current_price, change_percent=change_percent, high_52w=high_52w, low_52w=low_52w, volume=volume, updated_at=datetime.datetime.now().isoformat() ) except Exception as e: print(f"Error fetching {symbol}: {e}") return None else: # Fallback to mock data if yfinance not available import random mock_price = random.uniform(10000, 500000) mock_change = random.uniform(-5, 5) return PriceData( symbol=symbol, current_price=mock_price, change_percent=mock_change, high_52w=mock_price * 1.2, low_52w=mock_price * 0.8, volume=random.uniform(100000, 10000000), updated_at=datetime.datetime.now().isoformat() ) def update_prices(self) -> Dict[str, PriceData]: """Update all prices""" for key, pos in self.positions.items(): price = self.fetch_price(pos.symbol) if price: self.prices[key] = price self._save_prices() return self.prices # ============== 성과 계산 / Performance Calculation ============== def calculate_portfolio_summary(self) -> PortfolioSummary: """Calculate portfolio summary with P&L""" total_value = 0 total_cost = 0 positions_data = [] for key, pos in self.positions.items(): price = self.prices.get(key) if price: current_value = pos.quantity * price.current_price cost_basis = pos.quantity * pos.avg_cost pnl = current_value - cost_basis pnl_percent = (pnl / cost_basis) * 100 if cost_basis > 0 else 0 positions_data.append({ 'symbol': pos.symbol, 'type': pos.asset_type, 'quantity': pos.quantity, 'avg_cost': pos.avg_cost, 'current_price': price.current_price, 'current_value': current_value, 'cost_basis': cost_basis, 'pnl': pnl, 'pnl_percent': pnl_percent, 'change_24h': price.change_percent, '52w_high': price.high_52w, '52w_low': price.low_52w, }) total_value += current_value total_cost += cost_basis total_pnl = total_value - total_cost total_pnl_percent = (total_pnl / total_cost) * 100 if total_cost > 0 else 0 # Top/Worst performers sorted_positions = sorted(positions_data, key=lambda x: x['pnl_percent'], reverse=True) top_performer = sorted_positions[0] if sorted_positions else None worst_performer = sorted_positions[-1] if sorted_positions else None return PortfolioSummary( total_value=total_value, total_cost=total_cost, total_pnl=total_pnl, total_pnl_percent=total_pnl_percent, positions=positions_data, top_performer=top_performer, worst_performer=worst_performer ) # ============== Investment Recommendations ============== def check_investment_guidelines(self, symbol: str) -> Dict: """ Check if a stock meets investment guidelines Reference: 주식 투자 원칙 가이드.md """ # Placeholder - would fetch real data return { 'symbol': symbol, 'pbr': None, # Would fetch from data source 'roe': None, 'per': None, 'score': None, 'recommendation': None, 'checklist': { 'story_clear': False, 'earnings_uptrend': False, 'balance_sheet_healthy': False, 'capital_return_plan': False, 'governance_clean': False, 'market_liquidity': False, 'relative_strength': False, } } def get_recommendation(self) -> List[Dict]: """Generate investment recommendations based on guidelines""" # Filter positions that meet criteria recommendations = [] for key, pos in self.positions.items(): if pos.asset_type == 'stock': analysis = self.check_investment_guidelines(pos.symbol) recommendations.append({ 'symbol': pos.symbol, 'action': 'HOLD', 'reason': 'Review weekly', 'checklist_score': f"{sum(analysis['checklist'].values())}/7", 'pnl_percent': self._get_position_pnl(key) }) return recommendations def _get_position_pnl(self, key: str) -> float: pos = self.positions.get(key) price = self.prices.get(key) if pos and price: return ((price.current_price - pos.avg_cost) / pos.avg_cost) * 100 return 0 # ============== 암호화폐 및 시장 데이터 / Crypto & Market Data ============== def get_crypto_price(self, symbol: str = "BTC") -> Optional[PriceData]: """Fetch cryptocurrency price using yfinance""" if not YFINANCE_AVAILABLE: return None try: ticker = yf.Ticker(f"{symbol}-USD") hist = ticker.history(period='1d') hist_1d = ticker.history(period='2d') # Get 2 days for change if hist.empty: return None current_price = hist['Close'].iloc[-1] prev_close = hist_1d['Close'].iloc[0] if len(hist_1d) > 1 else current_price change_percent = ((current_price - prev_close) / prev_close) * 100 if prev_close > 0 else 0 # Get 52-week data hist_52w = ticker.history(period='1y') high_52w = hist_52w['High'].max() if not hist_52w.empty else current_price * 1.2 low_52w = hist_52w['Low'].min() if not hist_52w.empty else current_price * 0.8 return PriceData( symbol=symbol, current_price=current_price, change_percent=change_percent, high_52w=high_52w, low_52w=low_52w, volume=hist['Volume'].iloc[-1] if 'Volume' in hist.columns else 0, updated_at=datetime.datetime.now().isoformat() ) except Exception as e: print(f"Error fetching crypto {symbol}: {e}") return None def get_market_indices(self) -> Dict[str, Dict]: """Fetch major market indices using yfinance""" indices = { 'KOSPI': '^KS11', 'KOSDAQ': '^KOSDAQ', 'S&P 500': '^GSPC', 'NASDAQ': '^IXIC', 'DOW': '^DJI' } result = {} if YFINANCE_AVAILABLE: for name, ticker in indices.items(): try: t = yf.Ticker(ticker) hist = t.history(period='1d') hist_1d = t.history(period='2d') # Get 2 days for change calculation if not hist.empty: current = hist['Close'].iloc[-1] prev_close = hist_1d['Close'].iloc[0] if len(hist_1d) > 1 else current change = ((current - prev_close) / prev_close) * 100 if prev_close > 0 else 0 result[name] = {'price': current, 'change': change} else: result[name] = {'price': 0, 'change': 0} except Exception as e: print(f"Error fetching {name}: {e}") result[name] = {'price': 0, 'change': 0} return result # ============== 리포팅 / Reporting ============== def generate_daily_report(self) -> str: """Generate daily portfolio report""" summary = self.calculate_portfolio_summary() report = f""" 📊 **일일 포트폴리오 리포트** **Date:** {datetime.datetime.now().strftime('%Y-%m-%d')} 💰 **전체 현황** - 현재 가치: ₩{summary.total_value:,.0f} - 투자 원금: ₩{summary.total_cost:,.0f} - 총 손익: ₩{summary.total_pnl:,.0f} ({summary.total_pnl_percent:+.1f}%) 📈 **상위 수익** """ for pos in sorted(summary.positions, key=lambda x: x['pnl_percent'], reverse=True)[:3]: emoji = "🟢" if pos['pnl_percent'] > 0 else "🔴" report += f"- {emoji} **{pos['symbol']}**: {pos['pnl_percent']:+.1f}% (₩{pos['pnl']:,.0f})\n" report += "\n💡 **투자 원칙 체크**\n" report += "- ⬜ 3년 실적 우상향 확인\n" report += "- ⬜ PBR < 1 확인\n" report += "- ⬜ 추적 손절 –10% 설정\n" report += "- ⬜ 주 1회 점검 예정\n" return report def generate_weekly_report(self) -> str: """Generate weekly portfolio report""" summary = self.calculate_portfolio_summary() report = f""" 📈 **주간 포트폴리오 리포트** **Week:** {datetime.datetime.now().strftime('%Y-%W')} 🎯 **이번 주 목표** - [ ] 시장·섹터 상대강도 Top/Bottom 5 확인 - [ ] 관찰목록 체크리스트 재적용 - [ ] 엔트리·손절·추적손절 가격 기입 - [ ] 트레이드 로그 작성 💰 **포트폴리오 현황** | 항목 | 수치 | |------|------| | 총 가치 | ₩{summary.total_value:,.0f} | | 총 수익률 | {summary.total_pnl_percent:+.1f}% | | 베스트 | {summary.top_performer['symbol'] if summary.top_performer else 'N/A'} ({summary.top_performer['pnl_percent'] if summary.top_performer else 0:+.1f}%) | | 워스트 | {summary.worst_performer['symbol'] if summary.worst_performer else 'N/A'} ({summary.worst_performer['pnl_percent'] if summary.worst_performer else 0:+.1f}%) | 📋 **체크리스트 이행** - [ ] 가치 > 가격 확인 - [ ] –10% 손절 규칙 적용 - [ ] 핵심 2~5종목 집중 확인 """ return report # ============== CLI Interface ============== def main(): import argparse parser = argparse.ArgumentParser(description='Stock & Crypto Portfolio Tracker') subparsers = parser.add_subparsers(dest='command', help='Available commands') # Add position add_parser = subparsers.add_parser('add', help='Add a position') add_parser.add_argument('--symbol', required=True) add_parser.add_argument('--type', required=True, choices=['stock', 'crypto', 'etf']) add_parser.add_argument('--quantity', type=float, required=True) add_parser.add_argument('--cost', type=float, required=True) # Show portfolio subparsers.add_parser('show', help='Show portfolio summary') # Update prices subparsers.add_parser('update', help='Update prices from market') # Daily report subparsers.add_parser('daily', help='Generate daily report') # Weekly report subparsers.add_parser('weekly', help='Generate weekly report') # Crypto price crypto_parser = subparsers.add_parser('crypto', help='Get crypto price') crypto_parser.add_argument('--symbol', default='BTC', help='Crypto symbol (BTC, ETH, etc.)') # Market indices subparsers.add_parser('market', help='Show market indices') args = parser.parse_args() tracker = StockTracker() if args.command == 'add': tracker.add_position(args.symbol, args.type, args.quantity, args.cost) print(f"✅ Added {args.quantity} {args.symbol} @ ₩{args.cost}") elif args.command == 'show': summary = tracker.calculate_portfolio_summary() print(f"\n📊 Portfolio Summary") print(f"Total Value: ₩{summary.total_value:,.0f}") print(f"Total Cost: ₩{summary.total_cost:,.0f}") print(f"P&L: ₩{summary.total_pnl:,.0f} ({summary.total_pnl_percent:+.1f}%)") print(f"\nPositions ({len(summary.positions)}):") for pos in summary.positions: print(f" {pos['symbol']}: {pos['quantity']} @ ₩{pos['avg_cost']:,.0f} → ₩{pos['current_price']:,.0f} ({pos['pnl_percent']:+.1f}%)") elif args.command == 'update': prices = tracker.update_prices() print(f"✅ Updated {len(prices)} prices") elif args.command == 'daily': print(tracker.generate_daily_report()) elif args.command == 'weekly': print(tracker.generate_weekly_report()) elif args.command == 'crypto': price = tracker.get_crypto_price(args.symbol) if price: emoji = "🟢" if price.change_percent > 0 else "🔴" print(f"\n{emoji} {args.symbol}: ${price.current_price:,.2f} ({price.change_percent:+.2f}%)") print(f" 52W Range: ${price.low_52w:,.2f} - ${price.high_52w:,.2f}") else: print("❌ yfinance not available. Install: pip install yfinance") elif args.command == 'market': indices = tracker.get_market_indices() print("\n📈 Market Indices") for name, data in indices.items(): emoji = "🟢" if data['change'] > 0 else "🔴" print(f" {emoji} {name}: {data['price']:,.2f} ({data['change']:+.2f}%)") else: parser.print_help() if __name__ == '__main__': main()