최초 프로젝트 업로드 (Script Auto Commit)
This commit is contained in:
1
src/tests/__init__.py
Normal file
1
src/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
111
src/tests/test_boundary_conditions.py
Normal file
111
src/tests/test_boundary_conditions.py
Normal 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"])
|
||||
118
src/tests/test_critical_fixes.py
Normal file
118
src/tests/test_critical_fixes.py
Normal 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"])
|
||||
143
src/tests/test_evaluate_sell_conditions.py
Normal file
143
src/tests/test_evaluate_sell_conditions.py
Normal 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
74
src/tests/test_helpers.py
Normal 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
93
src/tests/test_main.py
Normal 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
|
||||
Reference in New Issue
Block a user