수동 타점(logos_trades.json) 흐름에 맞춘 순차 매매 로직을 추가하고, python simulation.py 실행 시 로고스 백테스트·HTML을 생성한다. 규칙 탐색·BB 안전장치 개선과 함께 reports HTML은 gitignore로 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
350 lines
11 KiB
Python
350 lines
11 KiB
Python
"""
|
|
로고스(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
|