From 29d48f0fe920b7d4b50157e40e2ea5dfe4f1df34 Mon Sep 17 00:00:00 2001 From: joungmin Date: Sun, 1 Mar 2026 23:57:45 +0900 Subject: [PATCH] feat: add shadow trading rehabilitation for WF-blocked tickers When WF filter blocks a ticker, automatically start a virtual (shadow) position with the same trailing/time stop logic. After WF_SHADOW_WINS consecutive shadow wins, reset WF history to re-enable real trading. - trader.py: add _shadow_positions, _shadow_cons_wins state; _shadow_enter(), get_shadow_positions(), update_shadow_peak(), close_shadow() functions; trigger shadow entry on WF block in buy() - monitor.py: add _check_shadow_position() with ATR trailing + time stop; check shadow positions every 10s in run_monitor() - Env: WF_SHADOW_WINS=2 (2 consecutive wins to rehabilitate) Co-Authored-By: Claude Opus 4.6 --- core/monitor.py | 51 +++++++++++ core/trader.py | 239 +++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 246 insertions(+), 44 deletions(-) diff --git a/core/monitor.py b/core/monitor.py index 6e636cd..9c93d4a 100644 --- a/core/monitor.py +++ b/core/monitor.py @@ -112,6 +112,47 @@ def _check_time_stop(ticker: str, pos: dict, current: float) -> bool: return True +def _check_shadow_position(ticker: str, spos: dict) -> None: + """Shadow 포지션 청산 조건 체크 (트레일링 + 타임 스탑). + + 실제 포지션과 동일한 로직을 적용하되 주문 없이 결과만 기록. + """ + current = get_current_price(ticker) + if current is None: + return + + trader.update_shadow_peak(ticker, current) + + # 갱신 후 최신 값 재조회 + spos = trader.get_shadow_positions().get(ticker) + if spos is None: + return + + buy_price = spos["buy_price"] + peak = spos["peak_price"] + entry_time = spos["entry_time"] + stop_pct = _get_adaptive_stop(ticker) + + drop_from_peak = (peak - current) / peak + elapsed_hours = (datetime.now() - entry_time).total_seconds() / 3600 + pnl_pct = (current - buy_price) / buy_price * 100 + + reason = None + if drop_from_peak >= stop_pct: + reason = ( + f"트레일링스탑 | 최고={peak:,.2f}→현재={current:,.2f}" + f" ({drop_from_peak:.2%} | 스탑={stop_pct:.2%})" + ) + elif elapsed_hours >= TIME_STOP_HOURS and pnl_pct < TIME_STOP_MIN_GAIN_PCT: + reason = ( + f"타임스탑 | {elapsed_hours:.1f}h 경과 " + f"수익률={pnl_pct:+.2f}% (기준={TIME_STOP_MIN_GAIN_PCT:+.2f}%)" + ) + + if reason: + trader.close_shadow(ticker, current, pnl_pct, reason) + + def _check_position(ticker: str, pos: dict) -> None: """단일 포지션 전체 체크 (트레일링 스탑 → 타임 스탑 순서).""" current = get_current_price(ticker) @@ -149,10 +190,20 @@ def run_monitor(interval: int = CHECK_INTERVAL) -> None: f"타임스탑={TIME_STOP_HOURS:.0f}h/{TIME_STOP_MIN_GAIN_PCT:+.2f}%" ) while True: + # 실제 포지션 감시 positions_snapshot = dict(trader.get_positions()) for ticker, pos in positions_snapshot.items(): try: _check_position(ticker, pos) except Exception as e: logger.error(f"모니터 오류 {ticker}: {e}") + + # Shadow 포지션 감시 (WF차단 종목 재활 추적) + shadow_snapshot = trader.get_shadow_positions() + for ticker, spos in shadow_snapshot.items(): + try: + _check_shadow_position(ticker, spos) + except Exception as e: + logger.error(f"Shadow 모니터 오류 {ticker}: {e}") + time.sleep(interval) diff --git a/core/trader.py b/core/trader.py index 34750fe..21d919c 100644 --- a/core/trader.py +++ b/core/trader.py @@ -24,6 +24,12 @@ load_dotenv() logger = logging.getLogger(__name__) +SIMULATION_MODE = os.getenv("SIMULATION_MODE", "").lower() in ("true", "1", "yes") +if SIMULATION_MODE: + logging.getLogger(__name__).warning( + "*** SIMULATION MODE ACTIVE — 실제 주문이 실행되지 않습니다 ***" + ) + INITIAL_BUDGET = int(os.getenv("MAX_BUDGET", "10000000")) # 초기 원금 (고정) MAX_POSITIONS = int(os.getenv("MAX_POSITIONS", "3")) # 최대 동시 보유 종목 수 @@ -54,6 +60,7 @@ def _recalc_compound_budget() -> None: # Walk-forward 필터 설정 WF_WINDOW = int(float(os.getenv("WF_WINDOW", "5"))) # 이력 윈도우 크기 WF_MIN_WIN_RATE = float(os.getenv("WF_MIN_WIN_RATE", "0.40")) # 최소 승률 임계값 +WF_SHADOW_WINS = int(os.getenv("WF_SHADOW_WINS", "2")) # shadow N연승 → WF 해제 _lock = threading.Lock() _positions: dict = {} @@ -65,6 +72,13 @@ _last_sell_prices: dict[str, float] = {} _trade_history: dict[str, list[bool]] = {} # walk-forward 이력: { ticker: [True/False, ...] } (True=수익) +_shadow_lock = threading.Lock() +_shadow_positions: dict[str, dict] = {} +# WF차단 종목 가상 포지션: { ticker: { buy_price, peak_price, entry_time } } + +_shadow_cons_wins: dict[str, int] = {} +# shadow 연속 승 횟수: { ticker: int } + _upbit: Optional[pyupbit.Upbit] = None @@ -107,6 +121,88 @@ def _update_history( logger.error(f"거래 이력 저장 실패 {ticker}: {e}") +# ── Shadow 재활 ──────────────────────────────────────────────────────────────── + +def _shadow_enter(ticker: str) -> None: + """WF 차단 종목에 shadow(가상) 포지션 진입. + + buy() 내부(_lock 보유 중)에서 호출됨. + API 호출 후 _shadow_lock으로만 shadow 상태 보호 (deadlock 방지). + """ + # 이미 shadow 중이면 스킵 + if ticker in _shadow_positions: + return + + price = pyupbit.get_current_price(ticker) + if not price: + return + + with _shadow_lock: + if ticker in _shadow_positions: # double-check + return + _shadow_positions[ticker] = { + "buy_price": price, + "peak_price": price, + "entry_time": datetime.now(), + } + + cons = _shadow_cons_wins.get(ticker, 0) + logger.info( + f"[Shadow진입] {ticker} @ {price:,.0f}원 " + f"(가상 — WF 재활 {cons}/{WF_SHADOW_WINS}연승 필요)" + ) + + +def get_shadow_positions() -> dict: + """Shadow 포지션 스냅샷 반환 (monitor 에서 조회용).""" + with _shadow_lock: + return {k: dict(v) for k, v in _shadow_positions.items()} + + +def update_shadow_peak(ticker: str, price: float) -> None: + """Shadow 포지션 최고가 갱신.""" + with _shadow_lock: + if ticker in _shadow_positions: + if price > _shadow_positions[ticker]["peak_price"]: + _shadow_positions[ticker]["peak_price"] = price + + +def close_shadow(ticker: str, sell_price: float, pnl_pct: float, reason: str) -> None: + """Shadow 포지션 청산 및 WF 재활 진행. + + 연속승 갱신 → WF_SHADOW_WINS 달성 시 WF 이력 초기화 + Telegram 알림. + """ + with _shadow_lock: + spos = _shadow_positions.pop(ticker, None) + if spos is None: + return + is_win = pnl_pct > 0 + cons = _shadow_cons_wins.get(ticker, 0) + cons = cons + 1 if is_win else 0 + _shadow_cons_wins[ticker] = cons + do_wf_reset = cons >= WF_SHADOW_WINS + if do_wf_reset: + _shadow_cons_wins.pop(ticker, None) + + mark = "✅" if is_win else "❌" + logger.info( + f"[Shadow청산] {ticker} {spos['buy_price']:,.0f}→{sell_price:,.0f}원 " + f"| {mark} {pnl_pct:+.1f}% | {reason} | 연속승={cons}/{WF_SHADOW_WINS}" + ) + + if do_wf_reset: + with _lock: # _shadow_lock은 이미 해제된 상태 (deadlock 없음) + _trade_history.pop(ticker, None) + logger.warning( + f"[WF해제] {ticker} Shadow {WF_SHADOW_WINS}연승 달성 → " + f"WF 이력 초기화, 실거래 재개" + ) + notify_error( + f"🎉 [{ticker}] WF 재활 완료!\n" + f"Shadow {WF_SHADOW_WINS}연승 달성 → 실거래 재개" + ) + + def _db_upsert(ticker: str, pos: dict) -> None: """포지션을 Oracle DB에 저장 (실패해도 거래는 계속).""" try: @@ -172,6 +268,30 @@ def restore_positions() -> None: logger.error(f"DB 포지션 로드 실패: {e}") saved = {} + if SIMULATION_MODE: + # --- 시뮬레이션: Upbit 잔고 조회 없이 DB 포지션만 복원 --- + logger.info("[SIMULATION] 시뮬레이션 모드 — Upbit 잔고 조회 생략, DB 포지션만 복원") + for ticker, s in saved.items(): + current = pyupbit.get_current_price(ticker) + if not current: + continue + peak = max(s["peak_price"], current) + entry_time = datetime.fromisoformat(s["entry_time"]) if isinstance(s["entry_time"], str) else s["entry_time"] + with _lock: + _positions[ticker] = { + "buy_price": s["buy_price"], + "peak_price": peak, + "amount": s.get("amount", 0), + "invested_krw": s["invested_krw"], + "entry_time": entry_time, + "trade_id": s.get("trade_id", ""), + } + logger.info( + f"[SIMULATION][복원] {ticker} 매수가={s['buy_price']:,.0f}원 | " + f"현재가={current:,.0f}원 (DB 복원)" + ) + return + upbit = _get_upbit() balances = upbit.get_balances() upbit_tickers = set() @@ -261,7 +381,7 @@ def buy(ticker: str) -> bool: ) return False - # Walk-forward 필터: 직전 WF_WINDOW건 승률이 낮으면 진입 차단 + # Walk-forward 필터: 직전 WF_WINDOW건 승률이 낮으면 진입 차단 + shadow 진입 if WF_MIN_WIN_RATE > 0: hist = _get_history(ticker) if len(hist) >= WF_WINDOW: @@ -269,8 +389,9 @@ def buy(ticker: str) -> bool: if recent_wr < WF_MIN_WIN_RATE: logger.info( f"[WF차단] {ticker} 직전{WF_WINDOW}건 승률={recent_wr*100:.0f}%" - f" < {WF_MIN_WIN_RATE*100:.0f}% → 진입 차단" + f" < {WF_MIN_WIN_RATE*100:.0f}% → 진입 차단 (shadow 재활 시작)" ) + _shadow_enter(ticker) # 가상 포지션으로 WF 재활 추적 return False if len(_positions) >= MAX_POSITIONS: @@ -285,19 +406,33 @@ def buy(ticker: str) -> bool: logger.info(f"잔여 예산 부족({order_krw:,}원), {ticker} 패스") return False - upbit = _get_upbit() try: - result = upbit.buy_market_order(ticker, order_krw) - if not result or "error" in str(result): - logger.error(f"매수 실패: {result}") - return False + if SIMULATION_MODE: + # --- 시뮬레이션 매수 --- + sim_price = pyupbit.get_current_price(ticker) + if not sim_price: + logger.error(f"[SIMULATION] 현재가 조회 실패: {ticker}") + return False + amount = order_krw / sim_price + actual_price = sim_price + logger.info( + f"[SIMULATION][매수] {ticker} @ {actual_price:,.0f}원 | " + f"수량={amount:.8f} | 투자금={order_krw:,}원 (모의 주문)" + ) + else: + # --- 실제 매수 --- + upbit = _get_upbit() + result = upbit.buy_market_order(ticker, order_krw) + if not result or "error" in str(result): + logger.error(f"매수 실패: {result}") + return False - time.sleep(0.5) # 체결 대기 - currency = ticker.split("-")[1] - amount = float(upbit.get_balance(currency) or 0) + time.sleep(0.5) # 체결 대기 + currency = ticker.split("-")[1] + amount = float(upbit.get_balance(currency) or 0) - # 실제 체결가 = 투자금 / 수량 (시장가 주문 슬리피지 반영) - actual_price = order_krw / amount if amount > 0 else pyupbit.get_current_price(ticker) + # 실제 체결가 = 투자금 / 수량 (시장가 주문 슬리피지 반영) + actual_price = order_krw / amount if amount > 0 else pyupbit.get_current_price(ticker) entry_time = datetime.now() trade_id = str(uuid.uuid4()) @@ -310,8 +445,9 @@ def buy(ticker: str) -> bool: "trade_id": trade_id, } _db_upsert(ticker, _positions[ticker]) + prefix = "[SIMULATION]" if SIMULATION_MODE else "" logger.info( - f"[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | " + f"{prefix}[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | " f"수량={amount} | 투자금={order_krw:,}원 | trade_id={trade_id[:8]}" ) notify_buy(ticker, actual_price, amount, order_krw, @@ -383,45 +519,60 @@ def sell(ticker: str, reason: str = "") -> bool: return False pos = _positions[ticker] - upbit = _get_upbit() try: - currency = ticker.split("-")[1] + if SIMULATION_MODE: + # --- 시뮬레이션 매도 --- + actual_amount = pos["amount"] + actual_sell_price = pyupbit.get_current_price(ticker) or pos["buy_price"] + sell_value = actual_sell_price * actual_amount + fee = pos["invested_krw"] * 0.0005 + sell_value * 0.0005 + krw_profit = sell_value - pos["invested_krw"] - fee + pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100 + logger.info( + f"[SIMULATION][매도] {ticker} @ {actual_sell_price:,.4f}원 | " + f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (모의 주문) | 사유={reason}" + ) + else: + # --- 실제 매도 --- + upbit = _get_upbit() + currency = ticker.split("-")[1] - # 실제 잔고 확인 (재시작 후 이미 매도된 경우 대비) - actual_amount = float(upbit.get_balance(currency) or 0) - if actual_amount < 0.00001: - logger.warning(f"[매도] {ticker} 실제 잔고 없음 → 포지션 정리 (이미 매도됨)") - del _positions[ticker] - return True - - result = upbit.sell_market_order(ticker, actual_amount) - if not result or "error" in str(result): - logger.error(f"매도 실패: {result}") - # 실패 후에도 잔고 재확인 → 0이면 실제로는 매도됨 - actual_amount2 = float(upbit.get_balance(currency) or 0) - if actual_amount2 < 0.00001: - logger.warning(f"[매도] {ticker} 잔고 소진 확인 → 포지션 정리") + # 실제 잔고 확인 (재시작 후 이미 매도된 경우 대비) + actual_amount = float(upbit.get_balance(currency) or 0) + if actual_amount < 0.00001: + logger.warning(f"[매도] {ticker} 실제 잔고 없음 → 포지션 정리 (이미 매도됨)") del _positions[ticker] return True - return False - time.sleep(0.5) # 체결 완료 대기 + result = upbit.sell_market_order(ticker, actual_amount) + if not result or "error" in str(result): + logger.error(f"매도 실패: {result}") + # 실패 후에도 잔고 재확인 → 0이면 실제로는 매도됨 + actual_amount2 = float(upbit.get_balance(currency) or 0) + if actual_amount2 < 0.00001: + logger.warning(f"[매도] {ticker} 잔고 소진 확인 → 포지션 정리") + del _positions[ticker] + return True + return False - # 실제 체결 내역으로 가중평균 매도가 계산 (분할 체결 대응) - order_uuid = result.get("uuid", "") if isinstance(result, dict) else "" - fallback_price = pyupbit.get_current_price(ticker) or pos["buy_price"] - actual_sell_price, actual_fee_from_order = _get_avg_fill_price( - upbit, order_uuid, ticker, fallback_price - ) + time.sleep(0.5) # 체결 완료 대기 - pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100 - sell_value = actual_sell_price * actual_amount - # 수수료: 주문 조회 성공 시 실제값, 아니면 추정값 (0.05% 양방향) - fee = actual_fee_from_order if actual_fee_from_order is not None \ - else (pos["invested_krw"] * 0.0005 + sell_value * 0.0005) - krw_profit = sell_value - pos["invested_krw"] - fee + # 실제 체결 내역으로 가중평균 매도가 계산 (분할 체결 대응) + order_uuid = result.get("uuid", "") if isinstance(result, dict) else "" + fallback_price = pyupbit.get_current_price(ticker) or pos["buy_price"] + actual_sell_price, actual_fee_from_order = _get_avg_fill_price( + upbit, order_uuid, ticker, fallback_price + ) + + pnl = (actual_sell_price - pos["buy_price"]) / pos["buy_price"] * 100 + sell_value = actual_sell_price * actual_amount + # 수수료: 주문 조회 성공 시 실제값, 아니면 추정값 (0.05% 양방향) + fee = actual_fee_from_order if actual_fee_from_order is not None \ + else (pos["invested_krw"] * 0.0005 + sell_value * 0.0005) + krw_profit = sell_value - pos["invested_krw"] - fee + prefix = "[SIMULATION]" if SIMULATION_MODE else "" logger.info( - f"[매도] {ticker} @ {actual_sell_price:,.4f}원 | " + f"{prefix}[매도] {ticker} @ {actual_sell_price:,.4f}원 | " f"수익률={pnl:+.1f}% | 순익={krw_profit:+,.0f}원 (수수료 {fee:,.0f}원) | 사유={reason}" ) try: