로고스 전략 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:
2026-05-29 19:07:10 +09:00
parent e218a8ea32
commit e631a5701f
12 changed files with 1639 additions and 100 deletions

349
logos_strategy.py Normal file
View 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