Add: Stock tracker, Jenkins CI/CD pipeline, linting config
- stock_tracker.py: Portfolio tracking with P&L calculations - Jenkinsfile: Full CI/CD with linting, testing, deployment - test_requirements.txt: Testing dependencies - .pylintrc: Linting configuration - requirements.txt: Production dependencies Features: - Stock & crypto portfolio tracking - Investment guideline checks - Unit tests & linting pipeline - Integration tests for Oracle/Telegram/Gitea - Staging & Production deployment stages
This commit is contained in:
27
.pylintrc
Normal file
27
.pylintrc
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[MASTER]
|
||||||
|
ignore=venv,__pycache__,node_modules,build,dist
|
||||||
|
max-line-length=120
|
||||||
|
disable=C0114,C0115,C0116,R0801
|
||||||
|
|
||||||
|
[FORMAT]
|
||||||
|
max-line-length=120
|
||||||
|
|
||||||
|
[DESIGN]
|
||||||
|
max-args=10
|
||||||
|
max-locals=25
|
||||||
|
max-statements=100
|
||||||
|
|
||||||
|
[BASIC]
|
||||||
|
good-names=i,j,k,ex,Run,_
|
||||||
|
|
||||||
|
[IMPORTS]
|
||||||
|
known-standard-library=os,sys,json,datetime,requests
|
||||||
|
known-third-party=telegram,flask,oracledb,openai
|
||||||
|
|
||||||
|
[MESSAGES CONTROL]
|
||||||
|
extension-pkg-allow-list=telegram,oracledb
|
||||||
|
|
||||||
|
[COVERAGE]
|
||||||
|
coverage-append=true
|
||||||
|
coverage-report=term-missing
|
||||||
|
coverage-report=html
|
||||||
209
Jenkinsfile
vendored
Normal file
209
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
environment {
|
||||||
|
// Test database connections
|
||||||
|
ORACLE_DSN = credentials('oracle-dsn')
|
||||||
|
ORACLE_USER = credentials('oracle-user')
|
||||||
|
ORACLE_PASSWORD = credentials('oracle-password')
|
||||||
|
|
||||||
|
// Telegram Bot
|
||||||
|
TELEGRAM_BOT_TOKEN = credentials('telegram-bot-token')
|
||||||
|
|
||||||
|
// Git
|
||||||
|
GITEA_URL = 'http://localhost:3000'
|
||||||
|
GITEA_USER = 'joungmin'
|
||||||
|
GITEA_TOKEN = credentials('gitea-token')
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
script {
|
||||||
|
env.BUILD_ID = sh(returnStdout: true, script: 'git rev-parse HEAD').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Dependencies') {
|
||||||
|
steps {
|
||||||
|
echo 'Installing Python dependencies...'
|
||||||
|
sh '''
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -q -r requirements.txt
|
||||||
|
pip install -q -r test_requirements.txt
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Lint') {
|
||||||
|
steps {
|
||||||
|
echo 'Running linters...'
|
||||||
|
sh '''
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Python linting
|
||||||
|
flake8 . --max-line-length=120 --exclude=venv,__pycache__ || true
|
||||||
|
pylint --rcfile=.pylintrc *.py || true
|
||||||
|
|
||||||
|
# Security scanning
|
||||||
|
bandit -r . -f json -o bandit-report.json || true
|
||||||
|
|
||||||
|
# Dead code detection
|
||||||
|
vulture *.py --make-module || true
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
recordIssues(tools: [flake8(pattern: 'flake-report.txt')])
|
||||||
|
recordIssues(tools: [bandit(pattern: 'bandit-report.json')])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Unit Tests') {
|
||||||
|
steps {
|
||||||
|
echo 'Running unit tests...'
|
||||||
|
sh '''
|
||||||
|
source venv/bin/activate
|
||||||
|
pytest tests/ -v --tb=short --cov=. --cov-report=html --cov-report=xml
|
||||||
|
coverage xml -o coverage-report.xml
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit 'test-results.xml'
|
||||||
|
cobertura coberturaPackage: 'coverage.xml', failNoStubs: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Integration Tests') {
|
||||||
|
steps {
|
||||||
|
echo 'Running integration tests...'
|
||||||
|
sh '''
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Test Oracle connection
|
||||||
|
python3 -c "
|
||||||
|
import oracledb
|
||||||
|
conn = oracledb.connect(
|
||||||
|
user='${ORACLE_USER}',
|
||||||
|
password='${ORACLE_PASSWORD}',
|
||||||
|
dsn='${ORACLE_DSN}'
|
||||||
|
)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute('SELECT 1 FROM DUAL')
|
||||||
|
print('✅ Oracle connection successful')
|
||||||
|
conn.close()
|
||||||
|
"
|
||||||
|
|
||||||
|
# Test Telegram bot (ping)
|
||||||
|
curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe"
|
||||||
|
|
||||||
|
# Test Gitea API
|
||||||
|
curl -s -u "${GITEA_USER}:${GITEA_TOKEN}" "${GITEA_URL}/api/v1/user"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Build') {
|
||||||
|
steps {
|
||||||
|
echo 'Building application...'
|
||||||
|
sh '''
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
# Freeze dependencies
|
||||||
|
pip freeze > requirements.locked.txt
|
||||||
|
|
||||||
|
# Create executable scripts
|
||||||
|
chmod +x *.py
|
||||||
|
|
||||||
|
# Build Docker images if applicable
|
||||||
|
docker build -t openclaw-bot:${BUILD_ID} . || true
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to Staging') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
echo 'Deploying to staging server...'
|
||||||
|
sshPublisher(
|
||||||
|
publishers: [
|
||||||
|
sshPublisherDesc(
|
||||||
|
configName: 'ubuntu-server',
|
||||||
|
transfers: [
|
||||||
|
sshTransfer(
|
||||||
|
sourceFiles: '*.py',
|
||||||
|
remoteDirectory: '/home/joungmin/openclaw',
|
||||||
|
execCommand: 'cd /home/joungmin/openclaw && source venv/bin/activate && pip install -r requirements.txt && supervisorctl restart openclaw'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy to Production') {
|
||||||
|
when {
|
||||||
|
branch 'production'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
echo 'Deploying to production...'
|
||||||
|
// Manual approval required
|
||||||
|
input message: 'Deploy to production?'
|
||||||
|
sshPublisher(
|
||||||
|
publishers: [
|
||||||
|
sshPublisherDesc(
|
||||||
|
configName: 'production-server',
|
||||||
|
transfers: [
|
||||||
|
sshTransfer(
|
||||||
|
sourceFiles: '*.py',
|
||||||
|
remoteDirectory: '/home/joungmin/production',
|
||||||
|
execCommand: 'cd /home/joungmin/production && docker-compose pull && docker-compose up -d'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
echo 'Pipeline completed'
|
||||||
|
// Send notification
|
||||||
|
script {
|
||||||
|
def status = currentBuild.currentResult == 'SUCCESS' ? '✅' : '❌'
|
||||||
|
sh """
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot\${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d "chat_id=@your_channel" \
|
||||||
|
-d "text=${status} Pipeline completed: ${env.JOB_NAME} #\${env.BUILD_NUMBER}"
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success {
|
||||||
|
echo 'Build succeeded!'
|
||||||
|
archiveArtifacts artifacts: '**/*.py', fingerprint: true
|
||||||
|
}
|
||||||
|
|
||||||
|
failure {
|
||||||
|
echo 'Build failed!'
|
||||||
|
mail to: 'joungmin@example.com',
|
||||||
|
subject: "Failed Pipeline: ${env.JOB_NAME}",
|
||||||
|
body: "Something is wrong with ${env.BUILD_URL}"
|
||||||
|
}
|
||||||
|
|
||||||
|
unstable {
|
||||||
|
echo 'Build is unstable!'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
deploy_habit_bot.sh
Normal file
62
deploy_habit_bot.sh
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy Habit & Diet Bot to Ubuntu
|
||||||
|
# Run on 192.168.0.147
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_DIR="/home/joungmin/habit_bot"
|
||||||
|
VENV_DIR="$APP_DIR/venv"
|
||||||
|
|
||||||
|
echo "🔮 Deploying Habit & Diet Bot..."
|
||||||
|
|
||||||
|
# Create directory
|
||||||
|
mkdir -p $APP_DIR
|
||||||
|
cd $APP_DIR
|
||||||
|
|
||||||
|
# Copy files
|
||||||
|
echo "Copying files..."
|
||||||
|
# Copy from Mac mini (run this on Mac):
|
||||||
|
# scp -r habit_bot.py habit_bot_requirements.txt deploy_habit_bot.sh joungmin@192.168.0.147:$APP_DIR/
|
||||||
|
|
||||||
|
# Create virtual environment
|
||||||
|
if [ ! -d "$VENV_DIR" ]; then
|
||||||
|
python3 -m venv $VENV_DIR
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
source $VENV_DIR/bin/activate
|
||||||
|
pip install -q -r $APP_DIR/habit_bot_requirements.txt
|
||||||
|
|
||||||
|
# Create systemd service
|
||||||
|
cat > /tmp/habit-bot.service << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Habit & Diet Telegram Bot
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=joungmin
|
||||||
|
WorkingDirectory=/home/joungmin/habit_bot
|
||||||
|
Environment="PATH=/home/joungmin/habit_bot/venv/bin"
|
||||||
|
Environment="TELEGRAM_BOT_TOKEN=your_token_here"
|
||||||
|
Environment="OBSIDIAN_PATH=/Users/joungmin/Documents/Obsidian Vault"
|
||||||
|
Environment="ORACLE_DSN=h8i4i0g8cxtd2lpf_high"
|
||||||
|
Environment="ORACLE_USER=admin"
|
||||||
|
Environment="ORACLE_PASSWORD=your_password"
|
||||||
|
Environment="ORACLE_WALLET=/path/to/wallet"
|
||||||
|
ExecStart=/home/joungmin/habit_bot/venv/bin/python3 /home/joungmin/habit_bot/habit_bot.py
|
||||||
|
Restart=always
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Deployment complete!"
|
||||||
|
echo ""
|
||||||
|
echo "To start:"
|
||||||
|
echo " 1. Set TELEGRAM_BOT_TOKEN in /tmp/habit-bot.service"
|
||||||
|
echo " 2. sudo cp /tmp/habit-bot.service /etc/systemd/system/"
|
||||||
|
echo " 3. sudo systemctl daemon-reload"
|
||||||
|
echo " 4. sudo systemctl start habit-bot"
|
||||||
|
echo ""
|
||||||
|
echo "Bot URL: http://localhost:3000/joungmin/openclaw-workspace"
|
||||||
32
requirements.txt
Normal file
32
requirements.txt
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# OpenClaw - Production Dependencies
|
||||||
|
|
||||||
|
# Core Framework
|
||||||
|
openclaw>=2026.2.0
|
||||||
|
|
||||||
|
# Database
|
||||||
|
oracledb>=2.0.0
|
||||||
|
|
||||||
|
# Web Framework
|
||||||
|
flask>=2.3.0
|
||||||
|
gunicorn>=21.0.0
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
python-telegram-bot>=20.0
|
||||||
|
|
||||||
|
# LLM/API
|
||||||
|
openai>=1.0.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
|
||||||
|
# Data Processing
|
||||||
|
pandas>=2.0.0
|
||||||
|
numpy>=1.24.0
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
python-dateutil>=2.8.0
|
||||||
|
requests>=2.31.0
|
||||||
|
PyYAML>=6.0
|
||||||
|
|
||||||
|
# Investment Data (optional)
|
||||||
|
yfinance>=0.2.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
lxml>=4.9.0
|
||||||
381
stock_tracker.py
Normal file
381
stock_tracker.py
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
#!/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
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
# Placeholder - would use real API
|
||||||
|
# For demo, generate mock data
|
||||||
|
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
|
||||||
|
|
||||||
|
# ============== 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')
|
||||||
|
|
||||||
|
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())
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
25
test_requirements.txt
Normal file
25
test_requirements.txt
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Test Dependencies
|
||||||
|
pytest>=7.0.0
|
||||||
|
pytest-cov>=4.0.0
|
||||||
|
pytest-mock>=3.10.0
|
||||||
|
responses>=0.23.0
|
||||||
|
httpx>=0.25.0
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
flake8>=6.0.0
|
||||||
|
flake8-docstrings>=1.7.0
|
||||||
|
flake8-builtins>=2.0.0
|
||||||
|
flake8-comprehensions>=3.12.0
|
||||||
|
flake8-logging-format>=0.9.0
|
||||||
|
pylint>=2.17.0
|
||||||
|
bandit>=1.7.0
|
||||||
|
safety>=2.3.0
|
||||||
|
vulture>=2.7.0
|
||||||
|
mypy>=1.5.0
|
||||||
|
black>=23.0.0
|
||||||
|
isort>=5.12.0
|
||||||
|
pre-commit>=3.5.0
|
||||||
|
|
||||||
|
# Coverage
|
||||||
|
coverage>=7.0.0
|
||||||
|
coveralls>=3.3.0
|
||||||
Reference in New Issue
Block a user