최초 프로젝트 업로드 (Script Auto Commit)

This commit is contained in:
2025-12-03 22:40:47 +09:00
commit dd9acf62a3
39 changed files with 5251 additions and 0 deletions

1
src/tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Test package

View File

@@ -0,0 +1,111 @@
"""
경계값 테스트: profit_rate가 정확히 10%, 30%일 때 매도 조건 검증
"""
import pytest
from src.signals import evaluate_sell_conditions
class TestBoundaryConditions:
"""매도 조건의 경계값 테스트"""
def test_profit_rate_exactly_10_percent_triggers_partial_sell(self):
"""수익률이 정확히 10%일 때 부분 매도(조건3) 발생"""
# Given: 매수가 100, 현재가 110 (정확히 10% 수익)
buy_price = 100.0
current_price = 110.0
max_price = 110.0
holding_info = {"partial_sell_done": False}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then
assert result["status"] == "stop_loss" # 부분 익절은 stop_loss (1시간 주기)
assert result["sell_ratio"] == 0.5
assert result["set_partial_sell_done"] is True
assert "부분 익절" in result["reasons"][0]
def test_profit_rate_exactly_30_percent_in_high_zone(self):
"""최고 수익률 30% 초과 구간에서 수익률이 정확히 30%로 떨어질 때"""
# Given: 최고가 135 (35% 수익), 현재가 130 (30% 수익)
buy_price = 100.0
current_price = 130.0
max_price = 135.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 수익률이 30% 이하(<= 30)로 하락하여 조건5-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
def test_profit_rate_below_30_percent_triggers_sell(self):
"""최고 수익률 30% 초과 구간에서 수익률이 30% 미만으로 떨어질 때"""
# Given: 최고가 135 (35% 수익), 현재가 129.99 (29.99% 수익)
buy_price = 100.0
current_price = 129.99
max_price = 135.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 조건5-2 발동 (수익률 30% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건5)" in result["reasons"][0]
def test_profit_rate_exactly_10_percent_in_mid_zone(self):
"""최고 수익률 10~30% 구간에서 수익률이 정확히 10%일 때"""
# Given: 최고가 120 (20% 수익), 현재가 110 (10% 수익)
buy_price = 100.0
current_price = 110.0
max_price = 120.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 수익률이 10% 이하(<= 10)로 하락하여 조건4-2 발동 (stop_loss)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
def test_profit_rate_below_10_percent_triggers_sell(self):
"""최고 수익률 10~30% 구간에서 수익률이 10% 미만으로 떨어질 때"""
# Given: 최고가 120 (20% 수익), 현재가 109.99 (9.99% 수익)
buy_price = 100.0
current_price = 109.99
max_price = 120.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 조건4-2 발동 (수익률 10% 미만으로 하락)
assert result["status"] == "stop_loss"
assert result["sell_ratio"] == 1.0
assert "수익률 보호(조건4)" in result["reasons"][0]
def test_partial_sell_already_done_no_duplicate(self):
"""부분 매도 이미 완료된 경우 중복 발동 안됨"""
# Given: 매수가 100, 현재가 110 (10% 수익), 이미 부분 매도 완료
buy_price = 100.0
current_price = 110.0
max_price = 110.0
holding_info = {"partial_sell_done": True}
# When
result = evaluate_sell_conditions(current_price, buy_price, max_price, holding_info)
# Then: 부분 매도 재발동 안됨
assert result["status"] == "hold"
assert result["sell_ratio"] == 0.0
assert result["set_partial_sell_done"] is False
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,118 @@
"""
치명적 문제 수정 사항 검증 테스트
- 원자적 파일 쓰기
- API 키 검증
- Decimal 정밀도
"""
import os
import json
import tempfile
import pytest
from decimal import Decimal
from src.holdings import save_holdings, load_holdings
from src.signals import record_trade, _adjust_sell_ratio_for_min_order
from src.config import build_runtime_config
class TestCriticalFixes:
"""치명적 문제 수정 사항 테스트"""
def test_atomic_holdings_save(self, tmp_path):
"""[C-1] holdings.json 원자적 쓰기 검증"""
holdings_file = tmp_path / "test_holdings.json"
# 초기 데이터 저장
initial_data = {"KRW-BTC": {"amount": 0.1, "buy_price": 50000000}}
save_holdings(initial_data, str(holdings_file))
# 파일 존재 및 내용 확인
assert holdings_file.exists()
loaded = load_holdings(str(holdings_file))
assert loaded == initial_data
# 임시 파일이 남아있지 않은지 확인 (원자적 교체 완료)
temp_files = list(tmp_path.glob("*.tmp"))
assert len(temp_files) == 0, "임시 파일이 남아있으면 안됩니다"
def test_trade_record_critical_flag(self, tmp_path):
"""[C-4] 거래 기록 critical 플래그 동작 검증"""
trades_file = tmp_path / "test_trades.json"
# critical=False: 저장 실패 시 예외 발생 안함
trade = {"symbol": "KRW-BTC", "side": "sell", "amount": 0.1}
# 정상 저장
record_trade(trade, str(trades_file), critical=False)
assert trades_file.exists()
# critical=True: 파일 권한 오류 시뮬레이션은 어려우므로 정상 케이스만 검증
trade2 = {"symbol": "KRW-ETH", "side": "buy", "amount": 1.0}
record_trade(trade2, str(trades_file), critical=True)
# 두 거래 모두 기록되었는지 확인
with open(trades_file, "r", encoding="utf-8") as f:
trades = json.load(f)
assert len(trades) == 2
assert trades[0]["symbol"] == "KRW-BTC"
assert trades[1]["symbol"] == "KRW-ETH"
def test_api_key_validation_in_config(self):
"""[C-2] API 키 검증 로직 확인"""
# dry_run=True: API 키 없어도 통과
config_dry = {"dry_run": True, "trading_mode": "auto_trade", "auto_trade": {}}
# 환경변수 없어도 예외 발생 안함
cfg = build_runtime_config(config_dry)
assert cfg.dry_run is True
# dry_run=False + auto_trade: 환경변수 필수 (실제 테스트는 환경변수 설정 필요)
# 여기서는 로직 존재 여부만 확인 (실제 ValueError 발생은 환경 의존적)
def test_decimal_precision_in_sell_ratio(self):
"""[C-3] Decimal을 사용한 부동소수점 오차 방지 검증"""
config = {"auto_trade": {"min_order_value_krw": 5000, "fee_safety_margin_pct": 0.05}}
# 테스트 케이스: 0.5 비율 매도 시 정밀 계산
total_amount = 0.00123456 # BTC
sell_ratio = 0.5
current_price = 50_000_000 # 50M KRW
adjusted_ratio = _adjust_sell_ratio_for_min_order("KRW-BTC", total_amount, sell_ratio, current_price, config)
# 부동소수점 오차 없이 계산되었는지 확인
# 예상 매도액: 0.00123456 * 0.5 * 50M * 0.9995 ≈ 30,863 KRW > 5000 → 0.5 유지
assert adjusted_ratio == 0.5
# 경계 케이스: 잔여액이 최소 금액 미만일 때 전량 매도로 전환
small_amount = 0.0001 # BTC
adjusted_ratio_small = _adjust_sell_ratio_for_min_order("KRW-BTC", small_amount, 0.5, current_price, config)
# 0.0001 * 0.5 * 50M * 0.9995 = 2498.75 < 5000 → 전량 매도(1.0)
assert adjusted_ratio_small == 1.0
def test_corrupted_trades_file_backup(self, tmp_path):
"""[C-4] 손상된 거래 파일 백업 기능 검증"""
trades_file = tmp_path / "corrupted_trades.json"
# 손상된 JSON 파일 생성
with open(trades_file, "w", encoding="utf-8") as f:
f.write("{invalid json content")
# 새 거래 기록 시도 → 손상 파일 백업 후 정상 저장
trade = {"symbol": "KRW-BTC", "side": "sell"}
record_trade(trade, str(trades_file), critical=False)
# 백업 파일 생성 확인
backup_files = list(tmp_path.glob("corrupted_trades.json.corrupted.*"))
assert len(backup_files) > 0, "손상된 파일이 백업되어야 합니다"
# 정상 파일로 복구 확인
with open(trades_file, "r", encoding="utf-8") as f:
trades = json.load(f)
assert len(trades) == 1
assert trades[0]["symbol"] == "KRW-BTC"
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -0,0 +1,143 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
import pytest
from src.signals import evaluate_sell_conditions
@pytest.fixture
def base_config():
"""Provides the auto_trade part of the configuration."""
return {
"auto_trade": {
"loss_threshold": -5.0,
"profit_threshold_1": 10.0,
"profit_threshold_2": 30.0,
"drawdown_1": 5.0,
"drawdown_2": 15.0,
}
}
# Test cases for the new strategy
def test_stop_loss_initial(base_config):
"""Rule 1: Sell if price drops 5% below buy price."""
res = evaluate_sell_conditions(
current_price=95.0, buy_price=100.0, max_price=100.0, holding_info={}, config=base_config
)
assert res["status"] == "stop_loss"
assert res["sell_ratio"] == 1.0
def test_trailing_stop_small_profit(base_config):
"""Rule 2: In small profit (<= 10%), sell if price drops 5% from high."""
res1 = evaluate_sell_conditions(
current_price=96.0, # +6% profit (just above +5% but below +10%)
buy_price=100.0,
max_price=110.0, # High was +10%
holding_info={"partial_sell_done": False},
config=base_config,
)
# Drawdown is (96-110)/110 = -12.7% which is > 5%
assert res1["status"] == "profit_taking" # Trailing stop classified as profit_taking
assert res1["sell_ratio"] == 1.0
def test_partial_profit_at_10_percent(base_config):
"""Rule 3: At profit == 10%, sell 50%."""
res = evaluate_sell_conditions(
current_price=110.0, # Exactly +10%
buy_price=100.0,
max_price=110.0,
holding_info={"partial_sell_done": False},
config=base_config,
)
assert res["status"] == "stop_loss" # Partial profit classified as stop_loss for 1h check
assert res["sell_ratio"] == 0.5
assert res["set_partial_sell_done"] is True
def test_trailing_stop_medium_profit_by_drawdown(base_config):
"""Rule 4: In mid profit (10-30%), sell if price drops 5% from high."""
res = evaluate_sell_conditions(
current_price=123.0, # +23% profit
buy_price=100.0,
max_price=130.0, # High was +30%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (123-130)/130 = -5.38% which is < -5%
assert res["status"] == "profit_taking" # Trailing stop classified as profit_taking for 4h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_medium_profit_by_floor(base_config):
"""Rule 4: In mid profit (10-30%), sell if profit drops to 10%."""
res = evaluate_sell_conditions(
current_price=110.0, # Profit drops to 10%
buy_price=100.0,
max_price=125.0, # High was +25%
holding_info={"partial_sell_done": True},
config=base_config,
)
assert res["status"] == "stop_loss" # Profit protection classified as stop_loss for 1h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_high_profit_by_drawdown(base_config):
"""Rule 5: In high profit (>30%), sell if price drops 15% from high."""
res = evaluate_sell_conditions(
current_price=135.0, # +35% profit
buy_price=100.0,
max_price=160.0, # High was +60%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (135-160)/160 = -15.625% which is < -15%
assert res["status"] == "profit_taking" # Trailing stop classified as profit_taking for 4h check
assert res["sell_ratio"] == 1.0
def test_trailing_stop_high_profit_by_floor(base_config):
"""Rule 5: In high profit (>30%), sell if profit drops to 30%."""
res = evaluate_sell_conditions(
current_price=130.0, # Profit drops to 30%
buy_price=100.0,
max_price=150.0, # High was +50%
holding_info={"partial_sell_done": True},
config=base_config,
)
assert res["status"] == "stop_loss" # Profit protection classified as stop_loss for 1h check
assert res["sell_ratio"] == 1.0
def test_hold_high_profit(base_config):
"""Rule 6: Hold if profit > 30% and drawdown is less than 15%."""
res = evaluate_sell_conditions(
current_price=140.0, # +40% profit
buy_price=100.0,
max_price=150.0, # High was +50%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (140-150)/150 = -6.67% which is > -15%
assert res["status"] == "hold"
assert res["sell_ratio"] == 0.0
def test_hold_medium_profit(base_config):
"""Hold if profit is 10-30% and drawdown is less than 5%."""
res = evaluate_sell_conditions(
current_price=128.0, # +28% profit
buy_price=100.0,
max_price=130.0, # High was +30%
holding_info={"partial_sell_done": True},
config=base_config,
)
# Drawdown is (128-130)/130 = -1.5% which is > -5%
assert res["status"] == "hold"
assert res["sell_ratio"] == 0.0

74
src/tests/test_helpers.py Normal file
View File

@@ -0,0 +1,74 @@
"""Test helper functions used primarily in test scenarios."""
import inspect
import sys
import os
# Add parent directory to path to import from src
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
from src.common import logger
from src.signals import process_symbol
from src.notifications import send_telegram
def safe_send_telegram(bot_token: str, chat_id: str, text: str, **kwargs) -> bool:
"""Flexibly call send_telegram even if monkeypatched version has a simpler signature.
Inspects the target callable and only passes accepted parameters."""
func = send_telegram
try:
sig = inspect.signature(func)
accepted = sig.parameters.keys()
call_kwargs = {}
# positional mapping
params = list(accepted)
pos_args = [bot_token, chat_id, text]
for i, val in enumerate(pos_args):
if i < len(params):
call_kwargs[params[i]] = val
# optional kwargs filtered
for k, v in kwargs.items():
if k in accepted:
call_kwargs[k] = v
return func(**call_kwargs)
except Exception:
# Fallback positional
try:
return func(bot_token, chat_id, text)
except Exception:
return False
def check_and_notify(
exchange: str,
symbol: str,
timeframe: str,
telegram_token: str,
telegram_chat_id: str,
limit: int = 200,
dry_run: bool = True,
):
"""Compatibility helper used by tests: run processing for a single symbol and send notification if needed.
exchange parameter is accepted for API compatibility but not used (we use pyupbit internally).
"""
try:
res = process_symbol(
symbol,
timeframe,
limit,
telegram_token,
telegram_chat_id,
dry_run,
indicators=None,
indicator_timeframe=None,
)
# If a telegram message was returned from process_symbol, send it (unless dry_run)
if res.get("telegram"):
if dry_run:
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
else:
if telegram_token and telegram_chat_id:
safe_send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False)
except Exception as e:
logger.exception("check_and_notify 오류: %s", e)

93
src/tests/test_main.py Normal file
View File

@@ -0,0 +1,93 @@
import sys
import os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
import builtins
import types
import pandas as pd
import pytest
import main
from .test_helpers import check_and_notify, safe_send_telegram
def test_compute_macd_hist_monkeypatch(monkeypatch):
# Arrange: monkeypatch pandas_ta.macd to return a DataFrame with MACDh column
dummy_macd = pd.DataFrame({"MACDh_12_26_9": [None, 0.5, 1.2, 2.3]})
def fake_macd(series, fast, slow, signal):
return dummy_macd
monkeypatch.setattr(main.ta, "macd", fake_macd)
close = pd.Series([1, 2, 3, 4])
# Act: import directly from indicators
from src.indicators import compute_macd_hist
hist = compute_macd_hist(close)
# Assert
assert isinstance(hist, pd.Series)
assert list(hist.dropna()) == [0.5, 1.2, 2.3]
def test_check_and_notify_positive_sends(monkeypatch):
# Prepare a fake OHLCV DataFrame with required OHLCV columns
idx = pd.date_range(end=pd.Timestamp.now(), periods=250, freq="h")
df = pd.DataFrame(
{
"open": list(range(100, 350)),
"high": list(range(105, 355)),
"low": list(range(95, 345)),
"close": list(range(100, 350)),
"volume": [1000] * 250,
},
index=idx,
)
# Monkeypatch at the point of use: src.signals imports from indicators
from src import signals
# Patch fetch_ohlcv to return complete OHLCV data
monkeypatch.setattr(signals, "fetch_ohlcv", lambda symbol, timeframe, limit=200, log_buffer=None: df)
# Fake pandas_ta.macd to return MACD crossover (signal cross)
def fake_macd(close, fast=12, slow=26, signal=9):
macd_df = pd.DataFrame(index=close.index)
# Create crossover: prev < signal, curr > signal
macd_values = [-0.5] * (len(close) - 1) + [1.5] # Last value crosses above
signal_values = [0.5] * len(close) # Constant signal line
macd_df["MACD_12_26_9"] = pd.Series(macd_values, index=close.index)
macd_df["MACDs_12_26_9"] = pd.Series(signal_values, index=close.index)
macd_df["MACDh_12_26_9"] = pd.Series([v - s for v, s in zip(macd_values, signal_values)], index=close.index)
return macd_df
monkeypatch.setattr(signals.ta, "macd", fake_macd)
# Fake pandas_ta.adx to return valid ADX data
def fake_adx(high, low, close, length=14):
adx_df = pd.DataFrame(index=close.index)
adx_df[f"ADX_{length}"] = pd.Series([30.0] * len(close), index=close.index)
return adx_df
monkeypatch.setattr(signals.ta, "adx", fake_adx)
# Capture calls to safe_send_telegram
called = {"count": 0}
def fake_safe_send(token, chat_id, text, **kwargs):
called["count"] += 1
return True
# Monkeypatch test_helpers module
from . import test_helpers
monkeypatch.setattr(test_helpers, "safe_send_telegram", fake_safe_send)
# Act: call check_and_notify (not dry-run)
check_and_notify("upbit", "KRW-BTC", "1h", "token", "chat", limit=10, dry_run=False)
# Assert: safe_send_telegram was called
assert called["count"] == 1