- tests/test_habit_bot.py: Habit tracking, food logging, keto guidance - tests/test_stock_tracker.py: Portfolio management, P&L calculation - pytest.ini: Pytest configuration - Updated Jenkinsfile: Emphasized testing stages before build Pipeline stages: 1. Code Quality Gates (lint + security) 2. Unit Tests (pytest with coverage) 3. Integration Tests (Oracle, Telegram, Gitea) 4. Build (only after tests pass) 5. Deploy to Staging
320 lines
11 KiB
Python
320 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Unit tests for Stock Tracker
|
|
Tests: Portfolio management, P&L calculation, price fetching
|
|
"""
|
|
|
|
import pytest
|
|
import sys
|
|
import os
|
|
import json
|
|
from datetime import datetime
|
|
from unittest.mock import Mock, patch, MagicMock
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
TEST_DATA_DIR = '/tmp/test_stock_tracker'
|
|
os.makedirs(TEST_DATA_DIR, exist_ok=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_prices():
|
|
"""Create mock price data"""
|
|
return {
|
|
'stock_AAPL': {
|
|
'symbol': 'AAPL',
|
|
'current_price': 180.0,
|
|
'change_percent': 2.5,
|
|
'high_52w': 200.0,
|
|
'low_52w': 150.0,
|
|
'volume': 50000000,
|
|
'updated_at': datetime.now().isoformat()
|
|
},
|
|
'stock_MSFT': {
|
|
'symbol': 'MSFT',
|
|
'current_price': 380.0,
|
|
'change_percent': -1.2,
|
|
'high_52w': 420.0,
|
|
'low_52w': 310.0,
|
|
'volume': 25000000,
|
|
'updated_at': datetime.now().isoformat()
|
|
},
|
|
'crypto_BTC': {
|
|
'symbol': 'BTC',
|
|
'current_price': 45000.0,
|
|
'change_percent': 3.8,
|
|
'high_52w': 69000.0,
|
|
'low_52w': 35000.0,
|
|
'volume': 30000000000,
|
|
'updated_at': datetime.now().isoformat()
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_positions():
|
|
"""Create mock positions"""
|
|
return {
|
|
'stock_AAPL': {
|
|
'symbol': 'AAPL',
|
|
'asset_type': 'stock',
|
|
'quantity': 10,
|
|
'avg_cost': 150.0,
|
|
'entry_date': '2025-01-15',
|
|
'streak': 0,
|
|
'is_active': True
|
|
},
|
|
'stock_MSFT': {
|
|
'symbol': 'MSFT',
|
|
'asset_type': 'stock',
|
|
'quantity': 5,
|
|
'avg_cost': 350.0,
|
|
'entry_date': '2025-02-01',
|
|
'streak': 0,
|
|
'is_active': True
|
|
}
|
|
}
|
|
|
|
|
|
class TestPortfolioManagement:
|
|
"""Test portfolio management functionality"""
|
|
|
|
def test_add_position(self, mock_positions):
|
|
"""Test adding a new position"""
|
|
from stock_tracker import StockTracker, Position
|
|
|
|
# Mock the file operations
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = {}
|
|
|
|
# Add a position
|
|
result = tracker.add_position(
|
|
symbol='NVDA',
|
|
asset_type='stock',
|
|
quantity=10,
|
|
avg_cost=800.0
|
|
)
|
|
|
|
assert result == True
|
|
key = 'stock_NVDA'
|
|
assert key in tracker.positions
|
|
assert tracker.positions[key].symbol == 'NVDA'
|
|
assert tracker.positions[key].quantity == 10
|
|
assert tracker.positions[key].avg_cost == 800.0
|
|
print(f"✅ Added position: NVDA 10 @ $800")
|
|
|
|
def test_remove_position(self, mock_positions):
|
|
"""Test removing a position"""
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
|
|
# Remove a position
|
|
result = tracker.remove_position('AAPL', 'stock')
|
|
|
|
assert result == True
|
|
assert 'stock_AAPL' not in tracker.positions
|
|
print("✅ Removed position: AAPL")
|
|
|
|
def test_get_positions(self, mock_positions):
|
|
"""Test getting all positions"""
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
|
|
positions = tracker.get_positions()
|
|
|
|
assert len(positions) == 2
|
|
print(f"✅ Retrieved {len(positions)} positions")
|
|
|
|
|
|
class TestPnLCalculation:
|
|
"""Test P&L calculation functionality"""
|
|
|
|
def test_calculate_profit(self, mock_positions, mock_prices):
|
|
"""Test profit calculation for winning position"""
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
tracker.prices = mock_prices
|
|
|
|
summary = tracker.calculate_portfolio_summary()
|
|
|
|
# AAPL: Bought @ $150, Current @ $180 = +20% profit
|
|
assert summary.total_value > summary.total_cost
|
|
assert summary.total_pnl_percent > 0
|
|
print(f"✅ Profit calculated: {summary.total_pnl_percent:.1f}%")
|
|
|
|
def test_calculate_loss(self, mock_positions, mock_prices):
|
|
"""Test loss calculation for losing position"""
|
|
# Modify MSFT to have a loss
|
|
mock_positions['stock_MSFT']['avg_cost'] = 400.0 # Bought higher than current
|
|
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
tracker.prices = mock_prices
|
|
|
|
summary = tracker.calculate_portfolio_summary()
|
|
|
|
# MSFT: Bought @ $400, Current @ $380 = -5% loss
|
|
msft_pos = next((p for p in summary.positions if p['symbol'] == 'MSFT'), None)
|
|
assert msft_pos['pnl_percent'] < 0
|
|
print(f"✅ Loss calculated: MSFT {msft_pos['pnl_percent']:.1f}%")
|
|
|
|
def test_pnl_percentage(self, mock_positions):
|
|
"""Test P&L percentage calculation"""
|
|
avg_cost = 100.0
|
|
current_price = 150.0
|
|
expected_pnl_percent = 50.0
|
|
|
|
pnl_percent = ((current_price - avg_cost) / avg_cost) * 100
|
|
|
|
assert pnl_percent == expected_pnl_percent
|
|
print(f"✅ P&L % calculated: {pnl_percent}%")
|
|
|
|
|
|
class TestInvestmentGuidelineChecks:
|
|
"""Test investment guideline compliance"""
|
|
|
|
def test_checklist_score_calculation(self):
|
|
"""Test 7-item checklist scoring"""
|
|
checklist = {
|
|
'story_clear': True,
|
|
'earnings_uptrend': True,
|
|
'balance_sheet_healthy': True,
|
|
'capital_return_plan': True,
|
|
'governance_clean': True,
|
|
'market_liquidity': True,
|
|
'relative_strength': False
|
|
}
|
|
|
|
score = sum(checklist.values())
|
|
max_score = len(checklist)
|
|
|
|
assert score == 6
|
|
assert f"{score}/{max_score}" == "6/7"
|
|
print(f"✅ Checklist score: {score}/{max_score}")
|
|
|
|
def test_pbr_evaluation(self):
|
|
"""Test PBR evaluation logic"""
|
|
# PBR < 1 is generally considered undervalued
|
|
pbr_values = {
|
|
'AAPL': 0.85, # Undervalued
|
|
'MSFT': 1.5, # Fair value
|
|
'GOOGL': 2.1, # Premium
|
|
'NVDA': 25.0 # Expensive (but justified by growth)
|
|
}
|
|
|
|
for symbol, pbr in pbr_values.items():
|
|
if pbr < 1:
|
|
status = "undervalued"
|
|
elif pbr < 3:
|
|
status = "fair value"
|
|
else:
|
|
status = "premium"
|
|
print(f"✅ {symbol} PBR: {pbr}x ({status})")
|
|
|
|
def test_stop_loss_calculation(self):
|
|
"""Test -10% stop loss calculation"""
|
|
entry_price = 100000 # KRW
|
|
|
|
# Hard stop loss
|
|
stop_loss_price = entry_price * 0.9 # -10%
|
|
assert stop_loss_price == 90000
|
|
|
|
# Trailing stop (from high)
|
|
high_price = 120000
|
|
trailing_stop = high_price * 0.9 # -10% from high
|
|
assert trailing_stop == 108000
|
|
|
|
print(f"✅ Stop loss: {stop_loss_price} (entry: {entry_price})")
|
|
print(f"✅ Trailing stop: {trailing_stop} (high: {high_price})")
|
|
|
|
|
|
class TestReportGeneration:
|
|
"""Test report generation functionality"""
|
|
|
|
def test_daily_report_structure(self, mock_positions, mock_prices):
|
|
"""Test daily report has required sections"""
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
tracker.prices = mock_prices
|
|
|
|
report = tracker.generate_daily_report()
|
|
|
|
# Check report contains key sections
|
|
assert '일일 포트폴리오 리포트' in report or 'Daily' in report
|
|
assert '총 가치' in report or 'Total Value' in report
|
|
assert '손익' in report or 'P&L' in report
|
|
print("✅ Daily report structure verified")
|
|
|
|
def test_weekly_report_structure(self, mock_positions, mock_prices):
|
|
"""Test weekly report has required sections"""
|
|
with patch('stock_tracker.load_json', return_value={}):
|
|
with patch('stock_tracker.save_json'):
|
|
tracker = StockTracker.__new__(StockTracker)
|
|
tracker.positions = mock_positions
|
|
tracker.prices = mock_prices
|
|
|
|
report = tracker.generate_weekly_report()
|
|
|
|
# Check report contains key sections
|
|
assert '주간 포트폴리오 리포트' in report or 'Weekly' in report
|
|
assert '목표' in report or 'Goal' in report
|
|
assert '체크리스트' in report or 'Checklist' in report
|
|
print("✅ Weekly report structure verified")
|
|
|
|
|
|
class TestDataTypes:
|
|
"""Test data type validation"""
|
|
|
|
def test_position_validation(self):
|
|
"""Test Position dataclass"""
|
|
from stock_tracker import Position
|
|
|
|
pos = Position(
|
|
symbol='TEST',
|
|
asset_type='stock',
|
|
quantity=100,
|
|
avg_cost=50.0,
|
|
entry_date='2025-01-01'
|
|
)
|
|
|
|
assert pos.symbol == 'TEST'
|
|
assert pos.quantity == 100
|
|
assert pos.avg_cost == 50.0
|
|
assert pos.is_active == True
|
|
print("✅ Position validation passed")
|
|
|
|
def test_price_data_validation(self):
|
|
"""Test PriceData dataclass"""
|
|
from stock_tracker import PriceData
|
|
|
|
price = PriceData(
|
|
symbol='TEST',
|
|
current_price=100.0,
|
|
change_percent=2.5,
|
|
high_52w=120.0,
|
|
low_52w=80.0,
|
|
volume=1000000.0
|
|
)
|
|
|
|
assert price.symbol == 'TEST'
|
|
assert price.current_price == 100.0
|
|
assert price.change_percent == 2.5
|
|
print("✅ PriceData validation passed")
|
|
|
|
|
|
# Pytest configuration
|
|
if __name__ == '__main__':
|
|
pytest.main([__file__, '-v'])
|