최초 프로젝트 업로드 (Script Auto Commit)
This commit is contained in:
4
src/__init__.py
Normal file
4
src/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .common import logger
|
||||
from .indicators import fetch_ohlcv, compute_macd_hist, ta
|
||||
from .signals import evaluate_sell_conditions
|
||||
from .notifications import send_telegram
|
||||
26
src/common.py
Normal file
26
src/common.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import logging.handlers
|
||||
|
||||
LOG_DIR = os.getenv("LOG_DIR", "logs")
|
||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
Path(LOG_DIR).mkdir(parents=True, exist_ok=True)
|
||||
LOG_FILE = os.path.join(LOG_DIR, "AutoStockTrader.log")
|
||||
|
||||
logger = logging.getLogger("macd_alarm")
|
||||
|
||||
def setup_logger(dry_run: bool):
|
||||
global logger
|
||||
logger.handlers.clear()
|
||||
logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
||||
formatter = logging.Formatter("%(asctime)s - %(levelname)s - [%(threadName)s] - %(message)s")
|
||||
if dry_run:
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
||||
ch.setFormatter(formatter)
|
||||
logger.addHandler(ch)
|
||||
fh = logging.handlers.RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=7, encoding="utf-8")
|
||||
fh.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
|
||||
fh.setFormatter(formatter)
|
||||
logger.addHandler(fh)
|
||||
94
src/config.py
Normal file
94
src/config.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import os, json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from .common import logger
|
||||
|
||||
def load_config() -> dict:
|
||||
paths = [os.path.join('config','config.json'), 'config.json']
|
||||
example_paths = [os.path.join('config','config.example.json'), 'config.example.json']
|
||||
for p in paths:
|
||||
if os.path.exists(p):
|
||||
with open(p,'r',encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
logger.info('설정 파일 로드: %s', p)
|
||||
return cfg
|
||||
for p in example_paths:
|
||||
if os.path.exists(p):
|
||||
with open(p,'r',encoding='utf-8') as f:
|
||||
cfg = json.load(f)
|
||||
logger.warning('기본 설정 없음; 예제 사용: %s', p)
|
||||
return cfg
|
||||
logger.error('설정 파일 없음: config/config.json 확인')
|
||||
return None
|
||||
|
||||
def read_symbols(path: str) -> list:
|
||||
syms = []
|
||||
try:
|
||||
with open(path,'r',encoding='utf-8') as f:
|
||||
for line in f:
|
||||
s=line.strip()
|
||||
if not s or s.startswith('#'): continue
|
||||
syms.append(s)
|
||||
logger.info('심볼 %d개 로드: %s', len(syms), path)
|
||||
except Exception as e:
|
||||
logger.exception('심볼 로드 실패: %s', e)
|
||||
return syms
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeConfig:
|
||||
timeframe: str
|
||||
indicator_timeframe: str
|
||||
candle_count: int
|
||||
symbol_delay: float
|
||||
interval: int
|
||||
loop: bool
|
||||
dry_run: bool
|
||||
max_threads: int
|
||||
telegram_parse_mode: Optional[str]
|
||||
trading_mode: str
|
||||
telegram_bot_token: Optional[str]
|
||||
telegram_chat_id: Optional[str]
|
||||
kiwoom_account_number: Optional[str]
|
||||
aggregate_alerts: bool = False
|
||||
benchmark: bool = False
|
||||
telegram_test: bool = False
|
||||
config: Optional[Dict[str, Any]] = None # 원본 config 포함
|
||||
|
||||
|
||||
def build_runtime_config(cfg_dict: dict) -> RuntimeConfig:
|
||||
# timeframe은 동적으로 결정되므로 기본값만 설정 (실제로는 매수/매도 주기에 따라 변경됨)
|
||||
timeframe = "1h" # 기본값
|
||||
aggregate_alerts = bool(cfg_dict.get("aggregate_alerts", False)) or bool(os.getenv("AGGREGATE_ALERTS", "False").lower() in ("1", "true", "yes"))
|
||||
benchmark = bool(cfg_dict.get("benchmark", False))
|
||||
telegram_test = os.getenv("TELEGRAM_TEST", "0") == "1"
|
||||
|
||||
return RuntimeConfig(
|
||||
timeframe=timeframe,
|
||||
indicator_timeframe=timeframe,
|
||||
candle_count=int(cfg_dict.get("candle_count", 200)),
|
||||
symbol_delay=float(cfg_dict.get("symbol_delay", 1.0)),
|
||||
interval=int(cfg_dict.get("interval", 60)),
|
||||
loop=bool(cfg_dict.get("loop", False)),
|
||||
dry_run=bool(cfg_dict.get("dry_run", False)),
|
||||
max_threads=int(cfg_dict.get("max_threads", 3)),
|
||||
telegram_parse_mode=cfg_dict.get("telegram_parse_mode"),
|
||||
trading_mode=cfg_dict.get("trading_mode", "signal_only"),
|
||||
telegram_bot_token=os.getenv("TELEGRAM_BOT_TOKEN"),
|
||||
telegram_chat_id=os.getenv("TELEGRAM_CHAT_ID"),
|
||||
kiwoom_account_number=cfg_dict.get("kiwoom", {}).get("account_number"),
|
||||
aggregate_alerts=aggregate_alerts,
|
||||
benchmark=benchmark,
|
||||
telegram_test=telegram_test,
|
||||
config=cfg_dict,
|
||||
)
|
||||
|
||||
def get_symbols_file(config: dict) -> str:
|
||||
"""Determine the symbols file path with fallback logic."""
|
||||
default_path = "config/symbols.txt" if os.path.exists("config/symbols.txt") else "symbols.txt"
|
||||
return config.get("symbols_file", default_path)
|
||||
|
||||
# Define valid trading modes as constants
|
||||
TRADING_MODES = ("signal_only", "auto_trade", "mixed")
|
||||
|
||||
|
||||
245
src/holdings.py
Normal file
245
src/holdings.py
Normal file
@@ -0,0 +1,245 @@
|
||||
import os, json
|
||||
import threading
|
||||
from .common import logger
|
||||
from .kiwoom_api import get_kiwoom_api
|
||||
|
||||
# 스레드 동기화를 위한 Lock 객체
|
||||
_holdings_lock = threading.Lock()
|
||||
|
||||
def _unsafe_load_holdings(holdings_file: str) -> dict:
|
||||
"""Lock 없이 홀딩 파일을 로드 (내부용)"""
|
||||
try:
|
||||
if os.path.exists(holdings_file):
|
||||
if os.path.getsize(holdings_file) == 0:
|
||||
logger.debug("보유 파일이 비어있습니다: %s", holdings_file)
|
||||
return {}
|
||||
with open(holdings_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning('보유 파일 로드 실패: %s', e)
|
||||
return {}
|
||||
|
||||
def _unsafe_save_holdings(holdings: dict, holdings_file: str):
|
||||
"""Lock 없이 홀딩 파일을 저장 (내부용)"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(holdings_file) or '.', exist_ok=True)
|
||||
with open(holdings_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(holdings, f, ensure_ascii=False, indent=2)
|
||||
logger.debug('보유 저장: %s', holdings_file)
|
||||
except Exception as e:
|
||||
logger.error('보유 저장 실패: %s', e)
|
||||
|
||||
def load_holdings(holdings_file: str = 'holdings.json') -> dict:
|
||||
"""스레드에 안전하게 홀딩 파일을 로드"""
|
||||
with _holdings_lock:
|
||||
return _unsafe_load_holdings(holdings_file)
|
||||
|
||||
def save_holdings(holdings: dict, holdings_file: str = 'holdings.json'):
|
||||
"""스레드에 안전하게 홀딩 파일을 저장"""
|
||||
with _holdings_lock:
|
||||
_unsafe_save_holdings(holdings, holdings_file)
|
||||
|
||||
def get_kiwoom_balances(account_number: str) -> dict:
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
deposit_info, _ = api.get_account_info(account_number)
|
||||
|
||||
balance = 0
|
||||
possible_keys = ['d+2추정예수금', 'D+2추정예수금', '예수금', '주문가능금액', '출금가능금액']
|
||||
|
||||
if isinstance(deposit_info, dict):
|
||||
for key in possible_keys:
|
||||
if key in deposit_info:
|
||||
try:
|
||||
balance = int(deposit_info[key])
|
||||
logger.debug(f'Kiwoom 잔고 조회 성공 (키: {key}): {balance}')
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
if balance == 0:
|
||||
logger.warning(f'예수금 키를 찾을 수 없음. 사용 가능한 키: {list(deposit_info.keys())}')
|
||||
else:
|
||||
logger.error(f'deposit_info 형식 오류: {type(deposit_info)}')
|
||||
|
||||
return {'KRW': balance}
|
||||
except Exception as e:
|
||||
logger.error('Kiwoom balances 실패: %s', e)
|
||||
return {}
|
||||
|
||||
def get_current_price(symbol: str) -> float:
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
data = api.get_ohlcv(symbol, "D")
|
||||
|
||||
if data is None:
|
||||
logger.debug('현재가 조회 실패 (None): %s', symbol)
|
||||
return 0.0
|
||||
|
||||
if hasattr(data, 'iloc'):
|
||||
close_col = next((c for c in ['close', '현재가', '종가'] if c in data.columns), None)
|
||||
if close_col:
|
||||
price = float(data[close_col].iloc[0])
|
||||
else:
|
||||
logger.warning('close 컬럼 없음: %s, columns: %s', symbol, data.columns.tolist())
|
||||
return 0.0
|
||||
elif isinstance(data, dict):
|
||||
close_col = next((c for c in ['close', '현재가', '종가'] if c in data), None)
|
||||
if close_col:
|
||||
price_data = data[close_col]
|
||||
price = float(price_data[0] if isinstance(price_data, (list, tuple)) else price_data)
|
||||
else:
|
||||
logger.warning('close 키 없음: %s, keys: %s', symbol, data.keys())
|
||||
return 0.0
|
||||
else:
|
||||
logger.warning('알 수 없는 데이터 형식: %s, type: %s', symbol, type(data))
|
||||
return 0.0
|
||||
|
||||
logger.debug('현재가 %s -> %.2f', symbol, price)
|
||||
return price if price > 0 else 0.0
|
||||
except Exception as e:
|
||||
logger.debug('현재가 실패 %s: %s', symbol, e)
|
||||
return 0.0
|
||||
|
||||
def add_new_holding(symbol: str, buy_price: float, amount: float, buy_timestamp: float = None, holdings_file: str = "holdings.json") -> bool:
|
||||
with _holdings_lock:
|
||||
try:
|
||||
import time
|
||||
holdings = _unsafe_load_holdings(holdings_file)
|
||||
timestamp = buy_timestamp if buy_timestamp is not None else time.time()
|
||||
|
||||
if symbol in holdings:
|
||||
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
|
||||
prev_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
|
||||
|
||||
total_amount = prev_amount + amount
|
||||
if total_amount > 0:
|
||||
new_avg_price = ((prev_price * prev_amount) + (buy_price * amount)) / total_amount
|
||||
holdings[symbol]["buy_price"] = new_avg_price
|
||||
holdings[symbol]["amount"] = total_amount
|
||||
|
||||
prev_max = float(holdings[symbol].get("max_price", 0.0) or 0.0)
|
||||
holdings[symbol]["max_price"] = max(new_avg_price, prev_max)
|
||||
|
||||
logger.info("[%s] holdings 추가 매수: 평균가 %.2f -> %.2f, 수량 %.8f -> %.8f",
|
||||
symbol, prev_price, new_avg_price, prev_amount, total_amount)
|
||||
else:
|
||||
holdings[symbol] = {
|
||||
"buy_price": buy_price,
|
||||
"amount": amount,
|
||||
"max_price": buy_price,
|
||||
"buy_timestamp": timestamp,
|
||||
"partial_sell_done": False
|
||||
}
|
||||
logger.info("[%s] holdings 신규 추가: 매수가=%.2f, 수량=%.8f", symbol, buy_price, amount)
|
||||
|
||||
_unsafe_save_holdings(holdings, holdings_file)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception("[%s] holdings 추가 실패: %s", symbol, e)
|
||||
return False
|
||||
|
||||
def update_holding_amount(symbol: str, amount_change: float, holdings_file: str = "holdings.json") -> bool:
|
||||
with _holdings_lock:
|
||||
try:
|
||||
holdings = _unsafe_load_holdings(holdings_file)
|
||||
if symbol not in holdings:
|
||||
logger.warning("[%s] holdings에 존재하지 않아 수량 업데이트 건너뜀", symbol)
|
||||
return False
|
||||
|
||||
prev_amount = float(holdings[symbol].get("amount", 0.0) or 0.0)
|
||||
new_amount = max(0.0, prev_amount + amount_change)
|
||||
|
||||
if new_amount <= 1e-8:
|
||||
holdings.pop(symbol, None)
|
||||
logger.info("[%s] holdings 업데이트: 전량 매도 완료, 보유 제거 (이전: %.8f, 변경: %.8f)",
|
||||
symbol, prev_amount, amount_change)
|
||||
else:
|
||||
holdings[symbol]["amount"] = new_amount
|
||||
logger.info("[%s] holdings 업데이트: 수량 변경 %.8f -> %.8f (변경량: %.8f)",
|
||||
symbol, prev_amount, new_amount, amount_change)
|
||||
|
||||
_unsafe_save_holdings(holdings, holdings_file)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception("[%s] holdings 수량 업데이트 실패: %s", symbol, e)
|
||||
return False
|
||||
|
||||
def set_holding_field(symbol: str, key: str, value, holdings_file: str = "holdings.json") -> bool:
|
||||
with _holdings_lock:
|
||||
try:
|
||||
holdings = _unsafe_load_holdings(holdings_file)
|
||||
if symbol not in holdings:
|
||||
logger.warning("[%s] holdings에 존재하지 않아 필드 설정 건너뜀", symbol)
|
||||
return False
|
||||
|
||||
holdings[symbol][key] = value
|
||||
logger.info("[%s] holdings 업데이트: 필드 '%s'를 '%s'(으)로 설정", symbol, key, value)
|
||||
|
||||
_unsafe_save_holdings(holdings, holdings_file)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception("[%s] holdings 필드 설정 실패: %s", symbol, e)
|
||||
return False
|
||||
|
||||
def fetch_holdings_from_kiwoom(account_number: str) -> dict:
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
_, balance_info = api.get_account_info(account_number)
|
||||
|
||||
if balance_info is None:
|
||||
logger.warning('balance_info가 None')
|
||||
return {}
|
||||
|
||||
holdings = {}
|
||||
# fetch_holdings_from_kiwoom는 외부 상태를 직접 바꾸지 않고 정보를 가져오므로,
|
||||
# 기존 holdings.json을 스레드 안전하게 로드합니다.
|
||||
existing_holdings = load_holdings('holdings.json')
|
||||
|
||||
items = []
|
||||
if hasattr(balance_info, 'iterrows'):
|
||||
items = balance_info.to_dict('records')
|
||||
elif isinstance(balance_info, list):
|
||||
items = balance_info
|
||||
else:
|
||||
logger.error(f'balance_info 형식 오류: {type(balance_info)}')
|
||||
return {}
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
symbol = item.get('종목번호', item.get('종목코드', ''))
|
||||
amount = item.get('보유수량', item.get('잔고수량', 0))
|
||||
buy_price = item.get('매입가', item.get('평균단가', 0))
|
||||
current_price = item.get('현재가', 0)
|
||||
|
||||
if isinstance(amount, str): amount = int(amount.replace('+', '').replace('-', '').strip())
|
||||
else: amount = int(amount)
|
||||
|
||||
if isinstance(buy_price, str): buy_price = int(buy_price.replace('+', '').replace('-', '').strip())
|
||||
else: buy_price = int(buy_price)
|
||||
|
||||
if isinstance(current_price, str): current_price = int(current_price.replace('+', '').replace('-', '').strip())
|
||||
else: current_price = int(current_price)
|
||||
|
||||
if not symbol or amount <= 0:
|
||||
continue
|
||||
|
||||
prev_max_price = existing_holdings.get(symbol, {}).get('max_price', 0)
|
||||
max_price = max(current_price, prev_max_price)
|
||||
|
||||
holdings[symbol] = {
|
||||
'buy_price': buy_price,
|
||||
'amount': amount,
|
||||
'max_price': max_price,
|
||||
'buy_timestamp': existing_holdings.get(symbol, {}).get('buy_timestamp'),
|
||||
'partial_sell_done': existing_holdings.get(symbol, {}).get('partial_sell_done', False),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f'항목 처리 실패: {e}, item: {item}')
|
||||
continue
|
||||
|
||||
logger.debug('Kiwoom holdings %d개', len(holdings))
|
||||
return holdings
|
||||
except Exception as e:
|
||||
logger.error('fetch_holdings 실패: %s', e)
|
||||
return {}
|
||||
192
src/indicators.py
Normal file
192
src/indicators.py
Normal file
@@ -0,0 +1,192 @@
|
||||
import os
|
||||
import time
|
||||
import random
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
from .common import logger
|
||||
from .kiwoom_api import get_kiwoom_api
|
||||
|
||||
__all__ = ["fetch_ohlcv", "compute_macd_hist", "compute_sma", "ta"]
|
||||
|
||||
def fetch_ohlcv(symbol: str, timeframe: str, candle_count: int = 200, log_buffer: list = None) -> pd.DataFrame:
|
||||
def _buf(level: str, msg: str):
|
||||
if log_buffer is not None:
|
||||
log_buffer.append(f"{level}: {msg}")
|
||||
else:
|
||||
getattr(logger, level.lower())(msg)
|
||||
_buf("debug", f"OHLCV 수집 시작: {symbol} {timeframe}")
|
||||
|
||||
# Kiwoom API 타임프레임 매핑
|
||||
# 분봉: "1", "3", "5", "15", "30", "60" (분 단위)
|
||||
# 일/주/월봉: "D", "W", "M"
|
||||
# 주의: 4시간봉은 Kiwoom API가 직접 지원하지 않으므로 60분봉 데이터를 리샘플링
|
||||
tf_map = {
|
||||
"1m": "1", "3m": "3", "5m": "5", "15m": "15", "30m": "30",
|
||||
"1h": "60",
|
||||
"4h": "60", # 60분봉 데이터를 가져와서 4시간으로 리샘플링
|
||||
"D": "D", "W": "W", "M": "M"
|
||||
}
|
||||
|
||||
# 리샘플링이 필요한 타임프레임 체크
|
||||
needs_resampling = timeframe == "4h"
|
||||
original_timeframe = timeframe
|
||||
original_candle_count = candle_count
|
||||
|
||||
# 4시간봉은 60분봉 4개를 합쳐야 하므로 candle_count를 4배로 조정
|
||||
if needs_resampling:
|
||||
candle_count = candle_count * 4
|
||||
_buf("debug", f"4시간봉 요청으로 candle_count 조정: {original_candle_count} -> {candle_count}")
|
||||
|
||||
tf = tf_map.get(timeframe, "D") # 기본값 'D' (일봉)
|
||||
|
||||
if timeframe not in tf_map:
|
||||
_buf("warning", f"지원하지 않는 타임프레임 '{timeframe}', 일봉(D)으로 대체")
|
||||
needs_resampling = False
|
||||
candle_count = original_candle_count
|
||||
|
||||
max_attempts = int(os.getenv("MAX_FETCH_ATTEMPTS", "5"))
|
||||
base_backoff = float(os.getenv("BASE_BACKOFF", "1.0"))
|
||||
jitter_factor = float(os.getenv("BACKOFF_JITTER", "0.5"))
|
||||
max_total_backoff = float(os.getenv("MAX_TOTAL_BACKOFF", "300"))
|
||||
cumulative_sleep = 0.0
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
df = api.get_ohlcv(symbol, tf)
|
||||
|
||||
if df is None or (hasattr(df, 'empty') and df.empty):
|
||||
_buf("warning", f"OHLCV 빈 결과: {symbol}")
|
||||
raise RuntimeError("empty ohlcv")
|
||||
|
||||
# 컬럼명 확인 및 변경
|
||||
# opt10081 (일/주/월봉): '일자', '시가', '고가', '저가', '현재가', '거래량' 등
|
||||
# opt10080 (분봉): '체결시간', '시가', '고가', '저가', '현재가', '거래량' 등
|
||||
column_mapping = {
|
||||
'일자': 'timestamp', # 일/주/월봉
|
||||
'체결시간': 'timestamp', # 분봉
|
||||
'시간': 'timestamp', # 대체 가능한 컬럼명
|
||||
'시가': 'open',
|
||||
'고가': 'high',
|
||||
'저가': 'low',
|
||||
'현재가': 'close',
|
||||
'종가': 'close', # 일부 응답에서 '종가'를 사용할 수 있음
|
||||
'거래량': 'volume'
|
||||
}
|
||||
|
||||
# 존재하는 컬럼만 rename
|
||||
rename_dict = {k: v for k, v in column_mapping.items() if k in df.columns}
|
||||
df = df.rename(columns=rename_dict)
|
||||
|
||||
# 필수 컬럼 확인
|
||||
required_cols = ['timestamp', 'open', 'high', 'low', 'close', 'volume']
|
||||
missing_cols = [col for col in required_cols if col not in df.columns]
|
||||
if missing_cols:
|
||||
_buf("error", f"필수 컬럼 누락: {missing_cols}, 존재하는 컬럼: {df.columns.tolist()}")
|
||||
raise RuntimeError(f"missing required columns: {missing_cols}")
|
||||
|
||||
# timestamp 처리
|
||||
# 분봉: 'YYYYMMDDHHMMSS' (14자리), 일/주/월봉: 'YYYYMMDD' (8자리)
|
||||
if df['timestamp'].dtype == 'object':
|
||||
# 첫 번째 값으로 형식 판별
|
||||
first_value = str(df['timestamp'].iloc[0]).strip()
|
||||
if len(first_value) >= 14: # 분봉 데이터 (YYYYMMDDHHMMSS)
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], format='%Y%m%d%H%M%S', errors='coerce')
|
||||
_buf("debug", f"분봉 timestamp 파싱: {first_value[:14]}")
|
||||
else: # 일/주/월봉 데이터 (YYYYMMDD)
|
||||
df['timestamp'] = pd.to_datetime(df['timestamp'], format='%Y%m%d', errors='coerce')
|
||||
_buf("debug", f"일/주/월봉 timestamp 파싱: {first_value}")
|
||||
df.set_index('timestamp', inplace=True)
|
||||
|
||||
# timestamp 파싱 실패 확인
|
||||
if df.index.isnull().any():
|
||||
null_count = df.index.isnull().sum()
|
||||
_buf("warning", f"timestamp 파싱 실패한 행 {null_count}개 발견, 제거합니다")
|
||||
df = df[df.index.notnull()]
|
||||
|
||||
# 데이터 타입을 float으로 변환
|
||||
for col in ['open', 'high', 'low', 'close', 'volume']:
|
||||
df[col] = pd.to_numeric(df[col], errors='coerce').astype(float)
|
||||
|
||||
# NaN 값이 있는 행 제거
|
||||
df = df.dropna()
|
||||
|
||||
if df.empty:
|
||||
_buf("warning", f"데이터 변환 후 빈 결과: {symbol}")
|
||||
raise RuntimeError("empty after conversion")
|
||||
|
||||
# 4시간봉 리샘플링 처리
|
||||
if needs_resampling and original_timeframe == "4h":
|
||||
try:
|
||||
_buf("debug", f"4시간봉 리샘플링 시작: {symbol}, 원본 rows={len(df)}")
|
||||
|
||||
# 4시간 간격으로 리샘플링 (OHLCV 재집계)
|
||||
df_4h = df.resample('4H').agg({
|
||||
'open': 'first', # 시가: 첫 번째 값
|
||||
'high': 'max', # 고가: 최대값
|
||||
'low': 'min', # 저가: 최소값
|
||||
'close': 'last', # 종가: 마지막 값
|
||||
'volume': 'sum' # 거래량: 합계
|
||||
}).dropna()
|
||||
|
||||
if df_4h.empty:
|
||||
_buf("warning", f"4시간봉 리샘플링 후 빈 데이터: {symbol}, 원본 데이터 사용")
|
||||
else:
|
||||
df = df_4h
|
||||
_buf("debug", f"4시간봉 리샘플링 완료: {symbol}, rows={len(df)}")
|
||||
except Exception as e:
|
||||
_buf("warning", f"4시간봉 리샘플링 실패: {symbol}, {e} - 60분봉 데이터 사용")
|
||||
|
||||
# 리샘플링된 경우 원래 요청한 candle_count로 제한
|
||||
final_candle_count = original_candle_count if needs_resampling else candle_count
|
||||
_buf("debug", f"OHLCV 수집 완료: {symbol}, timeframe={original_timeframe}, rows={len(df)}, returning={min(final_candle_count, len(df))}")
|
||||
return df.head(final_candle_count)
|
||||
except Exception as e:
|
||||
_buf("warning", f"OHLCV 수집 실패 (시도 {attempt}/{max_attempts}): {symbol} -> {e}")
|
||||
if attempt == max_attempts:
|
||||
_buf("error", f"OHLCV: 최대 재시도 도달 ({symbol})")
|
||||
return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp"))
|
||||
|
||||
sleep_time = base_backoff * (2 ** (attempt - 1))
|
||||
sleep_time = sleep_time + random.uniform(0, jitter_factor * sleep_time)
|
||||
if cumulative_sleep + sleep_time > max_total_backoff:
|
||||
logger.warning("누적 재시도 대기시간 초과 (%s)", symbol)
|
||||
return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp"))
|
||||
|
||||
cumulative_sleep += sleep_time
|
||||
_buf("debug", f"{sleep_time:.2f}초 후 재시도")
|
||||
time.sleep(sleep_time)
|
||||
|
||||
return pd.DataFrame(columns=["open","high","low","close","volume"]).set_index(pd.Index([], name="timestamp"))
|
||||
|
||||
|
||||
def compute_macd_hist(close_series: pd.Series, log_buffer: list = None) -> pd.Series:
|
||||
def _buf(level: str, msg: str):
|
||||
if log_buffer is not None:
|
||||
log_buffer.append(f"{level}: {msg}")
|
||||
else:
|
||||
getattr(logger, level.lower())(msg)
|
||||
try:
|
||||
macd_df = ta.macd(close_series, fast=12, slow=26, signal=9)
|
||||
hist_cols = [c for c in macd_df.columns if "MACDh" in c]
|
||||
if not hist_cols:
|
||||
_buf("error", "MACD histogram column not found")
|
||||
return pd.Series([])
|
||||
return macd_df[hist_cols[0]]
|
||||
except Exception as e:
|
||||
_buf("error", f"MACD 계산 실패: {e}")
|
||||
return pd.Series([])
|
||||
|
||||
|
||||
def compute_sma(close_series: pd.Series, window: int, log_buffer: list = None) -> pd.Series:
|
||||
"""단순 이동평균선(SMA) 계산"""
|
||||
def _buf(level: str, msg: str):
|
||||
if log_buffer is not None:
|
||||
log_buffer.append(f"{level}: {msg}")
|
||||
else:
|
||||
getattr(logger, level.lower())(msg)
|
||||
try:
|
||||
return close_series.rolling(window=window).mean()
|
||||
except Exception as e:
|
||||
_buf("error", f"SMA{window} 계산 실패: {e}")
|
||||
return pd.Series([])
|
||||
223
src/kiwoom_api.py
Normal file
223
src/kiwoom_api.py
Normal file
@@ -0,0 +1,223 @@
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
from pykiwoom.kiwoom import *
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class KiwoomAPI:
|
||||
def __init__(self):
|
||||
self.kiwoom = Kiwoom()
|
||||
self._login_lock = threading.Lock()
|
||||
self._last_connection_check = 0
|
||||
self._connection_check_interval = 60 # 연결 상태 확인 간격 (60초)
|
||||
self.login()
|
||||
|
||||
def login(self):
|
||||
"""Kiwoom API 로그인"""
|
||||
with self._login_lock:
|
||||
try:
|
||||
self.kiwoom.CommConnect(block=True)
|
||||
logger.info("Kiwoom API에 로그인 성공")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Kiwoom API 로그인 실패: {e}")
|
||||
return False
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
현재 연결 상태 확인
|
||||
반환값: 0=연결안됨, 1=연결완료
|
||||
"""
|
||||
try:
|
||||
state = self.kiwoom.GetConnectState()
|
||||
return state == 1
|
||||
except Exception as e:
|
||||
logger.warning(f"연결 상탄 확인 실패: {e}")
|
||||
return False
|
||||
|
||||
def ensure_connected(self) -> bool:
|
||||
"""
|
||||
연결 상탈 확인 및 필요시 재연결
|
||||
주기적으로 호출하도록 간격 제한 포함
|
||||
"""
|
||||
import time
|
||||
current_time = time.time()
|
||||
|
||||
# 마지막 확인 후 일정 시간이 지나지 않았으면 검사 생략
|
||||
if current_time - self._last_connection_check < self._connection_check_interval:
|
||||
return True
|
||||
|
||||
self._last_connection_check = current_time
|
||||
|
||||
if self.is_connected():
|
||||
logger.debug("연결 상탄 정상")
|
||||
return True
|
||||
|
||||
logger.warning("Kiwoom API 연결이 끊어졌습니다. 재연결을 시도합니다...")
|
||||
|
||||
# 최대 3회 재시도
|
||||
for attempt in range(1, 4):
|
||||
logger.info(f"재연결 시도 {attempt}/3")
|
||||
|
||||
if self.login():
|
||||
if self.is_connected():
|
||||
logger.info("재연결 성공")
|
||||
return True
|
||||
|
||||
if attempt < 3:
|
||||
wait_time = attempt * 5 # 5, 10초 대기
|
||||
logger.info(f"{wait_time}초 후 재시도...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.error("재연결 실패: 3회 모두 실패")
|
||||
|
||||
# 텔레그램 알림 (환경변수에서 토큰 가져오기)
|
||||
try:
|
||||
bot_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||||
chat_id = os.getenv("TELEGRAM_CHAT_ID")
|
||||
if bot_token and chat_id:
|
||||
import requests
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
message = "🔴 [긴급] Kiwoom API 재연결 실패\n\n3회 재시도 모두 실패했습니다.\n프로그램 관리자의 확인이 필요합니다."
|
||||
requests.post(url, json={"chat_id": chat_id, "text": message}, timeout=10)
|
||||
except Exception as e:
|
||||
logger.warning(f"재연결 실패 알림 전송 실패: {e}")
|
||||
|
||||
return False
|
||||
|
||||
def get_account_info(self, account_number):
|
||||
"""
|
||||
계좌 정보 조회 (예수금 및 잔고)
|
||||
"""
|
||||
# 연결 상탄 확인 및 재연결
|
||||
if not self.ensure_connected():
|
||||
raise ConnectionError("Kiwoom API 연결이 끊어졌고 재연결에 실패했습니다")
|
||||
|
||||
try:
|
||||
# 예수금 정보
|
||||
deposit_info = self.kiwoom.block_request("opw00001",
|
||||
계좌번호=account_number,
|
||||
비밀번호="",
|
||||
비밀번호입력매체구분="00",
|
||||
조회구분=2,
|
||||
output="예수금상세현황",
|
||||
next=0)
|
||||
|
||||
# 계좌평가잔고내역
|
||||
balance_info = self.kiwoom.block_request("opw00018",
|
||||
계좌번호=account_number,
|
||||
비밀번호="",
|
||||
비밀번호입력매체구분="00",
|
||||
조회구분=1,
|
||||
output="계좌평가잔고개별합산",
|
||||
next=0)
|
||||
return deposit_info, balance_info
|
||||
except Exception as e:
|
||||
logger.error(f"계좌 정보 조회 실패: {e}")
|
||||
raise
|
||||
|
||||
def get_ohlcv(self, code, timeframe):
|
||||
"""
|
||||
OHLCV 데이터 조회
|
||||
timeframe: '1', '3', '5', ... (분봉), 'D'(일봉), 'W'(주봉), 'M'(월봉)
|
||||
"""
|
||||
# 연결 상탄 확인 및 재연결
|
||||
if not self.ensure_connected():
|
||||
logger.error(f"API 연결 끊김: {code} OHLCV 조회 실패")
|
||||
return None
|
||||
|
||||
try:
|
||||
# timeframe이 숫자인지 문자인지 확인하여 API 분기
|
||||
if timeframe.isdigit():
|
||||
# 분봉/시간봉 데이터 요청 (opt10080)
|
||||
df = self.kiwoom.block_request("opt10080",
|
||||
종목코드=code,
|
||||
틱범위=timeframe,
|
||||
수정주가구분=1,
|
||||
output="주식분봉차트조회",
|
||||
next=0)
|
||||
logger.debug(f"분봉 데이터 요청: {code}, {timeframe}분")
|
||||
else:
|
||||
# 일/주/월봉 데이터 요청 (opt10081)
|
||||
df = self.kiwoom.block_request("opt10081",
|
||||
종목코드=code,
|
||||
틱범위=timeframe,
|
||||
수정주가구분=1,
|
||||
output="주식일봉차트조회",
|
||||
next=0)
|
||||
logger.debug(f"일/주/월봉 데이터 요청: {code}, {timeframe}")
|
||||
|
||||
if df is None or (hasattr(df, 'empty') and df.empty):
|
||||
logger.warning(f"OHLCV 조회 결과 없음: {code} ({timeframe})")
|
||||
return None
|
||||
return df
|
||||
except Exception as e:
|
||||
logger.error(f"OHLCV 조회 실패: {code}, {timeframe}, {e}")
|
||||
return None
|
||||
|
||||
def send_order(self, order_type, code, quantity, price, account_number):
|
||||
"""
|
||||
주문 실행
|
||||
order_type: 1=신규매수, 2=신규매도, 3=매수취소, 4=매도취소, 5=매수정정, 6=매도정정
|
||||
code: 종목코드 (6자리)
|
||||
quantity: 주문수량
|
||||
price: 주문가격 (0이면 시장가)
|
||||
"""
|
||||
# 연결 상탄 확인 및 재연결 (주문은 매우 중요하므로 반드시 확인)
|
||||
if not self.ensure_connected():
|
||||
error_msg = f"주문 실패: API 연결이 끊어졌고 재연결에 실패했습니다"
|
||||
logger.error(error_msg)
|
||||
raise ConnectionError(error_msg)
|
||||
|
||||
try:
|
||||
# 시장가 주문의 경우 가격을 0으로, 지정가 주문의 경우 해당 가격으로 설정
|
||||
price_to_send = 0 if price == 0 else int(price)
|
||||
# 거래구분: 지정가="00", 시장가="03"
|
||||
order_price_type = "03" if price == 0 else "00"
|
||||
|
||||
result = self.kiwoom.SendOrder(
|
||||
"자동매매", # 사용자구분명
|
||||
"0101", # 화면번호
|
||||
account_number, # 계좌번호
|
||||
order_type, # 주문유형
|
||||
code, # 종목코드
|
||||
quantity, # 주문수량
|
||||
price_to_send, # 주문가격
|
||||
order_price_type, # 거래구분 (00: 지정가, 03: 시장가)
|
||||
"" # 원주문번호
|
||||
)
|
||||
logger.info(f"주문 실행: type={order_type}, code={code}, qty={quantity}, price={price_to_send}, order_type={order_price_type}, result={result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"주문 실행 실패: {e}")
|
||||
raise
|
||||
|
||||
# Singleton instance
|
||||
kiwoom_instance = None
|
||||
|
||||
def get_kiwoom_api():
|
||||
global kiwoom_instance
|
||||
if kiwoom_instance is None:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
app = QApplication(sys.argv)
|
||||
kiwoom_instance = KiwoomAPI()
|
||||
return kiwoom_instance
|
||||
|
||||
if __name__ == '__main__':
|
||||
# For testing purposes
|
||||
app = QApplication(sys.argv)
|
||||
api = get_kiwoom_api()
|
||||
|
||||
# Example: Get account info
|
||||
account_num = "YOUR_ACCOUNT_NUMBER" # 실제 계좌번호로 변경 필요
|
||||
deposit, balance = api.get_account_info(account_num)
|
||||
print("예수금 정보:", deposit)
|
||||
print("계좌잔고:", balance)
|
||||
|
||||
# Example: Get OHLCV data
|
||||
ohlcv = api.get_ohlcv("005930", "D") # 삼성전자, 일봉
|
||||
print("삼성전자 일봉 데이터:", ohlcv)
|
||||
67
src/notifications.py
Normal file
67
src/notifications.py
Normal file
@@ -0,0 +1,67 @@
|
||||
import threading
|
||||
import requests
|
||||
import time
|
||||
from .common import logger
|
||||
|
||||
__all__ = ["send_telegram", "report_error", "send_startup_test_message"]
|
||||
|
||||
def send_telegram(bot_token: str, chat_id: str, text: str, add_thread_prefix: bool = True, parse_mode: str = None) -> bool:
|
||||
if add_thread_prefix:
|
||||
thread_name = threading.current_thread().name
|
||||
payload_text = f"[{thread_name}] {text}"
|
||||
else:
|
||||
payload_text = text
|
||||
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||
payload = {"chat_id": chat_id, "text": payload_text}
|
||||
if parse_mode:
|
||||
payload["parse_mode"] = parse_mode
|
||||
max_attempts = 4
|
||||
backoff = 1.0
|
||||
cumulative_sleep = 0.0
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
logger.warning("텔레그램 전송 실패 (시도 %d/%d) status=%s", attempt, max_attempts, resp.status_code)
|
||||
except Exception as e:
|
||||
logger.warning("텔레그램 예외 (시도 %d/%d): %s", attempt, max_attempts, e)
|
||||
if attempt == max_attempts:
|
||||
logger.error("텔레그램 최대 재시도 도달")
|
||||
return False
|
||||
sleep_time = backoff * attempt
|
||||
cumulative_sleep += sleep_time
|
||||
if cumulative_sleep > 30:
|
||||
logger.error("텔레그램 누적 대기시간 초과")
|
||||
return False
|
||||
time.sleep(sleep_time)
|
||||
return False
|
||||
|
||||
def report_error(bot_token: str, chat_id: str, message: str, dry_run: bool):
|
||||
"""
|
||||
Report an error via Telegram.
|
||||
"""
|
||||
if not dry_run and bot_token and chat_id:
|
||||
try:
|
||||
send_telegram(bot_token, chat_id, message)
|
||||
except Exception:
|
||||
logger.exception("에러 알림 전송 실패")
|
||||
|
||||
def send_startup_test_message(bot_token: str, chat_id: str, parse_mode: str, dry_run: bool):
|
||||
"""
|
||||
Send a startup test message to verify Telegram settings.
|
||||
"""
|
||||
if dry_run:
|
||||
logger.info("[dry-run] Telegram 테스트 메시지 전송 생략")
|
||||
return
|
||||
|
||||
if bot_token and chat_id:
|
||||
test_msg = "[테스트] Telegram 설정 확인용 메시지입니다. 봇/채팅 설정이 올바르면 이 메시지가 도착합니다."
|
||||
logger.info("텔레그램 테스트 메시지 전송 시도")
|
||||
if send_telegram(bot_token, chat_id, test_msg, add_thread_prefix=False, parse_mode=parse_mode):
|
||||
logger.info("텔레그램 테스트 메시지 전송 성공")
|
||||
else:
|
||||
logger.warning("텔레그램 테스트 메시지 전송 실패")
|
||||
else:
|
||||
logger.warning("TELEGRAM_TEST=1 이지만 TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID가 설정되어 있지 않습니다")
|
||||
317
src/order.py
Normal file
317
src/order.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import secrets
|
||||
from .common import logger
|
||||
from .notifications import send_telegram
|
||||
from .kiwoom_api import get_kiwoom_api
|
||||
|
||||
def _make_confirm_token(length: int = 16) -> str:
|
||||
return secrets.token_hex(length)
|
||||
|
||||
def _write_pending_order(token: str, order: dict, pending_file: str = "pending_orders.json"):
|
||||
try:
|
||||
pending = []
|
||||
if os.path.exists(pending_file):
|
||||
with open(pending_file, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
pending = json.load(f)
|
||||
except Exception:
|
||||
pending = []
|
||||
pending.append({"token": token, "order": order, "timestamp": time.time()})
|
||||
with open(pending_file, "w", encoding="utf-8") as f:
|
||||
json.dump(pending, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.exception("pending_orders 기록 실패: %s", e)
|
||||
|
||||
def _check_confirmation(token: str, timeout: int = 300) -> bool:
|
||||
start = time.time()
|
||||
confirm_file = f"confirm_{token}"
|
||||
tokens_file = "confirmed_tokens.txt"
|
||||
while time.time() - start < timeout:
|
||||
if os.path.exists(confirm_file):
|
||||
try:
|
||||
os.remove(confirm_file)
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
if os.path.exists(tokens_file):
|
||||
try:
|
||||
with open(tokens_file, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
if line.strip() == token:
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
def notify_order_result(symbol: str, order_result: dict, config: dict, telegram_token: str, telegram_chat_id: str) -> bool:
|
||||
if not (telegram_token and telegram_chat_id):
|
||||
return False
|
||||
notify_cfg = config.get("notify", {}) if config else {}
|
||||
status = order_result.get("status", "unknown")
|
||||
|
||||
should_notify = False
|
||||
msg = ""
|
||||
if status == "placed" and notify_cfg.get("order_filled", True):
|
||||
should_notify = True
|
||||
msg = f"[주문완료] {symbol}\n상태: 주문 성공"
|
||||
elif status in ("error", "failed") and notify_cfg.get("order_error", True):
|
||||
should_notify = True
|
||||
msg = f"[주문오류] {symbol}\n상태: {status}\n에러: {order_result.get('error')}"
|
||||
|
||||
if should_notify and msg:
|
||||
try:
|
||||
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.exception("주문 결과 알림 전송 실패: %s", e)
|
||||
return False
|
||||
return False
|
||||
|
||||
def _calculate_and_add_profit_rate(trade_record: dict, symbol: str, sell_price: float):
|
||||
try:
|
||||
from .holdings import load_holdings
|
||||
holdings = load_holdings("holdings.json")
|
||||
if symbol not in holdings:
|
||||
return
|
||||
|
||||
buy_price = float(holdings[symbol].get("buy_price", 0.0) or 0.0)
|
||||
|
||||
if buy_price > 0 and sell_price > 0:
|
||||
profit_rate = ((sell_price - buy_price) / buy_price) * 100
|
||||
trade_record["buy_price"] = buy_price
|
||||
trade_record["sell_price"] = sell_price
|
||||
trade_record["profit_rate"] = round(profit_rate, 2)
|
||||
logger.info("[%s] 매도 수익률: %.2f%% (매수가: %.2f, 매도가: %.2f)",
|
||||
symbol, profit_rate, buy_price, sell_price)
|
||||
else:
|
||||
logger.warning("[%s] 수익률 계산 불가: buy_price=%.2f, sell_price=%.2f", symbol, buy_price, sell_price)
|
||||
trade_record["profit_rate"] = None
|
||||
except Exception as e:
|
||||
logger.warning("매도 수익률 계산 중 오류 (기록은 계속 진행): %s", e)
|
||||
trade_record["profit_rate"] = None
|
||||
|
||||
def place_buy_order_kiwoom(market: str, amount: int, config: dict, account_number: str, dry_run: bool = True) -> dict:
|
||||
from .holdings import get_current_price
|
||||
|
||||
if dry_run:
|
||||
price = get_current_price(market)
|
||||
quantity = int(amount // price) if price > 0 else 0
|
||||
logger.info("[place_buy_order_kiwoom][dry-run] %s 매수 금액=%d, 예상 수량=%d", market, amount, quantity)
|
||||
return {"market": market, "side": "buy", "amount": amount, "price": price, "status": "simulated", "timestamp": time.time()}
|
||||
|
||||
if not account_number:
|
||||
msg = "Kiwoom 계좌번호 없음: 매수 주문을 실행할 수 없습니다"
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
price = get_current_price(market)
|
||||
if price <= 0:
|
||||
msg = f"현재가 조회 실패로 매수 불가: {market}"
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
quantity = int(amount // price)
|
||||
|
||||
min_order_value = config.get("auto_trade", {}).get("min_order_value", 100000)
|
||||
if amount < min_order_value:
|
||||
msg = f"시장가 매수 건너뜀: 주문 금액 {amount} < 최소 {min_order_value}"
|
||||
logger.warning(msg)
|
||||
return {"market": market, "side": "buy", "amount": amount, "status": "skipped_too_small", "reason": "min_order_value", "timestamp": time.time()}
|
||||
|
||||
# 시장가 매수 주문 (가격 0)
|
||||
order_result = api.send_order(1, market, quantity, 0, account_number)
|
||||
|
||||
logger.info("Kiwoom 시장가 매수 주문: %s, 금액=%d, 수량=%d", market, amount, quantity)
|
||||
logger.info("Kiwoom 주문 응답: %s", order_result)
|
||||
|
||||
status = "placed" if order_result == 0 else "failed"
|
||||
result = {"market": market, "side": "buy", "amount": amount, "quantity": quantity, "status": status, "response": order_result, "timestamp": time.time()}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Kiwoom 매수 주문 실패: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
def place_sell_order_kiwoom(market: str, quantity: int, config: dict, account_number: str, dry_run: bool = True) -> dict:
|
||||
"""
|
||||
매도 주문 실행
|
||||
market: 종목코드
|
||||
quantity: 매도 수량 (주)
|
||||
"""
|
||||
if dry_run:
|
||||
logger.info("[place_sell_order_kiwoom][dry-run] %s 매도 수량=%d", market, quantity)
|
||||
return {"market": market, "side": "sell", "quantity": quantity, "status": "simulated", "timestamp": time.time()}
|
||||
|
||||
if not account_number:
|
||||
msg = "Kiwoom 계좌번호 없음: 매도 주문을 실행할 수 없습니다"
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
try:
|
||||
api = get_kiwoom_api()
|
||||
|
||||
# 매도 수량 검증
|
||||
if quantity <= 0:
|
||||
msg = f"잘못된 매도 수량: {quantity}"
|
||||
logger.error(msg)
|
||||
return {"error": msg, "status": "failed", "timestamp": time.time()}
|
||||
|
||||
# 시장가 매도 주문 (가격 0)
|
||||
order_result = api.send_order(2, market, int(quantity), 0, account_number)
|
||||
|
||||
logger.info("Kiwoom 시장가 매도 주문: %s, 수량=%d", market, quantity)
|
||||
logger.info("Kiwoom 주문 응답: %s", order_result)
|
||||
|
||||
status = "placed" if order_result == 0 else "failed"
|
||||
result = {"market": market, "side": "sell", "quantity": quantity, "status": status, "response": order_result, "timestamp": time.time()}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("Kiwoom 매도 주문 실패: %s", e)
|
||||
return {"error": str(e), "status": "failed", "timestamp": time.time()}
|
||||
|
||||
def execute_sell_order_with_confirmation(symbol: str, quantity: int, config: dict, telegram_token: str, telegram_chat_id: str, account_number: str, dry_run: bool, parse_mode: str = "HTML") -> dict:
|
||||
"""
|
||||
확인 절차를 거쳐 매도 주문 실행
|
||||
symbol: 종목코드
|
||||
quantity: 매도 수량 (주)
|
||||
"""
|
||||
confirm_cfg = config.get("confirm", {})
|
||||
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
|
||||
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
|
||||
|
||||
result = None
|
||||
if not confirm_via_file:
|
||||
logger.info("파일 확인 비활성화: 즉시 매도 주문 실행")
|
||||
result = place_sell_order_kiwoom(symbol, quantity, config, account_number, dry_run)
|
||||
else:
|
||||
token = _make_confirm_token()
|
||||
order_info = {"symbol": symbol, "side": "sell", "quantity": quantity, "timestamp": time.time()}
|
||||
_write_pending_order(token, order_info)
|
||||
|
||||
if parse_mode == "HTML":
|
||||
msg = f"<b>[확인필요] 자동매도 주문 대기</b>\n"
|
||||
msg += f"토큰: <code>{token}</code>\n"
|
||||
msg += f"심볼: <b>{symbol}</b>\n"
|
||||
msg += f"매도수량: <b>{quantity:,d}주</b>\n\n"
|
||||
msg += f"확인 방법:\n"
|
||||
msg += f"1. 파일 생성: <code>confirm_{token}</code>\n"
|
||||
msg += f"2. 또는 <code>confirmed_tokens.txt</code>에 토큰 추가\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
else:
|
||||
msg = f"[확인필요] 자동매도 주문 대기\n"
|
||||
msg += f"토큰: {token}\n"
|
||||
msg += f"심볼: {symbol}\n"
|
||||
msg += f"매도수량: {quantity:,d}주\n\n"
|
||||
msg += f"확인 방법:\n"
|
||||
msg += f"1. 파일 생성: confirm_{token}\n"
|
||||
msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
|
||||
logger.info("[%s] 매도 확인 대기 중: 토큰=%s, 타임아웃=%d초", symbol, token, confirm_timeout)
|
||||
confirmed = _check_confirmation(token, confirm_timeout)
|
||||
|
||||
if not confirmed:
|
||||
logger.warning("[%s] 매도 확인 타임아웃: 주문 취소", symbol)
|
||||
if telegram_token and telegram_chat_id:
|
||||
cancel_msg = f"[주문취소] {symbol} 매도\n사유: 사용자 미확인 (타임아웃)"
|
||||
send_telegram(telegram_token, telegram_chat_id, cancel_msg, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
|
||||
else:
|
||||
logger.info("[%s] 매도 확인 완료: 주문 실행", symbol)
|
||||
result = place_sell_order_kiwoom(symbol, quantity, config, account_number, dry_run)
|
||||
|
||||
if result:
|
||||
notify_order_result(symbol, result, config, telegram_token, telegram_chat_id)
|
||||
|
||||
trade_status = result.get("status")
|
||||
if trade_status in ["simulated", "placed", "user_not_confirmed"]:
|
||||
from .holdings import get_current_price
|
||||
sell_price = get_current_price(symbol) if trade_status == "placed" else 0
|
||||
|
||||
trade_record = {"symbol": symbol, "side": "sell", "quantity": quantity, "timestamp": time.time(), "dry_run": dry_run, "result": result}
|
||||
|
||||
_calculate_and_add_profit_rate(trade_record, symbol, sell_price)
|
||||
from .signals import record_trade
|
||||
record_trade(trade_record)
|
||||
|
||||
if not dry_run and result.get("status") == "placed":
|
||||
from .holdings import update_holding_amount
|
||||
update_holding_amount(symbol, -quantity, "holdings.json")
|
||||
|
||||
return result
|
||||
|
||||
def execute_buy_order_with_confirmation(symbol: str, amount: int, config: dict, telegram_token: str, telegram_chat_id: str, account_number: str, dry_run: bool, parse_mode: str = "HTML") -> dict:
|
||||
confirm_cfg = config.get("confirm", {})
|
||||
confirm_via_file = confirm_cfg.get("confirm_via_file", True)
|
||||
confirm_timeout = confirm_cfg.get("confirm_timeout", 300)
|
||||
|
||||
result = None
|
||||
if not confirm_via_file:
|
||||
logger.info("파일 확인 비활성화: 즉시 매수 주문 실행")
|
||||
result = place_buy_order_kiwoom(symbol, amount, config, account_number, dry_run)
|
||||
else:
|
||||
token = _make_confirm_token()
|
||||
order_info = {"symbol": symbol, "side": "buy", "amount": amount, "timestamp": time.time()}
|
||||
_write_pending_order(token, order_info)
|
||||
|
||||
if parse_mode == "HTML":
|
||||
msg = f"<b>[확인필요] 자동매수 주문 대기</b>\n"
|
||||
msg += f"토큰: <code>{token}</code>\n"
|
||||
msg += f"심볼: <b>{symbol}</b>\n"
|
||||
msg += f"매수금액: <b>{amount:,.0f}</b>\n\n"
|
||||
msg += f"확인 방법:\n"
|
||||
msg += f"1. 파일 생성: <code>confirm_{token}</code>\n"
|
||||
msg += f"2. 또는 <code>confirmed_tokens.txt</code>에 토큰 추가\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
else:
|
||||
msg = f"[확인필요] 자동매수 주문 대기\n"
|
||||
msg += f"토큰: {token}\n"
|
||||
msg += f"심볼: {symbol}\n"
|
||||
msg += f"매수금액: {amount:,.0f}\n\n"
|
||||
msg += f"확인 방법:\n"
|
||||
msg += f"1. 파일 생성: confirm_{token}\n"
|
||||
msg += f"2. 또는 confirmed_tokens.txt에 토큰 추가\n"
|
||||
msg += f"타임아웃: {confirm_timeout}초"
|
||||
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
|
||||
logger.info("[%s] 매수 확인 대기 중: 토큰=%s, 타임아웃=%d초", symbol, token, confirm_timeout)
|
||||
confirmed = _check_confirmation(token, confirm_timeout)
|
||||
|
||||
if not confirmed:
|
||||
logger.warning("[%s] 매수 확인 타임아웃: 주문 취소", symbol)
|
||||
if telegram_token and telegram_chat_id:
|
||||
cancel_msg = f"[주문취소] {symbol} 매수\n사유: 사용자 미확인 (타임아웃)"
|
||||
send_telegram(telegram_token, telegram_chat_id, cancel_msg, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
result = {"status": "user_not_confirmed", "symbol": symbol, "timestamp": time.time()}
|
||||
else:
|
||||
logger.info("[%s] 매수 확인 완료: 주문 실행", symbol)
|
||||
result = place_buy_order_kiwoom(symbol, amount, config, account_number, dry_run)
|
||||
|
||||
if result:
|
||||
notify_order_result(symbol, result, config, telegram_token, telegram_chat_id)
|
||||
|
||||
trade_status = result.get("status")
|
||||
if trade_status in ["simulated", "placed", "user_not_confirmed"]:
|
||||
trade_record = {
|
||||
"symbol": symbol,
|
||||
"side": "buy",
|
||||
"amount": amount,
|
||||
"timestamp": time.time(),
|
||||
"dry_run": dry_run,
|
||||
"result": result
|
||||
}
|
||||
from .signals import record_trade
|
||||
record_trade(trade_record)
|
||||
|
||||
return result
|
||||
551
src/signals.py
Normal file
551
src/signals.py
Normal file
@@ -0,0 +1,551 @@
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import inspect
|
||||
from typing import List, Dict, Tuple, Any
|
||||
from enum import Enum
|
||||
import pandas as pd
|
||||
import pandas_ta as ta
|
||||
from datetime import datetime
|
||||
|
||||
from .common import logger
|
||||
from .config import RuntimeConfig
|
||||
from .indicators import fetch_ohlcv, compute_macd_hist, compute_sma
|
||||
from .holdings import fetch_holdings_from_kiwoom, get_current_price
|
||||
from .notifications import send_telegram
|
||||
|
||||
|
||||
class CheckType(str, Enum):
|
||||
"""매도 조건 체크 타입."""
|
||||
STOP_LOSS = "stop_loss" # 손절 조건 (1시간 주기)
|
||||
PROFIT_TAKING = "profit_taking" # 익절 조건 (4시간 주기)
|
||||
ALL = "all" # 모든 조건
|
||||
|
||||
|
||||
def make_trade_record(symbol, side, amount, dry_run, price=None, status="simulated"):
|
||||
now = float(time.time())
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"timestamp": now,
|
||||
"datetime": datetime.fromtimestamp(now).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"dry_run": dry_run,
|
||||
"result": {
|
||||
"market": symbol,
|
||||
"side": side,
|
||||
"amount": amount,
|
||||
"price": price,
|
||||
"status": status,
|
||||
"timestamp": now
|
||||
}
|
||||
}
|
||||
|
||||
def evaluate_sell_conditions(
|
||||
current_price: float,
|
||||
buy_price: float,
|
||||
max_price: float,
|
||||
holding_info: Dict[str, Any],
|
||||
config: Dict[str, Any] | None = None,
|
||||
check_type: str = CheckType.ALL
|
||||
) -> Dict[str, Any]:
|
||||
"""매도 조건을 평가하여 매도 여부와 비율을 반환.
|
||||
|
||||
Args:
|
||||
current_price: 현재 가격
|
||||
buy_price: 매수 가격
|
||||
max_price: 최고 가격
|
||||
holding_info: 보유 정보 (partial_sell_done 포함)
|
||||
config: 설정 딕셔너리
|
||||
check_type: 체크 타입 (CheckType.STOP_LOSS, CheckType.PROFIT_TAKING, CheckType.ALL)
|
||||
|
||||
Returns:
|
||||
매도 조건 평가 결과 딕셔너리
|
||||
"""
|
||||
config = config or {}
|
||||
auto_trade_config = config.get("auto_trade", {})
|
||||
loss_threshold = float(auto_trade_config.get("loss_threshold", -5.0))
|
||||
profit_threshold_1 = float(auto_trade_config.get("profit_threshold_1", 10.0))
|
||||
profit_threshold_2 = float(auto_trade_config.get("profit_threshold_2", 30.0))
|
||||
drawdown_1 = float(auto_trade_config.get("drawdown_1", 5.0))
|
||||
drawdown_2 = float(auto_trade_config.get("drawdown_2", 15.0))
|
||||
partial_sell_ratio = float(auto_trade_config.get("partial_sell_ratio", 0.5))
|
||||
|
||||
# 가격 유효성 검증
|
||||
if buy_price <= 0:
|
||||
logger.error(f"비정상 매수가: {buy_price}")
|
||||
return {
|
||||
"status": "error",
|
||||
"sell_ratio": 0.0,
|
||||
"reasons": [f"비정상 매수가: {buy_price}"],
|
||||
"profit_rate": 0,
|
||||
"max_drawdown": 0,
|
||||
"set_partial_sell_done": False,
|
||||
"check_interval_minutes": 60
|
||||
}
|
||||
|
||||
if max_price <= 0:
|
||||
logger.warning(f"비정상 최고가: {max_price}, 현재가로 대체")
|
||||
max_price = current_price
|
||||
|
||||
profit_rate = ((current_price - buy_price) / buy_price) * 100
|
||||
max_drawdown = ((current_price - max_price) / max_price) * 100
|
||||
|
||||
result = {
|
||||
"status": "hold",
|
||||
"sell_ratio": 0.0,
|
||||
"reasons": [],
|
||||
"profit_rate": profit_rate,
|
||||
"max_drawdown": max_drawdown,
|
||||
"set_partial_sell_done": False,
|
||||
"check_interval_minutes": 60 # 기본값: 1시간
|
||||
}
|
||||
|
||||
# check_type에 따른 조건 필터링
|
||||
# "stop_loss": 1시간 주기 조건만 (조건1, 조건3, 조건4-2, 조건5-2)
|
||||
# "profit_taking": 4시간 주기 조건만 (조건2, 조건4-1, 조건5-1)
|
||||
# "all": 모든 조건
|
||||
|
||||
# 조건 우선순위:
|
||||
# 1. 손절 (조건1) - 최우선
|
||||
# 2. 부분 익절 (조건3) - 수익 실현 시작 (1회성)
|
||||
# 3. 전량 익절 (조건4, 5) - 최고점 대비 하락 또는 수익률 하락
|
||||
|
||||
# 조건1: 손절 -5% (1시간 주기) - 손실 방지 최우선
|
||||
if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= loss_threshold:
|
||||
result.update(status="stop_loss", sell_ratio=1.0, check_interval_minutes=60)
|
||||
result["reasons"].append(f"손절(조건1): 수익률 {profit_rate:.2f}% <= {loss_threshold}%")
|
||||
return result
|
||||
|
||||
max_profit_rate = ((max_price - buy_price) / buy_price) * 100
|
||||
|
||||
# 조건3: 부분 익절 10% (1시간 주기) - profit_threshold_1 도달 시 일부 수익 확정
|
||||
# 주의: 부분 익절은 1회만 실행되며, 이후 전량 익절 조건만 평가됨
|
||||
partial_sell_done = holding_info.get("partial_sell_done", False)
|
||||
if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and not partial_sell_done and profit_rate >= profit_threshold_1:
|
||||
result.update(status="partial_profit", sell_ratio=partial_sell_ratio, check_interval_minutes=60)
|
||||
result["reasons"].append(f"부분 익절(조건3): 수익률 {profit_rate:.2f}% 달성, {int(partial_sell_ratio * 100)}% 매도")
|
||||
result["set_partial_sell_done"] = True
|
||||
return result
|
||||
|
||||
if max_profit_rate > profit_threshold_2:
|
||||
# 조건5-1: 최고점 -15% 하락 (4시간 주기)
|
||||
if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_2:
|
||||
result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=240)
|
||||
result["reasons"].append(f"전량 익절(조건5-1): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_2}%)")
|
||||
return result
|
||||
# 조건5-2: 수익률 30% 이하 하락 (1시간 주기)
|
||||
if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= profit_threshold_2:
|
||||
result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=60)
|
||||
result["reasons"].append(f"전량 익절(조건5-2): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 수익률 {profit_rate:.2f}%로 하락 (기준: {profit_threshold_2}%)")
|
||||
return result
|
||||
elif profit_threshold_1 < max_profit_rate <= profit_threshold_2:
|
||||
# 조건4-1: 최고점 -5% 하락 (4시간 주기)
|
||||
if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_1:
|
||||
result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=240)
|
||||
result["reasons"].append(f"전량 익절(조건4-1): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)")
|
||||
return result
|
||||
# 조건4-2: 수익률 10% 이하 하락 (1시간 주기)
|
||||
if check_type in (CheckType.STOP_LOSS, CheckType.ALL) and profit_rate <= profit_threshold_1:
|
||||
result.update(status="profit_taking", sell_ratio=1.0, check_interval_minutes=60)
|
||||
result["reasons"].append(f"전량 익절(조건4-2): 최고 수익률({max_profit_rate:.2f}%) 달성 후, 수익률 {profit_rate:.2f}%로 하락 (기준: {profit_threshold_1}%)")
|
||||
return result
|
||||
elif max_profit_rate <= profit_threshold_1:
|
||||
# 조건2: 10% 이하 + 최고점 -5% (4시간 주기)
|
||||
if check_type in (CheckType.PROFIT_TAKING, CheckType.ALL) and max_drawdown <= -drawdown_1:
|
||||
result.update(status="stop_loss", sell_ratio=1.0, check_interval_minutes=240)
|
||||
result["reasons"].append(f"손절(조건2): 최고점 대비 {abs(max_drawdown):.2f}% 하락 (기준: {drawdown_1}%)")
|
||||
return result
|
||||
|
||||
result["reasons"].append(f"홀드 (수익률 {profit_rate:.2f}%, 최고점 대비 하락 {max_drawdown:.2f}%)")
|
||||
return result
|
||||
|
||||
def build_sell_message(symbol: str, sell_result: dict, parse_mode: str = "HTML") -> str:
|
||||
status = sell_result.get("status", "unknown")
|
||||
profit = sell_result.get("profit_rate", 0.0)
|
||||
drawdown = sell_result.get("max_drawdown", 0.0)
|
||||
ratio = int(sell_result.get("sell_ratio", 0.0) * 100)
|
||||
reasons = sell_result.get("reasons", [])
|
||||
reason = reasons[0] if reasons else "사유 없음"
|
||||
|
||||
if parse_mode == "HTML":
|
||||
msg = f"<b>🔴 매도 신호: {symbol}</b>\n"
|
||||
msg += f"상태: <b>{status}</b>\n"
|
||||
msg += f"수익률: <b>{profit:.2f}%</b>\n"
|
||||
msg += f"최고점 대비: <b>{drawdown:.2f}%</b>\n"
|
||||
msg += f"매도 비율: <b>{ratio}%</b>\n"
|
||||
msg += f"사유: {reason}\n"
|
||||
return msg
|
||||
msg = f"🔴 매도 신호: {symbol}\n"
|
||||
msg += f"상태: {status}\n"
|
||||
msg += f"수익률: {profit:.2f}%\n"
|
||||
msg += f"최고점 대비: {drawdown:.2f}%\n"
|
||||
msg += f"매도 비율: {ratio}%\n"
|
||||
msg += f"사유: {reason}\n"
|
||||
return msg
|
||||
|
||||
def _adjust_sell_ratio_for_min_order(symbol: str, total_amount: float, sell_ratio: float, current_price: float, config: dict) -> float:
|
||||
if not (0 < sell_ratio < 1):
|
||||
return sell_ratio
|
||||
|
||||
auto_trade_cfg = (config or {}).get("auto_trade", {})
|
||||
try:
|
||||
min_order_value = float(auto_trade_cfg.get("min_order_value", 100000))
|
||||
except (ValueError, TypeError):
|
||||
min_order_value = 100000.0
|
||||
|
||||
amount_to_sell_partial = total_amount * sell_ratio
|
||||
value_to_sell = amount_to_sell_partial * current_price
|
||||
value_remaining = (total_amount - amount_to_sell_partial) * current_price
|
||||
|
||||
if value_to_sell < min_order_value or value_remaining < min_order_value:
|
||||
logger.info("[%s] 부분 매도(%.0f%%) 조건 충족했으나, 최소 주문 금액(%.0f) 문제로 전량 매도로 전환합니다. (예상 매도액: %.0f, 예상 잔여액: %.0f)",
|
||||
symbol, sell_ratio * 100, min_order_value, value_to_sell, value_remaining)
|
||||
return 1.0
|
||||
|
||||
return sell_ratio
|
||||
|
||||
def record_trade(trade: dict, trades_file: str = "trades.json"):
|
||||
try:
|
||||
trades = []
|
||||
if os.path.exists(trades_file):
|
||||
with open(trades_file, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
trades = json.load(f)
|
||||
except Exception:
|
||||
trades = []
|
||||
trades.append(trade)
|
||||
with open(trades_file, "w", encoding="utf-8") as f:
|
||||
json.dump(trades, f, ensure_ascii=False, indent=2)
|
||||
logger.debug("거래기록 저장됨: %s", trades_file)
|
||||
except Exception as e:
|
||||
logger.exception("거래기록 저장 실패: %s", e)
|
||||
|
||||
def _update_df_with_realtime_price(df: pd.DataFrame, symbol: str, timeframe: str, buffer: list) -> pd.DataFrame:
|
||||
"""
|
||||
실시간 가격으로 현재 캔들 업데이트
|
||||
타임프레임에 따라 현재 캔들이 아직 진행 중인지 판단하여 업데이트
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
current_price = get_current_price(symbol)
|
||||
if not (current_price > 0 and df is not None and not df.empty):
|
||||
return df
|
||||
|
||||
last_candle_time = df.index[-1]
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 타임프레임별 interval_seconds 계산
|
||||
interval_seconds = 0
|
||||
try:
|
||||
if 'm' in timeframe:
|
||||
interval_seconds = int(timeframe.replace('m', '')) * 60
|
||||
elif 'h' in timeframe:
|
||||
interval_seconds = int(timeframe.replace('h', '')) * 3600
|
||||
elif timeframe == 'D':
|
||||
interval_seconds = 86400
|
||||
elif timeframe == 'W':
|
||||
interval_seconds = 86400 * 7
|
||||
elif timeframe == 'M':
|
||||
# 월봉은 정확한 계산이 어려우므로 업데이트 건너뜀
|
||||
buffer.append("warning: 월봉(M)은 실시간 업데이트를 지원하지 않습니다")
|
||||
return df
|
||||
except (ValueError, AttributeError) as e:
|
||||
buffer.append(f"warning: 타임프레임 파싱 실패 ({timeframe}): {e}")
|
||||
return df
|
||||
|
||||
if interval_seconds > 0:
|
||||
if last_candle_time.tzinfo is None:
|
||||
last_candle_time = last_candle_time.tz_localize(timezone.utc)
|
||||
|
||||
next_candle_time = last_candle_time + pd.Timedelta(seconds=interval_seconds)
|
||||
|
||||
if last_candle_time <= now < next_candle_time:
|
||||
# 현재 캔들이 아직 진행 중이므로 실시간 가격으로 업데이트
|
||||
df.loc[df.index[-1], 'close'] = current_price
|
||||
df.loc[df.index[-1], 'high'] = max(df.loc[df.index[-1], 'high'], current_price)
|
||||
df.loc[df.index[-1], 'low'] = min(df.loc[df.index[-1], 'low'], current_price)
|
||||
buffer.append(f"실시간 캔들 업데이트 적용: close={current_price:.2f}, timeframe={timeframe}")
|
||||
else:
|
||||
buffer.append(f"info: 새로운 캔들 시작됨, 실시간 업데이트 건너뜀 (timeframe={timeframe})")
|
||||
else:
|
||||
buffer.append(f"warning: interval_seconds 계산 실패 (timeframe={timeframe})")
|
||||
except Exception as e:
|
||||
buffer.append(f"warning: 실시간 캔들 업데이트 실패: {e}")
|
||||
return df
|
||||
|
||||
def process_symbol(symbol: str, timeframe: str, candle_count: int, telegram_token: str, telegram_chat_id: str, dry_run: bool, indicators: dict = None, indicator_timeframe: str = None, cfg: RuntimeConfig | None = None) -> dict:
|
||||
result = {"symbol": symbol, "summary": [], "telegram": None, "error": None}
|
||||
try:
|
||||
if cfg is not None:
|
||||
timeframe = timeframe or cfg.timeframe
|
||||
candle_count = candle_count or cfg.candle_count
|
||||
telegram_token = telegram_token or cfg.telegram_bot_token
|
||||
telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id
|
||||
dry_run = cfg.dry_run if dry_run is None else dry_run
|
||||
indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe
|
||||
buffer = []
|
||||
use_tf = indicator_timeframe or timeframe
|
||||
|
||||
df = fetch_ohlcv(symbol, use_tf, candle_count=candle_count, log_buffer=buffer)
|
||||
df = _update_df_with_realtime_price(df, symbol, use_tf, buffer)
|
||||
|
||||
if buffer:
|
||||
for b in buffer:
|
||||
result["summary"].append(b)
|
||||
if df.empty or len(df) < 3:
|
||||
result["summary"].append(f"MACD 계산에 충분한 데이터 없음: {symbol}")
|
||||
result["error"] = "insufficient_data"
|
||||
return result
|
||||
|
||||
hist = compute_macd_hist(df["close"], log_buffer=buffer)
|
||||
|
||||
if buffer and len(result["summary"]) == 0:
|
||||
for b in buffer:
|
||||
result["summary"].append(b)
|
||||
if len(hist.dropna()) < 2:
|
||||
result["summary"].append(f"MACD 히스토그램 값 부족: {symbol}")
|
||||
result["error"] = "insufficient_macd"
|
||||
return result
|
||||
|
||||
ind = indicators or {}
|
||||
macd_fast = int(ind.get("macd_fast", 12))
|
||||
macd_slow = int(ind.get("macd_slow", 26))
|
||||
macd_signal = int(ind.get("macd_signal", 9))
|
||||
adx_length = int(ind.get("adx_length", 14))
|
||||
adx_threshold = float(ind.get("adx_threshold", 25))
|
||||
sma_short_len = int(ind.get("sma_short", 5))
|
||||
sma_long_len = int(ind.get("sma_long", 200))
|
||||
|
||||
macd_df = ta.macd(df["close"], fast= macd_fast, slow= macd_slow, signal= macd_signal)
|
||||
cols = list(macd_df.columns)
|
||||
hist_cols = [c for c in cols if "MACDh" in c or "hist" in c.lower()]
|
||||
macd_cols = [c for c in cols if ("MACD" in c and c not in hist_cols and not c.lower().endswith("s"))]
|
||||
signal_cols = [c for c in cols if ("MACDs" in c or c.lower().endswith("s") or "signal" in c.lower())]
|
||||
|
||||
if not macd_cols or not signal_cols:
|
||||
if len(cols) >= 3:
|
||||
macd_col = cols[0]
|
||||
signal_col = cols[-1]
|
||||
else:
|
||||
raise RuntimeError("MACD columns not found")
|
||||
else:
|
||||
macd_col = macd_cols[0]
|
||||
signal_col = signal_cols[0]
|
||||
|
||||
macd_line = macd_df[macd_col].dropna()
|
||||
signal_line = macd_df[signal_col].dropna()
|
||||
|
||||
sma_short = compute_sma(df["close"], sma_short_len, log_buffer=buffer)
|
||||
sma_long = compute_sma(df["close"], sma_long_len, log_buffer=buffer)
|
||||
|
||||
adx_df = ta.adx(df["high"], df["low"], df["close"], length=adx_length)
|
||||
adx_cols = [c for c in adx_df.columns if "ADX" in c.upper()]
|
||||
adx_series = adx_df[adx_cols[0]].dropna() if adx_cols else pd.Series([])
|
||||
|
||||
if macd_line is None or signal_line is None:
|
||||
if hist is not None and len(hist.dropna()) >= 2:
|
||||
slope = float(hist.dropna().iloc[-1] - hist.dropna().iloc[-2])
|
||||
if slope > 0:
|
||||
close_price = float(df["close"].iloc[-1])
|
||||
result["summary"].append(f"매수 신호발생 (히스토그램 기울기): {symbol}")
|
||||
result["telegram"] = f"매수 신호발생: {symbol} -> 히스토그램 기울기 양수\n가격: {close_price:.2f}\n사유: histogram slope {slope:.6f}"
|
||||
return result
|
||||
result["summary"].append("조건 미충족: 히스토그램 기울기 음수")
|
||||
result["error"] = "insufficient_macd"
|
||||
return result
|
||||
result["summary"].append("MACD 데이터 부족: 교차 판별 불가")
|
||||
result["error"] = "insufficient_macd"
|
||||
return result
|
||||
|
||||
if len(macd_line) < 2 or len(signal_line) < 2:
|
||||
result["summary"].append("MACD 데이터 부족: 교차 판별 불가")
|
||||
return result
|
||||
|
||||
close = float(df["close"].iloc[-1])
|
||||
prev_macd = float(macd_line.iloc[-2])
|
||||
curr_macd = float(macd_line.iloc[-1])
|
||||
prev_signal = float(signal_line.iloc[-2])
|
||||
curr_signal = float(signal_line.iloc[-1])
|
||||
prev_sma_short = float(sma_short.dropna().iloc[-2]) if len(sma_short.dropna()) >= 2 else None
|
||||
curr_sma_short = float(sma_short.dropna().iloc[-1]) if len(sma_short.dropna()) >= 1 else None
|
||||
prev_sma_long = float(sma_long.dropna().iloc[-2]) if len(sma_long.dropna()) >= 2 else None
|
||||
curr_sma_long = float(sma_long.dropna().iloc[-1]) if len(sma_long.dropna()) >= 1 else None
|
||||
prev_adx = float(adx_series.iloc[-2]) if len(adx_series) >= 2 else None
|
||||
curr_adx = float(adx_series.iloc[-1]) if len(adx_series) >= 1 else None
|
||||
|
||||
cross_macd_signal = (prev_macd < prev_signal and curr_macd > curr_signal)
|
||||
cross_macd_zero = (prev_macd < 0 and curr_macd > 0)
|
||||
macd_cross_ok = (cross_macd_signal or cross_macd_zero)
|
||||
macd_above_signal = (curr_macd > curr_signal)
|
||||
|
||||
sma_condition = (curr_sma_short is not None and curr_sma_long is not None and curr_sma_short > curr_sma_long)
|
||||
cross_sma = (prev_sma_short is not None and prev_sma_long is not None and prev_sma_short < prev_sma_long and curr_sma_short is not None and curr_sma_long is not None and curr_sma_short > curr_sma_long)
|
||||
|
||||
adx_ok = (curr_adx is not None and curr_adx > adx_threshold)
|
||||
cross_adx = (prev_adx is not None and curr_adx is not None and prev_adx <= adx_threshold and curr_adx > adx_threshold)
|
||||
|
||||
matches = []
|
||||
if macd_cross_ok and sma_condition:
|
||||
matches.append("매수조건1")
|
||||
if cross_sma and macd_above_signal and adx_ok:
|
||||
matches.append("매수조건2")
|
||||
if cross_adx and sma_condition and macd_above_signal:
|
||||
matches.append("매수조건3")
|
||||
|
||||
if matches:
|
||||
result["summary"].append(f"매수 신호발생: {symbol} -> {', '.join(matches)}")
|
||||
text = f"매수 신호발생: {symbol} -> {', '.join(matches)}\n가격: {close:.2f}\n"
|
||||
result["telegram"] = text
|
||||
|
||||
amount = cfg.config.get("auto_trade", {}).get("buy_amount", 0) if cfg else 0
|
||||
trade_recorded = False
|
||||
|
||||
if dry_run:
|
||||
trade = make_trade_record(symbol, "buy", amount, True, price=close, status="simulated")
|
||||
record_trade(trade, "trades.json")
|
||||
trade_recorded = True
|
||||
elif cfg is not None and cfg.trading_mode == "auto_trade":
|
||||
auto_trade_cfg = cfg.config.get("auto_trade", {})
|
||||
buy_enabled = auto_trade_cfg.get("buy_enabled", False)
|
||||
buy_amount = auto_trade_cfg.get("buy_amount", 0)
|
||||
allowed_symbols = auto_trade_cfg.get("allowed_symbols", [])
|
||||
|
||||
can_auto_buy = buy_enabled and buy_amount > 0
|
||||
if allowed_symbols and symbol not in allowed_symbols:
|
||||
can_auto_buy = False
|
||||
|
||||
if can_auto_buy:
|
||||
logger.info("[%s] 자동 매수 조건 충족: 매수 주문 시작 (금액: %d)", symbol, buy_amount)
|
||||
from .order import execute_buy_order_with_confirmation
|
||||
|
||||
buy_result = execute_buy_order_with_confirmation(symbol=symbol, amount=buy_amount, config=cfg.config, telegram_token=telegram_token, telegram_chat_id=telegram_chat_id, account_number=cfg.kiwoom_account_number, dry_run=False, parse_mode=cfg.telegram_parse_mode)
|
||||
result["buy_order"] = buy_result
|
||||
|
||||
if buy_result.get("status") == "placed":
|
||||
trade_recorded = True
|
||||
from .holdings import add_new_holding
|
||||
quantity = buy_result.get("quantity", 0)
|
||||
add_new_holding(symbol, close, quantity, time.time(), "holdings.json")
|
||||
|
||||
trade = make_trade_record(symbol, "buy", buy_amount, False, price=close, status="filled")
|
||||
record_trade(trade, "trades.json")
|
||||
|
||||
if not trade_recorded and not dry_run:
|
||||
trade = make_trade_record(symbol, "buy", amount, False, price=close, status="notified")
|
||||
record_trade(trade, "trades.json")
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.exception("심볼 처리 중 오류: %s -> %s", symbol, e)
|
||||
result["error"] = str(e)
|
||||
result["summary"].append(f"심볼 처리 중 오류: {symbol} -> {e}")
|
||||
return result
|
||||
|
||||
def check_sell_conditions(
|
||||
holdings: Dict[str, Dict[str, Any]],
|
||||
cfg: RuntimeConfig,
|
||||
config: Dict[str, Any] | None = None,
|
||||
check_type: str = CheckType.ALL
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""보유 종목의 매도 조건을 확인.
|
||||
|
||||
Args:
|
||||
holdings: 보유 종목 딕셔너리
|
||||
cfg: 런타임 설정
|
||||
config: 설정 딕셔너리
|
||||
check_type: 체크 타입 (CheckType.STOP_LOSS, CheckType.PROFIT_TAKING, CheckType.ALL)
|
||||
|
||||
Returns:
|
||||
(매도 결과 리스트, 매도 신호 개수)
|
||||
"""
|
||||
if config is None and cfg is not None and hasattr(cfg, "config"):
|
||||
config = cfg.config
|
||||
results = []
|
||||
|
||||
telegram_token = cfg.telegram_bot_token
|
||||
telegram_chat_id = cfg.telegram_chat_id
|
||||
dry_run = cfg.dry_run
|
||||
account_number = cfg.kiwoom_account_number
|
||||
trading_mode = cfg.trading_mode
|
||||
|
||||
if not holdings:
|
||||
logger.info("보유 정보가 없음 - 매도 조건 검사 건너뜀")
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매도 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=cfg.telegram_parse_mode or "HTML")
|
||||
return [], 0
|
||||
|
||||
sell_signal_count = 0
|
||||
from src.order import execute_sell_order_with_confirmation
|
||||
for symbol, holding_info in holdings.items():
|
||||
try:
|
||||
current_price = get_current_price(symbol)
|
||||
if current_price <= 0:
|
||||
logger.warning("현재가 조회 실패: %s", symbol)
|
||||
continue
|
||||
buy_price = float(holding_info.get("buy_price", 0))
|
||||
max_price = float(holding_info.get("max_price", current_price))
|
||||
if buy_price <= 0:
|
||||
logger.warning("매수가 정보 없음: %s", symbol)
|
||||
continue
|
||||
|
||||
sell_result = evaluate_sell_conditions(
|
||||
current_price,
|
||||
buy_price,
|
||||
max_price,
|
||||
holding_info,
|
||||
config,
|
||||
check_type
|
||||
)
|
||||
|
||||
logger.info("[%s] 매도 조건 검사 - 현재가: %.2f, 매수가: %.2f, 최고가: %.2f, 수익률: %.2f%%, 최고점대비: %.2f%%", symbol, current_price, buy_price, max_price, sell_result["profit_rate"], sell_result["max_drawdown"])
|
||||
logger.info("[%s] 매도 상태: %s (비율: %.0f%%), 사유: %s", symbol, sell_result["status"], sell_result["sell_ratio"] * 100, ", ".join(sell_result["reasons"]))
|
||||
result = {"symbol": symbol, "status": sell_result["status"], "sell_ratio": sell_result["sell_ratio"], "profit_rate": sell_result["profit_rate"], "max_drawdown": sell_result["max_drawdown"], "reasons": sell_result["reasons"], "current_price": current_price, "buy_price": buy_price, "max_price": max_price, "amount": holding_info.get("amount", 0)}
|
||||
results.append(result)
|
||||
|
||||
if sell_result.get("set_partial_sell_done", False) and dry_run:
|
||||
from src.holdings import set_holding_field
|
||||
set_holding_field(symbol, "partial_sell_done", True, "holdings.json")
|
||||
logger.info("[%s] partial_sell_done 플래그 설정 완료 (dry_run)", symbol)
|
||||
|
||||
if sell_result["sell_ratio"] > 0:
|
||||
sell_signal_count += 1
|
||||
if ((cfg.trading_mode == "auto_trade" and dry_run) or cfg.trading_mode != "auto_trade"):
|
||||
if telegram_token and telegram_chat_id:
|
||||
from .signals import build_sell_message
|
||||
msg = build_sell_message(symbol, sell_result, parse_mode=cfg.telegram_parse_mode or "HTML")
|
||||
send_telegram(telegram_token, telegram_chat_id, msg, add_thread_prefix=False, parse_mode=cfg.telegram_parse_mode or "HTML")
|
||||
|
||||
if cfg.trading_mode == "auto_trade" and not dry_run:
|
||||
total_amount = float(holding_info.get("amount", 0))
|
||||
sell_ratio = float(sell_result.get("sell_ratio", 0.0) or 0.0)
|
||||
|
||||
sell_ratio = _adjust_sell_ratio_for_min_order(symbol, total_amount, sell_ratio, current_price, config)
|
||||
|
||||
amount_to_sell = int(total_amount * sell_ratio)
|
||||
|
||||
if amount_to_sell > 0:
|
||||
logger.info("[%s] 자동 매도 조건 충족: 매도 주문 시작 (총 수량: %d, 매도 비율: %.0f%%, 주문 수량: %d)", symbol, total_amount, sell_ratio * 100, amount_to_sell)
|
||||
sell_order_result = execute_sell_order_with_confirmation(
|
||||
symbol=symbol,
|
||||
quantity=amount_to_sell,
|
||||
config=config,
|
||||
telegram_token=telegram_token,
|
||||
telegram_chat_id=telegram_chat_id,
|
||||
account_number=account_number,
|
||||
dry_run=dry_run,
|
||||
parse_mode=cfg.telegram_parse_mode or "HTML"
|
||||
)
|
||||
|
||||
if sell_order_result and sell_result.get("set_partial_sell_done"):
|
||||
if sell_order_result.get("status") == "placed":
|
||||
from .holdings import set_holding_field
|
||||
if set_holding_field(symbol, "partial_sell_done", True, holdings_file="holdings.json"):
|
||||
logger.info("[%s] 부분 매도(1회성) 완료, partial_sell_done 플래그를 True로 업데이트합니다.", symbol)
|
||||
else:
|
||||
logger.error("[%s] partial_sell_done 플래그 업데이트에 실패했습니다.", symbol)
|
||||
except Exception as e:
|
||||
logger.exception("매도 조건 확인 중 오류 (%s): %s", symbol, e)
|
||||
if telegram_token and telegram_chat_id and not any(r["sell_ratio"] > 0 for r in results):
|
||||
send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매도 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=cfg.telegram_parse_mode or "HTML")
|
||||
return results, sell_signal_count
|
||||
1
src/tests/__init__.py
Normal file
1
src/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Test package
|
||||
81
src/tests/test_evaluate_sell_conditions.py
Normal file
81
src/tests/test_evaluate_sell_conditions.py
Normal file
@@ -0,0 +1,81 @@
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from src.signals import evaluate_sell_conditions
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
return {
|
||||
"loss_threshold": -5.0,
|
||||
"profit_threshold_1": 10.0,
|
||||
"profit_threshold_2": 30.0,
|
||||
"drawdown_1": 5.0,
|
||||
"drawdown_2": 15.0,
|
||||
}
|
||||
|
||||
|
||||
def test_condition1_full_stop_loss(config):
|
||||
# current price down 6% from buy -> full sell (condition1)
|
||||
buy = 100.0
|
||||
curr = 94.0 # -6%
|
||||
maxp = 100.0
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["status"] in ("stop_loss", "손절", "loss_cut") or res["sell_ratio"] == 1.0
|
||||
|
||||
|
||||
def test_condition2_stop_on_drawdown_small_profit(config):
|
||||
# profit <= 10% and drawdown >= drawdown_1 (5%) -> full sell
|
||||
buy = 100.0
|
||||
maxp = 105.0
|
||||
curr = 100.0 # profit 0%, drawdown from max -4.76% (not enough)
|
||||
# adjust to make drawdown >5
|
||||
maxp = 110.0
|
||||
curr = 104.0 # profit 4%, drawdown ~5.45% -> full sell
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["sell_ratio"] == 1.0
|
||||
|
||||
|
||||
def test_condition3_partial_take_profit(config):
|
||||
# profit between 10% and 30% -> partial sell (50%)
|
||||
buy = 100.0
|
||||
maxp = 115.0
|
||||
curr = 112.0 # profit 12% -> partial
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["sell_ratio"] == 0.5
|
||||
|
||||
|
||||
def test_condition4_full_take_if_drawdown_in_10_30(config):
|
||||
# profit 20% and drawdown from max >= drawdown_1 (5%) -> full sell
|
||||
buy = 100.0
|
||||
maxp = 130.0
|
||||
curr = 120.0 # profit 20%, drawdown from max ~7.69% -> full
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["sell_ratio"] == 1.0
|
||||
|
||||
|
||||
def test_condition5_high_profit_drawdown(config):
|
||||
# profit >=30% and drawdown >=drawdown_2 (15%) -> full sell
|
||||
buy = 100.0
|
||||
maxp = 150.0
|
||||
curr = 125.0 # profit 25% (not enough) -> adjust
|
||||
curr = 140.0 # profit 40%, drawdown from max ~6.66% -> not full unless drawdown >=15
|
||||
# make drawdown >=15
|
||||
maxp = 170.0
|
||||
curr = 145.0 # profit 45%, drawdown ~14.7% (just below)
|
||||
maxp = 180.0
|
||||
curr = 145.0 # drawdown 19.44% -> full
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["sell_ratio"] == 1.0
|
||||
|
||||
|
||||
def test_condition6_hold(config):
|
||||
# profit >=30% and drawdown less than drawdown_2 -> hold
|
||||
buy = 100.0
|
||||
maxp = 150.0
|
||||
curr = 140.0 # profit 40%, drawdown ~6.66% -> hold
|
||||
res = evaluate_sell_conditions(curr, buy, maxp, config)
|
||||
assert res["sell_ratio"] == 0.0
|
||||
56
src/tests/test_helpers.py
Normal file
56
src/tests/test_helpers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""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, candle_count: 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, candle_count, 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)
|
||||
67
src/tests/test_main.py
Normal file
67
src/tests/test_main.py
Normal file
@@ -0,0 +1,67 @@
|
||||
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
|
||||
idx = pd.date_range(end=pd.Timestamp.now(), periods=4, freq="h")
|
||||
df = pd.DataFrame({"close": [100, 110, 120, 140]}, index=idx)
|
||||
|
||||
# Monkeypatch at the point of use: src.signals imports from indicators
|
||||
from src import signals
|
||||
|
||||
# Patch fetch_ohlcv and compute_macd_hist in signals module
|
||||
monkeypatch.setattr(signals, "fetch_ohlcv", lambda symbol, timeframe, candle_count=200, log_buffer=None: df)
|
||||
# Return histogram with at least 2 non-NA values and matching df index
|
||||
monkeypatch.setattr(signals, "compute_macd_hist", lambda close_series, log_buffer=None: pd.Series([0.0, 0.5, 1.0, 5.0], index=df.index))
|
||||
|
||||
# Monkeypatch pandas_ta.macd to raise exception so histogram fallback path is used
|
||||
def fake_macd_fail(*args, **kwargs):
|
||||
raise RuntimeError("force histogram fallback")
|
||||
monkeypatch.setattr(signals.ta, "macd", fake_macd_fail)
|
||||
|
||||
# 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", candle_count=10, dry_run=False)
|
||||
|
||||
# Assert: safe_send_telegram was called
|
||||
assert called["count"] == 1
|
||||
138
src/threading_utils.py
Normal file
138
src/threading_utils.py
Normal file
@@ -0,0 +1,138 @@
|
||||
import time
|
||||
import threading
|
||||
from typing import List
|
||||
from .config import RuntimeConfig
|
||||
|
||||
from .common import logger
|
||||
from .signals import process_symbol
|
||||
from .notifications import send_telegram
|
||||
|
||||
|
||||
def run_sequential(symbols: List[str], timeframe: str | None = None, indicator_timeframe: str | None = None, candle_count: int | None = None, telegram_token: str | None = None, telegram_chat_id: str | None = None, dry_run: bool | None = None, symbol_delay: float | None = None, parse_mode: str | None = None, aggregate_enabled: bool = False, cfg: RuntimeConfig | None = None):
|
||||
if cfg is not None:
|
||||
timeframe = timeframe or cfg.timeframe
|
||||
indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe
|
||||
candle_count = cfg.candle_count if (candle_count is None) else candle_count
|
||||
telegram_token = telegram_token or cfg.telegram_bot_token
|
||||
telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id
|
||||
dry_run = cfg.dry_run if (dry_run is None) else dry_run
|
||||
symbol_delay = cfg.symbol_delay if (symbol_delay is None) else symbol_delay
|
||||
parse_mode = parse_mode or cfg.telegram_parse_mode
|
||||
logger.info("순차 처리 시작 (심볼 수=%d)", len(symbols))
|
||||
alerts = []
|
||||
buy_signal_count = 0
|
||||
for i, sym in enumerate(symbols):
|
||||
try:
|
||||
res = process_symbol(sym, timeframe, candle_count, telegram_token, telegram_chat_id, dry_run, indicators=None, indicator_timeframe=indicator_timeframe, cfg=cfg)
|
||||
for line in res.get("summary", []):
|
||||
logger.info(line)
|
||||
if res.get("telegram"):
|
||||
buy_signal_count += 1
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
|
||||
else:
|
||||
# dry_run이 아닐 때는 콘솔에 메시지 출력하지 않음
|
||||
pass
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False, parse_mode=parse_mode)
|
||||
else:
|
||||
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
|
||||
alerts.append({"symbol": sym, "text": res["telegram"]})
|
||||
except Exception as e:
|
||||
logger.exception("심볼 처리 오류: %s -> %s", sym, e)
|
||||
if i < len(symbols) - 1 and symbol_delay is not None:
|
||||
logger.debug("다음 심볼까지 %.2f초 대기", symbol_delay)
|
||||
time.sleep(symbol_delay)
|
||||
if aggregate_enabled and len(alerts) > 1:
|
||||
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
|
||||
summary_lines += [f"- {a['symbol']}" for a in alerts]
|
||||
summary_text = "\n".join(summary_lines)
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
|
||||
else:
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, summary_text, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
else:
|
||||
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
|
||||
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
|
||||
if telegram_token and telegram_chat_id and not any(a.get("text") for a in alerts):
|
||||
send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=parse_mode)
|
||||
return buy_signal_count
|
||||
|
||||
|
||||
def run_with_threads(symbols: List[str], timeframe: str | None = None, indicator_timeframe: str | None = None, candle_count: int | None = None, telegram_token: str | None = None, telegram_chat_id: str | None = None, dry_run: bool | None = None, symbol_delay: float | None = None, max_threads: int | None = None, parse_mode: str | None = None, aggregate_enabled: bool = False, cfg: RuntimeConfig | None = None):
|
||||
if cfg is not None:
|
||||
timeframe = timeframe or cfg.timeframe
|
||||
indicator_timeframe = indicator_timeframe or cfg.indicator_timeframe
|
||||
candle_count = cfg.candle_count if (candle_count is None) else candle_count
|
||||
telegram_token = telegram_token or cfg.telegram_bot_token
|
||||
telegram_chat_id = telegram_chat_id or cfg.telegram_chat_id
|
||||
dry_run = cfg.dry_run if (dry_run is None) else dry_run
|
||||
symbol_delay = cfg.symbol_delay if (symbol_delay is None) else symbol_delay
|
||||
max_threads = cfg.max_threads if (max_threads is None) else max_threads
|
||||
parse_mode = parse_mode or cfg.telegram_parse_mode
|
||||
logger.info("병렬 처리 시작 (심볼 수=%d, 스레드 수=%d, 심볼 간 지연=%.2f초)", len(symbols), max_threads or 0, symbol_delay or 0.0)
|
||||
semaphore = threading.Semaphore(max_threads)
|
||||
threads = []
|
||||
last_request_time = [0]
|
||||
throttle_lock = threading.Lock()
|
||||
results = {}
|
||||
results_lock = threading.Lock()
|
||||
|
||||
def worker(symbol: str):
|
||||
try:
|
||||
with semaphore:
|
||||
with throttle_lock:
|
||||
elapsed = time.time() - last_request_time[0]
|
||||
if symbol_delay is not None and elapsed < symbol_delay:
|
||||
sleep_time = symbol_delay - elapsed
|
||||
logger.debug("[%s] 스로틀 대기: %.2f초", symbol, sleep_time)
|
||||
time.sleep(sleep_time)
|
||||
last_request_time[0] = time.time()
|
||||
res = process_symbol(symbol, timeframe, candle_count, telegram_token, telegram_chat_id, dry_run, indicators=None, indicator_timeframe=indicator_timeframe, cfg=cfg)
|
||||
with results_lock:
|
||||
results[symbol] = res
|
||||
except Exception as e:
|
||||
logger.exception("[%s] 워커 스레드 오류: %s", symbol, e)
|
||||
|
||||
for sym in symbols:
|
||||
t = threading.Thread(target=worker, args=(sym,), name=f"Worker-{sym}")
|
||||
threads.append(t)
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
alerts = []
|
||||
buy_signal_count = 0
|
||||
for sym in symbols:
|
||||
with results_lock:
|
||||
res = results.get(sym)
|
||||
if not res:
|
||||
logger.warning("심볼 결과 없음: %s", sym)
|
||||
continue
|
||||
for line in res.get("summary", []):
|
||||
logger.info(line)
|
||||
if res.get("telegram"):
|
||||
buy_signal_count += 1
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 알림 내용:\n%s", res["telegram"])
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, res["telegram"], add_thread_prefix=False, parse_mode=parse_mode)
|
||||
else:
|
||||
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 메시지 전송 불가")
|
||||
alerts.append({"symbol": sym, "text": res["telegram"]})
|
||||
if aggregate_enabled and len(alerts) > 1:
|
||||
summary_lines = [f"알림 발생 심볼 수: {len(alerts)}", "\n"]
|
||||
summary_lines += [f"- {a['symbol']}" for a in alerts]
|
||||
summary_text = "\n".join(summary_lines)
|
||||
if dry_run:
|
||||
logger.info("[dry-run] 알림 요약:\n%s", summary_text)
|
||||
else:
|
||||
if telegram_token and telegram_chat_id:
|
||||
send_telegram(telegram_token, telegram_chat_id, summary_text, add_thread_prefix=False, parse_mode=parse_mode)
|
||||
else:
|
||||
logger.warning("텔레그램 토큰/채팅 ID가 설정되지 않아 요약 메시지 전송 불가")
|
||||
# 매수 조건이 하나도 충족되지 않은 경우 알림 전송
|
||||
if telegram_token and telegram_chat_id and not any(a.get("text") for a in alerts):
|
||||
send_telegram(telegram_token, telegram_chat_id, "[알림] 충족된 매수 조건 없음 (프로그램 정상 작동 중)", add_thread_prefix=False, parse_mode=parse_mode)
|
||||
logger.info("병렬 처리 완료")
|
||||
return buy_signal_count
|
||||
Reference in New Issue
Block a user