Files
upbit-trader/core/notify.py
joungmin 27189b1ad9 feat: add Fear & Greed filter to entry logic
- core/fng.py: F&G API wrapper with 1h cache (alternative.me)
  - FNG_MIN_ENTRY=41 (env-configurable), blocks entry below threshold
- core/strategy.py: call is_entry_allowed() before volume/regime checks
- daemon/runner.py: log F&G status on every scan cycle
- core/notify.py: include F&G value in buy/signal/status notifications
- core/trader.py: pass current F&G value to notify_buy

Backtest evidence (1y / 18 tickers / 1h candles):
  - No filter:   820 trades, 32.7% WR, avg +0.012%, KRW +95k
  - F&G >= 41:   372 trades, 39.5% WR, avg +0.462%, KRW +1.72M
  - Blocked 452 trades (avg -0.372%, saved ~1.68M KRW loss)

Also add:
- backtest_db.py: Oracle DB storage for backtest runs/results/trades
- fng_1y_backtest.py, fng_adaptive_backtest.py, fng_sim_comparison.py

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 15:56:17 +09:00

180 lines
5.5 KiB
Python

"""Telegram 매매 알림."""
from __future__ import annotations
import logging
import os
import requests
logger = logging.getLogger(__name__)
_API = "https://api.telegram.org/bot{token}/sendMessage"
def _send(text: str) -> None:
token = os.getenv("TELEGRAM_TRADE_TOKEN", "")
chat_id = os.getenv("TELEGRAM_CHAT_ID", "")
if not token or not chat_id:
logger.warning("Telegram 설정 없음, 알림 스킵")
return
try:
requests.post(
_API.format(token=token),
json={"chat_id": chat_id, "text": text, "parse_mode": "HTML"},
timeout=5,
)
except Exception as e:
logger.error(f"Telegram 알림 실패: {e}")
def notify_buy(
ticker: str, price: float, amount: float, invested_krw: int,
max_budget: int = 0, per_position: int = 0,
fng: int = 0,
) -> None:
budget_line = (
f"운용예산: {max_budget:,}원 (포지션당 {per_position:,}원)\n"
if max_budget else ""
)
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
_send(
f"📈 <b>[매수]</b> {ticker}\n"
f"가격: {price:,.2f}\n"
f"수량: {amount:.8f}\n"
f"투자금: {invested_krw:,.2f}\n"
f"{fng_line}"
f"{budget_line}"
)
def notify_sell(
ticker: str, price: float, pnl_pct: float, reason: str,
krw_profit: float = 0.0, fee_krw: float = 0.0,
cum_profit: float = 0.0,
) -> None:
trade_emoji = "" if pnl_pct >= 0 else ""
cum_emoji = "💚" if cum_profit >= 0 else "🔴"
_send(
f"{trade_emoji} <b>[매도]</b> {ticker}\n"
f"가격: {price:,.2f}\n"
f"수익률: {pnl_pct:+.2f}%\n"
f"실손익: {krw_profit:+,.2f}원 (수수료 {fee_krw:,.2f}원)\n"
f"{cum_emoji} 누적손익: {cum_profit:+,.2f}\n"
f"사유: {reason}"
)
def notify_signal(ticker: str, signal_price: float, vol_mult: float, fng: int = 0) -> None:
"""거래량 축적 신호 감지 알림."""
from .fng import FNG_MIN_ENTRY
fng_label = (
"극탐욕" if fng >= 76 else
"탐욕" if fng >= 56 else
"중립" if fng >= 46 else
"약공포" if fng >= 41 else
"공포" if fng >= 26 else
"극공포"
) if fng else ""
fng_line = f"F&amp;G: {fng} ({fng_label})\n" if fng else ""
warn_line = (
f"⚠️ F&amp;G={fng} &lt; {FNG_MIN_ENTRY} → <b>진입차단중</b>\n"
if fng and fng < FNG_MIN_ENTRY else ""
)
_send(
f"🔍 <b>[축적감지]</b> {ticker}\n"
f"신호가: {signal_price:,.2f}\n"
f"거래량: {vol_mult:.1f}x 급증 + 2h 횡보\n"
f"{fng_line}"
f"{warn_line}"
f"진입 목표: {signal_price * 1.048:,.2f}원 (+4.8%)"
)
def notify_error(message: str) -> None:
_send(f"⚠️ <b>[오류]</b>\n{message}")
def notify_status(
positions: dict,
max_budget: int = 0,
per_position: int = 0,
cum_profit: float = 0.0,
) -> None:
"""정각마다 시장 레짐 + 1시간 이상 보유 포지션 현황 전송."""
from datetime import datetime
import pyupbit
from .market_regime import get_regime
now = datetime.now().strftime("%H:%M")
cum_sign = "+" if cum_profit >= 0 else ""
# 시장 레짐
regime = get_regime()
regime_line = (
f"{regime['emoji']} 시장: {regime['name'].upper()} "
f"(score {regime['score']:+.2f}%) "
f"| 조건 TREND≥{regime['trend_pct']}% / VOL≥{regime['vol_mult']}x\n"
)
# F&G 지수
from .fng import get_fng, FNG_MIN_ENTRY
fv = get_fng()
fng_label = (
"극탐욕" if fv >= 76 else
"탐욕" if fv >= 56 else
"중립" if fv >= 46 else
"약공포" if fv >= 41 else
"공포" if fv >= 26 else
"극공포"
)
fng_status = "✅진입허용" if fv >= FNG_MIN_ENTRY else "🚫진입차단"
fng_line = f"😨 F&amp;G: {fv} ({fng_label}) {fng_status}\n"
# 1시간 이상 보유 포지션만 필터
long_positions = {
ticker: pos for ticker, pos in positions.items()
if (datetime.now() - pos["entry_time"]).total_seconds() >= 3600
}
cum_emoji = "💚" if cum_profit >= 0 else "🔴"
budget_info = (
f"💰 운용예산: {max_budget:,}원 | 포지션당: {per_position:,}\n"
f"{cum_emoji} 누적손익: {cum_sign}{cum_profit:,.0f}\n"
if max_budget else ""
)
# 포지션 없어도 레짐 정보는 전송
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{fng_line}{budget_info}"
if not long_positions:
_send(header + "1h+ 보유 포지션 없음")
return
lines = [header]
for ticker, pos in long_positions.items():
current = pyupbit.get_current_price(ticker)
if not current:
continue
pnl = (current - pos["buy_price"]) / pos["buy_price"] * 100
peak = pos["peak_price"]
drop = (peak - current) / peak * 100
elapsed = (datetime.now() - pos["entry_time"]).total_seconds() / 3600
emoji = "📈" if pnl >= 0 else "📉"
lines.append(
f"{emoji} <b>{ticker}</b>\n"
f" 현재가: {current:,.2f}\n"
f" 수익률: {pnl:+.2f}%\n"
f" 최고가 대비: -{drop:.2f}%\n"
f" 보유: {elapsed:.2f}h"
)
_send("\n".join(lines))