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

317
fng_adaptive_backtest.py Normal file
View File

@@ -0,0 +1,317 @@
"""F&G 구간별 맞춤 파라미터 백테스트
핵심 가설:
극공포 구간은 시장이 불안정 → 더 엄격한 진입 기준 필요
탐욕 구간은 상승 모멘텀이 지속 → 다소 느슨한 기준도 가능
테스트 방식:
각 F&G 구간마다 다른 파라미터 조합을 적용하고 성과 비교.
구간별 최적 파라미터 도출 → 실제 전략에 반영
결과를 Oracle DB에 저장.
데이터: 1년치 1h 캔들 (배치 수집)
"""
from __future__ import annotations
import datetime
import json
import sys
import time
import urllib.request
import pandas as pd
import pyupbit
from dataclasses import dataclass
# ── DB 저장 ─────────────────────────────────────────────────
try:
from backtest_db import ensure_tables, insert_run, insert_result, insert_trades_bulk
DB_ENABLED = True
except Exception as e:
print(f" [DB 비활성화] {e}")
DB_ENABLED = False
TICKERS = [
"KRW-BTC", "KRW-ETH", "KRW-XRP", "KRW-SOL", "KRW-DOGE",
"KRW-ADA", "KRW-DOT", "KRW-NEAR", "KRW-AVAX", "KRW-LINK",
"KRW-SUI", "KRW-HBAR",
"KRW-VIRTUAL", "KRW-SXP", "KRW-CFG", "KRW-HOLO",
"KRW-KAVA", "KRW-KNC",
]
# ── F&G 구간별 파라미터 조합 ─────────────────────────────────
# (fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min)
ADAPTIVE_CONFIGS = [
# 기준선 (F&G 무관, 단일 파라미터)
(None, None, "기준선(전체/현행파라미터)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
# ── 극공포 (0~25) 구간 ── 엄격한 기준 ──
# 극공포에서는 변동성 급증이 흔함 → 볼륨 기준 올리고, 모멘텀 강화
(None, 25, "극공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(None, 25, "극공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/매우엄격(3x+5%+1%스탑)", 3.0, 2.0, 6, 5.0, 3.0, 0.010, 24, 3.0),
(None, 25, "극공포/넓은스탑(2x+3%+2%스탑)", 2.0, 2.0, 8, 3.0, 3.0, 0.020, 24, 3.0),
(None, 25, "극공포/짧은신호(3x+4%+4h유효)", 3.0, 2.0, 4, 4.0, 3.0, 0.015, 24, 3.0),
# ── 공포 (26~45) ── 중간 기준 ──
(26, 45, "공포/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/약강화(2.5x vol+3.5%mom)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
(26, 45, "공포/엄격(3x vol+4%mom)", 3.0, 2.0, 8, 4.0, 3.0, 0.010, 24, 3.0),
# ── 중립 이상 (46~100) ── 완화된 기준 가능 ──
(46, None, "중립이상/기준(2x vol+3%mom)", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/완화(1.5x vol+2.5%mom)",1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
(46, None, "중립이상/엄격(2.5x+3.5%)", 2.5, 2.0, 8, 3.5, 3.0, 0.015, 24, 3.0),
# ── 탐욕+ (56~100) ──
(56, None, "탐욕이상/기준", 2.0, 2.0, 8, 3.0, 3.0, 0.015, 24, 3.0),
(56, None, "탐욕이상/완화(1.5x+2.5%)", 1.5, 2.0, 8, 2.5, 3.0, 0.015, 24, 3.0),
]
# ── 데이터 수집 ──────────────────────────────────────────────
def fetch_1y(ticker: str, total_days: int = 365) -> pd.DataFrame | None:
all_dfs = []
end = datetime.datetime.now()
batch = 1440 # 60일치씩
prev_oldest = None
while True:
df = pyupbit.get_ohlcv(
ticker, interval="minute60", count=batch,
to=end.strftime("%Y-%m-%d %H:%M:%S"),
)
if df is None or df.empty:
break
all_dfs.append(df)
oldest = df.index[0]
# 상장 초기 종목: oldest가 진전되지 않으면 더 이상 오래된 데이터 없음
if prev_oldest is not None and oldest >= prev_oldest:
break
prev_oldest = oldest
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
if oldest <= cutoff:
break
end = oldest
time.sleep(0.12)
if not all_dfs:
return None
combined = pd.concat(all_dfs).sort_index()
combined = combined[~combined.index.duplicated(keep="last")]
cutoff = datetime.datetime.now() - datetime.timedelta(days=total_days)
return combined[combined.index >= cutoff]
def load_fng() -> dict[str, int]:
url = "https://api.alternative.me/fng/?limit=400&format=json"
with urllib.request.urlopen(url, timeout=10) as r:
data = json.loads(r.read())
return {
datetime.datetime.fromtimestamp(int(d["timestamp"])).strftime("%Y-%m-%d"):
int(d["value"])
for d in data["data"]
}
def fng_val(fng_map, ts) -> int:
return fng_map.get(ts.strftime("%Y-%m-%d"), 50)
# ── 시뮬레이션 ──────────────────────────────────────────────
@dataclass
class Trade:
pnl: float
h: int
fng: int
exit: str
def simulate(
df, fng_map,
fng_lo=None, fng_hi=None,
vol_mult=2.0, quiet_2h=2.0, sig_to_h=8,
mom_thr=3.0, sig_cancel=3.0, trail_stop=0.015,
time_h=24, time_min=3.0,
) -> list[Trade]:
closes = df["close"].values
vols = df["volume"].values
idx = df.index
trades: list[Trade] = []
sig_px = sig_i = None
pos_buy = pos_peak = pos_i = pos_fng = None
for i in range(7, len(closes) - max(time_h + 4, 10)):
if pos_buy is not None:
cur = closes[i]
if cur > pos_peak:
pos_peak = cur
if (pos_peak - cur) / pos_peak >= trail_stop:
trades.append(Trade((cur - pos_buy) / pos_buy * 100,
i - pos_i, pos_fng, "trail"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
if i - pos_i >= time_h:
pnl = (cur - pos_buy) / pos_buy * 100
if pnl < time_min:
trades.append(Trade(pnl, i - pos_i, pos_fng, "time"))
pos_buy = pos_peak = pos_i = pos_fng = sig_px = sig_i = None
continue
continue
if sig_px is not None:
if i - sig_i > sig_to_h:
sig_px = sig_i = None
elif (closes[i] - sig_px) / sig_px * 100 < -sig_cancel:
sig_px = sig_i = None
if sig_px is None:
vol_avg = vols[i - 6:i - 1].mean()
if vol_avg <= 0:
continue
if vols[i - 1] / vol_avg >= vol_mult:
if abs(closes[i] - closes[i - 2]) / closes[i - 2] * 100 < quiet_2h:
sig_px = closes[i]
sig_i = i
continue
fv = fng_val(fng_map, idx[i])
if fng_lo is not None and fv < fng_lo:
continue
if fng_hi is not None and fv > fng_hi:
continue
if (closes[i] - sig_px) / sig_px * 100 >= mom_thr:
pos_buy = pos_peak = closes[i]
pos_i = i
pos_fng = fv
sig_px = sig_i = None
return trades
def stats(trades):
if not trades:
return dict(n=0, wr=0, avg_pnl=0, total_pnl=0, rr=0,
avg_win=0, avg_loss=0, max_dd=0)
wins = [t for t in trades if t.pnl > 0]
losses = [t for t in trades if t.pnl <= 0]
aw = sum(t.pnl for t in wins) / len(wins) if wins else 0
al = sum(t.pnl for t in losses) / len(losses) if losses else 0
cum = pk = max_dd = 0.0
for t in trades:
cum += t.pnl
if cum > pk: pk = cum
if pk - cum > max_dd: max_dd = pk - cum
return dict(
n=len(trades), wr=len(wins) / len(trades) * 100,
avg_pnl=sum(t.pnl for t in trades) / len(trades),
total_pnl=sum(t.pnl for t in trades),
rr=abs(aw / al) if al else 0,
avg_win=aw, avg_loss=al, max_dd=max_dd,
)
def main():
print("F&G 데이터 로드...")
fng_map = load_fng()
print(f"종목 1년치 데이터 수집 중 ({len(TICKERS)}개)...")
datasets = {}
for i, tk in enumerate(TICKERS):
try:
df = fetch_1y(tk, total_days=365)
if df is not None and len(df) > 100:
datasets[tk] = df
sys.stderr.write(f"\r {i+1}/{len(TICKERS)} {tk} ({len(df)}h) ")
except Exception as e:
sys.stderr.write(f"\r {tk} 실패: {e} ")
sys.stderr.write("\n")
print(f" 완료: {len(datasets)}개 종목\n")
# ── DB 준비 ───────────────────────────────────────────
run_id = None
if DB_ENABLED:
ensure_tables()
params = {
"tickers": len(datasets),
"days": 365,
"candle": "1h",
"stop": "trail+time",
}
run_id = insert_run(
run_name="fng_adaptive_1y",
description="F&G 구간별 맞춤 파라미터 1년 백테스트",
params=params,
)
print(f" DB run_id: {run_id}\n")
# ── 결과 출력 ─────────────────────────────────────────
print("=" * 92)
print(" F&G 구간별 맞춤 파라미터 성과 비교 (1년치 / 1h 캔들)")
print("=" * 92)
print(f" {'조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8} "
f"{'손익비':>5} {'총PnL':>9} {'MaxDD':>7}")
print(" " + "-" * 86)
best_by_zone: dict[str, tuple] = {}
for cfg in ADAPTIVE_CONFIGS:
fng_lo, fng_hi, label, vol_mult, quiet_2h, sig_to_h, mom_thr, sig_cancel, trail_stop, time_h, time_min = cfg
all_trades: list[Trade] = []
per_ticker: dict[str, list[Trade]] = {}
for tk, df in datasets.items():
t = simulate(
df, fng_map,
fng_lo=fng_lo, fng_hi=fng_hi,
vol_mult=vol_mult, quiet_2h=quiet_2h, sig_to_h=sig_to_h,
mom_thr=mom_thr, sig_cancel=sig_cancel, trail_stop=trail_stop,
time_h=time_h, time_min=time_min,
)
all_trades.extend(t)
per_ticker[tk] = t
s = stats(all_trades)
# 구분선 (기준선 다음)
if label == "극공포/기준(2x vol+3%mom)":
print()
if s["n"] == 0:
print(f" {label:<42} 거래 없음")
continue
marker = "" if s["avg_pnl"] > 0 else ""
print(
f" {label:<42} {s['n']:>5}{s['wr']:>5.1f}% "
f"{s['avg_pnl']:>+7.3f}% {s['rr']:>4.2f} "
f"{s['total_pnl']:>+8.1f}% -{s['max_dd']:>5.1f}%{marker}"
)
# DB 저장
if DB_ENABLED and run_id:
insert_result(run_id, label, s, fng_lo, fng_hi)
for tk, t_list in per_ticker.items():
insert_trades_bulk(run_id, label, tk, t_list)
# 구간별 최고 avg_pnl 추적
zone_key = label.split("/")[0]
if zone_key not in best_by_zone or s["avg_pnl"] > best_by_zone[zone_key][1]:
best_by_zone[zone_key] = (label, s["avg_pnl"], s)
# ── 구간별 최적 요약 ──────────────────────────────────
print()
print(" ★ 구간별 최적 파라미터:")
print(f" {'구간':<14} {'최적 조건':<42} {'거래':>5} {'승률':>6} {'평균PnL':>8}")
print(" " + "-" * 72)
for zone, (label, best_pnl, s) in best_by_zone.items():
if s["n"] > 0:
print(f" {zone:<14} {label:<42} {s['n']:>5}{s['wr']:>5.1f}% {best_pnl:>+7.3f}%")
if DB_ENABLED and run_id:
print(f"\n [DB 저장 완료] run_id: {run_id}")
if __name__ == "__main__":
main()