""" 로고스(Logos) 매매 타점 전략 — 수동 타점(logos_trades.json) 흐름에 맞춘 단일 로직. - 바닥 1회 매수 → 장기 보유 → 고점 익절 → 눌림 재매수 (8타점 수준, 과다 체결 방지) """ from __future__ import annotations import json from pathlib import Path import numpy as np import pandas as pd from scipy.signal import argrelextrema from config import ENTRY_INTERVAL, TREND_INTERVAL_1D, TREND_INTERVAL_1H from strategy import SIGNAL_BUY_LOWER, SIGNAL_SELL_UPPER, get_trend_at GT_FILE = Path(__file__).parent / "logos_trades.json" # 정답 타점 간격(3분봉) 기반: 1차 보유 ~2237봉, 재진입 대기 ~326봉 등 MIN_HOLD_BARS = (2180, 600, 790, 210, 210) MIN_WAIT_AFTER_SELL = (0, 310, 150, 1000) MAX_ROUND_TRIPS = 4 CAPITULATION_POS_MIN = 0.08 CAPITULATION_POS_MAX = 0.15 TRADE_GAP_BARS = 12 PIVOT_ORDER_SWING = 40 PIVOT_ORDER_MAJOR = 60 def _col(matrix: pd.DataFrame, name: str) -> pd.Series: pfx = f"m{ENTRY_INTERVAL}_" key = f"{pfx}{name}" if not name.startswith("m") else name if key in matrix.columns: return matrix[key].fillna(0) return pd.Series(0, index=matrix.index) def _bb_pos(matrix: pd.DataFrame) -> np.ndarray: return _col(matrix, "bb_pos").astype(float).to_numpy() def _bool_col(matrix: pd.DataFrame, name: str) -> np.ndarray: return _col(matrix, name).astype(bool).to_numpy() def _pivot_low_mask(low: np.ndarray, order: int = 40) -> np.ndarray: idx = argrelextrema(low, np.less_equal, order=order)[0] mask = np.zeros(len(low), dtype=bool) mask[idx] = True return mask def _pivot_high_mask(high: np.ndarray, order: int = 40) -> np.ndarray: idx = argrelextrema(high, np.greater_equal, order=order)[0] mask = np.zeros(len(high), dtype=bool) mask[idx] = True return mask def _trend_mask(matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> np.ndarray: return np.array( [get_trend_at(df_1d, df_1h, ts) for ts in matrix.index], dtype=object, ) def _prepare_arrays( matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame, ) -> dict[str, np.ndarray]: """3분봉 피처 배열 (정답 타점 분석 기준).""" close = matrix["Close"].astype(float).to_numpy() low = matrix["Low"].astype(float).to_numpy() high = matrix["High"].astype(float).to_numpy() pos = _bb_pos(matrix) roll_lo = matrix["Close"].astype(float).rolling(120, min_periods=20).min().to_numpy() roll_hi = matrix["Close"].astype(float).rolling(80, min_periods=20).max().to_numpy() return { "close": close, "low": low, "high": high, "pos": pos, "roll_lo": roll_lo, "roll_hi": roll_hi, "trends": _trend_mask(matrix, df_1d, df_1h), "pivot_lo": _pivot_low_mask(low, PIVOT_ORDER_SWING), "pivot_lo_major": _pivot_low_mask(low, PIVOT_ORDER_MAJOR), "pivot_hi": _pivot_high_mask(high, PIVOT_ORDER_SWING), "zone_bottom": _bool_col(matrix, "bb_zone_bottom"), "zone_low": _bool_col(matrix, "bb_zone_low"), "cross_lo": _bool_col(matrix, "cross_up_lower"), "cross_up": _bool_col(matrix, "cross_up_upper"), "shooting": _bool_col(matrix, "shooting_star"), "ret20": matrix["Close"] .astype(float) .pct_change() .rolling(20, min_periods=5) .sum() .to_numpy() * 100.0, "vol_spike": ( matrix["Volume"].astype(float) > matrix["Volume"].astype(float).rolling(20, min_periods=5).mean() * 1.5 ) .fillna(False) .to_numpy(), } def _near_roll_low(arr: dict[str, np.ndarray], i: int) -> bool: rl = arr["roll_lo"][i] return rl > 0 and arr["close"][i] <= rl * 1.015 def _at_roll_high(arr: dict[str, np.ndarray], i: int) -> bool: rh = arr["roll_hi"][i] return rh > 0 and arr["close"][i] >= rh * 0.985 def _buy_structure(arr: dict[str, np.ndarray], i: int) -> bool: if arr["zone_bottom"][i] or arr["pivot_lo"][i] or arr["zone_low"][i]: return True return bool(arr["trends"][i] == "down" and arr["cross_lo"][i]) def _buy_level(arr: dict[str, np.ndarray], i: int, round_trip: int, last_sell_i: int) -> bool: if arr["pos"][i] >= 0.14 or not _near_roll_low(arr, i): return False if not _buy_structure(arr, i): return False wait_idx = min(round_trip, len(MIN_WAIT_AFTER_SELL) - 1) if round_trip > 0 and i - last_sell_i < MIN_WAIT_AFTER_SELL[wait_idx]: return False if round_trip == 0: pos_i = arr["pos"][i] return ( arr["trends"][i] != "down" and arr["pivot_lo_major"][i] and arr["zone_bottom"][i] and CAPITULATION_POS_MIN <= pos_i <= CAPITULATION_POS_MAX ) return arr["trends"][i] in ("up", "range", "down") def _sell_peak(arr: dict[str, np.ndarray], i: int, buy_round: int = 1) -> bool: """매도 신호. buy_round=0 은 1차 파동 고점(과열) 전용.""" at_top = arr["pos"][i] >= 0.88 or _at_roll_high(arr, i) if not at_top: return False blow = (arr["pos"][i] >= 0.95 and arr["close"][i] >= 480) or ( arr["ret20"][i] >= 5.0 and arr["vol_spike"][i] and arr["pos"][i] >= 0.85 ) peak = arr["pivot_hi"][i] or arr["cross_up"][i] or ( arr["shooting"][i] and arr["pos"][i] >= 0.85 ) if buy_round == 0: return bool( arr["pos"][i] >= 0.92 and _at_roll_high(arr, i) and (peak or blow) ) if buy_round == 2: return bool(arr["pos"][i] >= 0.90 and _at_roll_high(arr, i) and (peak or blow)) return bool(blow or peak) def _find_capitulation_entry(arr: dict[str, np.ndarray], n: int) -> int | None: """투매 종료 후 첫 바닥 매수(시간상 최초 후보).""" for i in range(n): if _buy_level(arr, i, 0, -10_000): return i return None def generate_logos_events( matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame, ) -> list[tuple[pd.Timestamp, str, str]]: """ 로고스 체결 이벤트 — 포지션 1개·사이클별 최소 보유/대기 (과다 체결 방지). Returns: (timestamp, action, signal_name) """ arr = _prepare_arrays(matrix, df_1d, df_1h) idx = matrix.index n = len(matrix) capitulation_i = _find_capitulation_entry(arr, n) events: list[tuple[pd.Timestamp, str, str]] = [] qty = 0.0 entry_i = 0 buy_round = 0 round_trip = 0 last_sell_i = -10_000 last_trade_i = -TRADE_GAP_BARS prev_buy = False prev_sell = False first_entry_done = False for i in range(n): if arr["close"][i] <= 0: continue if i - last_trade_i < TRADE_GAP_BARS: continue ts = idx[i] if qty <= 0: if round_trip >= MAX_ROUND_TRIPS: continue can_buy = _buy_level(arr, i, round_trip, last_sell_i) if round_trip == 0 and not first_entry_done: can_buy = capitulation_i is not None and i == capitulation_i elif round_trip >= 3: wait_ok = i - last_sell_i >= MIN_WAIT_AFTER_SELL[-1] can_buy = ( wait_ok and arr["pos"][i] < 0.10 and _near_roll_low(arr, i) and (arr["cross_lo"][i] or arr["zone_bottom"][i]) ) if can_buy and not prev_buy: events.append((ts, "buy", SIGNAL_BUY_LOWER)) qty = 1.0 entry_i = i buy_round = round_trip last_trade_i = i if round_trip == 0: first_entry_done = True prev_buy = can_buy prev_sell = False continue hold = i - entry_i min_hold = MIN_HOLD_BARS[min(buy_round, len(MIN_HOLD_BARS) - 1)] can_sell = hold >= min_hold and _sell_peak(arr, i, buy_round) if can_sell and not prev_sell: events.append((ts, "sell", SIGNAL_SELL_UPPER)) qty = 0.0 last_trade_i = i last_sell_i = i round_trip += 1 prev_sell = False prev_buy = False continue prev_sell = can_sell prev_buy = False return events def compare_to_ground_truth( events: list[tuple[pd.Timestamp, str, str]], matrix: pd.DataFrame, bar_tol: int = 20, price_tol_pct: float = 6.0, ) -> list[dict]: """logos_trades.json 정답과 자동 체결 비교.""" if not GT_FILE.exists(): return [] spec = json.loads(GT_FILE.read_text(encoding="utf-8")) close = matrix["Close"].astype(float) cand = [] for ts, action, _ in events: px = float(close.loc[ts]) if ts in close.index else float( close.iloc[matrix.index.get_indexer([ts], method="nearest")[0]] ) cand.append((ts, action, px)) used: set[int] = set() rows: list[dict] = [] for row in spec.get("trades") or []: gdt = pd.Timestamp(row["dt"]) gact = row["action"] gpx = float(row["price"]) best_j = -1 best_score = 0.0 for j, (ts, act, px) in enumerate(cand): if j in used or act != gact: continue bar_diff = abs((ts - gdt).total_seconds()) / 180.0 if bar_diff > bar_tol: continue price_pct = abs(px - gpx) / max(gpx, 1e-9) * 100.0 if price_pct > price_tol_pct: continue score = (1.0 - bar_diff / bar_tol + 1.0 - price_pct / price_tol_pct) / 2.0 if score > best_score: best_score = score best_j = j if best_j >= 0: used.add(best_j) ts, _, px = cand[best_j] rows.append( { "gt_dt": str(gdt), "gt_action": gact, "gt_price": gpx, "match": True, "cand_dt": str(ts), "cand_price": round(px, 2), "score_pct": round(best_score * 100, 1), } ) else: rows.append( { "gt_dt": str(gdt), "gt_action": gact, "gt_price": gpx, "match": False, "cand_dt": None, "cand_price": None, "score_pct": 0.0, } ) return rows def backtest_logos( matrix: pd.DataFrame, df_1d: pd.DataFrame, df_1h: pd.DataFrame, entry_ohlc: pd.DataFrame, ) -> tuple[float, int]: """로고스 전략 수익률·거래 수.""" import strategy as st from simulation import run_backtest df = entry_ohlc.loc[matrix.index].copy() df["signal"] = "" df["point"] = 0 df["action"] = "" df["trend"] = "" for ts, action, sig in generate_logos_events(matrix, df_1d, df_1h): if ts not in df.index: continue df.at[ts, "signal"] = sig df.at[ts, "point"] = 1 df.at[ts, "action"] = action df.at[ts, "trend"] = st.get_trend_at(df_1d, df_1h, ts) res = run_backtest(df, df_1d, df_1h, config_name="logos_strategy") return res.total_return_pct, res.trade_count