커밋 1 — 실매 가격 TTL 구멍 (본체)
왜: 체결이 없어도 마지막가는 유지인데, TTL로 None 만들고 매도/EOD를 건너뛰어 8/5 돌파·금요일 leftover가 남음. 호가필터 TTL 구멍과 같은 병. 넣을 파일 신규: kis_trader/engine/live_sell_price.py kis_trader/strategies/base.py (_ws_last_quote, _resolve_sell_price) 전략: momentum.py scalping.py tail_catch.py breakout.py range_break.py dart_strategy.py updow_strategy.py updown_feed.py us_momentum.py WS: ws_manager.py kis_ws.py kiwoom_ws.py ls_ws.py kis_ws_overseas.py kis_trader/web/live_config_schema.py (WS_PRICE_MAX_AGE_SEC 기본 0) database.py (키 주석 + legacy/ sys.path) kis_trader/execution/order_manager.py (잔고 있는데 40240000 ghost_purge 금지 — 같은 EOD 사고) EOD가 min_hold에 안 막히게 손본 momentum_hts_logic.py / scalping_engine.py / tail_engine.py (이 대화에서 손본 부분만 확인 후) 문서: docs/like_mcp.md/db_erd.md code_architecture.md (가격 TTL 문구) 메시지 초안 fix: 매수·매도 현재가를 TTL로 버리지 않음 (마지막 RAM) 횡보·체결 공백을 죽은 캐시로 오인해 None 처리하면 손절·EOD가 스킵된다. 호가필터와 같이 나이는 무시하고 마지막 체결가를 유지한다. EOD는 매수가 폴백. 영향: 실매 O / 백테·옵투나 봉 경로 거의 무관 (엔진 식 변경 아님) 커밋 2 — 루트 정리 (remove/ vs legacy/) 왜: 루트 단독봇·테스트는 지울 보관함으로. 웹·알람이 아직 쓰는 모듈은 remove에 두면 나중에 폴더째 삭제 때 깨짐. 넣을 파일 이동: 미사용 → remove/legacy_root/ (래퍼, ETF/키움 옛봇, 테스트, kiwoom_rest_api 등) 이동: 사용 중 → legacy/ (holding_bot kis_holding_ver1 news_analyzer kis_long_ver1/2) 신규: kis_trader/utils/legacy_root.py legacy/README.md remove/README.md import 경로: backtest_web.py mm_butler.py mm_remote.py updow_holding_cfg.py dbband_stock_cfg.py param_search_updow*.py dbband_param_search.py param_search_apply_snapshot.py verify_three_paths.py docs/like_mcp.md/code_architecture.md 수동 노트 메시지 초안 chore: 미사용 루트는 remove/, 웹·알람 구모듈은 legacy/ remove는 나중에 통째 삭제 예정. holding_bot·news_analyzer·kis_long은 ensure_legacy_root로 legacy/만 본다. 빼기: scratch/set_ws_price_max_age_zero.py (일회성)
This commit is contained in:
2577
legacy/holding_bot.py
Normal file
2577
legacy/holding_bot.py
Normal file
File diff suppressed because it is too large
Load Diff
492
legacy/kis_holding_ver1.py
Normal file
492
legacy/kis_holding_ver1.py
Normal file
@@ -0,0 +1,492 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
kis_holding_ver1.py — 홀딩 전략 V1 (RSI 3단계 분할매수 · 횡보장 특화)
|
||||
=======================================================================
|
||||
holding_bot.py(추세추종)의 대응 버전. 추세 판단 없이 RSI만으로 진입,
|
||||
가격이 더 빠질수록 비중을 늘려가는 전통적 '물타기(분할매수)' 전략.
|
||||
|
||||
전략 개요
|
||||
---------
|
||||
진입 (분할매수):
|
||||
· RSI ≤ rsi_buy1 → 1단계 매수 (slot_money × buy1_ratio)
|
||||
· 보유 중 RSI ≤ rsi_buy2 → 2단계 추가매수 (× buy2_ratio)
|
||||
· 보유 중 RSI ≤ rsi_buy3 → 3단계 추가매수 (× buy3_ratio)
|
||||
· 낙폭 필터(ath/year/w52)로 "충분히 싼 구간"에만 진입 가능
|
||||
|
||||
청산:
|
||||
· 평단가 대비 +take_profit_pct% → 익절
|
||||
· RSI ≥ rsi_sell → 과열 청산
|
||||
· 평단가 대비 −stop_loss_pct% → 손절
|
||||
|
||||
holding_bot.py와 동일한 DB 테이블(holding_candles, holding_stock_config) 사용.
|
||||
|
||||
실행 예시:
|
||||
python3 kis_holding_ver1.py --code 005930 \\
|
||||
--start 2024-01-01 --end 2026-03-06
|
||||
"""
|
||||
|
||||
import sys, os, json, argparse
|
||||
from datetime import date as _date, timedelta as _td, datetime
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# holding_bot.py와 DB/캔들 공통 함수 공유
|
||||
from holding_bot import (
|
||||
ensure_holding_tables, get_stored_candles,
|
||||
_rsi_series,
|
||||
)
|
||||
from database import TradeDB
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 기본 파라미터 (V1 전용 — MA/추세 관련 파라미터 없음)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
DEFAULT_V1_CONFIG: Dict = {
|
||||
"rsi_period": 14.0,
|
||||
|
||||
# ── 진입 RSI 임계값 (rsi_buy1 > rsi_buy2 > rsi_buy3 이어야 함) ──────────
|
||||
"rsi_buy1": 50.0, # 1단계: RSI ≤ 이 값 → 처음 매수
|
||||
"rsi_buy2": 40.0, # 2단계: RSI ≤ 이 값 → 추가매수
|
||||
"rsi_buy3": 30.0, # 3단계: RSI ≤ 이 값 → 최종 추가매수
|
||||
|
||||
# ── 청산 기준 ────────────────────────────────────────────────────────────
|
||||
"rsi_sell": 75.0, # RSI 과열 청산
|
||||
"take_profit_pct": 15.0, # 평단가 대비 익절 %
|
||||
"stop_loss_pct": 10.0, # 평단가 대비 손절 %
|
||||
|
||||
# ── 투자금 / 분할 비율 (holding_bot·웹 UI와 동일 — 0~100 퍼센트) ─────────
|
||||
"slot_money": 3_000_000.0,
|
||||
"buy1_ratio": 40.0, # 1단계 투자금 비율 (%)
|
||||
"buy2_ratio": 35.0, # 2단계 투자금 비율 (%)
|
||||
"buy3_ratio": 25.0, # 3단계 투자금 비율 (%)
|
||||
|
||||
# ── 비용 ────────────────────────────────────────────────────────────────
|
||||
"fee_rate": 0.0015, # 수수료율 (편도)
|
||||
"sell_tax": 0.0018, # 증권거래세
|
||||
|
||||
# ── 낙폭 필터 (0=비활성) ─────────────────────────────────────────────────
|
||||
"ath_drop_min_pct": 0.0, # 역대 최고가 대비 최소 낙폭 %
|
||||
"year_drop_min_pct": 0.0, # 당해연도 고점 대비 최소 낙폭 %
|
||||
"w52_drop_min_pct": 0.0, # 52주 고점 대비 최소 낙폭 %
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 백테스트 엔진
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def run_backtest_v1(candles: List[Dict], cfg: Dict) -> Dict:
|
||||
"""
|
||||
V1 RSI 분할매수 전략 백테스트.
|
||||
|
||||
실행가: 신호봉 다음 봉 시가 (1봉 지연, 실매매와 동일)
|
||||
수수료: 편도 fee_rate × 2 + 매도 시 sell_tax
|
||||
"""
|
||||
rsi_period = int(cfg.get("rsi_period", DEFAULT_V1_CONFIG["rsi_period"]))
|
||||
rsi_buy1 = float(cfg.get("rsi_buy1", DEFAULT_V1_CONFIG["rsi_buy1"]))
|
||||
rsi_buy2 = float(cfg.get("rsi_buy2", DEFAULT_V1_CONFIG["rsi_buy2"]))
|
||||
rsi_buy3 = float(cfg.get("rsi_buy3", DEFAULT_V1_CONFIG["rsi_buy3"]))
|
||||
rsi_sell = float(cfg.get("rsi_sell", DEFAULT_V1_CONFIG["rsi_sell"]))
|
||||
tp_pct = float(cfg.get("take_profit_pct",DEFAULT_V1_CONFIG["take_profit_pct"]))
|
||||
sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_V1_CONFIG["stop_loss_pct"]))
|
||||
slot_money = float(cfg.get("slot_money", DEFAULT_V1_CONFIG["slot_money"]))
|
||||
# 웹·DB는 30=30% 형식 — holding_bot.run_backtest 와 동일하게 /100
|
||||
buy1_r = float(cfg.get("buy1_ratio", DEFAULT_V1_CONFIG["buy1_ratio"])) / 100.0
|
||||
buy2_r = float(cfg.get("buy2_ratio", DEFAULT_V1_CONFIG["buy2_ratio"])) / 100.0
|
||||
buy3_r = float(cfg.get("buy3_ratio", DEFAULT_V1_CONFIG["buy3_ratio"])) / 100.0
|
||||
fee_rate = float(cfg.get("fee_rate", DEFAULT_V1_CONFIG["fee_rate"]))
|
||||
sell_tax = float(cfg.get("sell_tax", DEFAULT_V1_CONFIG["sell_tax"]))
|
||||
ath_drop_min = float(cfg.get("ath_drop_min_pct", DEFAULT_V1_CONFIG["ath_drop_min_pct"]))
|
||||
year_drop_min = float(cfg.get("year_drop_min_pct", DEFAULT_V1_CONFIG["year_drop_min_pct"]))
|
||||
w52_drop_min = float(cfg.get("w52_drop_min_pct", DEFAULT_V1_CONFIG["w52_drop_min_pct"]))
|
||||
|
||||
if len(candles) < rsi_period + 5:
|
||||
return {"error": f"봉 부족: {len(candles)}개 (최소 {rsi_period + 5}개 필요)"}
|
||||
|
||||
closes = [float(c["close"]) for c in candles]
|
||||
highs = [float(c["high"]) for c in candles]
|
||||
lows = [float(c["low"]) for c in candles]
|
||||
opens = [float(c["open"]) for c in candles]
|
||||
dates = [str(c["candle_date"])[:10] for c in candles]
|
||||
|
||||
rsis = _rsi_series(closes, rsi_period)
|
||||
|
||||
# ── 컨텍스트 배열 사전 계산 (ATH / 연도 고점 / 52주 고점) ─────────────────
|
||||
ath_arr: List[float] = []
|
||||
year_arr: List[float] = []
|
||||
w52_arr: List[float] = []
|
||||
_year_max: Dict[str, float] = {}
|
||||
_ath_run = 0.0
|
||||
|
||||
for i in range(len(candles)):
|
||||
h = highs[i]; y = dates[i][:4]
|
||||
_ath_run = max(_ath_run, h)
|
||||
_year_max[y] = max(_year_max.get(y, 0.0), h)
|
||||
# 52주(약 252거래일) 슬라이딩 윈도우
|
||||
w52_s = max(0, i - 251)
|
||||
try:
|
||||
cutoff = str(_date.fromisoformat(dates[i]) - _td(days=365))
|
||||
tmp = i
|
||||
while tmp > 0 and dates[tmp - 1] >= cutoff:
|
||||
tmp -= 1
|
||||
w52_s = tmp
|
||||
except Exception:
|
||||
pass
|
||||
ath_arr.append(_ath_run)
|
||||
year_arr.append(_year_max[y])
|
||||
w52_arr.append(max(highs[w52_s:i + 1]))
|
||||
|
||||
# ── 시뮬레이션 ────────────────────────────────────────────────────────────
|
||||
position = None # None 또는 Dict
|
||||
trades: List[Dict] = []
|
||||
equity: List[Dict] = []
|
||||
cum_pnl = 0.0
|
||||
start_i = rsi_period + 1
|
||||
|
||||
for i in range(start_i, len(candles) - 1):
|
||||
rsi = rsis[i]
|
||||
if rsi is None:
|
||||
continue
|
||||
|
||||
close = closes[i]
|
||||
next_open = float(opens[i + 1]) if opens[i + 1] > 0 else close
|
||||
if next_open <= 0:
|
||||
continue
|
||||
|
||||
date_str = dates[i]
|
||||
|
||||
def _drop(ref: float) -> float:
|
||||
"""현재가 기준 고점 대비 낙폭 %"""
|
||||
return (ref - close) / ref * 100 if ref > 0 else 0.0
|
||||
|
||||
# ─── 보유 중: 청산 먼저 체크, 그 다음 추가매수 ───────────────────────
|
||||
if position is not None:
|
||||
avg = position["avg"]
|
||||
qty = position["qty"]
|
||||
stage = position["stage"]
|
||||
|
||||
profit_pct_now = (close - avg) / avg * 100 if avg > 0 else 0.0
|
||||
|
||||
# 청산 조건
|
||||
sell_reason = None
|
||||
if profit_pct_now >= tp_pct:
|
||||
sell_reason = f"익절(+{profit_pct_now:.1f}%)"
|
||||
elif rsi >= rsi_sell:
|
||||
sell_reason = f"RSI과열({rsi:.1f})"
|
||||
elif profit_pct_now <= -sl_pct:
|
||||
sell_reason = f"손절({profit_pct_now:.1f}%)"
|
||||
|
||||
if sell_reason:
|
||||
exit_price = next_open
|
||||
fee = exit_price * qty * (fee_rate + sell_tax)
|
||||
pnl = (exit_price - avg) * qty - fee
|
||||
cum_pnl += pnl
|
||||
trades.append({
|
||||
"buy_date": dates[position["entry_i"]],
|
||||
"sell_date": dates[i + 1],
|
||||
"avg_price": round(avg),
|
||||
"exit_price": round(exit_price),
|
||||
"qty": qty,
|
||||
"pnl": round(pnl),
|
||||
"hold_days": i + 1 - position["entry_i"],
|
||||
"reason": sell_reason,
|
||||
"stage": stage,
|
||||
"ath_drop": position.get("ath_drop", 0),
|
||||
})
|
||||
equity.append({"date": dates[i + 1], "cum_pnl": round(cum_pnl)})
|
||||
position = None
|
||||
else:
|
||||
# ─ 추가매수 (분할매수 핵심) ─────────────────────────────────
|
||||
# 2단계: 더 빠져서 rsi_buy2 이하가 됐을 때 추가
|
||||
if stage == 1 and rsi <= rsi_buy2:
|
||||
add_inv = slot_money * buy2_r
|
||||
add_qty = max(1, int(add_inv / next_open))
|
||||
add_cost = next_open * add_qty * (1 + fee_rate)
|
||||
new_qty = qty + add_qty
|
||||
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
|
||||
position["qty"] = new_qty
|
||||
position["cost"] += add_cost
|
||||
position["stage"] = 2
|
||||
|
||||
# 3단계: 더 빠져서 rsi_buy3 이하가 됐을 때 추가
|
||||
elif stage == 2 and rsi <= rsi_buy3:
|
||||
add_inv = slot_money * buy3_r
|
||||
add_qty = max(1, int(add_inv / next_open))
|
||||
add_cost = next_open * add_qty * (1 + fee_rate)
|
||||
new_qty = qty + add_qty
|
||||
position["avg"] = (avg * qty + next_open * add_qty) / new_qty
|
||||
position["qty"] = new_qty
|
||||
position["cost"] += add_cost
|
||||
position["stage"] = 3
|
||||
|
||||
continue # 보유 중엔 신규 진입 스킵
|
||||
|
||||
# ─── 미보유: 낙폭 필터 → 1단계 진입 ──────────────────────────────────
|
||||
if ath_drop_min > 0 and _drop(ath_arr[i]) < ath_drop_min: continue
|
||||
if year_drop_min > 0 and _drop(year_arr[i]) < year_drop_min: continue
|
||||
if w52_drop_min > 0 and _drop(w52_arr[i]) < w52_drop_min: continue
|
||||
|
||||
if rsi > rsi_buy1:
|
||||
continue
|
||||
|
||||
invest = slot_money * buy1_r
|
||||
qty = max(1, int(invest / next_open))
|
||||
cost = next_open * qty * (1 + fee_rate)
|
||||
position = {
|
||||
"avg": next_open,
|
||||
"qty": qty,
|
||||
"cost": cost,
|
||||
"stage": 1,
|
||||
"entry_i": i + 1,
|
||||
"ath_drop": round(_drop(ath_arr[i]), 1),
|
||||
}
|
||||
|
||||
# ── 기간 종료 강제 청산 ───────────────────────────────────────────────────
|
||||
if position is not None and candles:
|
||||
exit_price = closes[-1]
|
||||
avg = position["avg"]; qty = position["qty"]
|
||||
fee = exit_price * qty * (fee_rate + sell_tax)
|
||||
pnl = (exit_price - avg) * qty - fee
|
||||
cum_pnl += pnl
|
||||
trades.append({
|
||||
"buy_date": dates[position["entry_i"]],
|
||||
"sell_date": dates[-1],
|
||||
"avg_price": round(avg),
|
||||
"exit_price": round(exit_price),
|
||||
"qty": qty,
|
||||
"pnl": round(pnl),
|
||||
"hold_days": len(candles) - 1 - position["entry_i"],
|
||||
"reason": "기간종료",
|
||||
"stage": position["stage"],
|
||||
"ath_drop": position.get("ath_drop", 0),
|
||||
})
|
||||
equity.append({"date": dates[-1], "cum_pnl": round(cum_pnl)})
|
||||
|
||||
# ── 요약 통계 ──────────────────────────────────────────────────────────────
|
||||
total = len(trades)
|
||||
wins = [t for t in trades if t["pnl"] > 0]
|
||||
losses = [t for t in trades if t["pnl"] < 0]
|
||||
|
||||
gross_profit = sum(t["pnl"] for t in wins)
|
||||
gross_loss = abs(sum(t["pnl"] for t in losses))
|
||||
pf = round(gross_profit / gross_loss, 2) if gross_loss > 0 else 9999.0
|
||||
|
||||
peak_eq = 0.0; mdd = 0.0; run_pnl = 0.0
|
||||
for t in trades:
|
||||
run_pnl += t["pnl"]
|
||||
peak_eq = max(peak_eq, run_pnl)
|
||||
mdd = max(mdd, peak_eq - run_pnl)
|
||||
|
||||
# Buy & Hold 비교 — 전구간·시뮬시작 동일 구간 모두 종가 기준 (추세BT와 동일 축)
|
||||
total_pnl = sum(t["pnl"] for t in trades)
|
||||
bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
|
||||
bnh_pct = bnh_pnl = bnh_aligned_pct = bnh_aligned_pnl = 0.0
|
||||
alpha_pct = alpha_aligned_pct = 0.0
|
||||
if candles and slot_money > 0 and closes[0] > 0:
|
||||
last_c = float(candles[-1]["close"])
|
||||
bnh_pct = round((last_c - closes[0]) / closes[0] * 100, 2)
|
||||
bnh_pnl = round(slot_money * bnh_pct / 100)
|
||||
c0_al = closes[start_i] if start_i < len(closes) else closes[0]
|
||||
if c0_al > 0:
|
||||
bnh_aligned_pct = round((last_c - c0_al) / c0_al * 100, 2)
|
||||
bnh_aligned_pnl = round(slot_money * bnh_aligned_pct / 100)
|
||||
alpha_pct = round(bot_pct - bnh_pct, 2)
|
||||
alpha_aligned_pct = round(bot_pct - bnh_aligned_pct, 2)
|
||||
|
||||
reason_dist: Dict[str, int] = {}
|
||||
for t in trades:
|
||||
r = t["reason"].split("(")[0]
|
||||
reason_dist[r] = reason_dist.get(r, 0) + 1
|
||||
|
||||
avg_hold = round(sum(t["hold_days"] for t in trades) / total, 1) if total else 0
|
||||
|
||||
return {
|
||||
"candle_count": len(candles),
|
||||
"summary": {
|
||||
"total_trades": total,
|
||||
"win_rate": round(len(wins) / total * 100, 1) if total else 0,
|
||||
"total_pnl": round(total_pnl),
|
||||
"profit_factor": pf,
|
||||
"max_drawdown": round(mdd),
|
||||
"avg_hold_days": avg_hold,
|
||||
"reason_dist": reason_dist,
|
||||
"bnh_pct": bnh_pct,
|
||||
"bnh_pnl": bnh_pnl,
|
||||
"bot_pct": bot_pct,
|
||||
"alpha_pct": alpha_pct,
|
||||
"bnh_aligned_pct": bnh_aligned_pct,
|
||||
"bnh_aligned_pnl": bnh_aligned_pnl,
|
||||
"alpha_aligned_pct": alpha_aligned_pct,
|
||||
},
|
||||
"equity": equity[-200:],
|
||||
"trades": trades[-200:],
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 파라미터 Grid Search
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def run_param_search_v1(
|
||||
candles: List[Dict],
|
||||
grid: Optional[Dict] = None,
|
||||
min_trades: int = 2,
|
||||
base_cfg: Optional[Dict] = None,
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
V1 전략 파라미터 Grid Search.
|
||||
|
||||
base_cfg: 탐색 그리드에 없는 파라미터 기준값.
|
||||
웹 카드에서 전달받은 사용자 설정값 사용.
|
||||
"""
|
||||
from itertools import product as iproduct
|
||||
|
||||
if grid is None:
|
||||
grid = {
|
||||
# ── 진입 RSI 임계값 ──────────────────────────────────────────────
|
||||
"rsi_buy1": [60, 55, 50, 45],
|
||||
"rsi_buy2": [50, 45, 40, 35],
|
||||
"rsi_buy3": [40, 35, 30, 25],
|
||||
# ── 매도 RSI ────────────────────────────────────────────────────
|
||||
"rsi_sell": [70, 75, 80],
|
||||
# ── 익절/손절 ────────────────────────────────────────────────────
|
||||
"take_profit_pct": [10.0, 15.0, 20.0, 30.0],
|
||||
"stop_loss_pct": [7.0, 10.0, 15.0],
|
||||
# ── ATH 낙폭 필터 ─────────────────────────────────────────────
|
||||
"ath_drop_min_pct": [0.0, 20.0],
|
||||
}
|
||||
|
||||
keys = list(grid.keys())
|
||||
combos = list(iproduct(*[grid[k] for k in keys]))
|
||||
|
||||
_base = dict(DEFAULT_V1_CONFIG)
|
||||
if base_cfg:
|
||||
_base.update(base_cfg)
|
||||
|
||||
results = []
|
||||
for vals in combos:
|
||||
cfg = dict(_base)
|
||||
cfg.update(dict(zip(keys, vals)))
|
||||
# rsi_buy1 > rsi_buy2 > rsi_buy3 조건 보정
|
||||
if not (cfg["rsi_buy1"] > cfg["rsi_buy2"] > cfg["rsi_buy3"]):
|
||||
continue
|
||||
|
||||
res = run_backtest_v1(candles, cfg)
|
||||
if "error" in res:
|
||||
continue
|
||||
s = res.get("summary", {})
|
||||
if min_trades > 0 and s.get("total_trades", 0) < min_trades:
|
||||
continue
|
||||
results.append({
|
||||
"params": {k: cfg[k] for k in keys},
|
||||
"total_pnl": s["total_pnl"],
|
||||
"win_rate": s["win_rate"],
|
||||
"total_trades": s["total_trades"],
|
||||
"pf": s["profit_factor"],
|
||||
"avg_hold": s["avg_hold_days"],
|
||||
"mdd": s["max_drawdown"],
|
||||
})
|
||||
|
||||
results.sort(key=lambda x: x["total_pnl"], reverse=True)
|
||||
return results
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# CLI 진입점
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
year_ago = (datetime.now() - _td(days=365)).strftime("%Y-%m-%d")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="홀딩 V1 (RSI 분할매수) 백테스트 · 파라미터탐색"
|
||||
)
|
||||
parser.add_argument("--code", required=True, help="종목 코드 (예: 005930)")
|
||||
parser.add_argument("--start", default=year_ago, help="시작일 YYYY-MM-DD")
|
||||
parser.add_argument("--end", default=today, help="종료일 YYYY-MM-DD")
|
||||
parser.add_argument("--search", action="store_true", help="파라미터 탐색 모드")
|
||||
parser.add_argument("--min_trades", default=1, type=int, help="탐색 최소 거래 수 (0=제한 없음)")
|
||||
parser.add_argument("--top", default=20, type=int, help="탐색 결과 상위 N개")
|
||||
parser.add_argument("--rsi_buy1", default=None, type=float)
|
||||
parser.add_argument("--rsi_buy2", default=None, type=float)
|
||||
parser.add_argument("--rsi_buy3", default=None, type=float)
|
||||
parser.add_argument("--rsi_sell", default=None, type=float)
|
||||
parser.add_argument("--tp", default=None, type=float, dest="take_profit_pct")
|
||||
parser.add_argument("--sl", default=None, type=float, dest="stop_loss_pct")
|
||||
parser.add_argument("--ath_drop", default=None, type=float, dest="ath_drop_min_pct")
|
||||
args = parser.parse_args()
|
||||
|
||||
import logging as _lg
|
||||
_lg.getLogger("TradeDB").setLevel(_lg.WARNING)
|
||||
|
||||
db = TradeDB()
|
||||
ensure_holding_tables(db)
|
||||
candles = get_stored_candles(db, args.code, args.start, args.end)
|
||||
db.close()
|
||||
|
||||
if not candles:
|
||||
print(f"❌ {args.code} 캔들 없음 (holding_candles 테이블 확인)")
|
||||
return
|
||||
print(f"✅ {args.code} | {len(candles)}봉 ({candles[0]['candle_date']} ~ {candles[-1]['candle_date']})")
|
||||
|
||||
# CLI 파라미터 오버라이드
|
||||
override = {}
|
||||
for k in ["rsi_buy1", "rsi_buy2", "rsi_buy3", "rsi_sell",
|
||||
"take_profit_pct", "stop_loss_pct", "ath_drop_min_pct"]:
|
||||
v = getattr(args, k, None)
|
||||
if v is not None:
|
||||
override[k] = v
|
||||
|
||||
if args.search:
|
||||
print(f"\n🔍 V1 파라미터 탐색 중…")
|
||||
results = run_param_search_v1(candles, min_trades=args.min_trades,
|
||||
base_cfg=override or None)
|
||||
if not results:
|
||||
print("⚠️ 유효 결과 없음")
|
||||
return
|
||||
print(f"\n{'='*70}")
|
||||
print(f" 🏆 RSI 분할매수 V1 — TOP {min(args.top, len(results))}")
|
||||
print(f"{'='*70}")
|
||||
keys = list(results[0]["params"].keys())
|
||||
hdr = " ".join(f"{k:>14}" for k in keys)
|
||||
print(f"{hdr} | {'손익':>10} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}")
|
||||
print("-" * (len(hdr) + 50))
|
||||
for r in results[:args.top]:
|
||||
p = r["params"]
|
||||
row = " ".join(f"{p[k]:>14.4g}" for k in keys)
|
||||
print(f"{row} | {r['total_pnl']:>+10,.0f} {r['win_rate']:>5.1f}% "
|
||||
f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>5.1f}일")
|
||||
else:
|
||||
cfg = dict(DEFAULT_V1_CONFIG)
|
||||
cfg.update(override)
|
||||
res = run_backtest_v1(candles, cfg)
|
||||
if "error" in res:
|
||||
print(f"❌ {res['error']}")
|
||||
return
|
||||
s = res["summary"]
|
||||
print(f"""
|
||||
╔══════════════════════════════════════════════╗
|
||||
║ 📊 V1 RSI 분할매수 백테스트 결과 ║
|
||||
╠══════════════════════════════════════════════╣
|
||||
║ 총 거래 : {s['total_trades']:>5}건 ║
|
||||
║ 승률 : {s['win_rate']:>5.1f}% ║
|
||||
║ 순손익 : {s['total_pnl']:>+12,.0f} 원 ║
|
||||
║ PF : {s['profit_factor']:>5.2f} ║
|
||||
║ MDD : {s['max_drawdown']:>12,.0f} 원 ║
|
||||
║ 평균보유 : {s['avg_hold_days']:>5.1f}일 ║
|
||||
╠══════════════════════════════════════════════╣
|
||||
║ 봇 수익률 : {s['bot_pct']:>+8.2f}% ║
|
||||
║ B&H 수익률 : {s['bnh_pct']:>+8.2f}% ║
|
||||
║ 알파 : {round(s['bot_pct']-s['bnh_pct'],2):>+8.2f}%p ║
|
||||
╚══════════════════════════════════════════════╝""")
|
||||
for t in res["trades"][-10:]:
|
||||
print(f" {t['buy_date']} → {t['sell_date']} "
|
||||
f"평단:{t['avg_price']:,} 매도:{t['exit_price']:,} "
|
||||
f"수량:{t['qty']} 손익:{t['pnl']:+,} 단계:{t['stage']} {t['reason']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1953
legacy/kis_long_ver1.py
Normal file
1953
legacy/kis_long_ver1.py
Normal file
File diff suppressed because it is too large
Load Diff
1008
legacy/kis_long_ver2.py
Normal file
1008
legacy/kis_long_ver2.py
Normal file
File diff suppressed because it is too large
Load Diff
215
legacy/news_analyzer.py
Normal file
215
legacy/news_analyzer.py
Normal file
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
뉴스 AI 분석 모듈 (참고용 알림)
|
||||
- 네이버 금융 뉴스 크롤링
|
||||
- Claude/GPT API로 요약 및 관련 업종 추출
|
||||
- Mattermost로 참고용 알림만 전송 (실제 매매 판단 안 함!)
|
||||
"""
|
||||
import os
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from datetime import datetime
|
||||
import logging
|
||||
logger = logging.getLogger("NewsAnalyzer")
|
||||
|
||||
try:
|
||||
import anthropic
|
||||
ANTHROPIC_AVAILABLE = True
|
||||
except ImportError:
|
||||
ANTHROPIC_AVAILABLE = False
|
||||
logger.warning("⚠️ anthropic 미설치! pip install anthropic")
|
||||
|
||||
|
||||
class NewsAnalyzer:
|
||||
"""뉴스 AI 분석기"""
|
||||
|
||||
def __init__(self, api_key: str = None):
|
||||
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("⚠️ ANTHROPIC_API_KEY 없음 - 뉴스 분석 불가")
|
||||
self.client = None
|
||||
elif not ANTHROPIC_AVAILABLE:
|
||||
logger.error("❌ anthropic 라이브_러리 미설치!")
|
||||
self.client = None
|
||||
else:
|
||||
self.client = anthropic.Anthropic(api_key=self.api_key)
|
||||
logger.info("✅ Claude API 초기화 완료")
|
||||
|
||||
def crawl_naver_finance_news(self, max_news: int = 5):
|
||||
"""
|
||||
네이버 금융 주요 뉴스 크롤링
|
||||
|
||||
Returns:
|
||||
[{'title': '...', 'link': '...', 'date': '...'}, ...]
|
||||
"""
|
||||
try:
|
||||
url = "https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=258"
|
||||
headers = {'User-Agent': 'Mozilla/5.0'}
|
||||
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
news_items = soup.select('.newsList .articleSubject a')
|
||||
|
||||
news_list = []
|
||||
for item in news_items[:max_news]:
|
||||
title = item.get('title', '').strip()
|
||||
link = "https://finance.naver.com" + item.get('href', '')
|
||||
|
||||
if title:
|
||||
news_list.append({
|
||||
'title': title,
|
||||
'link': link,
|
||||
'date': datetime.now().strftime('%Y-%m-%d %H:%M')
|
||||
})
|
||||
|
||||
logger.info(f"📰 네이버 금융 뉴스 {len(news_list)}건 크롤링 완료")
|
||||
return news_list
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 뉴스 크롤링 실패: {e}")
|
||||
return []
|
||||
|
||||
def analyze_news_with_claude(self, news_list: list) -> dict:
|
||||
"""
|
||||
Claude API로 뉴스 분석
|
||||
|
||||
Args:
|
||||
news_list: [{'title': '...', 'link': '...', 'date': '...'}, ...]
|
||||
|
||||
Returns:
|
||||
{
|
||||
'summary': '오늘의 주요 이슈 요약',
|
||||
'sectors': ['반도체', 'AI', '자동차'],
|
||||
'sentiment': 'positive/neutral/negative',
|
||||
'recommended_stocks': [{'code': '005930', 'name': '삼성전자', 'reason': '...'}]
|
||||
}
|
||||
"""
|
||||
if not self.client or not news_list:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 뉴스 제목들을 하나로 합치기
|
||||
news_titles = "\n".join([f"- {item['title']}" for item in news_list])
|
||||
|
||||
prompt = f"""다음은 오늘의 주요 금융 뉴스 제목들입니다:
|
||||
|
||||
{news_titles}
|
||||
|
||||
이 뉴스들을 분석하여 다음 정보를 JSON 형식으로 제공해주세요:
|
||||
|
||||
1. summary: 오늘의 주요 이슈를 2-3문장으로 요약
|
||||
2. sectors: 관련 업종 리스트 (최대 3개, 예: ["반도체", "AI", "자동차"])
|
||||
3. sentiment: 전반적 시장 분위기 (positive/neutral/negative)
|
||||
4. recommended_stocks: 관련 주요 종목 (최대 3개)
|
||||
- code: 종목코드 (6자리)
|
||||
- name: 종목명
|
||||
- reason: 추천 이유 (한 줄)
|
||||
|
||||
반드시 유효한 JSON 형식으로만 응답하세요. 설명 없이 JSON만 출력하세요.
|
||||
"""
|
||||
|
||||
message = self.client.messages.create(
|
||||
model="claude-sonnet-4-5",
|
||||
max_tokens=1024,
|
||||
messages=[{"role": "user", "content": prompt}]
|
||||
)
|
||||
|
||||
# JSON 파싱
|
||||
import json
|
||||
result_text = message.content[0].text.strip()
|
||||
|
||||
# JSON 코드 블록 제거 (```json ... ``` 형태)
|
||||
if result_text.startswith('```'):
|
||||
result_text = result_text.split('```')[1]
|
||||
if result_text.startswith('json'):
|
||||
result_text = result_text[4:]
|
||||
result_text = result_text.strip()
|
||||
|
||||
result = json.loads(result_text)
|
||||
|
||||
logger.info(f"✅ Claude 분석 완료")
|
||||
logger.info(f" 요약: {result.get('summary', '')[:50]}...")
|
||||
logger.info(f" 업종: {', '.join(result.get('sectors', []))}")
|
||||
logger.info(f" 분위기: {result.get('sentiment', 'unknown')}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Claude 분석 실패: {e}")
|
||||
return None
|
||||
|
||||
def format_analysis_for_mattermost(self, analysis: dict, news_list: list) -> str:
|
||||
"""
|
||||
Mattermost 알림 메시지 포맷
|
||||
|
||||
Returns:
|
||||
마크다운 포맷 메시지
|
||||
"""
|
||||
if not analysis:
|
||||
return None
|
||||
|
||||
msg = "## 📰 AI 뉴스 분석 (참고용)\n\n"
|
||||
|
||||
# 요약
|
||||
msg += f"**📌 오늘의 이슈**\n{analysis.get('summary', '요약 없음')}\n\n"
|
||||
|
||||
# 관련 업종
|
||||
sectors = analysis.get('sectors', [])
|
||||
if sectors:
|
||||
msg += f"**🏢 관련 업종**\n"
|
||||
msg += ", ".join([f"`{s}`" for s in sectors]) + "\n\n"
|
||||
|
||||
# 시장 분위기
|
||||
sentiment = analysis.get('sentiment', 'neutral')
|
||||
sentiment_emoji = {
|
||||
'positive': '😊 긍정적',
|
||||
'neutral': '😐 중립',
|
||||
'negative': '😰 부정적'
|
||||
}
|
||||
msg += f"**💭 시장 분위기**: {sentiment_emoji.get(sentiment, '알 수 없음')}\n\n"
|
||||
|
||||
# 추천 종목
|
||||
stocks = analysis.get('recommended_stocks', [])
|
||||
if stocks:
|
||||
msg += f"**📊 관련 종목**\n"
|
||||
for stock in stocks:
|
||||
msg += f"- `{stock.get('code', '')}` {stock.get('name', '')}: {stock.get('reason', '')}\n"
|
||||
msg += "\n"
|
||||
|
||||
# 뉴스 링크
|
||||
if news_list:
|
||||
msg += f"**🔗 주요 뉴스**\n"
|
||||
for news in news_list[:3]:
|
||||
msg += f"- [{news['title']}]({news['link']})\n"
|
||||
msg += "\n"
|
||||
|
||||
# 경고 문구
|
||||
msg += "---\n"
|
||||
msg += "⚠️ **주의**: 이 분석은 참고용입니다. 최종 매수 판단은 ML 모델 + 기술적 지표로 이루어집니다.\n"
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
# 사용 예시
|
||||
if __name__ == "__main__":
|
||||
analyzer = NewsAnalyzer()
|
||||
|
||||
# 뉴스 크롤링
|
||||
news = analyzer.crawl_naver_finance_news(max_news=5)
|
||||
|
||||
if news:
|
||||
print("\n📰 크롤링된 뉴스:")
|
||||
for item in news:
|
||||
print(f" - {item['title']}")
|
||||
|
||||
# Claude 분석
|
||||
if analyzer.client:
|
||||
analysis = analyzer.analyze_news_with_claude(news)
|
||||
|
||||
if analysis:
|
||||
# Mattermost 메시지 생성
|
||||
message = analyzer.format_analysis_for_mattermost(analysis, news)
|
||||
print("\n" + message)
|
||||
Reference in New Issue
Block a user