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>
This commit is contained in:
joungmin
2026-03-03 15:56:17 +09:00
parent 673ce08d84
commit 27189b1ad9
9 changed files with 1402 additions and 8 deletions

71
core/fng.py Normal file
View File

@@ -0,0 +1,71 @@
"""공포탐욕지수(F&G) 조회 모듈.
alternative.me API로 일일 F&G 값을 가져와 메모리에 캐시한다.
캐시 TTL은 1시간 (F&G는 하루 1회 업데이트).
환경변수:
FNG_MIN_ENTRY (기본값 41): 이 값 미만이면 진입 차단
"""
from __future__ import annotations
import json
import logging
import os
import time
import urllib.request
from datetime import datetime
logger = logging.getLogger(__name__)
FNG_MIN_ENTRY = int(os.getenv("FNG_MIN_ENTRY", "41")) # 진입 허용 최소 F&G 값
_FNG_API_URL = "https://api.alternative.me/fng/?limit=1&format=json"
_CACHE_TTL = 3600 # 1시간
_fng_value: int | None = None
_fng_cached_at: float = 0.0
_fng_date_str: str = ""
def get_fng() -> int:
"""오늘의 F&G 지수 반환 (0~100). API 실패 시 50(중립) 반환."""
global _fng_value, _fng_cached_at, _fng_date_str
now = time.time()
if _fng_value is not None and (now - _fng_cached_at) < _CACHE_TTL:
return _fng_value
try:
with urllib.request.urlopen(_FNG_API_URL, timeout=5) as r:
data = json.loads(r.read())
entry = data["data"][0]
_fng_value = int(entry["value"])
_fng_cached_at = now
_fng_date_str = entry.get("timestamp", "")
logger.info(
f"[F&G] 지수={_fng_value} ({entry.get('value_classification','')}) "
f"날짜={datetime.fromtimestamp(int(_fng_date_str)).strftime('%Y-%m-%d') if _fng_date_str else '?'}"
)
except Exception as e:
logger.warning(f"[F&G] API 조회 실패: {e} → 캐시/중립값 사용")
if _fng_value is None:
_fng_value = 50 # 폴백: 중립
return _fng_value # type: ignore[return-value]
def is_entry_allowed() -> bool:
"""현재 F&G 기준으로 진입 허용 여부 반환.
F&G ≥ FNG_MIN_ENTRY(41) 이면 True.
극공포/공포 구간(< 41)이면 False → 진입 차단.
"""
fv = get_fng()
allowed = fv >= FNG_MIN_ENTRY
if not allowed:
label = (
"극공포" if fv <= 25 else
"공포" if fv <= 40 else
"약공포"
)
logger.info(f"[F&G] 진입 차단 — F&G={fv} ({label}) < {FNG_MIN_ENTRY}")
return allowed

View File

@@ -31,16 +31,27 @@ def _send(text: str) -> None:
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}"
)
@@ -62,12 +73,28 @@ def notify_sell(
)
def notify_signal(ticker: str, signal_price: float, vol_mult: float) -> None:
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%)"
)
@@ -98,6 +125,20 @@ def notify_status(
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()
@@ -112,7 +153,7 @@ def notify_status(
)
# 포지션 없어도 레짐 정보는 전송
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{budget_info}"
header = f"📊 <b>[{now} 현황]</b>\n{regime_line}{fng_line}{budget_info}"
if not long_positions:
_send(header + "1h+ 보유 포지션 없음")

View File

@@ -23,6 +23,7 @@ import time
import pyupbit
from .fng import FNG_MIN_ENTRY, is_entry_allowed
from .market import get_current_price
from .market_regime import get_regime
from .notify import notify_signal
@@ -122,9 +123,14 @@ def _check_vol_spike(ticker: str, vol_mult: float) -> bool:
def should_buy(ticker: str) -> bool:
"""Volume Lead 전략.
1단계: 거래량 급증 + 2h 횡보 → 신호가 기록
2단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
1단계: F&G 필터 — 공포탐욕지수 < FNG_MIN_ENTRY(41)이면 즉시 차단
2단계: 거래량 급증 + 2h 횡보 → 신호가 기록
3단계: 신호가 대비 +TREND_AFTER_VOL% 상승 확인 시 진입
"""
# ── F&G 진입 필터 ─────────────────────────────────────
if not is_entry_allowed():
return False
regime = get_regime()
vol_mult = regime["vol_mult"]
@@ -177,11 +183,13 @@ def should_buy(ticker: str) -> bool:
entry_thr = _calc_entry_threshold(ratio)
_accum_signals[ticker] = {"price": current, "time": now, "vol_ratio": ratio}
from .fng import get_fng
fng_now = get_fng()
logger.info(
f"[축적감지] {ticker} 거래량 급증 + 2h 횡보 → 신호가={current:,.2f}"
f"(거래량 {ratio:.2f}x → 진입임계={entry_thr:.1f}%)"
f"(거래량 {ratio:.2f}x → 진입임계={entry_thr:.1f}% | F&G={fng_now})"
)
notify_signal(ticker, current, ratio)
notify_signal(ticker, current, ratio, fng=fng_now)
return False # 신호 첫 발생 시는 진입 안 함
# ── 신호 있음: 상승 확인 → 진입 ─────────────────────────

View File

@@ -480,8 +480,10 @@ def buy(ticker: str) -> bool:
f"{prefix}[매수] {ticker} @ {actual_price:,.0f}원 (실체결가) | "
f"수량={amount} | 투자금={order_krw:,}원 | trade_id={trade_id[:8]}"
)
from .fng import get_fng
notify_buy(ticker, actual_price, amount, order_krw,
max_budget=MAX_BUDGET, per_position=PER_POSITION)
max_budget=MAX_BUDGET, per_position=PER_POSITION,
fng=get_fng())
return True
except Exception as e:
logger.error(f"매수 예외 {ticker}: {e}")