로고스 전략 FSM을 simulation 기본 실행에 통합한다.
수동 타점(logos_trades.json) 흐름에 맞춘 순차 매매 로직을 추가하고, python simulation.py 실행 시 로고스 백테스트·HTML을 생성한다. 규칙 탐색·BB 안전장치 개선과 함께 reports HTML은 gitignore로 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
349
logos_strategy.py
Normal file
349
logos_strategy.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
로고스(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
|
||||
Reference in New Issue
Block a user