- stock_tracker.py now uses yfinance for real prices - get_market_indices(): KOSPI, S&P 500, NASDAQ, DOW - get_crypto_price(): BTC, ETH, SOL with 52W range - CLI commands: 'python stock_tracker.py market' and 'crypto' Features: - Live prices from Yahoo Finance - Market indices tracking - Cryptocurrency prices - 52-week high/low - Daily change percentage Example usage: python stock_tracker.py market # Show indices python stock_tracker.py crypto --symbol BTC # BTC price
505 lines
18 KiB
Python
505 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Stock & Crypto Portfolio Tracker
|
||
Features:
|
||
- Track stocks and crypto prices
|
||
- Calculate portfolio P&L
|
||
- Compare against market indices
|
||
- Generate investment recommendations based on guidelines
|
||
- 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()
|