- core/strategy.py: full rewrite to Volume Lead strategy - 10m candle direct detection (no 40m resampling) - F&G 3-tier vol threshold: <=40->6x, 41-50->5x, >50->blocked - Undying signal: price drop does not cancel signal (sig_p fixed) - Vol refresh: stronger vol_r updates signal price and timer - Watch alert: 4x-6x approaching threshold notifies via Telegram - WATCH_VOL_THRESH=4.0, WATCH_COOLDOWN_MIN=30, WATCH_VOL_JUMP=0.5 - daemon/runner.py: remove FNG_MIN_ENTRY block and Bear regime block - Only FNG_MAX_ENTRY(>50) blocks scan (greed/extreme greed) - Fast-poll loop cleaned of regime check - core/notify.py: add notify_watch() for near-signal Telegram alerts - Shows vol_r, distance to threshold, price, quiet pct - tests/: add 1y data collection and simulation scripts - collect_1y_data.py, refresh_cache.py - sim_10m_vol.py, sim_current.py, sim_regime_1y.py - sim_regime_sweep.py, sim_vol_override.py Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
6.2 KiB
Python
198 lines
6.2 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&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:
|
|
"""거래량 축적 신호 감지 알림."""
|
|
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&G: {fng} ({fng_label})\n" if fng else ""
|
|
from .strategy import TREND_AFTER_VOL
|
|
target = signal_price * (1 + TREND_AFTER_VOL / 100)
|
|
_send(
|
|
f"🔍 <b>[축적감지]</b> {ticker}\n"
|
|
f"신호가: {signal_price:,.2f}원\n"
|
|
f"거래량: {vol_mult:.1f}x 급증 + 횡보\n"
|
|
f"{fng_line}"
|
|
f"진입 목표: {target:,.2f}원 (+{TREND_AFTER_VOL}%)"
|
|
)
|
|
|
|
|
|
def notify_watch(
|
|
ticker: str, price: float, vol_r: float, vth: float, chg: float, fng: int = 0
|
|
) -> None:
|
|
"""거래량 근접 관찰 알림 (신호 임계값에 가까워진 종목)."""
|
|
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&G: {fng} ({fng_label})\n" if fng else ""
|
|
need = vth - vol_r
|
|
_send(
|
|
f"👀 <b>[관찰]</b> {ticker}\n"
|
|
f"거래량: {vol_r:.1f}x (신호까지 +{need:.1f}x 부족)\n"
|
|
f"현재가: {price:,.2f}원 | 횡보: {chg:.1f}%\n"
|
|
f"{fng_line}"
|
|
)
|
|
|
|
|
|
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&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))
|