Add: Unit tests for habit_bot and stock_tracker
- 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
This commit is contained in:
319
tests/test_stock_tracker.py
Normal file
319
tests/test_stock_tracker.py
Normal file
@@ -0,0 +1,319 @@
|
||||
#!/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'])
|
||||
Reference in New Issue
Block a user