diff --git a/.env.example b/.env.example
deleted file mode 100644
index 0659791..0000000
--- a/.env.example
+++ /dev/null
@@ -1,36 +0,0 @@
-# 빗썸 API
-BITHUMB_ACCESS_KEY=
-BITHUMB_SECRET_KEY=
-
-# 텔레그램
-COIN_TELEGRAM_BOT_TOKEN=
-COIN_TELEGRAM_CHAT_ID=
-
-# 쿨다운(초) — 3분 전략 (빈번 체결 완화)
-BUY_COOLDOWN_SEC=1800
-SELL_COOLDOWN_SEC=900
-SIGNAL_EDGE_ONLY=true
-TRADE_MIN_GAP_BARS=5
-DISCOVER_MAX_TRADES=120
-DISCOVER_TRADE_PENALTY_PCT=0.03
-SELL_MIN_BB_POS=0.4
-BUY_MAX_BB_POS_CHASE=0.55
-
-# 매수 금액(KRW)
-DEFAULT_BUY_KRW=30000
-RANGE_BUY_KRW=15000
-BREAKOUT_BUY_KRW=25000
-
-# BB: 폭이 BB_MIN_WIDTH_PCT(%) 미만이면 매매 중단
-BB_MIN_WIDTH_PCT=0.8
-
-# downloader.py — coins.db 적재 개월 수
-DOWNLOAD_MONTHS=6
-DOWNLOAD_MONTHS_1M=2
-USE_DISCOVERED_LIVE=true
-
-# simulation.py
-SIM_INITIAL_CASH_KRW=200000
-SIM_MIN_ORDER_KRW=5000
-TRADING_FEE_RATE=0.0005
-RSI_BUY_MAX=42
diff --git a/.gitignore b/.gitignore
index f80fd74..6cbe8b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,8 +86,12 @@ celerybeat-schedule
# dotenv
.env
-# 백테스트·시뮬레이션 HTML (로컬 재생성)
-reports/
+# docs 산출물 (로컬 재생성). reference/ 가이드는 Git 추적
+docs/02_ground_truth/
+docs/03_analysis/
+docs/04_matching/
+docs/05_ops/
+docs/charts/
# virtualenv
.venv
diff --git a/README.md b/README.md
index ea09da2..0f789d8 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,86 @@
-# DeepCoin — WLD 전봉 BB·일목 조합 매매
+# DeepCoin — WLD MTF 분석·정답·운영
-빗썸 KRW-WLD 현물. **1, 3, 5, 10, 15, 30, 60, 240, 1440분** 모든 봉에서
-볼린저 밴드·일목균형표 **캔들 위치**를 분석하고, 봉 조합으로 매수·매도 규칙을 탐색합니다.
+빗썸 KRW-WLD. **1, 3, 5, 10, 15, 30, 60, 240, 1440분** 봉을 적재하고,
+Ground Truth·기술적 분석·(예정) 규칙 매칭·1분 모니터까지 **단계별 폴더**로 관리합니다.
-## 구조
+## 로드맵
+
+| 단계 | 목적 | 실행 |
+|------|------|------|
+| 01 데이터 | 1년치 봉 적재 | `python scripts/01_download.py` |
+| 02 Ground Truth | 매수·매도 정답 타점 | `python scripts/02_ground_truth.py` |
+| 03 분석 | 8TF 기술 지표 enrich | `python scripts/03_analyze_enrich.py` |
+| 03b 분석 | GT 타점 MTF 스냅샷 | `python scripts/03_analyze_trades.py` |
+| 04 매칭 | GT 근접 규칙 선택 (예정) | `python scripts/04_match_rules.py` |
+| 05 운영 | 차트·1분 모니터 | `scripts/05_chart_*.py`, `05_run_monitor.py` |
+
+상세: [docs/reference/ROADMAP.md](docs/reference/ROADMAP.md)
+
+## 디렉터리 구조
```text
-downloader.py → coins.db (전 간격 증분)
-indicators.py → BB·일목 계산
-candle_features.py → 봉별 위치 특징 → 3분 타임라인 행렬
-combination_analyzer.py → 조합 분석·combination_report.json
-rule_discovery.py → discovered_rules.json
-strategy.py → 실시간 evaluate_discovered_live
-monitor_coin.py → 실거래 루프
-simulation.py → 백테스트·HTML 차트
+DeepCoin/
+├── .env, config.py
+├── scripts/ # ★ 단계별 CLI (유일한 진입점)
+├── deepcoin/
+│ ├── api/bithumb.py # 빗썸 API
+│ ├── data/ # 01 다운로드
+│ ├── ground_truth/ # 02 정답 타점
+│ ├── analysis/ # 03·03b 지표·스냅샷
+│ ├── matching/ # 04 규칙 매칭 (예정)
+│ └── ops/ # 05 모니터·차트
+├── data/ # coins.db, ground_truth/, ops/
+└── docs/
+ ├── reference/ # 가이드·기법 명세 (Git)
+ └── 02~05, charts/ # 단계별 HTML·CSV (재생성)
```
-## 봉별 분석 항목
+상세: [docs/reference/STRUCTURE.md](docs/reference/STRUCTURE.md) · [docs/README.md](docs/README.md)
-### 볼린저
-- 이벤트: `cross_up_lower`, `cross_up_upper`, `inside_band`, `squeeze` …
-- 구간: `bb_zone_bottom` ~ `bb_zone_top` (%B)
+## 환경 변수
-### 일목균형표
-- `ichi_above_cloud`, `ichi_below_cloud`, `ichi_in_cloud`
-- `ichi_tk_bull` / `ichi_tk_cross_up`, `ichi_cloud_bull` …
+| 파일 | 용도 |
+|------|------|
+| `.env` | 전역 설정·API 키 (Git 제외, 프로젝트 루트에 필수) |
-### 조합
-- 3분 기준 `merge_asof`로 모든 봉 특징을 한 행에 정렬
-- `discover`가 AND/OR 조합으로 매수·매도 규칙 탐색
-
-## 실행 순서
+`config.py`와 `scripts/_bootstrap.py`가 프로젝트 루트 `.env`를 `python-dotenv`로 자동 로드합니다. 새 환경에서는 팀에서 `.env`를 전달받거나 기존 로컬 파일을 복사하세요.
```bash
-cp .env.example .env
-python downloader.py # 1분봉 2개월, 나머지 6개월
-python simulation.py # analyze → discover → HTML (탐색 매수·매도 규칙 표시)
-python monitor_coin.py # 실거래
+pip install -r requirements.txt
```
-HTML 차트에는 `discovered_rules.json` 에서 찾은 **매수·매도 규칙**의 체결만 표시합니다.
-고급: `analyze`, `discover`, `compare`, `mtf`.
+## 빠른 시작
-## 설정 (`config.py`)
+```bash
+python scripts/01_download.py
+python scripts/02_ground_truth.py
+python scripts/03_analyze_enrich.py
+python scripts/03_analyze_trades.py
+python scripts/05_chart_truth.py
+```
+
+## 주요 설정 (`config.py` / `.env`)
| 항목 | 설명 |
|------|------|
-| `ALL_INTERVALS` | 1,3,5,10,15,30,60,240,1440 |
-| `ENTRY_INTERVAL` | 조합 행렬 기준 3분 |
-| `DOWNLOAD_MONTHS_1M` | 1분봉 보관 개월 (기본 2) |
-| `USE_DISCOVERED_LIVE` | 실거래에 discovered_rules 사용 |
+| `BITHUMB_ACCESS_KEY` | 빗썸 API (다운로드·시세) |
+| `DB_PATH` | `data/coins.db` (`.env`로 변경 가능) |
+| `GROUND_TRUTH_FILE` | `data/ground_truth/ground_truth_trades.json` |
+| `CHART_LOOKBACK_DAYS` | 기본 365일 |
+| `DOWNLOAD_MONTHS` | 3분 이상 봉 12개월 |
+| `MONITOR_LOOP_SLEEP_SEC` | 05 모니터 루프 주기(초) |
-## 출력 파일
+## 산출물
-| 파일 | 내용 |
+| 경로 | 내용 |
|------|------|
-| `combination_report.json` | 봉별 최신 위치·매수/매도 힌트 |
-| `discovered_rules.json` | 탐색된 매매 규칙 |
-| `reports/wld_bb_simulation.html` | 시뮬 차트 |
+| `data/coins.db` | 전 간격 OHLCV |
+| `data/ground_truth/ground_truth_trades.json` | 정답 타점 |
+| `docs/charts/wld_bb_chart.html` | 3분 BB 차트 |
+| `docs/02_ground_truth/wld_ground_truth_chart.html` | 정답 차트 |
+| `docs/03_analysis/latest/*_latest.csv` | 간격별 최근 봉 전 기법 |
+| `docs/03_analysis/general_analysis_trades.csv` | GT 타점 MTF 스냅샷 |
## 면책
-실거래 손실 책임은 사용자에게 있습니다.
+실거래는 사용자 책임입니다. 본 저장소는 주문 실행을 포함하지 않습니다.
diff --git a/coins_buy_time.json b/coins_buy_time.json
deleted file mode 100644
index 0967ef4..0000000
--- a/coins_buy_time.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/combination_analyzer.py b/combination_analyzer.py
deleted file mode 100644
index 2af7478..0000000
--- a/combination_analyzer.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""
-모든 봉의 BB·일목 위치를 조합해 매수/매도 후보 규칙을 분석합니다.
-
- python simulation.py analyze
-"""
-
-from __future__ import annotations
-
-import json
-from dataclasses import asdict, dataclass, field
-from pathlib import Path
-
-import numpy as np
-import pandas as pd
-
-from candle_features import (
- FEATURE_BOOL_COLS,
- INTERVAL_LABELS,
- build_master_feature_matrix,
- describe_latest_position,
- interval_prefix,
-)
-from config import ALL_INTERVALS, ENTRY_INTERVAL, SYMBOL
-
-REPORT_FILE = Path(__file__).parent / "combination_report.json"
-
-
-@dataclass
-class CombinationReport:
- """봉 조합 분석 결과."""
-
- generated_at: str
- intervals_loaded: list[int]
- latest_positions: list[dict]
- buy_recommendations: list[str] = field(default_factory=list)
- sell_recommendations: list[str] = field(default_factory=list)
- buy_avoid: list[str] = field(default_factory=list)
- top_buy_pairs: list[dict] = field(default_factory=list)
- top_sell_pairs: list[dict] = field(default_factory=list)
- suggested_rules: dict = field(default_factory=dict)
-
-
-def _forward_return(close: pd.Series, bars: int = 20) -> pd.Series:
- """N봉 후 수익률 (%)."""
- future = close.shift(-bars)
- return (future - close) / close.replace(0, np.nan) * 100
-
-
-def _predicate_keys(intervals: list[int]) -> list[str]:
- keys: list[str] = []
- for iv in intervals:
- pfx = interval_prefix(iv)
- for feat in FEATURE_BOOL_COLS:
- keys.append(f"{pfx}:{feat}")
- return keys
-
-
-def analyze_forward_edge(
- matrix: pd.DataFrame,
- forward_bars: int = 20,
- min_samples: int = 40,
-) -> tuple[list[dict], list[dict], list[dict]]:
- """
- 단일 조건·2봉 조합별 미래 수익 통계 (학습용 힌트).
-
- Returns:
- (매수 유리 top, 매도/회피 top)
- """
- close = matrix["Close"].astype(float)
- fwd = _forward_return(close, forward_bars)
- valid = fwd.notna()
- base_mean = float(fwd[valid].mean()) if valid.any() else 0.0
-
- singles: list[dict] = []
- cols = [c for c in matrix.columns if any(c.startswith(f"{interval_prefix(iv)}_") for iv in ALL_INTERVALS)]
-
- for col in cols:
- mask = matrix[col].fillna(0).astype(bool) & valid
- n = int(mask.sum())
- if n < min_samples:
- continue
- avg = float(fwd[mask].mean())
- singles.append(
- {
- "key": _col_to_key(col),
- "column": col,
- "count": n,
- "avg_forward_pct": round(avg, 4),
- "edge_vs_base": round(avg - base_mean, 4),
- }
- )
-
- singles.sort(key=lambda x: x["edge_vs_base"], reverse=True)
- buy_top = [s for s in singles if s["edge_vs_base"] > 0][:25]
- sell_top = sorted(singles, key=lambda x: x["edge_vs_base"])[:15]
-
- pairs: list[dict] = []
- buy_cols = [s["column"] for s in buy_top[:12]]
- for i, c1 in enumerate(buy_cols):
- for c2 in buy_cols[i + 1 :]:
- if c1.split("_")[0] == c2.split("_")[0]:
- continue
- mask = (
- matrix[c1].fillna(0).astype(bool)
- & matrix[c2].fillna(0).astype(bool)
- & valid
- )
- n = int(mask.sum())
- if n < min_samples // 2:
- continue
- avg = float(fwd[mask].mean())
- pairs.append(
- {
- "keys": [_col_to_key(c1), _col_to_key(c2)],
- "count": n,
- "avg_forward_pct": round(avg, 4),
- "edge_vs_base": round(avg - base_mean, 4),
- }
- )
- pairs.sort(key=lambda x: x["edge_vs_base"], reverse=True)
-
- return buy_top, pairs[:20], sell_top
-
-
-def _col_to_key(col: str) -> str:
- """m3_cross_up_lower -> m3:cross_up_lower."""
- for pfx in INTERVAL_LABELS.values():
- if col.startswith(f"{pfx}_"):
- return f"{pfx}:{col[len(pfx) + 1:]}"
- return col
-
-
-def build_recommendations(
- buy_top: list[dict],
- pair_top: list[dict],
- sell_top: list[dict],
-) -> tuple[list[str], list[str], list[str], dict]:
- """사람이 읽을 수 있는 권장·규칙 초안."""
- buy_rec: list[str] = []
- sell_rec: list[str] = []
- avoid: list[str] = []
-
- for s in buy_top[:8]:
- buy_rec.append(
- f"{s['key']} — {s['count']}회, {s['avg_forward_pct']:+.2f}% ({s['edge_vs_base']:+.2f}%p)"
- )
- for p in pair_top[:5]:
- buy_rec.append(
- f"조합 {' + '.join(p['keys'])} — {p['count']}회, {p['edge_vs_base']:+.2f}%p"
- )
- for s in sell_top[:6]:
- if s["edge_vs_base"] < -0.05:
- avoid.append(f"매수 회피: {s['key']} ({s['edge_vs_base']:.2f}%p)")
-
- for s in sell_top[:5]:
- if "cross_up_upper" in s["key"] or "above_upper" in s["key"] or "ichi_above" in s["key"]:
- sell_rec.append(f"매도 후보: {s['key']}")
-
- suggested: dict = {"buy_all": [], "buy_any": [], "sell_all": [], "sell_stop": []}
- if pair_top:
- suggested["buy_all"] = pair_top[0]["keys"]
- elif buy_top:
- suggested["buy_all"] = [buy_top[0]["key"]]
- if sell_top:
- for s in sell_top:
- if "cross_up_upper" in s.get("key", ""):
- suggested["sell_all"] = [s["key"]]
- break
-
- return buy_rec, sell_rec, avoid, suggested
-
-
-def analyze_combinations(frames: dict[int, pd.DataFrame]) -> CombinationReport:
- """전체 봉 BB·일목 위치 분석 + 조합 매매 힌트."""
- from datetime import datetime
-
- loaded = sorted(frames.keys())
- latest = [describe_latest_position(frames[iv], iv) for iv in ALL_INTERVALS if iv in frames]
-
- print("\n=== 봉별 최신 BB·일목 위치 ===")
- for p in latest:
- print(
- f" {p['label']:>6} | BB {p['bb_zone']:>6} ({p['bb_pos']:.2f}) {p['bb_state']:>16} | "
- f"일목 {p['ichi_position']:>12} TK={p['ichi_tk']} 구름={p['ichi_cloud']}"
- )
-
- matrix = build_master_feature_matrix(frames).iloc[52:].copy()
- print(f"\n특징 행렬: {len(matrix)}행 × {len(matrix.columns)}열")
-
- buy_top, pair_top, sell_top = analyze_forward_edge(matrix)
- buy_rec, sell_rec, avoid, suggested = build_recommendations(buy_top, pair_top, sell_top)
-
- print("\n=== 매수 유리 조건 (단일·상위) ===")
- for line in buy_rec[:10]:
- print(f" {line}")
- print("\n=== 매수 회피 / 매도 참고 ===")
- for line in avoid[:6]:
- print(f" {line}")
- for line in sell_rec[:5]:
- print(f" {line}")
-
- return CombinationReport(
- generated_at=datetime.now().isoformat(timespec="seconds"),
- intervals_loaded=loaded,
- latest_positions=latest,
- buy_recommendations=buy_rec,
- sell_recommendations=sell_rec,
- buy_avoid=avoid,
- top_buy_pairs=pair_top,
- suggested_rules=suggested,
- )
-
-
-def save_report(report: CombinationReport, path: Path = REPORT_FILE) -> None:
- path.write_text(json.dumps(asdict(report), ensure_ascii=False, indent=2), encoding="utf-8")
- print(f"\n저장: {path}")
-
-
-def load_frames(monitor) -> dict[int, pd.DataFrame]:
- from mtf_bb import load_frames_from_db
-
- return load_frames_from_db(monitor, SYMBOL)
diff --git a/combination_report.json b/combination_report.json
deleted file mode 100644
index d34df49..0000000
--- a/combination_report.json
+++ /dev/null
@@ -1,331 +0,0 @@
-{
- "generated_at": "2026-05-29T13:55:17",
- "intervals_loaded": [
- 1,
- 3,
- 5,
- 10,
- 15,
- 30,
- 60,
- 240,
- 1440
- ],
- "latest_positions": [
- {
- "interval": 1,
- "label": "1분",
- "close": 425.0,
- "bb_pos": 0.352,
- "bb_zone": "mid",
- "bb_state": "squeeze",
- "ichi_position": "in_cloud",
- "ichi_tk": "bear",
- "ichi_cloud": "bull"
- },
- {
- "interval": 3,
- "label": "3분",
- "close": 425.0,
- "bb_pos": 0.638,
- "bb_zone": "mid",
- "bb_state": "inside_band",
- "ichi_position": "above_cloud",
- "ichi_tk": "bull",
- "ichi_cloud": "bull"
- },
- {
- "interval": 5,
- "label": "5분",
- "close": 425.0,
- "bb_pos": 0.706,
- "bb_zone": "high",
- "bb_state": "inside_band",
- "ichi_position": "above_cloud",
- "ichi_tk": "bull",
- "ichi_cloud": "bear"
- },
- {
- "interval": 10,
- "label": "10분",
- "close": 425.0,
- "bb_pos": 0.744,
- "bb_zone": "high",
- "bb_state": "inside_band",
- "ichi_position": "in_cloud",
- "ichi_tk": "bear",
- "ichi_cloud": "bear"
- },
- {
- "interval": 15,
- "label": "15분",
- "close": 425.0,
- "bb_pos": 0.526,
- "bb_zone": "mid",
- "bb_state": "inside_band",
- "ichi_position": "above_cloud",
- "ichi_tk": "bear",
- "ichi_cloud": "bear"
- },
- {
- "interval": 30,
- "label": "30분",
- "close": 425.0,
- "bb_pos": 0.448,
- "bb_zone": "mid",
- "bb_state": "inside_band",
- "ichi_position": "above_cloud",
- "ichi_tk": "bear",
- "ichi_cloud": "bear"
- },
- {
- "interval": 60,
- "label": "60분",
- "close": 425.0,
- "bb_pos": 0.613,
- "bb_zone": "mid",
- "bb_state": "inside_band",
- "ichi_position": "below_cloud",
- "ichi_tk": "bull",
- "ichi_cloud": "bear"
- },
- {
- "interval": 240,
- "label": "240분",
- "close": 425.0,
- "bb_pos": 0.317,
- "bb_zone": "low",
- "bb_state": "inside_band",
- "ichi_position": "below_cloud",
- "ichi_tk": "bear",
- "ichi_cloud": "bear"
- },
- {
- "interval": 1440,
- "label": "일봉",
- "close": 425.0,
- "bb_pos": 0.417,
- "bb_zone": "mid",
- "bb_state": "inside_band",
- "ichi_position": "below_cloud",
- "ichi_tk": "bull",
- "ichi_cloud": "bull"
- }
- ],
- "buy_recommendations": [
- "m30:bullish — 410회, +1.08% (+1.04%p)",
- "m60:above_upper — 480회, +1.00% (+0.96%p)",
- "m60:cross_up_upper — 340회, +0.89% (+0.86%p)",
- "m60:bullish — 960회, +0.76% (+0.72%p)",
- "m240:hammer — 1588회, +0.70% (+0.66%p)",
- "m240:cross_up_upper — 160회, +0.66% (+0.62%p)",
- "m60:bb_zone_top — 1377회, +0.66% (+0.62%p)",
- "m30:ichi_tk_cross_down — 148회, +0.62% (+0.59%p)",
- "조합 m30:bullish + m60:above_upper — 50회, +2.48%p",
- "조합 m60:above_upper + m30:body_strong — 50회, +2.44%p",
- "조합 m30:bullish + d1:bb_zone_high — 130회, +2.39%p",
- "조합 m30:bullish + m60:cross_up_upper — 30회, +2.26%p",
- "조합 d1:bb_zone_high + m30:body_strong — 100회, +2.13%p"
- ],
- "sell_recommendations": [],
- "buy_avoid": [
- "매수 회피: m240:cross_down_lower (-0.93%p)",
- "매수 회피: m240:below_lower (-0.93%p)",
- "매수 회피: m10:body_ratio (-0.63%p)",
- "매수 회피: m15:cross_up_lower (-0.58%p)",
- "매수 회피: m10:bullish (-0.54%p)",
- "매수 회피: m240:cross_up_lower (-0.53%p)"
- ],
- "top_buy_pairs": [
- {
- "keys": [
- "m30:bullish",
- "m60:above_upper"
- ],
- "count": 50,
- "avg_forward_pct": 2.5214,
- "edge_vs_base": 2.4838
- },
- {
- "keys": [
- "m60:above_upper",
- "m30:body_strong"
- ],
- "count": 50,
- "avg_forward_pct": 2.4777,
- "edge_vs_base": 2.4401
- },
- {
- "keys": [
- "m30:bullish",
- "d1:bb_zone_high"
- ],
- "count": 130,
- "avg_forward_pct": 2.4265,
- "edge_vs_base": 2.3889
- },
- {
- "keys": [
- "m30:bullish",
- "m60:cross_up_upper"
- ],
- "count": 30,
- "avg_forward_pct": 2.2987,
- "edge_vs_base": 2.2611
- },
- {
- "keys": [
- "d1:bb_zone_high",
- "m30:body_strong"
- ],
- "count": 100,
- "avg_forward_pct": 2.1672,
- "edge_vs_base": 2.1296
- },
- {
- "keys": [
- "m60:bb_zone_top",
- "m30:body_strong"
- ],
- "count": 80,
- "avg_forward_pct": 2.0742,
- "edge_vs_base": 2.0366
- },
- {
- "keys": [
- "m240:hammer",
- "m30:body_strong"
- ],
- "count": 139,
- "avg_forward_pct": 1.9833,
- "edge_vs_base": 1.9457
- },
- {
- "keys": [
- "m30:bullish",
- "m240:hammer"
- ],
- "count": 190,
- "avg_forward_pct": 1.932,
- "edge_vs_base": 1.8944
- },
- {
- "keys": [
- "m30:bullish",
- "m60:bb_zone_top"
- ],
- "count": 140,
- "avg_forward_pct": 1.924,
- "edge_vs_base": 1.8864
- },
- {
- "keys": [
- "m30:bullish",
- "m60:bb_pos_high"
- ],
- "count": 140,
- "avg_forward_pct": 1.924,
- "edge_vs_base": 1.8864
- },
- {
- "keys": [
- "m30:body_strong",
- "m60:bb_pos_high"
- ],
- "count": 90,
- "avg_forward_pct": 1.8966,
- "edge_vs_base": 1.859
- },
- {
- "keys": [
- "m60:bullish",
- "m240:cross_up_upper"
- ],
- "count": 20,
- "avg_forward_pct": 1.8701,
- "edge_vs_base": 1.8324
- },
- {
- "keys": [
- "m60:cross_up_upper",
- "m30:body_strong"
- ],
- "count": 20,
- "avg_forward_pct": 1.731,
- "edge_vs_base": 1.6934
- },
- {
- "keys": [
- "m60:cross_up_upper",
- "m240:hammer"
- ],
- "count": 180,
- "avg_forward_pct": 1.5455,
- "edge_vs_base": 1.5079
- },
- {
- "keys": [
- "m60:above_upper",
- "m240:hammer"
- ],
- "count": 300,
- "avg_forward_pct": 1.4981,
- "edge_vs_base": 1.4605
- },
- {
- "keys": [
- "m30:bullish",
- "m60:hammer"
- ],
- "count": 210,
- "avg_forward_pct": 1.4713,
- "edge_vs_base": 1.4337
- },
- {
- "keys": [
- "m60:bullish",
- "m240:hammer"
- ],
- "count": 420,
- "avg_forward_pct": 1.3974,
- "edge_vs_base": 1.3598
- },
- {
- "keys": [
- "m240:hammer",
- "m60:bb_zone_top"
- ],
- "count": 480,
- "avg_forward_pct": 1.3812,
- "edge_vs_base": 1.3436
- },
- {
- "keys": [
- "m60:cross_up_upper",
- "d1:bb_zone_high"
- ],
- "count": 180,
- "avg_forward_pct": 1.3333,
- "edge_vs_base": 1.2956
- },
- {
- "keys": [
- "m60:bullish",
- "m30:body_strong"
- ],
- "count": 90,
- "avg_forward_pct": 1.3228,
- "edge_vs_base": 1.2852
- }
- ],
- "top_sell_pairs": [],
- "suggested_rules": {
- "buy_all": [
- "m30:bullish",
- "m60:above_upper"
- ],
- "buy_any": [],
- "sell_all": [],
- "sell_stop": []
- }
-}
\ No newline at end of file
diff --git a/config.py b/config.py
index cb36156..302a023 100644
--- a/config.py
+++ b/config.py
@@ -1,92 +1,264 @@
"""
-전역 설정 (WLD 월드코인, 3분 BB MTF 전략).
+전역 설정 (WLD). 값은 PROJECT_ROOT/.env → OS 환경 변수 순으로 읽습니다.
"""
+from __future__ import annotations
+
import os
-try:
- from dotenv import load_dotenv
+from deepcoin.env_loader import load_project_env
+
+load_project_env()
+
+
+def _getenv(key: str, default: str = "") -> str:
+ return os.getenv(key, default)
+
+
+def _getenv_int(key: str, default: str) -> int:
+ return int(_getenv(key, default))
+
+
+def _getenv_float(key: str, default: str) -> float:
+ return float(_getenv(key, default))
+
+
+def _parse_int_tuple(env_key: str, default: str) -> tuple[int, ...]:
+ raw = _getenv(env_key, default)
+ return tuple(int(x.strip()) for x in raw.split(",") if x.strip())
+
+
+def _parse_int_set(env_key: str, default: str) -> frozenset[int]:
+ return frozenset(_parse_int_tuple(env_key, default))
+
+
+def _parse_interval_map(env_key: str, default: str) -> dict[int, int]:
+ """
+ '3:120,5:100' → {3: 120, 5: 100}.
+ """
+ raw = _getenv(env_key, default)
+ out: dict[int, int] = {}
+ for part in raw.split(","):
+ part = part.strip()
+ if ":" not in part:
+ continue
+ k, v = part.split(":", 1)
+ out[int(k.strip())] = int(v.strip())
+ return out
+
+
+def _parse_str_map(env_key: str, default: str) -> dict[int, str]:
+ """'3:m3,1440:d1' → {3: 'm3', 1440: 'd1'}."""
+ raw = _getenv(env_key, default)
+ out: dict[int, str] = {}
+ for part in raw.split(","):
+ part = part.strip()
+ if ":" not in part:
+ continue
+ k, v = part.split(":", 1)
+ out[int(k.strip())] = v.strip()
+ return out
- load_dotenv()
-except ImportError:
- pass
# --- API / 알림 ---
-COIN_TELEGRAM_BOT_TOKEN = os.getenv("COIN_TELEGRAM_BOT_TOKEN", "")
-COIN_TELEGRAM_CHAT_ID = os.getenv("COIN_TELEGRAM_CHAT_ID", "")
+BITHUMB_ACCESS_KEY = _getenv("BITHUMB_ACCESS_KEY")
+BITHUMB_SECRET_KEY = _getenv("BITHUMB_SECRET_KEY")
+BITHUMB_API_URL = _getenv("BITHUMB_API_URL", "https://api.bithumb.com")
+BITHUMB_API_CANDLE_COUNT = _getenv_int("BITHUMB_API_CANDLE_COUNT", "200")
+BITHUMB_MINUTE_INTERVALS = _parse_int_set(
+ "BITHUMB_MINUTE_INTERVALS", "1,3,5,10,15,30,60,240"
+)
+HTS_API_RETRY_SLEEP_SEC = _getenv_float("HTS_API_RETRY_SLEEP_SEC", "0.5")
+
+COIN_TELEGRAM_BOT_TOKEN = _getenv("COIN_TELEGRAM_BOT_TOKEN")
+COIN_TELEGRAM_CHAT_ID = _getenv("COIN_TELEGRAM_CHAT_ID")
# --- 거래 대상 ---
-SYMBOL = "WLD"
-COIN_NAME = "월드코인"
-
-KR_COINS: dict[str, str] = {
- SYMBOL: COIN_NAME,
-}
+SYMBOL = _getenv("SYMBOL", "WLD")
+COIN_NAME = _getenv("COIN_NAME", "월드코인")
+KR_COINS: dict[str, str] = {SYMBOL: COIN_NAME}
# --- 타임프레임 (분) ---
-TREND_INTERVAL_1H = 60
-TREND_INTERVAL_1D = 1440
+DAILY_INTERVAL_MIN = _getenv_int("DAILY_INTERVAL_MIN", "1440")
+ENTRY_INTERVAL = _getenv_int("ENTRY_INTERVAL", "3")
+TREND_INTERVAL_1H = _getenv_int("TREND_INTERVAL_1H", "60")
+TREND_INTERVAL_1D = _getenv_int("TREND_INTERVAL_1D", "1440")
-# --- 쿨다운(초) — 3분봉: 기본 30분/15분 (빈번 체결 완화) ---
-BUY_COOLDOWN_SEC = int(os.getenv("BUY_COOLDOWN_SEC", "1800"))
-SELL_COOLDOWN_SEC = int(os.getenv("SELL_COOLDOWN_SEC", "900"))
-BUY_MINUTE_LIMIT = BUY_COOLDOWN_SEC
+ALL_INTERVALS: tuple[int, ...] = _parse_int_tuple(
+ "ALL_INTERVALS", "1,3,5,10,15,30,60,240,1440"
+)
+DOWNLOAD_INTERVALS: tuple[int, ...] = _parse_int_tuple(
+ "DOWNLOAD_INTERVALS",
+ ",".join(str(x) for x in ALL_INTERVALS),
+)
+GENERAL_ANALYSIS_INTERVALS: tuple[int, ...] = _parse_int_tuple(
+ "GENERAL_ANALYSIS_INTERVALS", "3,5,10,15,30,60,240,1440"
+)
+TIMING_INTERVALS: tuple[int, ...] = _parse_int_tuple(
+ "TIMING_INTERVALS", "3,5,10,15"
+)
+TREND_INTERVALS: tuple[int, ...] = _parse_int_tuple(
+ "TREND_INTERVALS", "60,240,1440"
+)
-# 매수·매도 신호는 조건이 False→True로 바뀐 봉에서만 (연속 참 방지)
-SIGNAL_EDGE_ONLY = os.getenv("SIGNAL_EDGE_ONLY", "true").lower() in ("1", "true", "yes")
+INTERVAL_PREFIX: dict[int, str] = _parse_str_map(
+ "INTERVAL_PREFIX",
+ "1:m1,3:m3,5:m5,10:m10,15:m15,30:m30,60:m60,240:m240,1440:d1",
+)
-# 체결(매수·매도 공통) 후 최소 대기 봉 수 (3분봉 5봉 = 15분)
-TRADE_MIN_GAP_BARS = int(os.getenv("TRADE_MIN_GAP_BARS", "5"))
+# --- 볼린저 / RSI ---
+BB_PERIOD = _getenv_int("BB_PERIOD", "20")
+BB_STD = _getenv_float("BB_STD", "2")
+BB_MIN_WIDTH_PCT = _getenv_float("BB_MIN_WIDTH_PCT", "0.8")
+RSI_PERIOD = _getenv_int("RSI_PERIOD", "14")
-# 규칙 탐색 시 거래 횟수 패널티 (학습 구간)
-DISCOVER_MAX_TRADES = int(os.getenv("DISCOVER_MAX_TRADES", "120"))
-DISCOVER_TRADE_PENALTY_PCT = float(os.getenv("DISCOVER_TRADE_PENALTY_PCT", "0.03"))
+# --- 이격도 ---
+DISPARITY_PERIODS: tuple[int, ...] = _parse_int_tuple("DISPARITY_PERIODS", "5,20,60")
+DISPARITY_OVERBOUGHT = _getenv_float("DISPARITY_OVERBOUGHT", "105")
+DISPARITY_OVERSOLD = _getenv_float("DISPARITY_OVERSOLD", "95")
-# 3분 BB 위치: 이 값 미만에서 상단돌파 매도 차단 (저점 익절 방지)
-SELL_MIN_BB_POS = float(os.getenv("SELL_MIN_BB_POS", "0.4"))
+# --- MACD / Stochastic ---
+MACD_FAST = _getenv_int("MACD_FAST", "12")
+MACD_SLOW = _getenv_int("MACD_SLOW", "26")
+MACD_SIGNAL = _getenv_int("MACD_SIGNAL", "9")
+STOCH_K_PERIOD = _getenv_int("STOCH_K_PERIOD", "14")
+STOCH_D_PERIOD = _getenv_int("STOCH_D_PERIOD", "3")
+STOCH_SMOOTH_K = _getenv_int("STOCH_SMOOTH_K", "3")
+STOCH_OVERSOLD = _getenv_float("STOCH_OVERSOLD", "20")
+STOCH_OVERBOUGHT = _getenv_float("STOCH_OVERBOUGHT", "80")
-# 3분 BB 위치: 이 값 이상이면 단독 상단구간 매수 차단 (고점 추격 방지)
-BUY_MAX_BB_POS_CHASE = float(os.getenv("BUY_MAX_BB_POS_CHASE", "0.55"))
+# --- 추세 ---
+TREND_RANGE_MA_GAP_PCT = _getenv_float("TREND_RANGE_MA_GAP_PCT", "0.5")
-# --- 볼린저 (3분봉, 20, 2σ) ---
-BB_PERIOD = 20
-BB_STD = 2
-BB_MIN_WIDTH_PCT = float(os.getenv("BB_MIN_WIDTH_PCT", "0.8"))
+# --- MTF 합성·정렬 ---
+ALIGN_RSI_OVERSOLD = _getenv_float("ALIGN_RSI_OVERSOLD", "35")
+ALIGN_RSI_OVERBOUGHT = _getenv_float("ALIGN_RSI_OVERBOUGHT", "65")
+ALIGN_RSI_CONFLICT_TIMING_LOW = _getenv_float("ALIGN_RSI_CONFLICT_TIMING_LOW", "40")
+ALIGN_RSI_CONFLICT_TIMING_HIGH = _getenv_float("ALIGN_RSI_CONFLICT_TIMING_HIGH", "65")
+ALIGN_RSI_CONFLICT_TREND_LOW = _getenv_float("ALIGN_RSI_CONFLICT_TREND_LOW", "40")
+ALIGN_RSI_CONFLICT_TREND_HIGH = _getenv_float("ALIGN_RSI_CONFLICT_TREND_HIGH", "65")
+ALIGN_BB_POS_LOW = _getenv_float("ALIGN_BB_POS_LOW", "0.2")
+ALIGN_BB_POS_HIGH = _getenv_float("ALIGN_BB_POS_HIGH", "0.8")
-# --- RSI / 거래량 (조합 필터) ---
-RSI_PERIOD = 14
-RSI_BUY_MAX = float(os.getenv("RSI_BUY_MAX", "42"))
-VOLUME_BUY_RATIO = float(os.getenv("VOLUME_BUY_RATIO", "1.0"))
+# --- 다운로드 / DB ---
+DOWNLOAD_MONTHS = _getenv_int("DOWNLOAD_MONTHS", "12")
+DOWNLOAD_MONTHS_1M = _getenv_int("DOWNLOAD_MONTHS_1M", "6")
+INCREMENTAL_OVERLAP_BARS = _getenv_int("INCREMENTAL_OVERLAP_BARS", "3")
+DOWNLOAD_BACKFILL_EXTRA_BARS = _getenv_int("DOWNLOAD_BACKFILL_EXTRA_BARS", "200")
+DOWNLOAD_MIN_INCREMENTAL_BARS = _getenv_int("DOWNLOAD_MIN_INCREMENTAL_BARS", "50")
+DOWNLOAD_DAILY_EXTRA_DAYS = _getenv_int("DOWNLOAD_DAILY_EXTRA_DAYS", "20")
+DB_READ_LIMIT_DEFAULT = _getenv_int("DB_READ_LIMIT_DEFAULT", "7000")
+DB_ROW_WARMUP_BARS = _getenv_int("DB_ROW_WARMUP_BARS", "200")
+DB_ROW_MIN_DAILY_BARS = _getenv_int("DB_ROW_MIN_DAILY_BARS", "100")
+DB_ROW_DAILY_PADDING_DAYS = _getenv_int("DB_ROW_DAILY_PADDING_DAYS", "30")
-# --- 추세 / 레짐 ---
-TREND_RANGE_MA_GAP_PCT = 0.5
-# --- 주문 ---
-DEFAULT_BUY_KRW = int(os.getenv("DEFAULT_BUY_KRW", "30000"))
-RANGE_BUY_KRW = int(os.getenv("RANGE_BUY_KRW", "15000"))
+def _paths():
+ from deepcoin.paths import (
+ ANALYSIS_CAPABILITY_HTML,
+ ANALYSIS_LATEST_DIR,
+ ANALYSIS_REPORT_HTML,
+ ANALYSIS_TRADES_CSV,
+ resolve_db_path,
+ resolve_ground_truth_file,
+ )
-# --- 수수료 (매수·매도 각각 적용, 시뮬레이션) ---
-TRADING_FEE_RATE = float(os.getenv("TRADING_FEE_RATE", "0.0005"))
+ return (
+ resolve_db_path(),
+ resolve_ground_truth_file(),
+ ANALYSIS_TRADES_CSV,
+ ANALYSIS_REPORT_HTML,
+ ANALYSIS_CAPABILITY_HTML,
+ ANALYSIS_LATEST_DIR,
+ )
-# --- coins.db (downloader.py 적재 간격, 분) ---
-# 빗썸 분봉 API: 1,3,5,10,15,30,60,240 / 일봉 1440
-ALL_INTERVALS: tuple[int, ...] = (1, 3, 5, 10, 15, 30, 60, 240, 1440)
-DOWNLOAD_INTERVALS: tuple[int, ...] = ALL_INTERVALS
-DOWNLOAD_MONTHS = int(os.getenv("DOWNLOAD_MONTHS", "6"))
-# 1분봉은 용량·API 부담으로 기본 2개월 (환경변수로 조정)
-DOWNLOAD_MONTHS_1M = int(os.getenv("DOWNLOAD_MONTHS_1M", "2"))
-DB_PATH = "coins.db"
-# 규칙 탐색·조합 분석 기준 타임라인
-ENTRY_INTERVAL = 3
+_db, _gt, _a_csv, _a_html, _a_cap, _a_latest = _paths()
+DB_PATH = _getenv("DB_PATH", str(_db))
+GROUND_TRUTH_PATH = _gt
+REPORTS_ANALYSIS_TRADES_CSV = _a_csv
+REPORTS_ANALYSIS_REPORT_HTML = _a_html
+REPORTS_ANALYSIS_CAPABILITY_HTML = _a_cap
+REPORTS_ANALYSIS_LATEST_DIR = _a_latest
+GROUND_TRUTH_FILE = _getenv("GROUND_TRUTH_FILE", str(_gt))
-# 실시간: discovered_rules + 전 봉 BB·일목 조합 (False면 mtf_bb_policy)
-USE_DISCOVERED_LIVE = os.getenv("USE_DISCOVERED_LIVE", "true").lower() in ("1", "true", "yes")
+# --- 차트 ---
+CHART_LOOKBACK_DAYS = _getenv_int("CHART_LOOKBACK_DAYS", "365")
+GT_UNLIMITED_CHRONOLOGICAL_DAYS = _getenv_int("GT_UNLIMITED_CHRONOLOGICAL_DAYS", "300")
-# --- 시뮬레이션 ---
-SIM_INITIAL_CASH_KRW = int(os.getenv("SIM_INITIAL_CASH_KRW", "200000"))
-SIM_MIN_ORDER_KRW = int(os.getenv("SIM_MIN_ORDER_KRW", "5000"))
+# --- Ground Truth ---
+GT_MIN_SWING_PCT = _getenv_float("GT_MIN_SWING_PCT", "4.0")
+GT_PIVOT_ORDER = _getenv_int("GT_PIVOT_ORDER", "20")
+GT_MIN_BARS_BETWEEN = _getenv_int("GT_MIN_BARS_BETWEEN", "30")
+GT_MAX_ROUND_TRIPS = _getenv_int("GT_MAX_ROUND_TRIPS", "24")
+GT_SELECTION_MODE = _getenv("GT_SELECTION_MODE", "split_buy_peak_sell")
+GT_MIN_LEG_PCT = _getenv_float("GT_MIN_LEG_PCT", "8.0")
+GT_BUY_MIN_SWING_PCT = _getenv_float("GT_BUY_MIN_SWING_PCT", "3.0")
+GT_BUY_BB_MAX = _getenv_float("GT_BUY_BB_MAX", "0.45")
+GT_BUY_MIN_BARS = _getenv_int("GT_BUY_MIN_BARS", "24")
+GT_MAX_BUYS_PER_LEG = _getenv_int("GT_MAX_BUYS_PER_LEG", "12")
+GT_MAX_SELLS_PER_LEG = _getenv_int("GT_MAX_SELLS_PER_LEG", "2")
+GT_SELL_SPLIT_GAP_PCT = _getenv_float("GT_SELL_SPLIT_GAP_PCT", "2.5")
+GT_MARKER_SIZE_MIN = _getenv_int("GT_MARKER_SIZE_MIN", "10")
+GT_MARKER_SIZE_MAX = _getenv_int("GT_MARKER_SIZE_MAX", "32")
+GT_INITIAL_CASH_KRW = _getenv_int("GT_INITIAL_CASH_KRW", "1000000")
+TRADING_FEE_RATE = _getenv_float("TRADING_FEE_RATE", "0.0005")
-# --- 실행 ---
-MONITOR_LOOP_SLEEP_SEC = 10
-COOLDOWN_FILE = "coins_buy_time.json"
+# --- 모니터 / API 수집 ---
+MONITOR_LOOP_SLEEP_SEC = _getenv_int("MONITOR_LOOP_SLEEP_SEC", "10")
+MONITOR_POOL_WORKERS = _getenv_int("MONITOR_POOL_WORKERS", "12")
+MONITOR_DEFAULT_INTERVAL = _getenv_int("MONITOR_DEFAULT_INTERVAL", "60")
+MONITOR_API_RETRIES = _getenv_int("MONITOR_API_RETRIES", "3")
+MONITOR_API_BONG_COUNT = _getenv_int("MONITOR_API_BONG_COUNT", "3000")
+MONITOR_SLEEP_AFTER_REQUEST_SEC = _getenv_float("MONITOR_SLEEP_AFTER_REQUEST_SEC", "0.5")
+MONITOR_SLEEP_RATE_LIMIT_SEC = _getenv_float("MONITOR_SLEEP_RATE_LIMIT_SEC", "5")
+MONITOR_SLEEP_BETWEEN_CHUNKS_SEC = _getenv_float("MONITOR_SLEEP_BETWEEN_CHUNKS_SEC", "0.3")
+MONITOR_API_CHUNK_BARS = _getenv_int("MONITOR_API_CHUNK_BARS", "200")
+MONITOR_MA_WINDOWS: tuple[int, ...] = _parse_int_tuple(
+ "MONITOR_MA_WINDOWS", "5,20,40,120,200,240,720,1440"
+)
+MONITOR_NORM_WINDOW = _getenv_int("MONITOR_NORM_WINDOW", "20")
+MONITOR_TELEGRAM_BATCH_SIZE = _getenv_int("MONITOR_TELEGRAM_BATCH_SIZE", "20")
+
+# --- general_analysis ---
+GA_COL_PREFIX = _getenv("GA_COL_PREFIX", "ga_")
+LOOKBACK_BARS: dict[int, int] = _parse_interval_map(
+ "LOOKBACK_BARS",
+ "3:120,5:100,10:80,15:60,30:50,60:40,240:30,1440:60",
+)
+CONTEXT_TAIL_ROWS: dict[int, int] = _parse_interval_map(
+ "CONTEXT_TAIL_ROWS",
+ "3:6000,5:5000,10:4000,15:3000,30:2000,60:1500,240:800,1440:500",
+)
+GA_DEFAULT_TAIL_EXPORT = _getenv_int("GA_DEFAULT_TAIL_EXPORT", "200")
+GA_PATTERN_TOLERANCE_PCT = _getenv_float("GA_PATTERN_TOLERANCE_PCT", "2.5")
+GA_VP_BINS = _getenv_int("GA_VP_BINS", "30")
+GA_VP_VALUE_AREA_PCT = _getenv_float("GA_VP_VALUE_AREA_PCT", "0.70")
+GA_HV_ROLLING_BARS = _getenv_int("GA_HV_ROLLING_BARS", "20")
+GA_HV_PERCENTILE_WINDOW = _getenv_int("GA_HV_PERCENTILE_WINDOW", "120")
+GA_HV_ANNUALIZE_SQRT = _getenv_float("GA_HV_ANNUALIZE_SQRT", "339.41148133")
+GA_DIVERGENCE_LOOKBACK = _getenv_int("GA_DIVERGENCE_LOOKBACK", "10")
+GA_SMA_PERIODS: tuple[int, ...] = _parse_int_tuple("GA_SMA_PERIODS", "5,20,60,120")
+GA_EMA_SPANS: tuple[int, ...] = _parse_int_tuple("GA_EMA_SPANS", "12,26")
+GA_ATR_PERIOD = _getenv_int("GA_ATR_PERIOD", "14")
+GA_KELTNER_ATR_MULT = _getenv_float("GA_KELTNER_ATR_MULT", "2")
+GA_AO_FAST = _getenv_int("GA_AO_FAST", "5")
+GA_AO_SLOW = _getenv_int("GA_AO_SLOW", "34")
+GA_LINREG_WINDOW = _getenv_int("GA_LINREG_WINDOW", "20")
+GA_ADX_PERIOD = _getenv_int("GA_ADX_PERIOD", "14")
+GA_ADX_TREND_THRESHOLD = _getenv_float("GA_ADX_TREND_THRESHOLD", "25")
+GA_SUPERTREND_ATR_MULT = _getenv_float("GA_SUPERTREND_ATR_MULT", "3")
+GA_VOL_SPIKE_MULT = _getenv_float("GA_VOL_SPIKE_MULT", "1.8")
+GA_VOL_MA_WINDOW = _getenv_int("GA_VOL_MA_WINDOW", "20")
+GA_CCI_PERIOD = _getenv_int("GA_CCI_PERIOD", "20")
+GA_WILLIAMS_PERIOD = _getenv_int("GA_WILLIAMS_PERIOD", "14")
+GA_ROC_PERIOD = _getenv_int("GA_ROC_PERIOD", "10")
+GA_MFI_PERIOD = _getenv_int("GA_MFI_PERIOD", "14")
+GA_CMF_PERIOD = _getenv_int("GA_CMF_PERIOD", "20")
+GA_DONCHIAN_PERIOD = _getenv_int("GA_DONCHIAN_PERIOD", "20")
+GA_BB_SQUEEZE_WINDOW = _getenv_int("GA_BB_SQUEEZE_WINDOW", "50")
+GA_BB_SQUEEZE_QUANTILE = _getenv_float("GA_BB_SQUEEZE_QUANTILE", "0.2")
+GA_PIVOT_ORDER = _getenv_int("GA_PIVOT_ORDER", "3")
+GA_PSAR_AF_START = _getenv_float("GA_PSAR_AF_START", "0.02")
+GA_PSAR_AF_STEP = _getenv_float("GA_PSAR_AF_STEP", "0.02")
+GA_PSAR_AF_MAX = _getenv_float("GA_PSAR_AF_MAX", "0.2")
diff --git a/data/ground_truth/ground_truth_trades.json b/data/ground_truth/ground_truth_trades.json
new file mode 100644
index 0000000..660634f
--- /dev/null
+++ b/data/ground_truth/ground_truth_trades.json
@@ -0,0 +1,5442 @@
+{
+ "name": "ground_truth_split_buy_peak_sell",
+ "method": "split_buy_at_troughs + peak_sell_1or2",
+ "symbol": "WLD",
+ "interval_min": 3,
+ "lookback_days": 365,
+ "period_start": "2025-06-04 03:57:00",
+ "period_end": "2026-05-30 21:31:00",
+ "trend_at_end": "range",
+ "params": {
+ "min_swing_pct": 4.0,
+ "pivot_order": 20,
+ "min_bars_between": 30,
+ "max_round_trips": 24,
+ "selection_mode": "split_buy_peak_sell",
+ "buy_min_swing_pct": 3.0,
+ "buy_bb_max": 0.45,
+ "max_sells_per_leg": 2
+ },
+ "summary": {
+ "pivot_candidates": 380,
+ "sell_peaks": 74,
+ "trade_count": 450,
+ "buy_count": 303,
+ "sell_count": 147,
+ "round_trips": 74,
+ "sum_sell_leg_return_pct": 1570.3,
+ "initial_cash_krw": 1000000,
+ "final_asset_krw": 1512765783.0,
+ "pnl_krw": 1511765783.0,
+ "pnl_pct": 151176.58,
+ "total_fees_krw": 16073557.0,
+ "cash_krw": -0.0,
+ "holding_qty": 3361701.741079,
+ "holding_value_krw": 1512765783.0,
+ "mark_price": 450.0,
+ "fee_rate": 0.0005
+ },
+ "note": "저점 분할 매수(삼각형 크기=비중), 고점 1~2회 매도. 사후 라벨·캘리브레이션용.",
+ "trades": [
+ {
+ "dt": "2025-06-06 06:12:00",
+ "action": "buy",
+ "price": 1421.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#0",
+ "weight": 1.0,
+ "leg_id": 0,
+ "bb_pos": 0.295,
+ "rsi": 35.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.05
+ },
+ {
+ "dt": "2025-06-07 19:57:00",
+ "action": "sell",
+ "price": 1578.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#0",
+ "weight": 0.65,
+ "leg_id": 0,
+ "bb_pos": 0.888,
+ "rsi": 72.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.05
+ },
+ {
+ "dt": "2025-06-07 21:42:00",
+ "action": "sell",
+ "price": 1575.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#0",
+ "weight": 0.35,
+ "leg_id": 0,
+ "bb_pos": 1.0,
+ "rsi": 61.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.84
+ },
+ {
+ "dt": "2025-06-08 19:27:00",
+ "action": "buy",
+ "price": 1509.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#1",
+ "weight": 0.076,
+ "leg_id": 1,
+ "bb_pos": 0.0,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -11.2
+ },
+ {
+ "dt": "2025-06-09 15:09:00",
+ "action": "buy",
+ "price": 1472.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#1",
+ "weight": 0.078,
+ "leg_id": 1,
+ "bb_pos": 0.159,
+ "rsi": 27.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -8.97
+ },
+ {
+ "dt": "2025-06-09 23:06:00",
+ "action": "buy",
+ "price": 1502.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#1",
+ "weight": 0.076,
+ "leg_id": 1,
+ "bb_pos": 0.132,
+ "rsi": 17.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": -10.79
+ },
+ {
+ "dt": "2025-06-10 15:12:00",
+ "action": "buy",
+ "price": 1536.0,
+ "memo": "저점 분할 매수 · 비중 7% · 12회 · BB하단 · leg#1",
+ "weight": 0.074,
+ "leg_id": 1,
+ "bb_pos": 0.339,
+ "rsi": 27.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -12.76
+ },
+ {
+ "dt": "2025-06-11 02:36:00",
+ "action": "buy",
+ "price": 1555.0,
+ "memo": "저점 분할 매수 · 비중 7% · 12회 · BB하단 · leg#1",
+ "weight": 0.074,
+ "leg_id": 1,
+ "bb_pos": 0.489,
+ "rsi": 58.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": -13.83
+ },
+ {
+ "dt": "2025-06-13 11:12:00",
+ "action": "buy",
+ "price": 1307.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.087,
+ "leg_id": 1,
+ "bb_pos": 0.011,
+ "rsi": 38.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.52
+ },
+ {
+ "dt": "2025-06-13 23:27:00",
+ "action": "buy",
+ "price": 1330.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.086,
+ "leg_id": 1,
+ "bb_pos": 0.183,
+ "rsi": 19.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 0.75
+ },
+ {
+ "dt": "2025-06-15 05:12:00",
+ "action": "buy",
+ "price": 1328.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.086,
+ "leg_id": 1,
+ "bb_pos": 0.0,
+ "rsi": 11.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 0.9
+ },
+ {
+ "dt": "2025-06-16 06:12:00",
+ "action": "buy",
+ "price": 1325.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.086,
+ "leg_id": 1,
+ "bb_pos": 0.175,
+ "rsi": 37.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 1.13
+ },
+ {
+ "dt": "2025-06-17 09:09:00",
+ "action": "buy",
+ "price": 1274.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.09,
+ "leg_id": 1,
+ "bb_pos": 0.0,
+ "rsi": 6.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.18
+ },
+ {
+ "dt": "2025-06-18 02:12:00",
+ "action": "buy",
+ "price": 1242.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.092,
+ "leg_id": 1,
+ "bb_pos": 0.289,
+ "rsi": 36.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.89
+ },
+ {
+ "dt": "2025-06-18 22:24:00",
+ "action": "buy",
+ "price": 1208.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#1",
+ "weight": 0.095,
+ "leg_id": 1,
+ "bb_pos": 0.0,
+ "rsi": 18.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.93
+ },
+ {
+ "dt": "2025-06-19 09:42:00",
+ "action": "sell",
+ "price": 1340.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#1",
+ "weight": 0.65,
+ "leg_id": 1,
+ "bb_pos": 0.95,
+ "rsi": 64.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": -2.35
+ },
+ {
+ "dt": "2025-06-19 10:24:00",
+ "action": "sell",
+ "price": 1317.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#1",
+ "weight": 0.35,
+ "leg_id": 1,
+ "bb_pos": 0.224,
+ "rsi": 18.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": -4.03
+ },
+ {
+ "dt": "2025-06-20 14:21:00",
+ "action": "buy",
+ "price": 1258.0,
+ "memo": "저점 분할 매수 · 비중 23% · 4회 · BB하단 · leg#2",
+ "weight": 0.234,
+ "leg_id": 2,
+ "bb_pos": 0.271,
+ "rsi": 36.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -5.8
+ },
+ {
+ "dt": "2025-06-20 23:54:00",
+ "action": "buy",
+ "price": 1251.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#2",
+ "weight": 0.235,
+ "leg_id": 2,
+ "bb_pos": 0.0,
+ "rsi": 28.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": -5.28
+ },
+ {
+ "dt": "2025-06-22 06:30:00",
+ "action": "buy",
+ "price": 1139.0,
+ "memo": "저점 분할 매수 · 비중 26% · 4회 · BB하단 · leg#2",
+ "weight": 0.259,
+ "leg_id": 2,
+ "bb_pos": 0.0,
+ "rsi": 27.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.04
+ },
+ {
+ "dt": "2025-06-23 05:18:00",
+ "action": "buy",
+ "price": 1083.0,
+ "memo": "저점 분할 매수 · 비중 27% · 4회 · BB하단 · leg#2",
+ "weight": 0.272,
+ "leg_id": 2,
+ "bb_pos": 0.0,
+ "rsi": 34.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.42
+ },
+ {
+ "dt": "2025-06-23 23:18:00",
+ "action": "sell",
+ "price": 1185.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#2",
+ "weight": 0.65,
+ "leg_id": 2,
+ "bb_pos": 0.784,
+ "rsi": 84.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 0.6
+ },
+ {
+ "dt": "2025-06-24 01:33:00",
+ "action": "buy",
+ "price": 1124.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#3",
+ "weight": 1.0,
+ "leg_id": 3,
+ "bb_pos": 0.081,
+ "rsi": 11.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.35
+ },
+ {
+ "dt": "2025-06-24 03:06:00",
+ "action": "sell",
+ "price": 1183.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#2",
+ "weight": 0.35,
+ "leg_id": 2,
+ "bb_pos": 0.8,
+ "rsi": 81.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 0.43
+ },
+ {
+ "dt": "2025-06-24 14:57:00",
+ "action": "sell",
+ "price": 1319.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#3",
+ "weight": 0.65,
+ "leg_id": 3,
+ "bb_pos": 0.817,
+ "rsi": 79.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 17.35
+ },
+ {
+ "dt": "2025-06-24 15:15:00",
+ "action": "sell",
+ "price": 1318.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#3",
+ "weight": 0.35,
+ "leg_id": 3,
+ "bb_pos": 0.719,
+ "rsi": 63.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 17.26
+ },
+ {
+ "dt": "2025-06-26 07:39:00",
+ "action": "buy",
+ "price": 1205.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#4",
+ "weight": 0.327,
+ "leg_id": 4,
+ "bb_pos": 0.001,
+ "rsi": 27.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.3
+ },
+ {
+ "dt": "2025-06-26 23:09:00",
+ "action": "buy",
+ "price": 1172.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#4",
+ "weight": 0.336,
+ "leg_id": 4,
+ "bb_pos": 0.172,
+ "rsi": 40.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.35
+ },
+ {
+ "dt": "2025-06-27 10:06:00",
+ "action": "buy",
+ "price": 1171.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#4",
+ "weight": 0.337,
+ "leg_id": 4,
+ "bb_pos": 0.097,
+ "rsi": 19.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.44
+ },
+ {
+ "dt": "2025-06-30 07:51:00",
+ "action": "sell",
+ "price": 1305.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#4",
+ "weight": 0.65,
+ "leg_id": 4,
+ "bb_pos": 0.83,
+ "rsi": 83.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.36
+ },
+ {
+ "dt": "2025-06-30 08:03:00",
+ "action": "sell",
+ "price": 1303.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#4",
+ "weight": 0.35,
+ "leg_id": 4,
+ "bb_pos": 0.724,
+ "rsi": 64.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.19
+ },
+ {
+ "dt": "2025-06-30 23:06:00",
+ "action": "buy",
+ "price": 1212.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#5",
+ "weight": 0.197,
+ "leg_id": 5,
+ "bb_pos": 0.092,
+ "rsi": 29.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 26.57
+ },
+ {
+ "dt": "2025-07-02 10:15:00",
+ "action": "buy",
+ "price": 1161.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#5",
+ "weight": 0.206,
+ "leg_id": 5,
+ "bb_pos": 0.258,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 32.13
+ },
+ {
+ "dt": "2025-07-03 02:45:00",
+ "action": "buy",
+ "price": 1274.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#5",
+ "weight": 0.188,
+ "leg_id": 5,
+ "bb_pos": 0.222,
+ "rsi": 38.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 20.41
+ },
+ {
+ "dt": "2025-07-06 03:48:00",
+ "action": "buy",
+ "price": 1174.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#5",
+ "weight": 0.204,
+ "leg_id": 5,
+ "bb_pos": 0.309,
+ "rsi": 28.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 30.66
+ },
+ {
+ "dt": "2025-07-09 00:24:00",
+ "action": "buy",
+ "price": 1169.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#5",
+ "weight": 0.205,
+ "leg_id": 5,
+ "bb_pos": 0.185,
+ "rsi": 21.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 31.22
+ },
+ {
+ "dt": "2025-07-11 15:06:00",
+ "action": "sell",
+ "price": 1534.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#5",
+ "weight": 0.65,
+ "leg_id": 5,
+ "bb_pos": 0.863,
+ "rsi": 57.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 28.2
+ },
+ {
+ "dt": "2025-07-11 15:15:00",
+ "action": "sell",
+ "price": 1527.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#5",
+ "weight": 0.35,
+ "leg_id": 5,
+ "bb_pos": 0.717,
+ "rsi": 58.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 27.61
+ },
+ {
+ "dt": "2025-07-12 07:30:00",
+ "action": "buy",
+ "price": 1365.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#6",
+ "weight": 0.336,
+ "leg_id": 6,
+ "bb_pos": 0.0,
+ "rsi": 4.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.21
+ },
+ {
+ "dt": "2025-07-13 00:42:00",
+ "action": "buy",
+ "price": 1355.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#6",
+ "weight": 0.338,
+ "leg_id": 6,
+ "bb_pos": 0.05,
+ "rsi": 28.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.03
+ },
+ {
+ "dt": "2025-07-14 07:27:00",
+ "action": "buy",
+ "price": 1409.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#6",
+ "weight": 0.326,
+ "leg_id": 6,
+ "bb_pos": 0.164,
+ "rsi": 39.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.74
+ },
+ {
+ "dt": "2025-07-14 14:12:00",
+ "action": "sell",
+ "price": 1518.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#6",
+ "weight": 0.65,
+ "leg_id": 6,
+ "bb_pos": 0.966,
+ "rsi": 77.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.32
+ },
+ {
+ "dt": "2025-07-14 14:33:00",
+ "action": "sell",
+ "price": 1513.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#6",
+ "weight": 0.35,
+ "leg_id": 6,
+ "bb_pos": 0.658,
+ "rsi": 57.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.96
+ },
+ {
+ "dt": "2025-07-15 05:15:00",
+ "action": "buy",
+ "price": 1398.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#7",
+ "weight": 0.2,
+ "leg_id": 7,
+ "bb_pos": 0.0,
+ "rsi": 25.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.3
+ },
+ {
+ "dt": "2025-07-15 12:18:00",
+ "action": "buy",
+ "price": 1368.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#7",
+ "weight": 0.204,
+ "leg_id": 7,
+ "bb_pos": 0.0,
+ "rsi": 18.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.74
+ },
+ {
+ "dt": "2025-07-15 23:54:00",
+ "action": "buy",
+ "price": 1365.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#7",
+ "weight": 0.205,
+ "leg_id": 7,
+ "bb_pos": 0.0,
+ "rsi": 10.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.99
+ },
+ {
+ "dt": "2025-07-16 04:45:00",
+ "action": "buy",
+ "price": 1416.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#7",
+ "weight": 0.197,
+ "leg_id": 7,
+ "bb_pos": 0.0,
+ "rsi": 15.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.89
+ },
+ {
+ "dt": "2025-07-16 11:39:00",
+ "action": "buy",
+ "price": 1439.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#7",
+ "weight": 0.194,
+ "leg_id": 7,
+ "bb_pos": 0.089,
+ "rsi": 12.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.13
+ },
+ {
+ "dt": "2025-07-17 06:27:00",
+ "action": "sell",
+ "price": 1556.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#7",
+ "weight": 0.65,
+ "leg_id": 7,
+ "bb_pos": 1.0,
+ "rsi": 61.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.41
+ },
+ {
+ "dt": "2025-07-17 06:48:00",
+ "action": "sell",
+ "price": 1547.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#7",
+ "weight": 0.35,
+ "leg_id": 7,
+ "bb_pos": 0.641,
+ "rsi": 61.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.77
+ },
+ {
+ "dt": "2025-07-17 12:39:00",
+ "action": "buy",
+ "price": 1484.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#8",
+ "weight": 0.493,
+ "leg_id": 8,
+ "bb_pos": 0.046,
+ "rsi": 20.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.18
+ },
+ {
+ "dt": "2025-07-18 05:03:00",
+ "action": "buy",
+ "price": 1444.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#8",
+ "weight": 0.507,
+ "leg_id": 8,
+ "bb_pos": 0.0,
+ "rsi": 8.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.23
+ },
+ {
+ "dt": "2025-07-18 14:42:00",
+ "action": "sell",
+ "price": 1635.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#8",
+ "weight": 0.65,
+ "leg_id": 8,
+ "bb_pos": 0.727,
+ "rsi": 72.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.7
+ },
+ {
+ "dt": "2025-07-18 15:51:00",
+ "action": "sell",
+ "price": 1632.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#8",
+ "weight": 0.35,
+ "leg_id": 8,
+ "bb_pos": 0.901,
+ "rsi": 67.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.5
+ },
+ {
+ "dt": "2025-07-18 20:21:00",
+ "action": "buy",
+ "price": 1560.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#9",
+ "weight": 0.245,
+ "leg_id": 9,
+ "bb_pos": 0.0,
+ "rsi": 36.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.6
+ },
+ {
+ "dt": "2025-07-19 05:39:00",
+ "action": "buy",
+ "price": 1515.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#9",
+ "weight": 0.252,
+ "leg_id": 9,
+ "bb_pos": 0.089,
+ "rsi": 16.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.92
+ },
+ {
+ "dt": "2025-07-19 10:03:00",
+ "action": "buy",
+ "price": 1506.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#9",
+ "weight": 0.253,
+ "leg_id": 9,
+ "bb_pos": 0.0,
+ "rsi": 14.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.6
+ },
+ {
+ "dt": "2025-07-19 23:57:00",
+ "action": "buy",
+ "price": 1526.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#9",
+ "weight": 0.25,
+ "leg_id": 9,
+ "bb_pos": 0.144,
+ "rsi": 43.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.09
+ },
+ {
+ "dt": "2025-07-21 01:00:00",
+ "action": "sell",
+ "price": 1741.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#9",
+ "weight": 0.65,
+ "leg_id": 9,
+ "bb_pos": 0.9,
+ "rsi": 90.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 14.05
+ },
+ {
+ "dt": "2025-07-21 01:15:00",
+ "action": "sell",
+ "price": 1730.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#9",
+ "weight": 0.35,
+ "leg_id": 9,
+ "bb_pos": 0.68,
+ "rsi": 71.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.33
+ },
+ {
+ "dt": "2025-07-21 10:36:00",
+ "action": "buy",
+ "price": 1639.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#10",
+ "weight": 0.199,
+ "leg_id": 10,
+ "bb_pos": 0.117,
+ "rsi": 39.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.31
+ },
+ {
+ "dt": "2025-07-22 04:30:00",
+ "action": "buy",
+ "price": 1633.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#10",
+ "weight": 0.199,
+ "leg_id": 10,
+ "bb_pos": 0.0,
+ "rsi": 38.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.74
+ },
+ {
+ "dt": "2025-07-22 10:15:00",
+ "action": "buy",
+ "price": 1635.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#10",
+ "weight": 0.199,
+ "leg_id": 10,
+ "bb_pos": 0.0,
+ "rsi": 20.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.6
+ },
+ {
+ "dt": "2025-07-22 17:24:00",
+ "action": "buy",
+ "price": 1598.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#10",
+ "weight": 0.204,
+ "leg_id": 10,
+ "bb_pos": 0.146,
+ "rsi": 19.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 18.27
+ },
+ {
+ "dt": "2025-07-23 02:24:00",
+ "action": "buy",
+ "price": 1634.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#10",
+ "weight": 0.199,
+ "leg_id": 10,
+ "bb_pos": 0.153,
+ "rsi": 31.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.67
+ },
+ {
+ "dt": "2025-07-23 08:57:00",
+ "action": "sell",
+ "price": 1890.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#10",
+ "weight": 0.65,
+ "leg_id": 10,
+ "bb_pos": 0.913,
+ "rsi": 56.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 16.12
+ },
+ {
+ "dt": "2025-07-23 09:24:00",
+ "action": "sell",
+ "price": 1854.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#10",
+ "weight": 0.35,
+ "leg_id": 10,
+ "bb_pos": 0.351,
+ "rsi": 44.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.91
+ },
+ {
+ "dt": "2025-07-23 22:48:00",
+ "action": "buy",
+ "price": 1636.0,
+ "memo": "저점 분할 매수 · 비중 31% · 3회 · BB하단 · leg#11",
+ "weight": 0.314,
+ "leg_id": 11,
+ "bb_pos": 0.038,
+ "rsi": 24.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.22
+ },
+ {
+ "dt": "2025-07-24 06:24:00",
+ "action": "buy",
+ "price": 1537.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#11",
+ "weight": 0.334,
+ "leg_id": 11,
+ "bb_pos": 0.055,
+ "rsi": 41.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.14
+ },
+ {
+ "dt": "2025-07-24 15:51:00",
+ "action": "buy",
+ "price": 1462.0,
+ "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#11",
+ "weight": 0.352,
+ "leg_id": 11,
+ "bb_pos": 0.072,
+ "rsi": 31.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.53
+ },
+ {
+ "dt": "2025-07-25 00:15:00",
+ "action": "sell",
+ "price": 1616.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#11",
+ "weight": 0.65,
+ "leg_id": 11,
+ "bb_pos": 0.806,
+ "rsi": 90.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.82
+ },
+ {
+ "dt": "2025-07-25 01:03:00",
+ "action": "sell",
+ "price": 1611.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#11",
+ "weight": 0.35,
+ "leg_id": 11,
+ "bb_pos": 0.68,
+ "rsi": 48.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.5
+ },
+ {
+ "dt": "2025-07-25 12:33:00",
+ "action": "buy",
+ "price": 1502.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#12",
+ "weight": 0.501,
+ "leg_id": 12,
+ "bb_pos": 0.0,
+ "rsi": 38.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.05
+ },
+ {
+ "dt": "2025-07-26 00:39:00",
+ "action": "buy",
+ "price": 1506.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#12",
+ "weight": 0.499,
+ "leg_id": 12,
+ "bb_pos": 0.0,
+ "rsi": 42.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.75
+ },
+ {
+ "dt": "2025-07-28 17:06:00",
+ "action": "sell",
+ "price": 1683.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#12",
+ "weight": 0.65,
+ "leg_id": 12,
+ "bb_pos": 0.832,
+ "rsi": 73.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.9
+ },
+ {
+ "dt": "2025-07-28 18:54:00",
+ "action": "sell",
+ "price": 1680.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#12",
+ "weight": 0.35,
+ "leg_id": 12,
+ "bb_pos": 0.854,
+ "rsi": 57.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.7
+ },
+ {
+ "dt": "2025-07-29 11:54:00",
+ "action": "buy",
+ "price": 1506.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#13",
+ "weight": 0.13,
+ "leg_id": 13,
+ "bb_pos": 0.089,
+ "rsi": 36.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": -6.44
+ },
+ {
+ "dt": "2025-07-30 20:54:00",
+ "action": "buy",
+ "price": 1420.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#13",
+ "weight": 0.138,
+ "leg_id": 13,
+ "bb_pos": 0.151,
+ "rsi": 11.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.77
+ },
+ {
+ "dt": "2025-07-31 04:06:00",
+ "action": "buy",
+ "price": 1414.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#13",
+ "weight": 0.139,
+ "leg_id": 13,
+ "bb_pos": 0.0,
+ "rsi": 1.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.35
+ },
+ {
+ "dt": "2025-08-01 09:51:00",
+ "action": "buy",
+ "price": 1357.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#13",
+ "weight": 0.145,
+ "leg_id": 13,
+ "bb_pos": 0.0,
+ "rsi": 25.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.83
+ },
+ {
+ "dt": "2025-08-01 22:51:00",
+ "action": "buy",
+ "price": 1336.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#13",
+ "weight": 0.147,
+ "leg_id": 13,
+ "bb_pos": 0.0,
+ "rsi": 15.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.46
+ },
+ {
+ "dt": "2025-08-02 07:39:00",
+ "action": "buy",
+ "price": 1319.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#13",
+ "weight": 0.149,
+ "leg_id": 13,
+ "bb_pos": 0.098,
+ "rsi": 21.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.82
+ },
+ {
+ "dt": "2025-08-03 03:48:00",
+ "action": "buy",
+ "price": 1287.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#13",
+ "weight": 0.152,
+ "leg_id": 13,
+ "bb_pos": 0.0,
+ "rsi": 35.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.48
+ },
+ {
+ "dt": "2025-08-05 02:15:00",
+ "action": "sell",
+ "price": 1409.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#13",
+ "weight": 0.65,
+ "leg_id": 13,
+ "bb_pos": 1.0,
+ "rsi": 75.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 2.58
+ },
+ {
+ "dt": "2025-08-05 02:48:00",
+ "action": "sell",
+ "price": 1396.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#13",
+ "weight": 0.35,
+ "leg_id": 13,
+ "bb_pos": 0.184,
+ "rsi": 35.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 1.63
+ },
+ {
+ "dt": "2025-08-05 17:06:00",
+ "action": "buy",
+ "price": 1317.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#14",
+ "weight": 0.494,
+ "leg_id": 14,
+ "bb_pos": 0.293,
+ "rsi": 28.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.81
+ },
+ {
+ "dt": "2025-08-06 13:15:00",
+ "action": "buy",
+ "price": 1287.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#14",
+ "weight": 0.506,
+ "leg_id": 14,
+ "bb_pos": 0.0,
+ "rsi": 25.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.48
+ },
+ {
+ "dt": "2025-08-10 13:27:00",
+ "action": "sell",
+ "price": 1512.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#14",
+ "weight": 0.65,
+ "leg_id": 14,
+ "bb_pos": 1.0,
+ "rsi": 59.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 16.15
+ },
+ {
+ "dt": "2025-08-10 13:42:00",
+ "action": "sell",
+ "price": 1510.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#14",
+ "weight": 0.35,
+ "leg_id": 14,
+ "bb_pos": 0.708,
+ "rsi": 59.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 15.99
+ },
+ {
+ "dt": "2025-08-10 17:06:00",
+ "action": "buy",
+ "price": 1417.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#15",
+ "weight": 0.325,
+ "leg_id": 15,
+ "bb_pos": 0.05,
+ "rsi": 8.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.39
+ },
+ {
+ "dt": "2025-08-11 22:06:00",
+ "action": "buy",
+ "price": 1399.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#15",
+ "weight": 0.33,
+ "leg_id": 15,
+ "bb_pos": 0.13,
+ "rsi": 37.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.79
+ },
+ {
+ "dt": "2025-08-12 20:09:00",
+ "action": "buy",
+ "price": 1336.0,
+ "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#15",
+ "weight": 0.345,
+ "leg_id": 15,
+ "bb_pos": 0.229,
+ "rsi": 37.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.02
+ },
+ {
+ "dt": "2025-08-14 08:12:00",
+ "action": "sell",
+ "price": 1550.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#15",
+ "weight": 0.65,
+ "leg_id": 15,
+ "bb_pos": 0.948,
+ "rsi": 78.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.07
+ },
+ {
+ "dt": "2025-08-14 11:54:00",
+ "action": "sell",
+ "price": 1543.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#15",
+ "weight": 0.35,
+ "leg_id": 15,
+ "bb_pos": 1.0,
+ "rsi": 71.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.56
+ },
+ {
+ "dt": "2025-08-14 22:03:00",
+ "action": "buy",
+ "price": 1380.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#16",
+ "weight": 0.136,
+ "leg_id": 16,
+ "bb_pos": 0.13,
+ "rsi": 20.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.61
+ },
+ {
+ "dt": "2025-08-15 06:33:00",
+ "action": "buy",
+ "price": 1371.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#16",
+ "weight": 0.137,
+ "leg_id": 16,
+ "bb_pos": 0.0,
+ "rsi": 23.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.28
+ },
+ {
+ "dt": "2025-08-16 01:09:00",
+ "action": "buy",
+ "price": 1332.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#16",
+ "weight": 0.141,
+ "leg_id": 16,
+ "bb_pos": 0.156,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.31
+ },
+ {
+ "dt": "2025-08-16 21:24:00",
+ "action": "buy",
+ "price": 1350.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#16",
+ "weight": 0.139,
+ "leg_id": 16,
+ "bb_pos": 0.221,
+ "rsi": 26.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.89
+ },
+ {
+ "dt": "2025-08-20 08:54:00",
+ "action": "buy",
+ "price": 1251.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#16",
+ "weight": 0.15,
+ "leg_id": 16,
+ "bb_pos": 0.067,
+ "rsi": 35.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.19
+ },
+ {
+ "dt": "2025-08-20 23:09:00",
+ "action": "buy",
+ "price": 1270.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#16",
+ "weight": 0.148,
+ "leg_id": 16,
+ "bb_pos": 0.0,
+ "rsi": 30.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.5
+ },
+ {
+ "dt": "2025-08-22 21:36:00",
+ "action": "buy",
+ "price": 1257.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#16",
+ "weight": 0.149,
+ "leg_id": 16,
+ "bb_pos": 0.013,
+ "rsi": 12.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.65
+ },
+ {
+ "dt": "2025-08-23 06:42:00",
+ "action": "sell",
+ "price": 1416.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#16",
+ "weight": 0.65,
+ "leg_id": 16,
+ "bb_pos": 0.948,
+ "rsi": 64.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.77
+ },
+ {
+ "dt": "2025-08-23 06:48:00",
+ "action": "sell",
+ "price": 1416.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#16",
+ "weight": 0.35,
+ "leg_id": 16,
+ "bb_pos": 0.852,
+ "rsi": 59.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.77
+ },
+ {
+ "dt": "2025-08-23 11:36:00",
+ "action": "buy",
+ "price": 1362.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#17",
+ "weight": 0.238,
+ "leg_id": 17,
+ "bb_pos": 0.0,
+ "rsi": 17.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.32
+ },
+ {
+ "dt": "2025-08-24 19:36:00",
+ "action": "buy",
+ "price": 1329.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#17",
+ "weight": 0.244,
+ "leg_id": 17,
+ "bb_pos": 0.072,
+ "rsi": 12.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 1.13
+ },
+ {
+ "dt": "2025-08-25 19:33:00",
+ "action": "buy",
+ "price": 1278.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#17",
+ "weight": 0.254,
+ "leg_id": 17,
+ "bb_pos": 0.059,
+ "rsi": 21.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.16
+ },
+ {
+ "dt": "2025-08-26 07:15:00",
+ "action": "buy",
+ "price": 1229.0,
+ "memo": "저점 분할 매수 · 비중 26% · 4회 · BB하단 · leg#17",
+ "weight": 0.264,
+ "leg_id": 17,
+ "bb_pos": 0.0,
+ "rsi": 42.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.36
+ },
+ {
+ "dt": "2025-08-27 13:12:00",
+ "action": "sell",
+ "price": 1344.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#17",
+ "weight": 0.65,
+ "leg_id": 17,
+ "bb_pos": 0.932,
+ "rsi": 80.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 3.58
+ },
+ {
+ "dt": "2025-08-27 13:18:00",
+ "action": "sell",
+ "price": 1344.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#17",
+ "weight": 0.35,
+ "leg_id": 17,
+ "bb_pos": 0.952,
+ "rsi": 86.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 3.58
+ },
+ {
+ "dt": "2025-08-28 09:33:00",
+ "action": "buy",
+ "price": 1294.0,
+ "memo": "저점 분할 매수 · 비중 16% · 6회 · BB하단 · leg#18",
+ "weight": 0.157,
+ "leg_id": 18,
+ "bb_pos": 0.177,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.52
+ },
+ {
+ "dt": "2025-08-30 10:57:00",
+ "action": "buy",
+ "price": 1239.0,
+ "memo": "저점 분할 매수 · 비중 16% · 6회 · BB하단 · leg#18",
+ "weight": 0.164,
+ "leg_id": 18,
+ "bb_pos": 0.0,
+ "rsi": 4.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 18.56
+ },
+ {
+ "dt": "2025-09-01 13:45:00",
+ "action": "buy",
+ "price": 1195.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#18",
+ "weight": 0.17,
+ "leg_id": 18,
+ "bb_pos": 0.126,
+ "rsi": 26.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 22.93
+ },
+ {
+ "dt": "2025-09-02 06:39:00",
+ "action": "buy",
+ "price": 1170.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#18",
+ "weight": 0.174,
+ "leg_id": 18,
+ "bb_pos": 0.205,
+ "rsi": 28.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.56
+ },
+ {
+ "dt": "2025-09-05 00:30:00",
+ "action": "buy",
+ "price": 1207.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#18",
+ "weight": 0.169,
+ "leg_id": 18,
+ "bb_pos": 0.244,
+ "rsi": 31.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 21.71
+ },
+ {
+ "dt": "2025-09-05 23:45:00",
+ "action": "buy",
+ "price": 1230.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#18",
+ "weight": 0.165,
+ "leg_id": 18,
+ "bb_pos": 0.214,
+ "rsi": 15.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 19.43
+ },
+ {
+ "dt": "2025-09-07 15:03:00",
+ "action": "sell",
+ "price": 1469.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#18",
+ "weight": 0.65,
+ "leg_id": 18,
+ "bb_pos": 0.853,
+ "rsi": 79.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 20.29
+ },
+ {
+ "dt": "2025-09-07 15:21:00",
+ "action": "sell",
+ "price": 1435.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#18",
+ "weight": 0.35,
+ "leg_id": 18,
+ "bb_pos": 0.472,
+ "rsi": 59.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 17.5
+ },
+ {
+ "dt": "2025-09-07 18:42:00",
+ "action": "buy",
+ "price": 1410.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#19",
+ "weight": 0.504,
+ "leg_id": 19,
+ "bb_pos": 0.0,
+ "rsi": 35.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 27.66
+ },
+ {
+ "dt": "2025-09-08 07:42:00",
+ "action": "buy",
+ "price": 1432.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#19",
+ "weight": 0.496,
+ "leg_id": 19,
+ "bb_pos": 0.241,
+ "rsi": 47.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.7
+ },
+ {
+ "dt": "2025-09-08 14:12:00",
+ "action": "sell",
+ "price": 1800.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#19",
+ "weight": 0.65,
+ "leg_id": 19,
+ "bb_pos": 1.0,
+ "rsi": 83.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 26.68
+ },
+ {
+ "dt": "2025-09-08 15:42:00",
+ "action": "sell",
+ "price": 1784.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#19",
+ "weight": 0.35,
+ "leg_id": 19,
+ "bb_pos": 0.738,
+ "rsi": 52.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 25.55
+ },
+ {
+ "dt": "2025-09-08 16:54:00",
+ "action": "buy",
+ "price": 1718.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#20",
+ "weight": 0.491,
+ "leg_id": 20,
+ "bb_pos": 0.0,
+ "rsi": 33.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.03
+ },
+ {
+ "dt": "2025-09-08 20:15:00",
+ "action": "buy",
+ "price": 1658.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#20",
+ "weight": 0.509,
+ "leg_id": 20,
+ "bb_pos": 0.065,
+ "rsi": 29.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 29.55
+ },
+ {
+ "dt": "2025-09-09 00:03:00",
+ "action": "sell",
+ "price": 2148.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#20",
+ "weight": 0.65,
+ "leg_id": 20,
+ "bb_pos": 1.0,
+ "rsi": 83.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 27.29
+ },
+ {
+ "dt": "2025-09-09 02:36:00",
+ "action": "sell",
+ "price": 2136.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#20",
+ "weight": 0.35,
+ "leg_id": 20,
+ "bb_pos": 0.891,
+ "rsi": 64.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 26.58
+ },
+ {
+ "dt": "2025-09-09 03:09:00",
+ "action": "buy",
+ "price": 1990.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#21",
+ "weight": 1.0,
+ "leg_id": 21,
+ "bb_pos": 0.0,
+ "rsi": 24.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 24.87
+ },
+ {
+ "dt": "2025-09-09 09:27:00",
+ "action": "sell",
+ "price": 2485.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#21",
+ "weight": 0.65,
+ "leg_id": 21,
+ "bb_pos": 0.849,
+ "rsi": 80.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 24.87
+ },
+ {
+ "dt": "2025-09-09 11:45:00",
+ "action": "buy",
+ "price": 2334.0,
+ "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#22",
+ "weight": 0.355,
+ "leg_id": 22,
+ "bb_pos": 0.19,
+ "rsi": 35.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.92
+ },
+ {
+ "dt": "2025-09-09 12:45:00",
+ "action": "sell",
+ "price": 2542.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#21",
+ "weight": 0.35,
+ "leg_id": 21,
+ "bb_pos": 0.957,
+ "rsi": 79.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 27.74
+ },
+ {
+ "dt": "2025-09-09 14:00:00",
+ "action": "buy",
+ "price": 2508.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#22",
+ "weight": 0.33,
+ "leg_id": 22,
+ "bb_pos": 0.191,
+ "rsi": 38.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.19
+ },
+ {
+ "dt": "2025-09-09 16:54:00",
+ "action": "buy",
+ "price": 2624.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#22",
+ "weight": 0.315,
+ "leg_id": 22,
+ "bb_pos": 0.238,
+ "rsi": 19.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.0
+ },
+ {
+ "dt": "2025-09-09 19:33:00",
+ "action": "sell",
+ "price": 2939.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#22",
+ "weight": 0.65,
+ "leg_id": 22,
+ "bb_pos": 1.0,
+ "rsi": 90.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 18.38
+ },
+ {
+ "dt": "2025-09-09 19:42:00",
+ "action": "sell",
+ "price": 2932.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#22",
+ "weight": 0.35,
+ "leg_id": 22,
+ "bb_pos": 0.85,
+ "rsi": 82.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 18.09
+ },
+ {
+ "dt": "2025-09-10 01:36:00",
+ "action": "buy",
+ "price": 2395.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#23",
+ "weight": 0.345,
+ "leg_id": 23,
+ "bb_pos": 0.05,
+ "rsi": 22.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 18.91
+ },
+ {
+ "dt": "2025-09-10 06:21:00",
+ "action": "buy",
+ "price": 2457.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#23",
+ "weight": 0.336,
+ "leg_id": 23,
+ "bb_pos": 0.0,
+ "rsi": 41.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.91
+ },
+ {
+ "dt": "2025-09-10 09:24:00",
+ "action": "buy",
+ "price": 2583.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#23",
+ "weight": 0.32,
+ "leg_id": 23,
+ "bb_pos": 0.0,
+ "rsi": 26.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.26
+ },
+ {
+ "dt": "2025-09-10 10:36:00",
+ "action": "sell",
+ "price": 2848.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#23",
+ "weight": 0.65,
+ "leg_id": 23,
+ "bb_pos": 0.87,
+ "rsi": 84.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 15.03
+ },
+ {
+ "dt": "2025-09-10 11:09:00",
+ "action": "sell",
+ "price": 2800.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#23",
+ "weight": 0.35,
+ "leg_id": 23,
+ "bb_pos": 0.559,
+ "rsi": 42.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.09
+ },
+ {
+ "dt": "2025-09-10 17:00:00",
+ "action": "buy",
+ "price": 2550.0,
+ "memo": "저점 분할 매수 · 비중 23% · 4회 · BB하단 · leg#24",
+ "weight": 0.231,
+ "leg_id": 24,
+ "bb_pos": 0.0,
+ "rsi": 30.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -5.45
+ },
+ {
+ "dt": "2025-09-11 01:30:00",
+ "action": "buy",
+ "price": 2460.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#24",
+ "weight": 0.24,
+ "leg_id": 24,
+ "bb_pos": 0.166,
+ "rsi": 12.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.99
+ },
+ {
+ "dt": "2025-09-12 01:51:00",
+ "action": "buy",
+ "price": 2217.0,
+ "memo": "저점 분할 매수 · 비중 27% · 4회 · BB하단 · leg#24",
+ "weight": 0.266,
+ "leg_id": 24,
+ "bb_pos": 0.182,
+ "rsi": 32.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.75
+ },
+ {
+ "dt": "2025-09-12 12:09:00",
+ "action": "buy",
+ "price": 2235.0,
+ "memo": "저점 분할 매수 · 비중 26% · 4회 · BB하단 · leg#24",
+ "weight": 0.264,
+ "leg_id": 24,
+ "bb_pos": 0.132,
+ "rsi": 36.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.87
+ },
+ {
+ "dt": "2025-09-12 15:36:00",
+ "action": "sell",
+ "price": 2411.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#24",
+ "weight": 0.65,
+ "leg_id": 24,
+ "bb_pos": 0.977,
+ "rsi": 92.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 2.3
+ },
+ {
+ "dt": "2025-09-12 15:45:00",
+ "action": "sell",
+ "price": 2384.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#24",
+ "weight": 0.35,
+ "leg_id": 24,
+ "bb_pos": 0.715,
+ "rsi": 71.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 1.15
+ },
+ {
+ "dt": "2025-09-13 00:36:00",
+ "action": "buy",
+ "price": 2166.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#25",
+ "weight": 1.0,
+ "leg_id": 25,
+ "bb_pos": 0.114,
+ "rsi": 24.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.88
+ },
+ {
+ "dt": "2025-09-13 09:27:00",
+ "action": "sell",
+ "price": 2380.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#25",
+ "weight": 0.65,
+ "leg_id": 25,
+ "bb_pos": 0.881,
+ "rsi": 80.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.88
+ },
+ {
+ "dt": "2025-09-13 09:39:00",
+ "action": "sell",
+ "price": 2368.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#25",
+ "weight": 0.35,
+ "leg_id": 25,
+ "bb_pos": 0.685,
+ "rsi": 68.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.33
+ },
+ {
+ "dt": "2025-09-14 00:39:00",
+ "action": "buy",
+ "price": 2205.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#26",
+ "weight": 0.499,
+ "leg_id": 26,
+ "bb_pos": 0.02,
+ "rsi": 23.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.57
+ },
+ {
+ "dt": "2025-09-14 02:12:00",
+ "action": "buy",
+ "price": 2200.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#26",
+ "weight": 0.501,
+ "leg_id": 26,
+ "bb_pos": 0.503,
+ "rsi": 52.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.82
+ },
+ {
+ "dt": "2025-09-14 16:36:00",
+ "action": "sell",
+ "price": 2394.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#26",
+ "weight": 0.65,
+ "leg_id": 26,
+ "bb_pos": 0.968,
+ "rsi": 54.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.69
+ },
+ {
+ "dt": "2025-09-14 16:54:00",
+ "action": "sell",
+ "price": 2382.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#26",
+ "weight": 0.35,
+ "leg_id": 26,
+ "bb_pos": 0.67,
+ "rsi": 61.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.15
+ },
+ {
+ "dt": "2025-09-15 17:00:00",
+ "action": "buy",
+ "price": 2146.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#27",
+ "weight": 0.488,
+ "leg_id": 27,
+ "bb_pos": 0.159,
+ "rsi": 22.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.31
+ },
+ {
+ "dt": "2025-09-16 01:00:00",
+ "action": "buy",
+ "price": 2047.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#27",
+ "weight": 0.512,
+ "leg_id": 27,
+ "bb_pos": 0.188,
+ "rsi": 34.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.3
+ },
+ {
+ "dt": "2025-09-16 22:33:00",
+ "action": "sell",
+ "price": 2217.0,
+ "memo": "고점 매도 · 비중 100% · 1회 · leg#27",
+ "weight": 1.0,
+ "leg_id": 27,
+ "bb_pos": 0.962,
+ "rsi": 74.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 5.81
+ },
+ {
+ "dt": "2025-09-17 14:21:00",
+ "action": "buy",
+ "price": 2059.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#28",
+ "weight": 0.513,
+ "leg_id": 28,
+ "bb_pos": 0.312,
+ "rsi": 29.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.02
+ },
+ {
+ "dt": "2025-09-18 19:27:00",
+ "action": "buy",
+ "price": 2165.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#28",
+ "weight": 0.487,
+ "leg_id": 28,
+ "bb_pos": 0.028,
+ "rsi": 18.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.48
+ },
+ {
+ "dt": "2025-09-18 23:24:00",
+ "action": "sell",
+ "price": 2327.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#28",
+ "weight": 0.65,
+ "leg_id": 28,
+ "bb_pos": 1.0,
+ "rsi": 89.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.25
+ },
+ {
+ "dt": "2025-09-19 00:03:00",
+ "action": "sell",
+ "price": 2270.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#28",
+ "weight": 0.35,
+ "leg_id": 28,
+ "bb_pos": 0.547,
+ "rsi": 38.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.55
+ },
+ {
+ "dt": "2025-09-22 15:00:00",
+ "action": "buy",
+ "price": 1851.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#29",
+ "weight": 0.196,
+ "leg_id": 29,
+ "bb_pos": 0.0,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.7
+ },
+ {
+ "dt": "2025-09-23 20:21:00",
+ "action": "buy",
+ "price": 1875.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#29",
+ "weight": 0.193,
+ "leg_id": 29,
+ "bb_pos": 0.28,
+ "rsi": 51.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.33
+ },
+ {
+ "dt": "2025-09-24 13:06:00",
+ "action": "buy",
+ "price": 1794.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#29",
+ "weight": 0.202,
+ "leg_id": 29,
+ "bb_pos": 0.135,
+ "rsi": 38.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.09
+ },
+ {
+ "dt": "2025-09-26 02:12:00",
+ "action": "buy",
+ "price": 1775.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#29",
+ "weight": 0.204,
+ "leg_id": 29,
+ "bb_pos": 0.42,
+ "rsi": 49.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.27
+ },
+ {
+ "dt": "2025-09-26 20:06:00",
+ "action": "buy",
+ "price": 1773.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#29",
+ "weight": 0.204,
+ "leg_id": 29,
+ "bb_pos": 0.552,
+ "rsi": 53.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.39
+ },
+ {
+ "dt": "2025-09-27 14:51:00",
+ "action": "sell",
+ "price": 1975.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#29",
+ "weight": 0.65,
+ "leg_id": 29,
+ "bb_pos": 1.0,
+ "rsi": 92.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.96
+ },
+ {
+ "dt": "2025-09-27 15:12:00",
+ "action": "sell",
+ "price": 1944.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#29",
+ "weight": 0.35,
+ "leg_id": 29,
+ "bb_pos": 0.655,
+ "rsi": 67.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.25
+ },
+ {
+ "dt": "2025-09-28 19:03:00",
+ "action": "buy",
+ "price": 1792.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#30",
+ "weight": 0.202,
+ "leg_id": 30,
+ "bb_pos": 0.0,
+ "rsi": 42.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.6
+ },
+ {
+ "dt": "2025-09-30 02:00:00",
+ "action": "buy",
+ "price": 1795.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#30",
+ "weight": 0.202,
+ "leg_id": 30,
+ "bb_pos": 0.034,
+ "rsi": 17.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.42
+ },
+ {
+ "dt": "2025-10-01 03:45:00",
+ "action": "buy",
+ "price": 1758.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#30",
+ "weight": 0.206,
+ "leg_id": 30,
+ "bb_pos": 0.242,
+ "rsi": 45.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.72
+ },
+ {
+ "dt": "2025-10-03 00:03:00",
+ "action": "buy",
+ "price": 1849.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#30",
+ "weight": 0.196,
+ "leg_id": 30,
+ "bb_pos": 0.108,
+ "rsi": 33.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.22
+ },
+ {
+ "dt": "2025-10-03 23:18:00",
+ "action": "buy",
+ "price": 1871.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#30",
+ "weight": 0.194,
+ "leg_id": 30,
+ "bb_pos": 0.18,
+ "rsi": 24.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.97
+ },
+ {
+ "dt": "2025-10-04 01:45:00",
+ "action": "sell",
+ "price": 1964.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#30",
+ "weight": 0.65,
+ "leg_id": 30,
+ "bb_pos": 1.0,
+ "rsi": 82.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.38
+ },
+ {
+ "dt": "2025-10-04 01:54:00",
+ "action": "sell",
+ "price": 1950.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#30",
+ "weight": 0.35,
+ "leg_id": 30,
+ "bb_pos": 0.712,
+ "rsi": 61.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.61
+ },
+ {
+ "dt": "2025-10-05 09:57:00",
+ "action": "buy",
+ "price": 1795.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#31",
+ "weight": 0.327,
+ "leg_id": 31,
+ "bb_pos": 0.0,
+ "rsi": 28.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.67
+ },
+ {
+ "dt": "2025-10-06 10:00:00",
+ "action": "buy",
+ "price": 1792.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#31",
+ "weight": 0.328,
+ "leg_id": 31,
+ "bb_pos": 0.339,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.85
+ },
+ {
+ "dt": "2025-10-08 21:06:00",
+ "action": "buy",
+ "price": 1704.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#31",
+ "weight": 0.345,
+ "leg_id": 31,
+ "bb_pos": 0.169,
+ "rsi": 15.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.16
+ },
+ {
+ "dt": "2025-10-09 05:39:00",
+ "action": "sell",
+ "price": 1843.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#31",
+ "weight": 0.65,
+ "leg_id": 31,
+ "bb_pos": 1.0,
+ "rsi": 72.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.56
+ },
+ {
+ "dt": "2025-10-09 08:54:00",
+ "action": "sell",
+ "price": 1843.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#31",
+ "weight": 0.35,
+ "leg_id": 31,
+ "bb_pos": 0.808,
+ "rsi": 55.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.56
+ },
+ {
+ "dt": "2025-10-09 23:33:00",
+ "action": "buy",
+ "price": 1728.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#32",
+ "weight": 0.508,
+ "leg_id": 32,
+ "bb_pos": 0.082,
+ "rsi": 35.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.26
+ },
+ {
+ "dt": "2025-10-10 16:36:00",
+ "action": "buy",
+ "price": 1786.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#32",
+ "weight": 0.492,
+ "leg_id": 32,
+ "bb_pos": 0.292,
+ "rsi": 39.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.71
+ },
+ {
+ "dt": "2025-10-10 22:09:00",
+ "action": "sell",
+ "price": 1888.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#32",
+ "weight": 0.65,
+ "leg_id": 32,
+ "bb_pos": 1.0,
+ "rsi": 87.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.48
+ },
+ {
+ "dt": "2025-10-10 22:24:00",
+ "action": "sell",
+ "price": 1887.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#32",
+ "weight": 0.35,
+ "leg_id": 32,
+ "bb_pos": 0.816,
+ "rsi": 74.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.43
+ },
+ {
+ "dt": "2025-10-11 06:18:00",
+ "action": "buy",
+ "price": 1149.0,
+ "memo": "저점 분할 매수 · 비중 53% · 2회 · BB하단 · leg#33",
+ "weight": 0.533,
+ "leg_id": 33,
+ "bb_pos": 0.0,
+ "rsi": 6.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 30.55
+ },
+ {
+ "dt": "2025-10-11 10:51:00",
+ "action": "buy",
+ "price": 1312.0,
+ "memo": "저점 분할 매수 · 비중 47% · 2회 · BB하단 · leg#33",
+ "weight": 0.467,
+ "leg_id": 33,
+ "bb_pos": 0.14,
+ "rsi": 28.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.33
+ },
+ {
+ "dt": "2025-10-11 18:03:00",
+ "action": "sell",
+ "price": 1500.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#33",
+ "weight": 0.65,
+ "leg_id": 33,
+ "bb_pos": 0.96,
+ "rsi": 73.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 22.44
+ },
+ {
+ "dt": "2025-10-11 18:27:00",
+ "action": "sell",
+ "price": 1498.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#33",
+ "weight": 0.35,
+ "leg_id": 33,
+ "bb_pos": 0.734,
+ "rsi": 70.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 22.27
+ },
+ {
+ "dt": "2025-10-12 04:51:00",
+ "action": "buy",
+ "price": 1375.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#34",
+ "weight": 0.499,
+ "leg_id": 34,
+ "bb_pos": 0.218,
+ "rsi": 23.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.89
+ },
+ {
+ "dt": "2025-10-12 11:09:00",
+ "action": "buy",
+ "price": 1370.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#34",
+ "weight": 0.501,
+ "leg_id": 34,
+ "bb_pos": 0.043,
+ "rsi": 19.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.31
+ },
+ {
+ "dt": "2025-10-13 02:03:00",
+ "action": "sell",
+ "price": 1566.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#34",
+ "weight": 0.65,
+ "leg_id": 34,
+ "bb_pos": 0.704,
+ "rsi": 56.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 14.1
+ },
+ {
+ "dt": "2025-10-13 03:39:00",
+ "action": "buy",
+ "price": 1517.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#35",
+ "weight": 0.13,
+ "leg_id": 35,
+ "bb_pos": 0.0,
+ "rsi": 31.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.25
+ },
+ {
+ "dt": "2025-10-13 05:03:00",
+ "action": "sell",
+ "price": 1565.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#34",
+ "weight": 0.35,
+ "leg_id": 34,
+ "bb_pos": 0.923,
+ "rsi": 72.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 14.03
+ },
+ {
+ "dt": "2025-10-13 21:00:00",
+ "action": "buy",
+ "price": 1464.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#35",
+ "weight": 0.135,
+ "leg_id": 35,
+ "bb_pos": 0.198,
+ "rsi": 28.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": -3.89
+ },
+ {
+ "dt": "2025-10-14 15:45:00",
+ "action": "buy",
+ "price": 1367.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#35",
+ "weight": 0.144,
+ "leg_id": 35,
+ "bb_pos": 0.0,
+ "rsi": 12.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.93
+ },
+ {
+ "dt": "2025-10-16 18:33:00",
+ "action": "buy",
+ "price": 1395.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#35",
+ "weight": 0.141,
+ "leg_id": 35,
+ "bb_pos": 0.186,
+ "rsi": 46.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 0.86
+ },
+ {
+ "dt": "2025-10-17 17:18:00",
+ "action": "buy",
+ "price": 1262.0,
+ "memo": "저점 분할 매수 · 비중 16% · 7회 · BB하단 · leg#35",
+ "weight": 0.156,
+ "leg_id": 35,
+ "bb_pos": 0.083,
+ "rsi": 34.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.49
+ },
+ {
+ "dt": "2025-10-19 18:27:00",
+ "action": "buy",
+ "price": 1322.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#35",
+ "weight": 0.149,
+ "leg_id": 35,
+ "bb_pos": 0.055,
+ "rsi": 35.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.43
+ },
+ {
+ "dt": "2025-10-20 09:30:00",
+ "action": "buy",
+ "price": 1356.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#35",
+ "weight": 0.145,
+ "leg_id": 35,
+ "bb_pos": 0.023,
+ "rsi": 3.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.76
+ },
+ {
+ "dt": "2025-10-20 15:42:00",
+ "action": "sell",
+ "price": 1407.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#35",
+ "weight": 0.65,
+ "leg_id": 35,
+ "bb_pos": 0.953,
+ "rsi": 70.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 2.04
+ },
+ {
+ "dt": "2025-10-20 15:54:00",
+ "action": "sell",
+ "price": 1407.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#35",
+ "weight": 0.35,
+ "leg_id": 35,
+ "bb_pos": 0.768,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 2.04
+ },
+ {
+ "dt": "2025-10-21 13:45:00",
+ "action": "buy",
+ "price": 1329.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#36",
+ "weight": 0.243,
+ "leg_id": 36,
+ "bb_pos": 0.117,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.55
+ },
+ {
+ "dt": "2025-10-23 06:09:00",
+ "action": "buy",
+ "price": 1250.0,
+ "memo": "저점 분할 매수 · 비중 26% · 4회 · BB하단 · leg#36",
+ "weight": 0.258,
+ "leg_id": 36,
+ "bb_pos": 0.0,
+ "rsi": 23.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.28
+ },
+ {
+ "dt": "2025-10-25 01:39:00",
+ "action": "buy",
+ "price": 1286.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#36",
+ "weight": 0.251,
+ "leg_id": 36,
+ "bb_pos": 0.383,
+ "rsi": 46.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.11
+ },
+ {
+ "dt": "2025-10-25 16:00:00",
+ "action": "buy",
+ "price": 1301.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#36",
+ "weight": 0.248,
+ "leg_id": 36,
+ "bb_pos": 0.049,
+ "rsi": 31.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.84
+ },
+ {
+ "dt": "2025-10-27 04:12:00",
+ "action": "sell",
+ "price": 1416.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#36",
+ "weight": 0.65,
+ "leg_id": 36,
+ "bb_pos": 1.0,
+ "rsi": 72.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.69
+ },
+ {
+ "dt": "2025-10-27 07:48:00",
+ "action": "sell",
+ "price": 1415.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#36",
+ "weight": 0.35,
+ "leg_id": 36,
+ "bb_pos": 0.756,
+ "rsi": 60.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.62
+ },
+ {
+ "dt": "2025-10-30 03:39:00",
+ "action": "buy",
+ "price": 1263.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#37",
+ "weight": 0.317,
+ "leg_id": 37,
+ "bb_pos": 0.0,
+ "rsi": 38.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.91
+ },
+ {
+ "dt": "2025-10-31 02:33:00",
+ "action": "buy",
+ "price": 1170.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#37",
+ "weight": 0.342,
+ "leg_id": 37,
+ "bb_pos": 0.113,
+ "rsi": 28.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.25
+ },
+ {
+ "dt": "2025-11-01 02:21:00",
+ "action": "buy",
+ "price": 1176.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#37",
+ "weight": 0.341,
+ "leg_id": 37,
+ "bb_pos": 0.13,
+ "rsi": 14.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.67
+ },
+ {
+ "dt": "2025-11-01 17:12:00",
+ "action": "sell",
+ "price": 1325.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#37",
+ "weight": 0.65,
+ "leg_id": 37,
+ "bb_pos": 0.946,
+ "rsi": 88.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.28
+ },
+ {
+ "dt": "2025-11-01 17:21:00",
+ "action": "sell",
+ "price": 1321.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#37",
+ "weight": 0.35,
+ "leg_id": 37,
+ "bb_pos": 0.782,
+ "rsi": 77.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.94
+ },
+ {
+ "dt": "2025-11-01 18:24:00",
+ "action": "buy",
+ "price": 1266.0,
+ "memo": "저점 분할 매수 · 비중 12% · 7회 · BB하단 · leg#38",
+ "weight": 0.117,
+ "leg_id": 38,
+ "bb_pos": 0.177,
+ "rsi": 5.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -12.16
+ },
+ {
+ "dt": "2025-11-04 17:15:00",
+ "action": "buy",
+ "price": 1037.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#38",
+ "weight": 0.143,
+ "leg_id": 38,
+ "bb_pos": 0.055,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.23
+ },
+ {
+ "dt": "2025-11-04 23:00:00",
+ "action": "buy",
+ "price": 1038.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#38",
+ "weight": 0.143,
+ "leg_id": 38,
+ "bb_pos": 0.228,
+ "rsi": 37.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.13
+ },
+ {
+ "dt": "2025-11-05 02:15:00",
+ "action": "buy",
+ "price": 1020.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#38",
+ "weight": 0.146,
+ "leg_id": 38,
+ "bb_pos": 0.0,
+ "rsi": 15.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.02
+ },
+ {
+ "dt": "2025-11-05 05:15:00",
+ "action": "buy",
+ "price": 991.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#38",
+ "weight": 0.15,
+ "leg_id": 38,
+ "bb_pos": 0.057,
+ "rsi": 8.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.21
+ },
+ {
+ "dt": "2025-11-05 06:33:00",
+ "action": "buy",
+ "price": 983.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#38",
+ "weight": 0.151,
+ "leg_id": 38,
+ "bb_pos": 0.0,
+ "rsi": 21.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.12
+ },
+ {
+ "dt": "2025-11-05 10:24:00",
+ "action": "buy",
+ "price": 997.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#38",
+ "weight": 0.149,
+ "leg_id": 38,
+ "bb_pos": 0.0,
+ "rsi": 22.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.53
+ },
+ {
+ "dt": "2025-11-06 07:57:00",
+ "action": "sell",
+ "price": 1112.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#38",
+ "weight": 0.65,
+ "leg_id": 38,
+ "bb_pos": 1.0,
+ "rsi": 81.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 6.88
+ },
+ {
+ "dt": "2025-11-06 08:30:00",
+ "action": "sell",
+ "price": 1111.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#38",
+ "weight": 0.35,
+ "leg_id": 38,
+ "bb_pos": 0.805,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 6.78
+ },
+ {
+ "dt": "2025-11-07 01:42:00",
+ "action": "buy",
+ "price": 1038.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#39",
+ "weight": 1.0,
+ "leg_id": 39,
+ "bb_pos": 0.0,
+ "rsi": 27.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.92
+ },
+ {
+ "dt": "2025-11-07 16:57:00",
+ "action": "sell",
+ "price": 1141.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#39",
+ "weight": 0.65,
+ "leg_id": 39,
+ "bb_pos": 1.0,
+ "rsi": 75.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.92
+ },
+ {
+ "dt": "2025-11-07 17:15:00",
+ "action": "sell",
+ "price": 1132.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#39",
+ "weight": 0.35,
+ "leg_id": 39,
+ "bb_pos": 0.66,
+ "rsi": 67.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.06
+ },
+ {
+ "dt": "2025-11-07 21:48:00",
+ "action": "buy",
+ "price": 1074.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#40",
+ "weight": 1.0,
+ "leg_id": 40,
+ "bb_pos": 0.052,
+ "rsi": 11.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 19.93
+ },
+ {
+ "dt": "2025-11-08 07:57:00",
+ "action": "sell",
+ "price": 1288.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#40",
+ "weight": 0.65,
+ "leg_id": 40,
+ "bb_pos": 1.0,
+ "rsi": 84.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 19.93
+ },
+ {
+ "dt": "2025-11-08 11:42:00",
+ "action": "sell",
+ "price": 1266.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#40",
+ "weight": 0.35,
+ "leg_id": 40,
+ "bb_pos": 0.895,
+ "rsi": 70.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 17.88
+ },
+ {
+ "dt": "2025-11-09 12:24:00",
+ "action": "buy",
+ "price": 1165.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.076,
+ "leg_id": 41,
+ "bb_pos": 0.094,
+ "rsi": 26.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": -9.36
+ },
+ {
+ "dt": "2025-11-11 02:00:00",
+ "action": "buy",
+ "price": 1201.0,
+ "memo": "저점 분할 매수 · 비중 7% · 12회 · BB하단 · leg#41",
+ "weight": 0.074,
+ "leg_id": 41,
+ "bb_pos": 0.17,
+ "rsi": 18.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -12.07
+ },
+ {
+ "dt": "2025-11-12 09:57:00",
+ "action": "buy",
+ "price": 1147.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.077,
+ "leg_id": 41,
+ "bb_pos": 0.172,
+ "rsi": 47.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.93
+ },
+ {
+ "dt": "2025-11-13 02:21:00",
+ "action": "buy",
+ "price": 1122.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.079,
+ "leg_id": 41,
+ "bb_pos": 0.184,
+ "rsi": 29.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -5.88
+ },
+ {
+ "dt": "2025-11-14 03:33:00",
+ "action": "buy",
+ "price": 1101.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.08,
+ "leg_id": 41,
+ "bb_pos": 0.153,
+ "rsi": 36.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": -4.09
+ },
+ {
+ "dt": "2025-11-14 13:39:00",
+ "action": "buy",
+ "price": 1070.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.083,
+ "leg_id": 41,
+ "bb_pos": 0.0,
+ "rsi": 18.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.31
+ },
+ {
+ "dt": "2025-11-14 21:39:00",
+ "action": "buy",
+ "price": 1064.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.083,
+ "leg_id": 41,
+ "bb_pos": 0.0,
+ "rsi": 29.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.75
+ },
+ {
+ "dt": "2025-11-15 08:06:00",
+ "action": "buy",
+ "price": 1058.0,
+ "memo": "저점 분할 매수 · 비중 8% · 12회 · BB하단 · leg#41",
+ "weight": 0.084,
+ "leg_id": 41,
+ "bb_pos": 0.0,
+ "rsi": 28.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.19
+ },
+ {
+ "dt": "2025-11-17 02:54:00",
+ "action": "buy",
+ "price": 988.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#41",
+ "weight": 0.09,
+ "leg_id": 41,
+ "bb_pos": 0.244,
+ "rsi": 39.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.88
+ },
+ {
+ "dt": "2025-11-17 22:51:00",
+ "action": "buy",
+ "price": 985.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#41",
+ "weight": 0.09,
+ "leg_id": 41,
+ "bb_pos": 0.058,
+ "rsi": 10.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.21
+ },
+ {
+ "dt": "2025-11-18 09:42:00",
+ "action": "buy",
+ "price": 960.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#41",
+ "weight": 0.092,
+ "leg_id": 41,
+ "bb_pos": 0.0,
+ "rsi": 25.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.0
+ },
+ {
+ "dt": "2025-11-20 04:24:00",
+ "action": "buy",
+ "price": 950.0,
+ "memo": "저점 분할 매수 · 비중 9% · 12회 · BB하단 · leg#41",
+ "weight": 0.093,
+ "leg_id": 41,
+ "bb_pos": 0.328,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.16
+ },
+ {
+ "dt": "2025-11-20 22:15:00",
+ "action": "sell",
+ "price": 1056.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#41",
+ "weight": 0.65,
+ "leg_id": 41,
+ "bb_pos": 0.887,
+ "rsi": 68.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": -0.53
+ },
+ {
+ "dt": "2025-11-20 22:51:00",
+ "action": "sell",
+ "price": 1053.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#41",
+ "weight": 0.35,
+ "leg_id": 41,
+ "bb_pos": 0.518,
+ "rsi": 47.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": -0.81
+ },
+ {
+ "dt": "2025-11-21 04:00:00",
+ "action": "buy",
+ "price": 960.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.085,
+ "leg_id": 42,
+ "bb_pos": 0.174,
+ "rsi": 15.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.29
+ },
+ {
+ "dt": "2025-11-21 21:27:00",
+ "action": "buy",
+ "price": 871.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.094,
+ "leg_id": 42,
+ "bb_pos": 0.0,
+ "rsi": 23.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.74
+ },
+ {
+ "dt": "2025-11-22 01:15:00",
+ "action": "buy",
+ "price": 891.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.092,
+ "leg_id": 42,
+ "bb_pos": 0.182,
+ "rsi": 31.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.21
+ },
+ {
+ "dt": "2025-11-22 08:00:00",
+ "action": "buy",
+ "price": 865.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.095,
+ "leg_id": 42,
+ "bb_pos": 0.098,
+ "rsi": 13.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.53
+ },
+ {
+ "dt": "2025-11-22 14:39:00",
+ "action": "buy",
+ "price": 880.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.093,
+ "leg_id": 42,
+ "bb_pos": 0.206,
+ "rsi": 40.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.59
+ },
+ {
+ "dt": "2025-11-24 09:18:00",
+ "action": "buy",
+ "price": 911.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.09,
+ "leg_id": 42,
+ "bb_pos": 0.0,
+ "rsi": 22.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.79
+ },
+ {
+ "dt": "2025-11-24 23:27:00",
+ "action": "buy",
+ "price": 901.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.091,
+ "leg_id": 42,
+ "bb_pos": 0.218,
+ "rsi": 14.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.99
+ },
+ {
+ "dt": "2025-11-25 23:48:00",
+ "action": "buy",
+ "price": 925.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.088,
+ "leg_id": 42,
+ "bb_pos": 0.0,
+ "rsi": 24.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.16
+ },
+ {
+ "dt": "2025-11-27 00:45:00",
+ "action": "buy",
+ "price": 925.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.088,
+ "leg_id": 42,
+ "bb_pos": 0.081,
+ "rsi": 36.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.16
+ },
+ {
+ "dt": "2025-11-28 11:27:00",
+ "action": "buy",
+ "price": 953.0,
+ "memo": "저점 분할 매수 · 비중 9% · 11회 · BB하단 · leg#42",
+ "weight": 0.086,
+ "leg_id": 42,
+ "bb_pos": 0.217,
+ "rsi": 26.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.04
+ },
+ {
+ "dt": "2025-12-02 02:30:00",
+ "action": "buy",
+ "price": 836.0,
+ "memo": "저점 분할 매수 · 비중 10% · 11회 · BB하단 · leg#42",
+ "weight": 0.098,
+ "leg_id": 42,
+ "bb_pos": 0.245,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.46
+ },
+ {
+ "dt": "2025-12-03 01:00:00",
+ "action": "sell",
+ "price": 982.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#42",
+ "weight": 0.65,
+ "leg_id": 42,
+ "bb_pos": 0.861,
+ "rsi": 71.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.1
+ },
+ {
+ "dt": "2025-12-03 01:51:00",
+ "action": "sell",
+ "price": 981.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#42",
+ "weight": 0.35,
+ "leg_id": 42,
+ "bb_pos": 0.798,
+ "rsi": 50.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.98
+ },
+ {
+ "dt": "2025-12-03 23:03:00",
+ "action": "buy",
+ "price": 923.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#43",
+ "weight": 0.187,
+ "leg_id": 43,
+ "bb_pos": 0.0,
+ "rsi": 9.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.43
+ },
+ {
+ "dt": "2025-12-06 05:45:00",
+ "action": "buy",
+ "price": 846.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#43",
+ "weight": 0.204,
+ "leg_id": 43,
+ "bb_pos": 0.061,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.63
+ },
+ {
+ "dt": "2025-12-07 23:54:00",
+ "action": "buy",
+ "price": 835.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#43",
+ "weight": 0.206,
+ "leg_id": 43,
+ "bb_pos": 0.25,
+ "rsi": 30.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.06
+ },
+ {
+ "dt": "2025-12-08 08:03:00",
+ "action": "buy",
+ "price": 840.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#43",
+ "weight": 0.205,
+ "leg_id": 43,
+ "bb_pos": 0.303,
+ "rsi": 52.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.4
+ },
+ {
+ "dt": "2025-12-09 04:15:00",
+ "action": "buy",
+ "price": 868.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#43",
+ "weight": 0.198,
+ "leg_id": 43,
+ "bb_pos": 0.164,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.88
+ },
+ {
+ "dt": "2025-12-09 13:03:00",
+ "action": "sell",
+ "price": 919.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#43",
+ "weight": 0.65,
+ "leg_id": 43,
+ "bb_pos": 1.0,
+ "rsi": 87.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 6.7
+ },
+ {
+ "dt": "2025-12-09 13:18:00",
+ "action": "sell",
+ "price": 918.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#43",
+ "weight": 0.35,
+ "leg_id": 43,
+ "bb_pos": 0.865,
+ "rsi": 69.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 6.59
+ },
+ {
+ "dt": "2025-12-09 18:39:00",
+ "action": "buy",
+ "price": 876.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#44",
+ "weight": 1.0,
+ "leg_id": 44,
+ "bb_pos": 0.11,
+ "rsi": 23.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.7
+ },
+ {
+ "dt": "2025-12-11 05:15:00",
+ "action": "sell",
+ "price": 961.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#44",
+ "weight": 0.65,
+ "leg_id": 44,
+ "bb_pos": 1.0,
+ "rsi": 66.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.7
+ },
+ {
+ "dt": "2025-12-11 05:33:00",
+ "action": "sell",
+ "price": 953.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#44",
+ "weight": 0.35,
+ "leg_id": 44,
+ "bb_pos": 0.591,
+ "rsi": 58.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.79
+ },
+ {
+ "dt": "2025-12-11 13:00:00",
+ "action": "buy",
+ "price": 866.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#45",
+ "weight": 0.13,
+ "leg_id": 45,
+ "bb_pos": 0.194,
+ "rsi": 46.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": -9.47
+ },
+ {
+ "dt": "2025-12-13 01:36:00",
+ "action": "buy",
+ "price": 848.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#45",
+ "weight": 0.132,
+ "leg_id": 45,
+ "bb_pos": 0.248,
+ "rsi": 31.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.55
+ },
+ {
+ "dt": "2025-12-15 08:30:00",
+ "action": "buy",
+ "price": 835.0,
+ "memo": "저점 분할 매수 · 비중 13% · 7회 · BB하단 · leg#45",
+ "weight": 0.134,
+ "leg_id": 45,
+ "bb_pos": 0.649,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -6.11
+ },
+ {
+ "dt": "2025-12-16 16:51:00",
+ "action": "buy",
+ "price": 776.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#45",
+ "weight": 0.145,
+ "leg_id": 45,
+ "bb_pos": 0.089,
+ "rsi": 9.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 1.03
+ },
+ {
+ "dt": "2025-12-17 17:30:00",
+ "action": "buy",
+ "price": 763.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#45",
+ "weight": 0.147,
+ "leg_id": 45,
+ "bb_pos": 0.009,
+ "rsi": 12.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.75
+ },
+ {
+ "dt": "2025-12-18 15:00:00",
+ "action": "buy",
+ "price": 731.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#45",
+ "weight": 0.154,
+ "leg_id": 45,
+ "bb_pos": 0.22,
+ "rsi": 42.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.25
+ },
+ {
+ "dt": "2025-12-19 10:48:00",
+ "action": "buy",
+ "price": 708.0,
+ "memo": "저점 분할 매수 · 비중 16% · 7회 · BB하단 · leg#45",
+ "weight": 0.158,
+ "leg_id": 45,
+ "bb_pos": 0.29,
+ "rsi": 45.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.73
+ },
+ {
+ "dt": "2025-12-20 05:48:00",
+ "action": "sell",
+ "price": 784.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#45",
+ "weight": 0.65,
+ "leg_id": 45,
+ "bb_pos": 0.771,
+ "rsi": 54.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": -0.19
+ },
+ {
+ "dt": "2025-12-20 06:00:00",
+ "action": "sell",
+ "price": 783.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#45",
+ "weight": 0.35,
+ "leg_id": 45,
+ "bb_pos": 0.674,
+ "rsi": 56.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": -0.32
+ },
+ {
+ "dt": "2025-12-21 22:54:00",
+ "action": "buy",
+ "price": 741.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#46",
+ "weight": 0.192,
+ "leg_id": 46,
+ "bb_pos": 0.164,
+ "rsi": 14.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.06
+ },
+ {
+ "dt": "2025-12-24 23:36:00",
+ "action": "buy",
+ "price": 708.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#46",
+ "weight": 0.201,
+ "leg_id": 46,
+ "bb_pos": 0.02,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 21.47
+ },
+ {
+ "dt": "2025-12-26 08:36:00",
+ "action": "buy",
+ "price": 704.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#46",
+ "weight": 0.202,
+ "leg_id": 46,
+ "bb_pos": 0.14,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 22.16
+ },
+ {
+ "dt": "2025-12-27 01:54:00",
+ "action": "buy",
+ "price": 711.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#46",
+ "weight": 0.2,
+ "leg_id": 46,
+ "bb_pos": 0.4,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 20.96
+ },
+ {
+ "dt": "2026-01-01 02:54:00",
+ "action": "buy",
+ "price": 689.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#46",
+ "weight": 0.206,
+ "leg_id": 46,
+ "bb_pos": 0.287,
+ "rsi": 45.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 24.82
+ },
+ {
+ "dt": "2026-01-03 15:21:00",
+ "action": "sell",
+ "price": 860.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#46",
+ "weight": 0.65,
+ "leg_id": 46,
+ "bb_pos": 1.0,
+ "rsi": 88.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 21.09
+ },
+ {
+ "dt": "2026-01-03 15:33:00",
+ "action": "sell",
+ "price": 854.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#46",
+ "weight": 0.35,
+ "leg_id": 46,
+ "bb_pos": 0.761,
+ "rsi": 77.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 20.25
+ },
+ {
+ "dt": "2026-01-03 17:03:00",
+ "action": "buy",
+ "price": 806.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#47",
+ "weight": 0.343,
+ "leg_id": 47,
+ "bb_pos": 0.171,
+ "rsi": 30.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.74
+ },
+ {
+ "dt": "2026-01-04 05:03:00",
+ "action": "buy",
+ "price": 828.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#47",
+ "weight": 0.334,
+ "leg_id": 47,
+ "bb_pos": 0.516,
+ "rsi": 53.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.61
+ },
+ {
+ "dt": "2026-01-05 13:33:00",
+ "action": "buy",
+ "price": 855.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#47",
+ "weight": 0.323,
+ "leg_id": 47,
+ "bb_pos": 0.0,
+ "rsi": 12.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.99
+ },
+ {
+ "dt": "2026-01-06 04:12:00",
+ "action": "sell",
+ "price": 949.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#47",
+ "weight": 0.65,
+ "leg_id": 47,
+ "bb_pos": 1.0,
+ "rsi": 83.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 14.45
+ },
+ {
+ "dt": "2026-01-06 04:33:00",
+ "action": "sell",
+ "price": 928.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#47",
+ "weight": 0.35,
+ "leg_id": 47,
+ "bb_pos": 0.334,
+ "rsi": 48.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.92
+ },
+ {
+ "dt": "2026-01-06 16:48:00",
+ "action": "buy",
+ "price": 897.0,
+ "memo": "저점 분할 매수 · 비중 12% · 8회 · BB하단 · leg#48",
+ "weight": 0.118,
+ "leg_id": 48,
+ "bb_pos": 0.02,
+ "rsi": 16.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.23
+ },
+ {
+ "dt": "2026-01-07 02:57:00",
+ "action": "buy",
+ "price": 887.0,
+ "memo": "저점 분할 매수 · 비중 12% · 8회 · BB하단 · leg#48",
+ "weight": 0.12,
+ "leg_id": 48,
+ "bb_pos": 0.0,
+ "rsi": 24.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 3.38
+ },
+ {
+ "dt": "2026-01-07 11:00:00",
+ "action": "buy",
+ "price": 891.0,
+ "memo": "저점 분할 매수 · 비중 12% · 8회 · BB하단 · leg#48",
+ "weight": 0.119,
+ "leg_id": 48,
+ "bb_pos": 0.158,
+ "rsi": 35.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 2.92
+ },
+ {
+ "dt": "2026-01-08 23:42:00",
+ "action": "buy",
+ "price": 830.0,
+ "memo": "저점 분할 매수 · 비중 13% · 8회 · BB하단 · leg#48",
+ "weight": 0.128,
+ "leg_id": 48,
+ "bb_pos": 0.572,
+ "rsi": 47.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.48
+ },
+ {
+ "dt": "2026-01-10 15:18:00",
+ "action": "buy",
+ "price": 838.0,
+ "memo": "저점 분할 매수 · 비중 13% · 8회 · BB하단 · leg#48",
+ "weight": 0.127,
+ "leg_id": 48,
+ "bb_pos": 0.0,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.43
+ },
+ {
+ "dt": "2026-01-12 08:03:00",
+ "action": "buy",
+ "price": 823.0,
+ "memo": "저점 분할 매수 · 비중 13% · 8회 · BB하단 · leg#48",
+ "weight": 0.129,
+ "leg_id": 48,
+ "bb_pos": 0.223,
+ "rsi": 43.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.42
+ },
+ {
+ "dt": "2026-01-12 23:24:00",
+ "action": "buy",
+ "price": 821.0,
+ "memo": "저점 분할 매수 · 비중 13% · 8회 · BB하단 · leg#48",
+ "weight": 0.129,
+ "leg_id": 48,
+ "bb_pos": 0.085,
+ "rsi": 35.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.69
+ },
+ {
+ "dt": "2026-01-13 08:03:00",
+ "action": "buy",
+ "price": 818.0,
+ "memo": "저점 분할 매수 · 비중 13% · 8회 · BB하단 · leg#48",
+ "weight": 0.13,
+ "leg_id": 48,
+ "bb_pos": 0.0,
+ "rsi": 46.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.1
+ },
+ {
+ "dt": "2026-01-14 07:48:00",
+ "action": "sell",
+ "price": 917.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#48",
+ "weight": 0.65,
+ "leg_id": 48,
+ "bb_pos": 0.783,
+ "rsi": 91.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.96
+ },
+ {
+ "dt": "2026-01-14 08:03:00",
+ "action": "sell",
+ "price": 916.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#48",
+ "weight": 0.35,
+ "leg_id": 48,
+ "bb_pos": 0.718,
+ "rsi": 73.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.84
+ },
+ {
+ "dt": "2026-01-15 12:54:00",
+ "action": "buy",
+ "price": 848.0,
+ "memo": "저점 분할 매수 · 비중 8% · 10회 · BB하단 · leg#49",
+ "weight": 0.084,
+ "leg_id": 49,
+ "bb_pos": 0.243,
+ "rsi": 20.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.9
+ },
+ {
+ "dt": "2026-01-17 00:45:00",
+ "action": "buy",
+ "price": 801.0,
+ "memo": "저점 분할 매수 · 비중 9% · 10회 · BB하단 · leg#49",
+ "weight": 0.088,
+ "leg_id": 49,
+ "bb_pos": 0.078,
+ "rsi": 23.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.23
+ },
+ {
+ "dt": "2026-01-19 09:00:00",
+ "action": "buy",
+ "price": 712.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.099,
+ "leg_id": 49,
+ "bb_pos": 0.093,
+ "rsi": 12.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 28.51
+ },
+ {
+ "dt": "2026-01-21 08:24:00",
+ "action": "buy",
+ "price": 694.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.102,
+ "leg_id": 49,
+ "bb_pos": 0.434,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 31.84
+ },
+ {
+ "dt": "2026-01-21 22:36:00",
+ "action": "buy",
+ "price": 699.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.101,
+ "leg_id": 49,
+ "bb_pos": 0.292,
+ "rsi": 36.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 30.9
+ },
+ {
+ "dt": "2026-01-22 02:30:00",
+ "action": "buy",
+ "price": 690.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.103,
+ "leg_id": 49,
+ "bb_pos": 0.206,
+ "rsi": 31.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 32.61
+ },
+ {
+ "dt": "2026-01-23 00:27:00",
+ "action": "buy",
+ "price": 694.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.102,
+ "leg_id": 49,
+ "bb_pos": 0.0,
+ "rsi": 30.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 31.84
+ },
+ {
+ "dt": "2026-01-24 00:00:00",
+ "action": "buy",
+ "price": 688.0,
+ "memo": "저점 분할 매수 · 비중 10% · 10회 · BB하단 · leg#49",
+ "weight": 0.103,
+ "leg_id": 49,
+ "bb_pos": 0.19,
+ "rsi": 23.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 32.99
+ },
+ {
+ "dt": "2026-01-26 05:33:00",
+ "action": "buy",
+ "price": 645.0,
+ "memo": "저점 분할 매수 · 비중 11% · 10회 · BB하단 · leg#49",
+ "weight": 0.11,
+ "leg_id": 49,
+ "bb_pos": 0.06,
+ "rsi": 42.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 41.86
+ },
+ {
+ "dt": "2026-01-28 02:48:00",
+ "action": "buy",
+ "price": 656.0,
+ "memo": "저점 분할 매수 · 비중 11% · 10회 · BB하단 · leg#49",
+ "weight": 0.108,
+ "leg_id": 49,
+ "bb_pos": 0.152,
+ "rsi": 16.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 39.48
+ },
+ {
+ "dt": "2026-01-29 07:48:00",
+ "action": "sell",
+ "price": 915.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#49",
+ "weight": 0.65,
+ "leg_id": 49,
+ "bb_pos": 0.972,
+ "rsi": 93.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 29.22
+ },
+ {
+ "dt": "2026-01-29 08:18:00",
+ "action": "sell",
+ "price": 913.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#49",
+ "weight": 0.35,
+ "leg_id": 49,
+ "bb_pos": 0.757,
+ "rsi": 64.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 28.93
+ },
+ {
+ "dt": "2026-01-30 00:12:00",
+ "action": "buy",
+ "price": 693.0,
+ "memo": "저점 분할 매수 · 비중 23% · 4회 · BB하단 · leg#50",
+ "weight": 0.233,
+ "leg_id": 50,
+ "bb_pos": 0.161,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": -10.25
+ },
+ {
+ "dt": "2026-01-30 11:24:00",
+ "action": "buy",
+ "price": 662.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#50",
+ "weight": 0.244,
+ "leg_id": 50,
+ "bb_pos": 0.194,
+ "rsi": 26.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -6.04
+ },
+ {
+ "dt": "2026-01-30 18:21:00",
+ "action": "buy",
+ "price": 669.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#50",
+ "weight": 0.241,
+ "leg_id": 50,
+ "bb_pos": 0.091,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.03
+ },
+ {
+ "dt": "2026-02-01 03:42:00",
+ "action": "buy",
+ "price": 572.0,
+ "memo": "저점 분할 매수 · 비중 28% · 4회 · BB하단 · leg#50",
+ "weight": 0.282,
+ "leg_id": 50,
+ "bb_pos": 0.0,
+ "rsi": 24.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.74
+ },
+ {
+ "dt": "2026-02-01 09:54:00",
+ "action": "sell",
+ "price": 622.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#50",
+ "weight": 0.65,
+ "leg_id": 50,
+ "bb_pos": 0.809,
+ "rsi": 47.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": -3.65
+ },
+ {
+ "dt": "2026-02-01 11:00:00",
+ "action": "sell",
+ "price": 614.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#50",
+ "weight": 0.35,
+ "leg_id": 50,
+ "bb_pos": 0.594,
+ "rsi": 68.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": -4.88
+ },
+ {
+ "dt": "2026-02-02 08:09:00",
+ "action": "buy",
+ "price": 570.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#51",
+ "weight": 0.203,
+ "leg_id": 51,
+ "bb_pos": 0.0,
+ "rsi": 38.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.82
+ },
+ {
+ "dt": "2026-02-02 12:36:00",
+ "action": "buy",
+ "price": 574.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#51",
+ "weight": 0.202,
+ "leg_id": 51,
+ "bb_pos": 0.104,
+ "rsi": 22.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.06
+ },
+ {
+ "dt": "2026-02-02 15:36:00",
+ "action": "buy",
+ "price": 572.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#51",
+ "weight": 0.202,
+ "leg_id": 51,
+ "bb_pos": 0.125,
+ "rsi": 40.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.44
+ },
+ {
+ "dt": "2026-02-03 12:00:00",
+ "action": "buy",
+ "price": 590.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#51",
+ "weight": 0.196,
+ "leg_id": 51,
+ "bb_pos": 0.103,
+ "rsi": 22.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.1
+ },
+ {
+ "dt": "2026-02-04 03:21:00",
+ "action": "buy",
+ "price": 590.0,
+ "memo": "저점 분할 매수 · 비중 20% · 5회 · BB하단 · leg#51",
+ "weight": 0.196,
+ "leg_id": 51,
+ "bb_pos": 0.134,
+ "rsi": 23.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.1
+ },
+ {
+ "dt": "2026-02-04 04:33:00",
+ "action": "sell",
+ "price": 626.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#51",
+ "weight": 0.65,
+ "leg_id": 51,
+ "bb_pos": 0.97,
+ "rsi": 87.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.11
+ },
+ {
+ "dt": "2026-02-04 06:03:00",
+ "action": "sell",
+ "price": 626.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#51",
+ "weight": 0.35,
+ "leg_id": 51,
+ "bb_pos": 0.819,
+ "rsi": 77.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.11
+ },
+ {
+ "dt": "2026-02-05 01:36:00",
+ "action": "buy",
+ "price": 588.0,
+ "memo": "저점 분할 매수 · 비중 15% · 6회 · BB하단 · leg#52",
+ "weight": 0.148,
+ "leg_id": 52,
+ "bb_pos": 0.0,
+ "rsi": 42.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.12
+ },
+ {
+ "dt": "2026-02-06 07:21:00",
+ "action": "buy",
+ "price": 503.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#52",
+ "weight": 0.173,
+ "leg_id": 52,
+ "bb_pos": 0.064,
+ "rsi": 34.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 24.06
+ },
+ {
+ "dt": "2026-02-06 09:18:00",
+ "action": "buy",
+ "price": 461.0,
+ "memo": "저점 분할 매수 · 비중 19% · 6회 · BB하단 · leg#52",
+ "weight": 0.188,
+ "leg_id": 52,
+ "bb_pos": 0.0,
+ "rsi": 15.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 35.36
+ },
+ {
+ "dt": "2026-02-06 12:21:00",
+ "action": "buy",
+ "price": 504.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#52",
+ "weight": 0.172,
+ "leg_id": 52,
+ "bb_pos": 0.423,
+ "rsi": 43.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 23.81
+ },
+ {
+ "dt": "2026-02-06 16:15:00",
+ "action": "buy",
+ "price": 509.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#52",
+ "weight": 0.171,
+ "leg_id": 52,
+ "bb_pos": 0.178,
+ "rsi": 23.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 22.59
+ },
+ {
+ "dt": "2026-02-07 10:21:00",
+ "action": "buy",
+ "price": 587.0,
+ "memo": "저점 분할 매수 · 비중 15% · 6회 · BB하단 · leg#52",
+ "weight": 0.148,
+ "leg_id": 52,
+ "bb_pos": 0.097,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.3
+ },
+ {
+ "dt": "2026-02-07 13:18:00",
+ "action": "sell",
+ "price": 624.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#52",
+ "weight": 0.65,
+ "leg_id": 52,
+ "bb_pos": 1.0,
+ "rsi": 76.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 19.7
+ },
+ {
+ "dt": "2026-02-07 13:33:00",
+ "action": "sell",
+ "price": 615.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#52",
+ "weight": 0.35,
+ "leg_id": 52,
+ "bb_pos": 0.62,
+ "rsi": 60.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 17.97
+ },
+ {
+ "dt": "2026-02-07 20:24:00",
+ "action": "buy",
+ "price": 575.0,
+ "memo": "저점 분할 매수 · 비중 16% · 6회 · BB하단 · leg#53",
+ "weight": 0.16,
+ "leg_id": 53,
+ "bb_pos": 0.033,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.48
+ },
+ {
+ "dt": "2026-02-09 19:30:00",
+ "action": "buy",
+ "price": 556.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#53",
+ "weight": 0.165,
+ "leg_id": 53,
+ "bb_pos": 0.088,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.29
+ },
+ {
+ "dt": "2026-02-10 15:45:00",
+ "action": "buy",
+ "price": 561.0,
+ "memo": "저점 분할 매수 · 비중 16% · 6회 · BB하단 · leg#53",
+ "weight": 0.164,
+ "leg_id": 53,
+ "bb_pos": 0.0,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.26
+ },
+ {
+ "dt": "2026-02-11 19:15:00",
+ "action": "buy",
+ "price": 536.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#53",
+ "weight": 0.172,
+ "leg_id": 53,
+ "bb_pos": 0.022,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 19.59
+ },
+ {
+ "dt": "2026-02-12 00:06:00",
+ "action": "buy",
+ "price": 536.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#53",
+ "weight": 0.172,
+ "leg_id": 53,
+ "bb_pos": 0.182,
+ "rsi": 20.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 19.59
+ },
+ {
+ "dt": "2026-02-13 05:51:00",
+ "action": "buy",
+ "price": 549.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#53",
+ "weight": 0.168,
+ "leg_id": 53,
+ "bb_pos": 0.0,
+ "rsi": 30.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.76
+ },
+ {
+ "dt": "2026-02-15 04:39:00",
+ "action": "sell",
+ "price": 641.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#53",
+ "weight": 0.65,
+ "leg_id": 53,
+ "bb_pos": 1.0,
+ "rsi": 76.2,
+ "pivot_kind": "peak",
+ "forward_return_pct": 16.16
+ },
+ {
+ "dt": "2026-02-15 06:18:00",
+ "action": "sell",
+ "price": 641.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#53",
+ "weight": 0.35,
+ "leg_id": 53,
+ "bb_pos": 0.89,
+ "rsi": 72.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 16.16
+ },
+ {
+ "dt": "2026-02-17 23:45:00",
+ "action": "buy",
+ "price": 574.0,
+ "memo": "저점 분할 매수 · 비중 16% · 6회 · BB하단 · leg#54",
+ "weight": 0.159,
+ "leg_id": 54,
+ "bb_pos": 0.0,
+ "rsi": 22.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.71
+ },
+ {
+ "dt": "2026-02-19 22:42:00",
+ "action": "buy",
+ "price": 545.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#54",
+ "weight": 0.168,
+ "leg_id": 54,
+ "bb_pos": 0.056,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.5
+ },
+ {
+ "dt": "2026-02-20 22:36:00",
+ "action": "buy",
+ "price": 552.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#54",
+ "weight": 0.166,
+ "leg_id": 54,
+ "bb_pos": 0.05,
+ "rsi": 23.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.04
+ },
+ {
+ "dt": "2026-02-23 10:51:00",
+ "action": "buy",
+ "price": 537.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#54",
+ "weight": 0.17,
+ "leg_id": 54,
+ "bb_pos": 0.376,
+ "rsi": 45.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.2
+ },
+ {
+ "dt": "2026-02-24 13:15:00",
+ "action": "buy",
+ "price": 536.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#54",
+ "weight": 0.171,
+ "leg_id": 54,
+ "bb_pos": 0.0,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.42
+ },
+ {
+ "dt": "2026-02-25 06:15:00",
+ "action": "buy",
+ "price": 548.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#54",
+ "weight": 0.167,
+ "leg_id": 54,
+ "bb_pos": 0.078,
+ "rsi": 30.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.87
+ },
+ {
+ "dt": "2026-02-26 04:36:00",
+ "action": "sell",
+ "price": 624.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#54",
+ "weight": 0.65,
+ "leg_id": 54,
+ "bb_pos": 0.955,
+ "rsi": 71.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.79
+ },
+ {
+ "dt": "2026-02-26 04:54:00",
+ "action": "sell",
+ "price": 621.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#54",
+ "weight": 0.35,
+ "leg_id": 54,
+ "bb_pos": 0.789,
+ "rsi": 65.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.24
+ },
+ {
+ "dt": "2026-02-27 01:48:00",
+ "action": "buy",
+ "price": 565.0,
+ "memo": "저점 분할 매수 · 비중 48% · 2회 · BB하단 · leg#55",
+ "weight": 0.485,
+ "leg_id": 55,
+ "bb_pos": 0.0,
+ "rsi": 28.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.6
+ },
+ {
+ "dt": "2026-02-28 18:06:00",
+ "action": "buy",
+ "price": 532.0,
+ "memo": "저점 분할 매수 · 비중 52% · 2회 · BB하단 · leg#55",
+ "weight": 0.515,
+ "leg_id": 55,
+ "bb_pos": 0.144,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.09
+ },
+ {
+ "dt": "2026-03-01 11:45:00",
+ "action": "sell",
+ "price": 591.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#55",
+ "weight": 0.65,
+ "leg_id": 55,
+ "bb_pos": 0.729,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.85
+ },
+ {
+ "dt": "2026-03-01 12:00:00",
+ "action": "sell",
+ "price": 587.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#55",
+ "weight": 0.35,
+ "leg_id": 55,
+ "bb_pos": 0.321,
+ "rsi": 46.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 7.12
+ },
+ {
+ "dt": "2026-03-02 05:39:00",
+ "action": "buy",
+ "price": 549.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#56",
+ "weight": 1.0,
+ "leg_id": 56,
+ "bb_pos": 0.167,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.38
+ },
+ {
+ "dt": "2026-03-03 03:06:00",
+ "action": "sell",
+ "price": 606.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#56",
+ "weight": 0.65,
+ "leg_id": 56,
+ "bb_pos": 1.0,
+ "rsi": 71.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.38
+ },
+ {
+ "dt": "2026-03-03 03:15:00",
+ "action": "sell",
+ "price": 606.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#56",
+ "weight": 0.35,
+ "leg_id": 56,
+ "bb_pos": 0.916,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.38
+ },
+ {
+ "dt": "2026-03-04 12:33:00",
+ "action": "buy",
+ "price": 567.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#57",
+ "weight": 1.0,
+ "leg_id": 57,
+ "bb_pos": 0.0,
+ "rsi": 23.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.52
+ },
+ {
+ "dt": "2026-03-05 00:54:00",
+ "action": "sell",
+ "price": 638.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#57",
+ "weight": 0.65,
+ "leg_id": 57,
+ "bb_pos": 0.941,
+ "rsi": 83.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.52
+ },
+ {
+ "dt": "2026-03-05 01:00:00",
+ "action": "sell",
+ "price": 638.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#57",
+ "weight": 0.35,
+ "leg_id": 57,
+ "bb_pos": 0.838,
+ "rsi": 74.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.52
+ },
+ {
+ "dt": "2026-03-06 03:00:00",
+ "action": "buy",
+ "price": 591.0,
+ "memo": "저점 분할 매수 · 비중 15% · 6회 · BB하단 · leg#58",
+ "weight": 0.149,
+ "leg_id": 58,
+ "bb_pos": 0.221,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 0.17
+ },
+ {
+ "dt": "2026-03-10 04:12:00",
+ "action": "buy",
+ "price": 530.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#58",
+ "weight": 0.166,
+ "leg_id": 58,
+ "bb_pos": 0.34,
+ "rsi": 37.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.7
+ },
+ {
+ "dt": "2026-03-11 16:42:00",
+ "action": "buy",
+ "price": 515.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#58",
+ "weight": 0.171,
+ "leg_id": 58,
+ "bb_pos": 0.0,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.95
+ },
+ {
+ "dt": "2026-03-12 12:45:00",
+ "action": "buy",
+ "price": 512.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#58",
+ "weight": 0.172,
+ "leg_id": 58,
+ "bb_pos": 0.262,
+ "rsi": 44.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 15.62
+ },
+ {
+ "dt": "2026-03-12 23:39:00",
+ "action": "buy",
+ "price": 510.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#58",
+ "weight": 0.172,
+ "leg_id": 58,
+ "bb_pos": 0.0,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.08
+ },
+ {
+ "dt": "2026-03-14 21:57:00",
+ "action": "buy",
+ "price": 516.0,
+ "memo": "저점 분할 매수 · 비중 17% · 6회 · BB하단 · leg#58",
+ "weight": 0.17,
+ "leg_id": 58,
+ "bb_pos": 0.257,
+ "rsi": 20.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.73
+ },
+ {
+ "dt": "2026-03-17 07:42:00",
+ "action": "sell",
+ "price": 592.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#58",
+ "weight": 0.65,
+ "leg_id": 58,
+ "bb_pos": 0.676,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.2
+ },
+ {
+ "dt": "2026-03-17 07:48:00",
+ "action": "sell",
+ "price": 592.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#58",
+ "weight": 0.35,
+ "leg_id": 58,
+ "bb_pos": 0.824,
+ "rsi": 58.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.2
+ },
+ {
+ "dt": "2026-03-17 23:00:00",
+ "action": "buy",
+ "price": 567.0,
+ "memo": "저점 분할 매수 · 비중 9% · 9회 · BB하단 · leg#59",
+ "weight": 0.09,
+ "leg_id": 59,
+ "bb_pos": 0.153,
+ "rsi": 43.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": -23.63
+ },
+ {
+ "dt": "2026-03-19 01:33:00",
+ "action": "buy",
+ "price": 532.0,
+ "memo": "저점 분할 매수 · 비중 10% · 9회 · BB하단 · leg#59",
+ "weight": 0.096,
+ "leg_id": 59,
+ "bb_pos": 0.336,
+ "rsi": 37.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": -18.61
+ },
+ {
+ "dt": "2026-03-21 00:00:00",
+ "action": "buy",
+ "price": 476.0,
+ "memo": "저점 분할 매수 · 비중 11% · 9회 · BB하단 · leg#59",
+ "weight": 0.107,
+ "leg_id": 59,
+ "bb_pos": 0.159,
+ "rsi": 42.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": -9.03
+ },
+ {
+ "dt": "2026-03-21 22:33:00",
+ "action": "buy",
+ "price": 469.0,
+ "memo": "저점 분할 매수 · 비중 11% · 9회 · BB하단 · leg#59",
+ "weight": 0.109,
+ "leg_id": 59,
+ "bb_pos": 0.444,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.68
+ },
+ {
+ "dt": "2026-03-23 06:45:00",
+ "action": "buy",
+ "price": 455.0,
+ "memo": "저점 분할 매수 · 비중 11% · 9회 · BB하단 · leg#59",
+ "weight": 0.112,
+ "leg_id": 59,
+ "bb_pos": 0.344,
+ "rsi": 45.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -4.84
+ },
+ {
+ "dt": "2026-03-23 18:33:00",
+ "action": "buy",
+ "price": 457.0,
+ "memo": "저점 분할 매수 · 비중 11% · 9회 · BB하단 · leg#59",
+ "weight": 0.112,
+ "leg_id": 59,
+ "bb_pos": 0.156,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -5.25
+ },
+ {
+ "dt": "2026-03-24 23:48:00",
+ "action": "buy",
+ "price": 469.0,
+ "memo": "저점 분할 매수 · 비중 11% · 9회 · BB하단 · leg#59",
+ "weight": 0.109,
+ "leg_id": 59,
+ "bb_pos": 0.111,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -7.68
+ },
+ {
+ "dt": "2026-03-28 10:33:00",
+ "action": "buy",
+ "price": 372.0,
+ "memo": "저점 분할 매수 · 비중 14% · 9회 · BB하단 · leg#59",
+ "weight": 0.137,
+ "leg_id": 59,
+ "bb_pos": 0.045,
+ "rsi": 7.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.4
+ },
+ {
+ "dt": "2026-03-28 18:48:00",
+ "action": "buy",
+ "price": 402.0,
+ "memo": "저점 분할 매수 · 비중 13% · 9회 · BB하단 · leg#59",
+ "weight": 0.127,
+ "leg_id": 59,
+ "bb_pos": 0.114,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 7.71
+ },
+ {
+ "dt": "2026-03-30 19:51:00",
+ "action": "sell",
+ "price": 433.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#59",
+ "weight": 0.65,
+ "leg_id": 59,
+ "bb_pos": 0.958,
+ "rsi": 81.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": -5.85
+ },
+ {
+ "dt": "2026-03-30 20:03:00",
+ "action": "sell",
+ "price": 431.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#59",
+ "weight": 0.35,
+ "leg_id": 59,
+ "bb_pos": 0.549,
+ "rsi": 57.1,
+ "pivot_kind": "peak",
+ "forward_return_pct": -6.28
+ },
+ {
+ "dt": "2026-03-31 04:09:00",
+ "action": "buy",
+ "price": 411.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#60",
+ "weight": 0.189,
+ "leg_id": 60,
+ "bb_pos": 0.133,
+ "rsi": 44.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.97
+ },
+ {
+ "dt": "2026-04-01 02:54:00",
+ "action": "buy",
+ "price": 412.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#60",
+ "weight": 0.189,
+ "leg_id": 60,
+ "bb_pos": 0.0,
+ "rsi": 35.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.21
+ },
+ {
+ "dt": "2026-04-02 22:39:00",
+ "action": "buy",
+ "price": 400.0,
+ "memo": "저점 분할 매수 · 비중 19% · 5회 · BB하단 · leg#60",
+ "weight": 0.195,
+ "leg_id": 60,
+ "bb_pos": 0.516,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 1.75
+ },
+ {
+ "dt": "2026-04-06 06:12:00",
+ "action": "buy",
+ "price": 365.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#60",
+ "weight": 0.213,
+ "leg_id": 60,
+ "bb_pos": 0.3,
+ "rsi": 50.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.51
+ },
+ {
+ "dt": "2026-04-07 11:03:00",
+ "action": "buy",
+ "price": 364.0,
+ "memo": "저점 분할 매수 · 비중 21% · 5회 · BB하단 · leg#60",
+ "weight": 0.214,
+ "leg_id": 60,
+ "bb_pos": 0.0,
+ "rsi": 22.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.81
+ },
+ {
+ "dt": "2026-04-08 08:45:00",
+ "action": "sell",
+ "price": 407.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#60",
+ "weight": 0.65,
+ "leg_id": 60,
+ "bb_pos": 0.939,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.58
+ },
+ {
+ "dt": "2026-04-08 08:51:00",
+ "action": "sell",
+ "price": 407.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#60",
+ "weight": 0.35,
+ "leg_id": 60,
+ "bb_pos": 0.838,
+ "rsi": 73.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.58
+ },
+ {
+ "dt": "2026-04-08 23:30:00",
+ "action": "buy",
+ "price": 384.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#61",
+ "weight": 0.255,
+ "leg_id": 61,
+ "bb_pos": 0.14,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 16.67
+ },
+ {
+ "dt": "2026-04-09 10:06:00",
+ "action": "buy",
+ "price": 377.0,
+ "memo": "저점 분할 매수 · 비중 26% · 4회 · BB하단 · leg#61",
+ "weight": 0.26,
+ "leg_id": 61,
+ "bb_pos": 0.286,
+ "rsi": 36.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 18.83
+ },
+ {
+ "dt": "2026-04-10 09:18:00",
+ "action": "buy",
+ "price": 395.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#61",
+ "weight": 0.248,
+ "leg_id": 61,
+ "bb_pos": 0.109,
+ "rsi": 41.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.42
+ },
+ {
+ "dt": "2026-04-11 22:00:00",
+ "action": "buy",
+ "price": 411.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#61",
+ "weight": 0.238,
+ "leg_id": 61,
+ "bb_pos": 0.047,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.0
+ },
+ {
+ "dt": "2026-04-12 07:21:00",
+ "action": "sell",
+ "price": 448.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#61",
+ "weight": 0.65,
+ "leg_id": 61,
+ "bb_pos": 0.907,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 14.48
+ },
+ {
+ "dt": "2026-04-12 10:30:00",
+ "action": "sell",
+ "price": 446.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#61",
+ "weight": 0.35,
+ "leg_id": 61,
+ "bb_pos": 1.0,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 13.97
+ },
+ {
+ "dt": "2026-04-12 10:51:00",
+ "action": "buy",
+ "price": 423.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#62",
+ "weight": 0.253,
+ "leg_id": 62,
+ "bb_pos": 0.0,
+ "rsi": 14.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.95
+ },
+ {
+ "dt": "2026-04-13 00:15:00",
+ "action": "buy",
+ "price": 423.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#62",
+ "weight": 0.253,
+ "leg_id": 62,
+ "bb_pos": 0.367,
+ "rsi": 45.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 13.95
+ },
+ {
+ "dt": "2026-04-13 17:51:00",
+ "action": "buy",
+ "price": 431.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#62",
+ "weight": 0.249,
+ "leg_id": 62,
+ "bb_pos": 0.043,
+ "rsi": 28.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.83
+ },
+ {
+ "dt": "2026-04-14 03:15:00",
+ "action": "buy",
+ "price": 437.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#62",
+ "weight": 0.245,
+ "leg_id": 62,
+ "bb_pos": 0.189,
+ "rsi": 20.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.3
+ },
+ {
+ "dt": "2026-04-14 16:51:00",
+ "action": "sell",
+ "price": 482.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#62",
+ "weight": 0.65,
+ "leg_id": 62,
+ "bb_pos": 0.894,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.51
+ },
+ {
+ "dt": "2026-04-14 17:06:00",
+ "action": "sell",
+ "price": 479.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#62",
+ "weight": 0.35,
+ "leg_id": 62,
+ "bb_pos": 0.777,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.81
+ },
+ {
+ "dt": "2026-04-15 09:57:00",
+ "action": "buy",
+ "price": 433.0,
+ "memo": "저점 분할 매수 · 비중 51% · 2회 · BB하단 · leg#63",
+ "weight": 0.515,
+ "leg_id": 63,
+ "bb_pos": 0.034,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.78
+ },
+ {
+ "dt": "2026-04-17 02:36:00",
+ "action": "buy",
+ "price": 459.0,
+ "memo": "저점 분할 매수 · 비중 49% · 2회 · BB하단 · leg#63",
+ "weight": 0.485,
+ "leg_id": 63,
+ "bb_pos": 0.109,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.45
+ },
+ {
+ "dt": "2026-04-17 07:45:00",
+ "action": "sell",
+ "price": 484.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#63",
+ "weight": 0.65,
+ "leg_id": 63,
+ "bb_pos": 1.0,
+ "rsi": 73.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.62
+ },
+ {
+ "dt": "2026-04-17 08:36:00",
+ "action": "sell",
+ "price": 483.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#63",
+ "weight": 0.35,
+ "leg_id": 63,
+ "bb_pos": 0.655,
+ "rsi": 50.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.39
+ },
+ {
+ "dt": "2026-04-19 13:39:00",
+ "action": "buy",
+ "price": 386.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#64",
+ "weight": 0.136,
+ "leg_id": 64,
+ "bb_pos": 0.368,
+ "rsi": 44.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.04
+ },
+ {
+ "dt": "2026-04-20 07:21:00",
+ "action": "buy",
+ "price": 383.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#64",
+ "weight": 0.138,
+ "leg_id": 64,
+ "bb_pos": 0.0,
+ "rsi": 29.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.26
+ },
+ {
+ "dt": "2026-04-22 04:39:00",
+ "action": "buy",
+ "price": 385.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#64",
+ "weight": 0.137,
+ "leg_id": 64,
+ "bb_pos": 0.057,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": -0.78
+ },
+ {
+ "dt": "2026-04-24 02:39:00",
+ "action": "buy",
+ "price": 381.0,
+ "memo": "저점 분할 매수 · 비중 14% · 7회 · BB하단 · leg#64",
+ "weight": 0.138,
+ "leg_id": 64,
+ "bb_pos": 0.0,
+ "rsi": 25.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 0.26
+ },
+ {
+ "dt": "2026-04-30 03:30:00",
+ "action": "buy",
+ "price": 359.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#64",
+ "weight": 0.147,
+ "leg_id": 64,
+ "bb_pos": 0.144,
+ "rsi": 20.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.41
+ },
+ {
+ "dt": "2026-05-02 14:21:00",
+ "action": "buy",
+ "price": 345.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#64",
+ "weight": 0.153,
+ "leg_id": 64,
+ "bb_pos": 0.0,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.72
+ },
+ {
+ "dt": "2026-05-04 09:21:00",
+ "action": "buy",
+ "price": 348.0,
+ "memo": "저점 분할 매수 · 비중 15% · 7회 · BB하단 · leg#64",
+ "weight": 0.151,
+ "leg_id": 64,
+ "bb_pos": 0.072,
+ "rsi": 0.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.77
+ },
+ {
+ "dt": "2026-05-06 19:33:00",
+ "action": "sell",
+ "price": 382.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#64",
+ "weight": 0.65,
+ "leg_id": 64,
+ "bb_pos": 0.964,
+ "rsi": 71.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 3.59
+ },
+ {
+ "dt": "2026-05-06 19:39:00",
+ "action": "sell",
+ "price": 382.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#64",
+ "weight": 0.35,
+ "leg_id": 64,
+ "bb_pos": 1.0,
+ "rsi": 71.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 3.59
+ },
+ {
+ "dt": "2026-05-07 11:12:00",
+ "action": "buy",
+ "price": 365.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#65",
+ "weight": 0.502,
+ "leg_id": 65,
+ "bb_pos": 0.0,
+ "rsi": 22.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.05
+ },
+ {
+ "dt": "2026-05-08 00:27:00",
+ "action": "buy",
+ "price": 368.0,
+ "memo": "저점 분할 매수 · 비중 50% · 2회 · BB하단 · leg#65",
+ "weight": 0.498,
+ "leg_id": 65,
+ "bb_pos": 0.0,
+ "rsi": 40.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.14
+ },
+ {
+ "dt": "2026-05-09 04:30:00",
+ "action": "sell",
+ "price": 409.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#65",
+ "weight": 0.65,
+ "leg_id": 65,
+ "bb_pos": 1.0,
+ "rsi": 71.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 11.6
+ },
+ {
+ "dt": "2026-05-09 06:51:00",
+ "action": "sell",
+ "price": 404.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#65",
+ "weight": 0.35,
+ "leg_id": 65,
+ "bb_pos": 0.969,
+ "rsi": 54.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.23
+ },
+ {
+ "dt": "2026-05-10 10:27:00",
+ "action": "buy",
+ "price": 391.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#66",
+ "weight": 1.0,
+ "leg_id": 66,
+ "bb_pos": 0.212,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.21
+ },
+ {
+ "dt": "2026-05-11 01:12:00",
+ "action": "sell",
+ "price": 427.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#66",
+ "weight": 0.65,
+ "leg_id": 66,
+ "bb_pos": 1.0,
+ "rsi": 93.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.21
+ },
+ {
+ "dt": "2026-05-11 04:36:00",
+ "action": "sell",
+ "price": 423.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#66",
+ "weight": 0.35,
+ "leg_id": 66,
+ "bb_pos": 0.978,
+ "rsi": 72.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 8.18
+ },
+ {
+ "dt": "2026-05-11 06:12:00",
+ "action": "buy",
+ "price": 399.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#67",
+ "weight": 0.331,
+ "leg_id": 67,
+ "bb_pos": 0.194,
+ "rsi": 16.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.02
+ },
+ {
+ "dt": "2026-05-11 12:36:00",
+ "action": "buy",
+ "price": 402.0,
+ "memo": "저점 분할 매수 · 비중 33% · 3회 · BB하단 · leg#67",
+ "weight": 0.329,
+ "leg_id": 67,
+ "bb_pos": 0.153,
+ "rsi": 26.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.22
+ },
+ {
+ "dt": "2026-05-13 01:48:00",
+ "action": "buy",
+ "price": 388.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#67",
+ "weight": 0.34,
+ "leg_id": 67,
+ "bb_pos": 0.0,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 9.02
+ },
+ {
+ "dt": "2026-05-13 18:45:00",
+ "action": "sell",
+ "price": 423.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#67",
+ "weight": 0.65,
+ "leg_id": 67,
+ "bb_pos": 0.962,
+ "rsi": 81.8,
+ "pivot_kind": "peak",
+ "forward_return_pct": 6.75
+ },
+ {
+ "dt": "2026-05-13 19:18:00",
+ "action": "sell",
+ "price": 417.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#67",
+ "weight": 0.35,
+ "leg_id": 67,
+ "bb_pos": 0.227,
+ "rsi": 33.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 5.24
+ },
+ {
+ "dt": "2026-05-14 12:48:00",
+ "action": "buy",
+ "price": 377.0,
+ "memo": "저점 분할 매수 · 비중 31% · 3회 · BB하단 · leg#68",
+ "weight": 0.314,
+ "leg_id": 68,
+ "bb_pos": 0.111,
+ "rsi": 31.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": -1.86
+ },
+ {
+ "dt": "2026-05-16 19:03:00",
+ "action": "buy",
+ "price": 352.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#68",
+ "weight": 0.337,
+ "leg_id": 68,
+ "bb_pos": 0.153,
+ "rsi": 44.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 5.11
+ },
+ {
+ "dt": "2026-05-18 08:39:00",
+ "action": "buy",
+ "price": 340.0,
+ "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#68",
+ "weight": 0.349,
+ "leg_id": 68,
+ "bb_pos": 0.132,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 8.82
+ },
+ {
+ "dt": "2026-05-19 15:27:00",
+ "action": "sell",
+ "price": 370.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#68",
+ "weight": 0.65,
+ "leg_id": 68,
+ "bb_pos": 0.906,
+ "rsi": 63.6,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.03
+ },
+ {
+ "dt": "2026-05-19 15:33:00",
+ "action": "sell",
+ "price": 370.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#68",
+ "weight": 0.35,
+ "leg_id": 68,
+ "bb_pos": 0.863,
+ "rsi": 60.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 4.03
+ },
+ {
+ "dt": "2026-05-20 21:24:00",
+ "action": "buy",
+ "price": 352.0,
+ "memo": "저점 분할 매수 · 비중 100% · 1회 · BB하단 · leg#69",
+ "weight": 1.0,
+ "leg_id": 69,
+ "bb_pos": 0.071,
+ "rsi": 30.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.22
+ },
+ {
+ "dt": "2026-05-21 18:03:00",
+ "action": "sell",
+ "price": 395.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#69",
+ "weight": 0.65,
+ "leg_id": 69,
+ "bb_pos": 1.0,
+ "rsi": 88.9,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.22
+ },
+ {
+ "dt": "2026-05-21 18:09:00",
+ "action": "sell",
+ "price": 395.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#69",
+ "weight": 0.35,
+ "leg_id": 69,
+ "bb_pos": 0.943,
+ "rsi": 80.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.22
+ },
+ {
+ "dt": "2026-05-21 20:42:00",
+ "action": "buy",
+ "price": 378.0,
+ "memo": "저점 분할 매수 · 비중 27% · 4회 · BB하단 · leg#70",
+ "weight": 0.267,
+ "leg_id": 70,
+ "bb_pos": 0.0,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 17.72
+ },
+ {
+ "dt": "2026-05-22 06:45:00",
+ "action": "buy",
+ "price": 397.0,
+ "memo": "저점 분할 매수 · 비중 25% · 4회 · BB하단 · leg#70",
+ "weight": 0.254,
+ "leg_id": 70,
+ "bb_pos": 0.025,
+ "rsi": 27.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.09
+ },
+ {
+ "dt": "2026-05-22 18:33:00",
+ "action": "buy",
+ "price": 417.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#70",
+ "weight": 0.242,
+ "leg_id": 70,
+ "bb_pos": 0.021,
+ "rsi": 37.5,
+ "pivot_kind": "trough",
+ "forward_return_pct": 6.71
+ },
+ {
+ "dt": "2026-05-22 23:21:00",
+ "action": "buy",
+ "price": 425.0,
+ "memo": "저점 분할 매수 · 비중 24% · 4회 · BB하단 · leg#70",
+ "weight": 0.237,
+ "leg_id": 70,
+ "bb_pos": 0.0,
+ "rsi": 38.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.71
+ },
+ {
+ "dt": "2026-05-23 00:30:00",
+ "action": "sell",
+ "price": 445.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#70",
+ "weight": 0.65,
+ "leg_id": 70,
+ "bb_pos": 1.0,
+ "rsi": 80.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.31
+ },
+ {
+ "dt": "2026-05-23 01:09:00",
+ "action": "sell",
+ "price": 443.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#70",
+ "weight": 0.35,
+ "leg_id": 70,
+ "bb_pos": 0.592,
+ "rsi": 51.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 9.82
+ },
+ {
+ "dt": "2026-05-23 04:33:00",
+ "action": "buy",
+ "price": 407.0,
+ "memo": "저점 분할 매수 · 비중 34% · 3회 · BB하단 · leg#71",
+ "weight": 0.338,
+ "leg_id": 71,
+ "bb_pos": 0.0,
+ "rsi": 17.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": 14.0
+ },
+ {
+ "dt": "2026-05-23 16:48:00",
+ "action": "buy",
+ "price": 392.0,
+ "memo": "저점 분할 매수 · 비중 35% · 3회 · BB하단 · leg#71",
+ "weight": 0.351,
+ "leg_id": 71,
+ "bb_pos": 0.076,
+ "rsi": 9.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": 18.37
+ },
+ {
+ "dt": "2026-05-24 08:42:00",
+ "action": "buy",
+ "price": 444.0,
+ "memo": "저점 분할 매수 · 비중 31% · 3회 · BB하단 · leg#71",
+ "weight": 0.31,
+ "leg_id": 71,
+ "bb_pos": 0.054,
+ "rsi": 30.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 4.5
+ },
+ {
+ "dt": "2026-05-24 22:45:00",
+ "action": "sell",
+ "price": 464.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#71",
+ "weight": 0.65,
+ "leg_id": 71,
+ "bb_pos": 1.0,
+ "rsi": 68.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 12.29
+ },
+ {
+ "dt": "2026-05-24 23:00:00",
+ "action": "sell",
+ "price": 455.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#71",
+ "weight": 0.35,
+ "leg_id": 71,
+ "bb_pos": 0.416,
+ "rsi": 48.4,
+ "pivot_kind": "peak",
+ "forward_return_pct": 10.11
+ },
+ {
+ "dt": "2026-05-25 06:42:00",
+ "action": "buy",
+ "price": 427.0,
+ "memo": "저점 분할 매수 · 비중 36% · 3회 · BB하단 · leg#72",
+ "weight": 0.361,
+ "leg_id": 72,
+ "bb_pos": 0.083,
+ "rsi": 30.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 41.22
+ },
+ {
+ "dt": "2026-05-26 05:42:00",
+ "action": "buy",
+ "price": 482.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#72",
+ "weight": 0.32,
+ "leg_id": 72,
+ "bb_pos": 0.107,
+ "rsi": 48.0,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.1
+ },
+ {
+ "dt": "2026-05-26 09:51:00",
+ "action": "buy",
+ "price": 482.0,
+ "memo": "저점 분할 매수 · 비중 32% · 3회 · BB하단 · leg#72",
+ "weight": 0.32,
+ "leg_id": 72,
+ "bb_pos": 0.185,
+ "rsi": 36.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 25.1
+ },
+ {
+ "dt": "2026-05-26 21:30:00",
+ "action": "sell",
+ "price": 603.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#72",
+ "weight": 0.65,
+ "leg_id": 72,
+ "bb_pos": 1.0,
+ "rsi": 66.7,
+ "pivot_kind": "peak",
+ "forward_return_pct": 30.47
+ },
+ {
+ "dt": "2026-05-26 23:03:00",
+ "action": "buy",
+ "price": 564.0,
+ "memo": "저점 분할 매수 · 비중 14% · 6회 · BB하단 · leg#73",
+ "weight": 0.138,
+ "leg_id": 73,
+ "bb_pos": 0.136,
+ "rsi": 46.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": -18.26
+ },
+ {
+ "dt": "2026-05-27 01:00:00",
+ "action": "sell",
+ "price": 597.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#72",
+ "weight": 0.35,
+ "leg_id": 72,
+ "bb_pos": 1.0,
+ "rsi": 68.3,
+ "pivot_kind": "peak",
+ "forward_return_pct": 29.17
+ },
+ {
+ "dt": "2026-05-27 13:18:00",
+ "action": "buy",
+ "price": 522.0,
+ "memo": "저점 분할 매수 · 비중 15% · 6회 · BB하단 · leg#73",
+ "weight": 0.149,
+ "leg_id": 73,
+ "bb_pos": 0.08,
+ "rsi": 16.7,
+ "pivot_kind": "trough",
+ "forward_return_pct": -11.69
+ },
+ {
+ "dt": "2026-05-27 18:15:00",
+ "action": "buy",
+ "price": 520.0,
+ "memo": "저점 분할 매수 · 비중 15% · 6회 · BB하단 · leg#73",
+ "weight": 0.149,
+ "leg_id": 73,
+ "bb_pos": 0.0,
+ "rsi": 23.1,
+ "pivot_kind": "trough",
+ "forward_return_pct": -11.35
+ },
+ {
+ "dt": "2026-05-29 02:45:00",
+ "action": "buy",
+ "price": 412.0,
+ "memo": "저점 분할 매수 · 비중 19% · 6회 · BB하단 · leg#73",
+ "weight": 0.188,
+ "leg_id": 73,
+ "bb_pos": 0.413,
+ "rsi": 47.4,
+ "pivot_kind": "trough",
+ "forward_return_pct": 11.89
+ },
+ {
+ "dt": "2026-05-29 07:27:00",
+ "action": "buy",
+ "price": 408.0,
+ "memo": "저점 분할 매수 · 비중 19% · 6회 · BB하단 · leg#73",
+ "weight": 0.19,
+ "leg_id": 73,
+ "bb_pos": 0.135,
+ "rsi": 27.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 12.99
+ },
+ {
+ "dt": "2026-05-29 13:09:00",
+ "action": "buy",
+ "price": 417.0,
+ "memo": "저점 분할 매수 · 비중 19% · 6회 · BB하단 · leg#73",
+ "weight": 0.186,
+ "leg_id": 73,
+ "bb_pos": 0.056,
+ "rsi": 27.8,
+ "pivot_kind": "trough",
+ "forward_return_pct": 10.55
+ },
+ {
+ "dt": "2026-05-29 16:27:00",
+ "action": "sell",
+ "price": 461.0,
+ "memo": "고점 매도 · 비중 65% · 분할 · leg#73",
+ "weight": 0.65,
+ "leg_id": 73,
+ "bb_pos": 0.992,
+ "rsi": 90.0,
+ "pivot_kind": "peak",
+ "forward_return_pct": -0.99
+ },
+ {
+ "dt": "2026-05-29 17:00:00",
+ "action": "sell",
+ "price": 458.0,
+ "memo": "고점 매도 · 비중 35% · 분할 · leg#73",
+ "weight": 0.35,
+ "leg_id": 73,
+ "bb_pos": 0.627,
+ "rsi": 53.5,
+ "pivot_kind": "peak",
+ "forward_return_pct": -1.64
+ },
+ {
+ "dt": "2026-05-29 21:33:00",
+ "action": "buy",
+ "price": 430.0,
+ "memo": "저점 분할 매수(미청산) · 비중 25%",
+ "weight": 0.25,
+ "leg_id": 74,
+ "bb_pos": 0.6,
+ "rsi": 53.6,
+ "pivot_kind": "trough",
+ "forward_return_pct": null
+ },
+ {
+ "dt": "2026-05-30 01:24:00",
+ "action": "buy",
+ "price": 439.0,
+ "memo": "저점 분할 매수(미청산) · 비중 24%",
+ "weight": 0.244,
+ "leg_id": 74,
+ "bb_pos": 0.085,
+ "rsi": 29.2,
+ "pivot_kind": "trough",
+ "forward_return_pct": null
+ },
+ {
+ "dt": "2026-05-30 09:57:00",
+ "action": "buy",
+ "price": 424.0,
+ "memo": "저점 분할 매수(미청산) · 비중 25%",
+ "weight": 0.253,
+ "leg_id": 74,
+ "bb_pos": 0.192,
+ "rsi": 33.3,
+ "pivot_kind": "trough",
+ "forward_return_pct": null
+ },
+ {
+ "dt": "2026-05-30 13:30:00",
+ "action": "buy",
+ "price": 424.0,
+ "memo": "저점 분할 매수(미청산) · 비중 25%",
+ "weight": 0.253,
+ "leg_id": 74,
+ "bb_pos": 0.206,
+ "rsi": 42.9,
+ "pivot_kind": "trough",
+ "forward_return_pct": null
+ }
+ ]
+}
\ No newline at end of file
diff --git a/data/ops/.gitkeep b/data/ops/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/deepcoin/__init__.py b/deepcoin/__init__.py
new file mode 100644
index 0000000..78b1972
--- /dev/null
+++ b/deepcoin/__init__.py
@@ -0,0 +1,14 @@
+"""
+DeepCoin 패키지 — WLD MTF 분석·정답·운영 단계.
+"""
+
+from pathlib import Path
+import sys
+
+_PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(_PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(_PROJECT_ROOT))
+
+from deepcoin.paths import ensure_dirs
+
+ensure_dirs()
diff --git a/deepcoin/analysis/README.md b/deepcoin/analysis/README.md
new file mode 100644
index 0000000..fbf832c
--- /dev/null
+++ b/deepcoin/analysis/README.md
@@ -0,0 +1,6 @@
+# analysis — 03·03b 기술적 분석
+
+- **03 enrich**: `general_analysis_enrich_runner.py` — 봉 전구간 지표·패턴 → `docs/03_analysis/latest/`
+- **03b GT 스냅샷**: `general_analysis_runner.py` — 정답 매수·매도 시점 MTF 상태 → `general_analysis_trades.csv`
+
+실행은 `scripts/03_analyze_enrich.py`, `scripts/03_analyze_trades.py`만 사용합니다.
diff --git a/deepcoin/analysis/__init__.py b/deepcoin/analysis/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/deepcoin/analysis/general_analysis_align.py b/deepcoin/analysis/general_analysis_align.py
new file mode 100644
index 0000000..4c88e0b
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_align.py
@@ -0,0 +1,153 @@
+"""
+general_analysis MTF 합성·정렬 점수.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pandas as pd
+
+from config import (
+ ALIGN_BB_POS_HIGH,
+ ALIGN_BB_POS_LOW,
+ ALIGN_RSI_CONFLICT_TIMING_HIGH,
+ ALIGN_RSI_CONFLICT_TIMING_LOW,
+ ALIGN_RSI_CONFLICT_TREND_HIGH,
+ ALIGN_RSI_CONFLICT_TREND_LOW,
+ ALIGN_RSI_OVERBOUGHT,
+ ALIGN_RSI_OVERSOLD,
+)
+from deepcoin.analysis.general_analysis_config import TIMING_INTERVALS, TREND_INTERVALS
+from deepcoin.analysis.general_analysis_core import ga_col, interval_tf_prefix
+
+
+def general_analysis_mtf_scores(
+ prefixed_row: dict[str, Any],
+) -> dict[str, float | int | str]:
+ """
+ 간격 접두사가 붙은 스냅샷 행에서 MTF 합성 점수 계산.
+
+ Args:
+ prefixed_row: m3_ga_rsi 형태 flat dict.
+
+ Returns:
+ ga_align_* 점수.
+ """
+ rsi_oversold = 0
+ rsi_overbought = 0
+ trend_up = 0
+ trend_down = 0
+ n_timing = 0
+ n_trend = 0
+ conflict = 0
+
+ for interval in TIMING_INTERVALS:
+ p = interval_tf_prefix(interval)
+ rk = f"{p}_RSI"
+ if rk in prefixed_row and prefixed_row[rk] is not None:
+ n_timing += 1
+ rsi = float(prefixed_row[rk])
+ if rsi < ALIGN_RSI_OVERSOLD:
+ rsi_oversold += 1
+ if rsi > ALIGN_RSI_OVERBOUGHT:
+ rsi_overbought += 1
+
+ for interval in TREND_INTERVALS:
+ p = interval_tf_prefix(interval)
+ sk = f"{p}_{ga_col('struct_trend')}"
+ if sk in prefixed_row:
+ n_trend += 1
+ t = prefixed_row[sk]
+ if t == "up":
+ trend_up += 1
+ elif t == "down":
+ trend_down += 1
+
+ m3_rsi = prefixed_row.get("m3_RSI")
+ d1_rsi = prefixed_row.get("d1_RSI")
+ if m3_rsi is not None and d1_rsi is not None:
+ if (
+ float(m3_rsi) < ALIGN_RSI_CONFLICT_TIMING_LOW
+ and float(d1_rsi) > ALIGN_RSI_CONFLICT_TREND_HIGH
+ ):
+ conflict = 1
+ if (
+ float(m3_rsi) > ALIGN_RSI_CONFLICT_TIMING_HIGH
+ and float(d1_rsi) < ALIGN_RSI_CONFLICT_TREND_LOW
+ ):
+ conflict = 1
+
+ timing_buy_align = rsi_oversold / max(len(TIMING_INTERVALS), 1)
+ timing_sell_align = rsi_overbought / max(len(TIMING_INTERVALS), 1)
+
+ return {
+ "ga_align_rsi_oversold_tf": rsi_oversold,
+ "ga_align_rsi_overbought_tf": rsi_overbought,
+ "ga_align_trend_up_tf": trend_up,
+ "ga_align_trend_down_tf": trend_down,
+ "ga_align_timing_buy_score": round(timing_buy_align, 3),
+ "ga_align_timing_sell_score": round(timing_sell_align, 3),
+ "ga_align_trend_score": round(
+ (trend_up - trend_down) / max(n_trend, 1), 3
+ ),
+ "ga_align_mtf_conflict": conflict,
+ }
+
+
+def general_analysis_mtf_vote_latest(
+ frames_enriched: dict[int, pd.DataFrame],
+) -> dict[str, float | int | str]:
+ """
+ 각 TF 최신 완성봉 지표로 TF 가중 투표·필터 점수 산출.
+
+ Args:
+ frames_enriched: interval → enrich된 DataFrame.
+
+ Returns:
+ ga_vote_* 점수 (접두사 없음, ga_col로 감쌀 것).
+ """
+ votes_buy = 0
+ votes_sell = 0
+ trend_ok = 0
+ n = 0
+
+ for interval in TIMING_INTERVALS:
+ df = frames_enriched.get(interval)
+ if df is None or df.empty:
+ continue
+ row = df.iloc[-1]
+ n += 1
+ rsi = row.get("RSI")
+ if rsi is not None and not pd.isna(rsi):
+ if float(rsi) < ALIGN_RSI_OVERSOLD:
+ votes_buy += 1
+ if float(rsi) > ALIGN_RSI_OVERBOUGHT:
+ votes_sell += 1
+ bb_pos = row.get("bb_pos")
+ if bb_pos is not None and float(bb_pos) < ALIGN_BB_POS_LOW:
+ votes_buy += 1
+ if bb_pos is not None and float(bb_pos) > ALIGN_BB_POS_HIGH:
+ votes_sell += 1
+
+ for interval in TREND_INTERVALS:
+ df = frames_enriched.get(interval)
+ if df is None or df.empty:
+ continue
+ row = df.iloc[-1]
+ st = row.get(ga_col("struct_trend"), "range")
+ if st == "up":
+ trend_ok += 1
+ elif st == "down":
+ trend_ok -= 1
+
+ return {
+ "vote_timing_buy": votes_buy,
+ "vote_timing_sell": votes_sell,
+ "vote_trend_score": trend_ok,
+ "vote_tf_used": n,
+ }
+
+
+def general_analysis_vote_columns() -> list[str]:
+ return ["vote_timing_buy", "vote_timing_sell", "vote_trend_score", "vote_tf_used"]
diff --git a/deepcoin/analysis/general_analysis_candles.py b/deepcoin/analysis/general_analysis_candles.py
new file mode 100644
index 0000000..61c7d8e
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_candles.py
@@ -0,0 +1,114 @@
+"""
+general_analysis 캔들·차트 변환 (Heikin-Ashi, 복수봉 패턴).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_core import ga_col
+
+
+def general_analysis_apply_candles(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ 단일·복수 봉 캔들 패턴 및 Heikin-Ashi 컬럼 추가.
+
+ Args:
+ df: OHLCV.
+
+ Returns:
+ ga_* 캔들 컬럼이 추가된 DataFrame.
+ """
+ out = df.copy()
+ o = out["Open"].astype(float)
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+
+ rng = (h - l).replace(0, np.nan)
+ body = (c - o).abs()
+ out[ga_col("body_ratio")] = (body / rng).fillna(0).clip(0, 1)
+ upper_wick = h - np.maximum(o, c)
+ lower_wick = np.minimum(o, c) - l
+ out[ga_col("upper_wick_ratio")] = (upper_wick / rng).fillna(0).clip(0, 1)
+ out[ga_col("lower_wick_ratio")] = (lower_wick / rng).fillna(0).clip(0, 1)
+ out[ga_col("bullish")] = (c > o).astype(int)
+ out[ga_col("bearish")] = (c < o).astype(int)
+ out[ga_col("hammer")] = (
+ (out[ga_col("lower_wick_ratio")] > 0.45) & (out[ga_col("body_ratio")] < 0.35)
+ ).astype(int)
+ out[ga_col("shooting_star")] = (
+ (out[ga_col("upper_wick_ratio")] > 0.45) & (out[ga_col("body_ratio")] < 0.35)
+ ).astype(int)
+ out[ga_col("doji")] = (out[ga_col("body_ratio")] < 0.1).astype(int)
+
+ prev_o, prev_c = o.shift(1), c.shift(1)
+ out[ga_col("bullish_engulfing")] = (
+ (c > o) & (prev_c < prev_o) & (c >= prev_o) & (o <= prev_c)
+ ).astype(int)
+ out[ga_col("bearish_engulfing")] = (
+ (c < o) & (prev_c > prev_o) & (c <= prev_o) & (o >= prev_c)
+ ).astype(int)
+
+ o2, c2 = o.shift(2), c.shift(2)
+ mid1 = (o.shift(1) + c.shift(1)) / 2
+ out[ga_col("morning_star")] = (
+ (c2 < o2)
+ & (abs(c.shift(1) - o.shift(1)) < rng.shift(1) * 0.15)
+ & (c > o)
+ & (c > mid1)
+ ).astype(int)
+ out[ga_col("evening_star")] = (
+ (c2 > o2)
+ & (abs(c.shift(1) - o.shift(1)) < rng.shift(1) * 0.15)
+ & (c < o)
+ & (c < mid1)
+ ).astype(int)
+
+ out[ga_col("three_white_soldiers")] = (
+ (c > o)
+ & (c.shift(1) > o.shift(1))
+ & (c.shift(2) > o.shift(2))
+ & (c > c.shift(1))
+ & (c.shift(1) > c.shift(2))
+ ).astype(int)
+ out[ga_col("three_black_crows")] = (
+ (c < o)
+ & (c.shift(1) < o.shift(1))
+ & (c.shift(2) < o.shift(2))
+ & (c < c.shift(1))
+ & (c.shift(1) < c.shift(2))
+ ).astype(int)
+
+ # Heikin-Ashi
+ ha_close = (o + h + l + c) / 4
+ ha_open = ha_close.copy()
+ ha_open.iloc[0] = (o.iloc[0] + c.iloc[0]) / 2
+ for i in range(1, len(out)):
+ ha_open.iloc[i] = (ha_open.iloc[i - 1] + ha_close.iloc[i - 1]) / 2
+ out[ga_col("ha_close")] = ha_close
+ out[ga_col("ha_open")] = ha_open
+ out[ga_col("ha_bull")] = (ha_close > ha_open).astype(int)
+ out[ga_col("ha_trend_up")] = (
+ (ha_close > ha_close.shift(1)) & (ha_close.shift(1) > ha_close.shift(2))
+ ).astype(int)
+
+ return out
+
+
+def general_analysis_candle_columns() -> list[str]:
+ return [
+ "body_ratio",
+ "hammer",
+ "shooting_star",
+ "doji",
+ "bullish_engulfing",
+ "bearish_engulfing",
+ "morning_star",
+ "evening_star",
+ "three_white_soldiers",
+ "three_black_crows",
+ "ha_bull",
+ "ha_trend_up",
+ ]
diff --git a/deepcoin/analysis/general_analysis_chart.py b/deepcoin/analysis/general_analysis_chart.py
new file mode 100644
index 0000000..863e97b
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_chart.py
@@ -0,0 +1,159 @@
+"""
+general_analysis 차트 유형 (캔들·선·바·Renko·P&F).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_core import ga_col
+
+
+def _renko_direction_series(close: pd.Series, brick: pd.Series) -> pd.Series:
+ """ATR 기반 브릭 크기로 Renko 방향 (+1/-1/0) 시계열."""
+ n = len(close)
+ direction = pd.Series(0, index=close.index, dtype=int)
+ if n < 2:
+ return direction
+
+ price = float(close.iloc[0])
+ for i in range(1, n):
+ b = float(brick.iloc[i]) if not np.isnan(brick.iloc[i]) else float(close.diff().abs().median())
+ if b < 1e-9:
+ b = 1e-9
+ c = float(close.iloc[i])
+ if c >= price + b:
+ steps = int((c - price) // b)
+ direction.iloc[i] = 1
+ price += steps * b
+ elif c <= price - b:
+ steps = int((price - c) // b)
+ direction.iloc[i] = -1
+ price -= steps * b
+ return direction
+
+
+def general_analysis_apply_chart_bars(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ 봉 단위 차트 파생 컬럼 (선 기울기, Renko, P&F).
+
+ Args:
+ df: OHLCV (+ ga_atr_14 권장).
+
+ Returns:
+ ga_chart_* 시계열 컬럼 추가.
+ """
+ out = df.copy()
+ c = out["Close"].astype(float)
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+
+ out[ga_col("chart_line_slope_1")] = c.diff()
+ out[ga_col("chart_bar_range_pct")] = (h - l) / c.replace(0, np.nan) * 100
+
+ from config import GA_ATR_PERIOD
+
+ brick = (
+ out[ga_col("atr_14")]
+ if ga_col("atr_14") in out.columns
+ else (h - l).rolling(GA_ATR_PERIOD).mean()
+ )
+ brick = brick.fillna(c.diff().abs().rolling(20).median()).replace(0, np.nan).bfill()
+ renko_dir = _renko_direction_series(c, brick)
+ out[ga_col("chart_renko_dir")] = renko_dir
+ out[ga_col("chart_renko_up")] = (renko_dir == 1).astype(int)
+
+ box = brick.fillna(1.0)
+ pnf = pd.Series(0, index=out.index, dtype=int)
+ col = 0
+ for i in range(1, len(c)):
+ b = float(box.iloc[i])
+ move = c.iloc[i] - c.iloc[i - 1]
+ if move >= b:
+ col += 1
+ pnf.iloc[i] = 1
+ elif move <= -b:
+ col -= 1
+ pnf.iloc[i] = -1
+ out[ga_col("chart_pnf_col")] = pnf
+
+ if "Volume" in out.columns:
+ v = out["Volume"].astype(float)
+ out[ga_col("chart_vol_spike")] = (v > v.rolling(20).mean() * 1.8).astype(int)
+
+ if ga_col("ha_trend_up") in out.columns:
+ out[ga_col("chart_ha_trend")] = out[ga_col("ha_trend_up")]
+ elif ga_col("ha_bull") in out.columns:
+ out[ga_col("chart_ha_trend")] = out[ga_col("ha_bull")]
+
+ return out
+
+
+def general_analysis_chart_metrics(df: pd.DataFrame) -> dict[str, object]:
+ """
+ lookback 구간 차트 요약 (마지막 봉 스냅샷용).
+
+ Returns:
+ ga_chart_* dict.
+ """
+ res: dict[str, object] = {
+ "chart_type_candle": 1,
+ "chart_line_slope": 0.0,
+ "chart_bar_range_pct": 0.0,
+ "chart_ha_trend": 0,
+ "chart_renko_brick_up_ratio": 0.5,
+ "chart_renko_dir": 0,
+ "chart_pnf_col": 0,
+ "chart_vol_spike": 0,
+ }
+ if df is None or len(df) < 5:
+ return {ga_col(k): v for k, v in res.items()}
+
+ row = df.iloc[-1]
+ c = df["Close"].astype(float)
+ res["chart_line_slope"] = float((c.iloc[-1] - c.iloc[0]) / max(len(c) - 1, 1))
+ res["chart_bar_range_pct"] = float(
+ (df["High"].iloc[-1] - df["Low"].iloc[-1]) / max(c.iloc[-1], 1e-9) * 100
+ )
+
+ if ga_col("chart_renko_dir") in df.columns:
+ rd = df[ga_col("chart_renko_dir")].astype(float)
+ up = (rd == 1).sum()
+ down = (rd == -1).sum()
+ res["chart_renko_brick_up_ratio"] = round(up / max(up + down, 1), 3)
+ res["chart_renko_dir"] = int(rd.iloc[-1])
+ else:
+ diff = c.diff().fillna(0)
+ up = (diff > 0).sum()
+ down = (diff < 0).sum()
+ res["chart_renko_brick_up_ratio"] = round(up / max(up + down, 1), 3)
+
+ if ga_col("chart_pnf_col") in df.columns:
+ res["chart_pnf_col"] = int(df[ga_col("chart_pnf_col")].iloc[-1])
+ if ga_col("chart_ha_trend") in df.columns:
+ res["chart_ha_trend"] = int(row[ga_col("chart_ha_trend")])
+ elif ga_col("ha_trend_up") in df.columns:
+ res["chart_ha_trend"] = int(row[ga_col("ha_trend_up")])
+ if ga_col("chart_vol_spike") in df.columns:
+ res["chart_vol_spike"] = int(row[ga_col("chart_vol_spike")])
+ elif "Volume" in df.columns:
+ v = df["Volume"].astype(float)
+ res["chart_vol_spike"] = int(v.iloc[-1] > v.iloc[-20:].mean() * 1.8)
+
+ return {ga_col(k): v for k, v in res.items()}
+
+
+def general_analysis_chart_columns() -> list[str]:
+ return [
+ "chart_type_candle",
+ "chart_line_slope",
+ "chart_line_slope_1",
+ "chart_bar_range_pct",
+ "chart_ha_trend",
+ "chart_renko_brick_up_ratio",
+ "chart_renko_dir",
+ "chart_renko_up",
+ "chart_pnf_col",
+ "chart_vol_spike",
+ ]
diff --git a/deepcoin/analysis/general_analysis_config.py b/deepcoin/analysis/general_analysis_config.py
new file mode 100644
index 0000000..e82d4d4
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_config.py
@@ -0,0 +1,26 @@
+"""
+general_analysis MTF 설정 (config.py 재노출).
+"""
+
+from __future__ import annotations
+
+from config import (
+ CONTEXT_TAIL_ROWS,
+ GA_COL_PREFIX,
+ GENERAL_ANALYSIS_INTERVALS,
+ GROUND_TRUTH_FILE,
+ INTERVAL_PREFIX,
+ LOOKBACK_BARS,
+ REPORTS_ANALYSIS_CAPABILITY_HTML,
+ REPORTS_ANALYSIS_LATEST_DIR,
+ REPORTS_ANALYSIS_REPORT_HTML,
+ REPORTS_ANALYSIS_TRADES_CSV,
+ TIMING_INTERVALS,
+ TREND_INTERVALS,
+)
+
+DEFAULT_TRADES_FILE = GROUND_TRUTH_FILE
+DEFAULT_OUTPUT_CSV = str(REPORTS_ANALYSIS_TRADES_CSV)
+DEFAULT_OUTPUT_HTML = str(REPORTS_ANALYSIS_REPORT_HTML)
+DEFAULT_CAPABILITY_HTML = str(REPORTS_ANALYSIS_CAPABILITY_HTML)
+DEFAULT_LATEST_DIR = str(REPORTS_ANALYSIS_LATEST_DIR)
diff --git a/deepcoin/analysis/general_analysis_context.py b/deepcoin/analysis/general_analysis_context.py
new file mode 100644
index 0000000..534abb5
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_context.py
@@ -0,0 +1,98 @@
+"""
+general_analysis lookback 컨텍스트 특징 (패턴·파동·VP·하모닉) 봉별 적용.
+"""
+
+from __future__ import annotations
+
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
+from deepcoin.analysis.general_analysis_core import ga_col
+from deepcoin.analysis.general_analysis_harmonic import (
+ general_analysis_harmonic_columns,
+ general_analysis_harmonic_snapshot,
+)
+from deepcoin.analysis.general_analysis_patterns import general_analysis_apply_patterns_to_bars
+from deepcoin.analysis.general_analysis_volume import (
+ general_analysis_volume_columns,
+ general_analysis_volume_snapshot,
+)
+from deepcoin.analysis.general_analysis_wave import general_analysis_apply_wave_to_bars
+
+
+def general_analysis_apply_volume_to_bars(
+ df: pd.DataFrame,
+ interval: int,
+ tail_rows: int | None = None,
+) -> pd.DataFrame:
+ """Volume Profile 컬럼을 최근 봉에 롤링 적용."""
+ out = df.copy()
+ for k in general_analysis_volume_columns():
+ out[ga_col(k)] = 0.0 if k != "vp_in_value_area" else 0
+
+ lb = LOOKBACK_BARS.get(interval, 80)
+ n = len(out)
+ if n < lb + 1:
+ return out
+ if tail_rows is None:
+ tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
+ start = max(lb, n - tail_rows)
+
+ for i in range(start, n):
+ snap = general_analysis_volume_snapshot(out.iloc[i - lb : i])
+ idx = out.index[i]
+ for k, v in snap.items():
+ out.at[idx, k] = v
+
+ return out
+
+
+def general_analysis_apply_harmonic_to_bars(
+ df: pd.DataFrame,
+ interval: int,
+ tail_rows: int | None = None,
+) -> pd.DataFrame:
+ """하모닉 패턴 컬럼 롤링 적용."""
+ out = df.copy()
+ for k in general_analysis_harmonic_columns():
+ default = "none" if k == "harmonic_label" else 0
+ out[ga_col(k)] = default
+
+ lb = LOOKBACK_BARS.get(interval, 80)
+ n = len(out)
+ if n < lb + 1:
+ return out
+ if tail_rows is None:
+ tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
+ start = max(lb, n - tail_rows)
+
+ for i in range(start, n):
+ snap = general_analysis_harmonic_snapshot(out.iloc[i - lb : i])
+ idx = out.index[i]
+ for k, v in snap.items():
+ out.at[idx, k] = v
+
+ return out
+
+
+def general_analysis_apply_context_features(
+ df: pd.DataFrame,
+ interval: int,
+ tail_rows: int | None = None,
+) -> pd.DataFrame:
+ """
+ 패턴·파동·VP·하모닉 lookback 라벨을 봉 시계열에 병합.
+
+ Args:
+ df: general_analysis_enrich_bars 1단계 결과.
+ interval: 분봉 간격(분).
+ tail_rows: 롤링 적용 봉 수 상한.
+
+ Returns:
+ 컨텍스트 ga_* 컬럼이 추가된 DataFrame.
+ """
+ out = general_analysis_apply_patterns_to_bars(df, interval, tail_rows)
+ out = general_analysis_apply_wave_to_bars(out, interval, tail_rows)
+ out = general_analysis_apply_volume_to_bars(out, interval, tail_rows)
+ out = general_analysis_apply_harmonic_to_bars(out, interval, tail_rows)
+ return out
diff --git a/deepcoin/analysis/general_analysis_core.py b/deepcoin/analysis/general_analysis_core.py
new file mode 100644
index 0000000..921ad4a
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_core.py
@@ -0,0 +1,92 @@
+"""
+general_analysis 공통 유틸 (슬라이스·피벗·컬럼 접두사).
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_config import GA_COL_PREFIX, LOOKBACK_BARS
+
+
+def ga_col(name: str) -> str:
+ """general_analysis 출력 컬럼명."""
+ return f"{GA_COL_PREFIX}{name}"
+
+
+def interval_tf_prefix(interval: int) -> str:
+ """간격 접두사 (m3, d1)."""
+ from deepcoin.analysis.general_analysis_config import INTERVAL_PREFIX
+
+ return INTERVAL_PREFIX.get(interval, f"m{interval}")
+
+
+def prefixed_snapshot(
+ row: pd.Series,
+ interval: int,
+ keys: list[str] | tuple[str, ...],
+) -> dict[str, Any]:
+ """한 봉의 ga_ 컬럼을 {m3_ga_rsi: ...} 형태로 변환."""
+ p = interval_tf_prefix(interval)
+ out: dict[str, Any] = {}
+ for k in keys:
+ col = ga_col(k)
+ if col in row.index:
+ v = row[col]
+ out[f"{p}_{col}"] = None if pd.isna(v) else v
+ return out
+
+
+def slice_to_timestamp(df: pd.DataFrame, ts: pd.Timestamp) -> pd.DataFrame:
+ """타점 시각 이전 완성봉만 (해당 시각 봉 미포함)."""
+ if df.empty:
+ return df
+ if not isinstance(df.index, pd.DatetimeIndex):
+ df = df.copy()
+ df.index = pd.to_datetime(df.index)
+ ts = pd.Timestamp(ts)
+ if ts.tzinfo is not None and df.index.tz is None:
+ ts = ts.tz_localize(None)
+ return df[df.index < ts].copy()
+
+
+def lookback_slice(df: pd.DataFrame, interval: int, end_ts: pd.Timestamp) -> pd.DataFrame:
+ """타점 직전 lookback 구간."""
+ sliced = slice_to_timestamp(df, end_ts)
+ n = LOOKBACK_BARS.get(interval, 80)
+ if len(sliced) > n:
+ return sliced.iloc[-n:].copy()
+ return sliced
+
+
+def find_pivots(
+ highs: np.ndarray,
+ lows: np.ndarray,
+ order: int = 3,
+) -> tuple[list[int], list[int]]:
+ """국소 고점·저점 인덱스 (양쪽 order 봉보다 극값)."""
+ peak_idx: list[int] = []
+ trough_idx: list[int] = []
+ n = len(highs)
+ if n < order * 2 + 1:
+ return peak_idx, trough_idx
+ for i in range(order, n - order):
+ if highs[i] >= highs[i - order : i + order + 1].max():
+ peak_idx.append(i)
+ if lows[i] <= lows[i - order : i + order + 1].min():
+ trough_idx.append(i)
+ return peak_idx, trough_idx
+
+
+def last_row_dict(df: pd.DataFrame, cols: list[str]) -> dict[str, Any]:
+ """마지막 봉의 지정 컬럼 dict."""
+ if df.empty:
+ return {ga_col(c): None for c in cols}
+ row = df.iloc[-1]
+ return {
+ ga_col(c): (None if c not in row.index or pd.isna(row[c]) else row[c])
+ for c in cols
+ }
diff --git a/deepcoin/analysis/general_analysis_enrich_runner.py b/deepcoin/analysis/general_analysis_enrich_runner.py
new file mode 100644
index 0000000..c645e97
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_enrich_runner.py
@@ -0,0 +1,148 @@
+"""
+general_analysis 봉 데이터 전구간 enrich + 최신봉·기법 점검 리포트.
+
+ python scripts/03_analyze_enrich.py
+ python scripts/03_analyze_enrich.py --interval 1440
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+import pandas as pd
+
+from config import CHART_LOOKBACK_DAYS, GA_DEFAULT_TAIL_EXPORT, SYMBOL
+from deepcoin.analysis.general_analysis_align import (
+ general_analysis_mtf_scores,
+ general_analysis_mtf_vote_latest,
+)
+from deepcoin.analysis.general_analysis_config import (
+ DEFAULT_CAPABILITY_HTML,
+ DEFAULT_LATEST_DIR,
+ GENERAL_ANALYSIS_INTERVALS,
+)
+from deepcoin.analysis.general_analysis_core import ga_col, interval_tf_prefix
+from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars
+from deepcoin.ops.monitor import Monitor
+from deepcoin.data.mtf_bb import load_frames_from_db
+
+
+def _latest_row_summary(df: pd.DataFrame, prefix: str) -> dict[str, object]:
+ """최신 봉의 ga_·핵심 레거시 컬럼 요약."""
+ if df.empty:
+ return {}
+ row = df.iloc[-1]
+ out: dict[str, object] = {"dt": str(df.index[-1]), "tf": prefix}
+ for c in df.columns:
+ if c.startswith("ga_") or c in ("RSI", "bb_pos", "macd_hist", "stoch_k"):
+ v = row[c]
+ if pd.isna(v):
+ continue
+ out[c] = v
+ return out
+
+
+def write_capability_html(
+ summaries: dict[str, dict[str, object]],
+ vote: dict[str, object],
+ path: Path,
+) -> None:
+ """기법 컬럼 존재 여부·최신값 요약 HTML."""
+ rows = ""
+ for tf, snap in summaries.items():
+ ga_cols = [k for k in snap if str(k).startswith("ga_")]
+ rows += f"
| {tf} | {snap.get('dt','')} | {len(ga_cols)} |
"
+
+ vote_rows = "".join(f"{k}: {v}" for k, v in vote.items())
+
+ html = f"""
+
+general_analysis 기법 점검
+
+general_analysis 기법 점검 ({SYMBOL})
+3분~일봉 enrich 완료. 최신 봉 기준 컬럼 수·MTF 투표.
+간격별 ga_ 컬럼 수
+
+MTF 투표 (최신 봉)
+
+상세 CSV: {DEFAULT_LATEST_DIR}/
+"""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(html, encoding="utf-8")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="general_analysis 8TF enrich")
+ parser.add_argument("--interval", type=int, default=0, help="단일 간격만 (0=전체)")
+ parser.add_argument(
+ "--tail-export",
+ type=int,
+ default=GA_DEFAULT_TAIL_EXPORT,
+ help="CSV 저장 최근 N봉",
+ )
+ args = parser.parse_args()
+
+ from deepcoin.paths import ANALYSIS_CAPABILITY_HTML, ANALYSIS_LATEST_DIR
+
+ intervals = (
+ (args.interval,)
+ if args.interval > 0
+ else GENERAL_ANALYSIS_INTERVALS
+ )
+
+ print(f"=== general_analysis enrich {SYMBOL} ===")
+ mon = Monitor(cooldown_file=None)
+ frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
+ if not frames:
+ raise RuntimeError("coins.db 데이터 없음")
+
+ enriched: dict[int, pd.DataFrame] = {}
+ summaries: dict[str, dict[str, object]] = {}
+ out_dir = ANALYSIS_LATEST_DIR
+ out_dir.mkdir(parents=True, exist_ok=True)
+
+ for iv in intervals:
+ raw = frames.get(iv)
+ if raw is None or raw.empty:
+ print(f" skip {iv}: no data")
+ continue
+ p = interval_tf_prefix(iv)
+ print(f" [{p}] enrich {len(raw)} bars...")
+ ef = general_analysis_enrich_bars(raw, iv, full_context=True)
+ enriched[iv] = ef
+ tail = ef.tail(args.tail_export)
+ csv_path = out_dir / f"{p}_latest.csv"
+ tail.to_csv(csv_path, encoding="utf-8-sig")
+ print(f" -> {csv_path} ({len(tail)} rows x {len(tail.columns)} cols)")
+ summaries[p] = _latest_row_summary(ef, p)
+
+ flat_vote: dict[str, object] = {}
+ if len(enriched) >= 2:
+ for k, v in general_analysis_mtf_vote_latest(enriched).items():
+ flat_vote[ga_col(k)] = v
+ prefixed = {}
+ for iv, ef in enriched.items():
+ p = interval_tf_prefix(iv)
+ row = ef.iloc[-1]
+ for c in ("RSI", "bb_pos"):
+ if c in row.index:
+ prefixed[f"{p}_{c}"] = row[c]
+ st = row.get(ga_col("struct_trend"))
+ if st is not None:
+ prefixed[f"{p}_{ga_col('struct_trend')}"] = st
+ flat_vote.update(general_analysis_mtf_scores(prefixed))
+
+ write_capability_html(summaries, flat_vote, ANALYSIS_CAPABILITY_HTML)
+ print(f"점검 리포트: {cap_path}")
+ print("완료.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deepcoin/analysis/general_analysis_harmonic.py b/deepcoin/analysis/general_analysis_harmonic.py
new file mode 100644
index 0000000..3b2f1bc
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_harmonic.py
@@ -0,0 +1,72 @@
+"""
+general_analysis 하모닉 패턴 (Gartley, Bat 근사).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+from deepcoin.analysis.general_analysis_core import find_pivots, ga_col
+
+
+def _ratio(a: float, b: float) -> float:
+ if abs(b) < 1e-12:
+ return 0.0
+ return abs(a / b)
+
+
+def _near(x: float, target: float, tol: float = 0.08) -> bool:
+ return abs(x - target) <= tol
+
+
+def general_analysis_harmonic_snapshot(win) -> dict[str, object]:
+ """
+ 최근 5개 피벗으로 Gartley·Bat 유사 비율 검사.
+
+ Args:
+ win: OHLCV DataFrame.
+
+ Returns:
+ ga_harmonic_* dict.
+ """
+ res: dict[str, object] = {
+ "harmonic_gartley": 0,
+ "harmonic_bat": 0,
+ "harmonic_label": "none",
+ }
+ if win is None or len(win) < 30:
+ return {ga_col(k): v for k, v in res.items()}
+
+ h = win["High"].astype(float).values
+ l = win["Low"].astype(float).values
+ peaks, troughs = find_pivots(h, l, order=2)
+ pivots = sorted([(i, "H", h[i]) for i in peaks] + [(i, "L", l[i]) for i in troughs])
+ if len(pivots) < 5:
+ return {ga_col(k): v for k, v in res.items()}
+
+ pts = pivots[-5:]
+ prices = [p[2] for p in pts]
+ xa = abs(prices[1] - prices[0])
+ ab = abs(prices[2] - prices[1])
+ bc = abs(prices[3] - prices[2])
+ cd = abs(prices[4] - prices[3])
+
+ if xa < 1e-9:
+ return {ga_col(k): v for k, v in res.items()}
+
+ r_ab = _ratio(ab, xa)
+ r_bc = _ratio(bc, ab) if ab > 1e-9 else 0.0
+ r_cd = _ratio(cd, bc) if bc > 1e-9 else 0.0
+
+ if _near(r_ab, 0.618) and 0.35 <= r_bc <= 0.95 and 1.1 <= r_cd <= 1.75:
+ res["harmonic_gartley"] = 1
+ res["harmonic_label"] = "gartley"
+ if _near(r_ab, 0.382) and 0.35 <= r_bc <= 0.95 and 1.5 <= r_cd <= 2.1:
+ res["harmonic_bat"] = 1
+ res["harmonic_label"] = "bat"
+
+ return {ga_col(k): v for k, v in res.items()}
+
+
+def general_analysis_harmonic_columns() -> list[str]:
+ return ["harmonic_gartley", "harmonic_bat", "harmonic_label"]
diff --git a/deepcoin/analysis/general_analysis_indicators.py b/deepcoin/analysis/general_analysis_indicators.py
new file mode 100644
index 0000000..1415b03
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_indicators.py
@@ -0,0 +1,382 @@
+"""
+general_analysis 확장 기술적 지표 (추세·모멘텀·변동성·거래량).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import (
+ BB_PERIOD,
+ BB_STD,
+ GA_ADX_PERIOD,
+ GA_ADX_TREND_THRESHOLD,
+ GA_AO_FAST,
+ GA_AO_SLOW,
+ GA_ATR_PERIOD,
+ GA_BB_SQUEEZE_QUANTILE,
+ GA_BB_SQUEEZE_WINDOW,
+ GA_CCI_PERIOD,
+ GA_CMF_PERIOD,
+ GA_DIVERGENCE_LOOKBACK,
+ GA_DONCHIAN_PERIOD,
+ GA_EMA_SPANS,
+ GA_HV_ANNUALIZE_SQRT,
+ GA_HV_PERCENTILE_WINDOW,
+ GA_HV_ROLLING_BARS,
+ GA_KELTNER_ATR_MULT,
+ GA_LINREG_WINDOW,
+ GA_MFI_PERIOD,
+ GA_PSAR_AF_MAX,
+ GA_PSAR_AF_START,
+ GA_PSAR_AF_STEP,
+ GA_ROC_PERIOD,
+ GA_SMA_PERIODS,
+ GA_SUPERTREND_ATR_MULT,
+ GA_VOL_MA_WINDOW,
+ GA_WILLIAMS_PERIOD,
+)
+from deepcoin.analysis.general_analysis_core import ga_col
+from deepcoin.common.indicators import apply_bar_indicators
+
+
+def _ema(series: pd.Series, span: int) -> pd.Series:
+ return series.ewm(span=span, adjust=False).mean()
+
+
+def _parabolic_sar(
+ high: np.ndarray,
+ low: np.ndarray,
+ af_start: float = GA_PSAR_AF_START,
+ af_step: float = GA_PSAR_AF_STEP,
+ af_max: float = GA_PSAR_AF_MAX,
+) -> tuple[np.ndarray, np.ndarray]:
+ """
+ Parabolic SAR 시계열.
+
+ Returns:
+ (sar, bull_flag 0/1)
+ """
+ n = len(high)
+ sar = np.zeros(n)
+ bull = np.ones(n, dtype=int)
+ if n < 2:
+ return sar, bull
+
+ is_bull = True
+ af = af_start
+ ep = high[0]
+ sar[0] = low[0]
+
+ for i in range(1, n):
+ prev_sar = sar[i - 1]
+ if is_bull:
+ sar[i] = prev_sar + af * (ep - prev_sar)
+ sar[i] = min(sar[i], low[i - 1], low[i] if i > 0 else low[i - 1])
+ if low[i] < sar[i]:
+ is_bull = False
+ sar[i] = ep
+ ep = low[i]
+ af = af_start
+ else:
+ if high[i] > ep:
+ ep = high[i]
+ af = min(af + af_step, af_max)
+ else:
+ sar[i] = prev_sar + af * (ep - prev_sar)
+ sar[i] = max(sar[i], high[i - 1], high[i] if i > 0 else high[i - 1])
+ if high[i] > sar[i]:
+ is_bull = True
+ sar[i] = ep
+ ep = high[i]
+ af = af_start
+ else:
+ if low[i] < ep:
+ ep = low[i]
+ af = min(af + af_step, af_max)
+ bull[i] = int(is_bull)
+
+ return sar, bull
+
+
+def general_analysis_apply_indicators(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ 기존 apply_bar_indicators 위에 ga_ 접두사 확장 지표를 추가합니다.
+
+ Args:
+ df: OHLCV.
+
+ Returns:
+ ga_* 컬럼이 추가된 DataFrame.
+ """
+ out = apply_bar_indicators(df.copy())
+ o = out["Open"].astype(float)
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+ v = out["Volume"].astype(float)
+
+ # --- 추세: MA ---
+ sma_fast = GA_SMA_PERIODS[0] if GA_SMA_PERIODS else 5
+ sma_slow = GA_SMA_PERIODS[1] if len(GA_SMA_PERIODS) > 1 else 20
+ for p in GA_SMA_PERIODS:
+ ma = c.rolling(p).mean()
+ out[ga_col(f"sma_{p}")] = ma
+ out[ga_col(f"close_vs_sma_{p}_pct")] = (c / ma.replace(0, np.nan) - 1) * 100
+
+ ema_fast = GA_EMA_SPANS[0] if GA_EMA_SPANS else 12
+ ema_slow = GA_EMA_SPANS[1] if len(GA_EMA_SPANS) > 1 else 26
+ out[ga_col(f"ema_{ema_fast}")] = _ema(c, ema_fast)
+ out[ga_col(f"ema_{ema_slow}")] = _ema(c, ema_slow)
+ out[ga_col("golden_cross")] = (
+ (out[ga_col(f"sma_{sma_fast}")] > out[ga_col(f"sma_{sma_slow}")])
+ & (out[ga_col(f"sma_{sma_fast}")].shift(1) <= out[ga_col(f"sma_{sma_slow}")].shift(1))
+ ).astype(int)
+ out[ga_col("death_cross")] = (
+ (out[ga_col(f"sma_{sma_fast}")] < out[ga_col(f"sma_{sma_slow}")])
+ & (out[ga_col(f"sma_{sma_fast}")].shift(1) >= out[ga_col(f"sma_{sma_slow}")].shift(1))
+ ).astype(int)
+
+ # --- ATR / 변동성 ---
+ tr = pd.concat(
+ [
+ h - l,
+ (h - c.shift(1)).abs(),
+ (l - c.shift(1)).abs(),
+ ],
+ axis=1,
+ ).max(axis=1)
+ atr = tr.rolling(GA_ATR_PERIOD).mean()
+ out[ga_col("atr_14")] = atr
+ out[ga_col("atr_pct")] = atr / c.replace(0, np.nan) * 100
+ out[ga_col("bb_width_pct")] = out.get("BB_Width", (out["Upper"] - out["Lower"]) / out["MA"] * 100)
+ bw = out[ga_col("bb_width_pct")].astype(float)
+ out[ga_col("bb_squeeze")] = (
+ bw < bw.rolling(GA_BB_SQUEEZE_WINDOW).quantile(GA_BB_SQUEEZE_QUANTILE)
+ ).astype(int)
+
+ dc = GA_DONCHIAN_PERIOD
+ out[ga_col("donchian_high_20")] = h.rolling(dc).max()
+ out[ga_col("donchian_low_20")] = l.rolling(dc).min()
+ out[ga_col("donchian_pos")] = (c - out[ga_col("donchian_low_20")]) / (
+ out[ga_col("donchian_high_20")] - out[ga_col("donchian_low_20")]
+ ).replace(0, np.nan)
+
+ # --- 모멘텀: CCI, Williams %R ---
+ tp = (h + l + c) / 3
+ cci_period = GA_CCI_PERIOD
+ sma_tp = tp.rolling(cci_period).mean()
+ mad = tp.rolling(cci_period).apply(lambda x: np.abs(x - x.mean()).mean(), raw=True)
+ out[ga_col("cci_20")] = (tp - sma_tp) / (0.015 * mad.replace(0, np.nan))
+ out[ga_col("cci_oversold")] = (out[ga_col("cci_20")] < -100).astype(int)
+ out[ga_col("cci_overbought")] = (out[ga_col("cci_20")] > 100).astype(int)
+
+ hh = h.rolling(GA_WILLIAMS_PERIOD).max()
+ ll = l.rolling(GA_WILLIAMS_PERIOD).min()
+ out[ga_col("williams_r")] = (hh - c) / (hh - ll).replace(0, np.nan) * -100
+ out[ga_col("williams_oversold")] = (out[ga_col("williams_r")] < -80).astype(int)
+ out[ga_col("williams_overbought")] = (out[ga_col("williams_r")] > -20).astype(int)
+
+ div_lb = GA_DIVERGENCE_LOOKBACK
+ out[ga_col("roc_10")] = (c / c.shift(GA_ROC_PERIOD).replace(0, np.nan) - 1) * 100
+
+ # MFI
+ raw_mf = tp * v
+ pos_mf = raw_mf.where(tp > tp.shift(1), 0.0).rolling(GA_MFI_PERIOD).sum()
+ neg_mf = raw_mf.where(tp < tp.shift(1), 0.0).rolling(GA_MFI_PERIOD).sum()
+ mfr = pos_mf / neg_mf.replace(0, np.nan)
+ out[ga_col("mfi_14")] = 100 - (100 / (1 + mfr))
+
+ # MACD / RSI / Stoch 다이버전스
+ if "RSI" in out.columns and "macd_hist" in out.columns:
+ price_up = (c > c.shift(div_lb)).astype(int)
+ rsi_up = (out["RSI"] > out["RSI"].shift(div_lb)).astype(int)
+ macd_up = (out["macd_hist"] > out["macd_hist"].shift(div_lb)).astype(int)
+ out[ga_col("rsi_bull_div")] = ((price_up == 0) & (rsi_up == 1)).astype(int)
+ out[ga_col("rsi_bear_div")] = ((price_up == 1) & (rsi_up == 0)).astype(int)
+ out[ga_col("macd_bull_div")] = ((price_up == 0) & (macd_up == 1)).astype(int)
+ out[ga_col("macd_bear_div")] = ((price_up == 1) & (macd_up == 0)).astype(int)
+ if "stoch_k" in out.columns:
+ price_up = (c > c.shift(div_lb)).astype(int)
+ st_up = (out["stoch_k"] > out["stoch_k"].shift(div_lb)).astype(int)
+ out[ga_col("stoch_bull_div")] = ((price_up == 0) & (st_up == 1)).astype(int)
+ out[ga_col("stoch_bear_div")] = ((price_up == 1) & (st_up == 0)).astype(int)
+
+ # 봉 간 변화 (타점 Δ와 동일 정의, 전 구간)
+ if "RSI" in out.columns:
+ out[ga_col("rsi_delta_1")] = out["RSI"].diff()
+ if "macd_hist" in out.columns:
+ out[ga_col("macd_hist_delta_1")] = out["macd_hist"].diff()
+ if "stoch_k" in out.columns:
+ out[ga_col("stoch_k_delta_1")] = out["stoch_k"].diff()
+
+ # --- 거래량 ---
+ vol_ma = v.rolling(GA_VOL_MA_WINDOW).mean()
+ out[ga_col("vol_ma20")] = vol_ma
+ out[ga_col("vol_ratio")] = v / vol_ma.replace(0, np.nan)
+ out[ga_col("obv")] = (np.sign(c.diff().fillna(0)) * v).cumsum()
+ obv = out[ga_col("obv")].astype(float)
+ out[ga_col("obv_slope_10")] = obv - obv.shift(div_lb)
+ out[ga_col("obv_bull_div")] = (
+ (c < c.shift(div_lb)) & (obv > obv.shift(div_lb))
+ ).astype(int)
+ out[ga_col("obv_bear_div")] = (
+ (c > c.shift(div_lb)) & (obv < obv.shift(div_lb))
+ ).astype(int)
+
+ # CMF
+ mfv = ((c - l) - (h - c)) / (h - l).replace(0, np.nan) * v
+ out[ga_col("cmf_20")] = (
+ mfv.rolling(GA_CMF_PERIOD).sum() / v.rolling(GA_CMF_PERIOD).sum().replace(0, np.nan)
+ )
+
+ # Accumulation/Distribution Line
+ clv = ((c - l) - (h - c)) / (h - l).replace(0, np.nan)
+ out[ga_col("ad_line")] = (clv * v).cumsum()
+ ad = out[ga_col("ad_line")].astype(float)
+ out[ga_col("ad_slope_10")] = ad - ad.shift(div_lb)
+
+ # VWAP 근사 (누적, 세션 리셋 없음)
+ cum_vp = (tp * v).cumsum()
+ cum_v = v.cumsum().replace(0, np.nan)
+ out[ga_col("vwap")] = cum_vp / cum_v
+ out[ga_col("close_vs_vwap_pct")] = (c / out[ga_col("vwap")] - 1) * 100
+
+ # Keltner Channel
+ k_mid = _ema(c, BB_PERIOD)
+ out[ga_col("keltner_mid")] = k_mid
+ out[ga_col("keltner_upper")] = k_mid + GA_KELTNER_ATR_MULT * atr
+ out[ga_col("keltner_lower")] = k_mid - GA_KELTNER_ATR_MULT * atr
+ out[ga_col("keltner_pos")] = (c - out[ga_col("keltner_lower")]) / (
+ out[ga_col("keltner_upper")] - out[ga_col("keltner_lower")]
+ ).replace(0, np.nan)
+
+ # Awesome Oscillator: median price SMA5 - SMA34
+ mp = (h + l) / 2
+ out[ga_col("ao")] = mp.rolling(GA_AO_FAST).mean() - mp.rolling(GA_AO_SLOW).mean()
+ out[ga_col("ao_bull")] = (
+ (out[ga_col("ao")] > 0) & (out[ga_col("ao")].shift(1) <= 0)
+ ).astype(int)
+ out[ga_col("ao_bear")] = (
+ (out[ga_col("ao")] < 0) & (out[ga_col("ao")].shift(1) >= 0)
+ ).astype(int)
+
+ # Historical Volatility (로그수익 20봉 표준편차, 연율화 계수 1=봉 단위)
+ log_ret = np.log(c / c.shift(1).replace(0, np.nan))
+ hv = log_ret.rolling(GA_HV_ROLLING_BARS).std() * GA_HV_ANNUALIZE_SQRT
+ out[ga_col("hv_20")] = hv
+ out[ga_col("hv_percentile")] = hv.rolling(GA_HV_PERCENTILE_WINDOW).apply(
+ lambda x: float((x[:-1] < x[-1]).mean()) if len(x) > 1 and not np.isnan(x[-1]) else 0.5,
+ raw=True,
+ )
+
+ # Parabolic SAR
+ sar, psar_bull = _parabolic_sar(h.values, l.values)
+ out[ga_col("psar")] = sar
+ out[ga_col("psar_bull")] = psar_bull
+ ps = pd.Series(psar_bull, index=out.index)
+ out[ga_col("psar_flip_bull")] = ((ps == 1) & (ps.shift(1) == 0)).astype(int)
+ out[ga_col("psar_flip_bear")] = ((ps == 0) & (ps.shift(1) == 1)).astype(int)
+
+ # ADX
+ up_move = h.diff()
+ down_move = -l.diff()
+ plus_dm = np.where((up_move > down_move) & (up_move > 0), up_move, 0.0)
+ minus_dm = np.where((down_move > up_move) & (down_move > 0), down_move, 0.0)
+ atr_safe = atr.replace(0, np.nan)
+ plus_di = 100 * pd.Series(plus_dm, index=out.index).rolling(GA_ADX_PERIOD).mean() / atr_safe
+ minus_di = 100 * pd.Series(minus_dm, index=out.index).rolling(GA_ADX_PERIOD).mean() / atr_safe
+ dx = (plus_di - minus_di).abs() / (plus_di + minus_di).replace(0, np.nan) * 100
+ out[ga_col("adx_14")] = dx.rolling(GA_ADX_PERIOD).mean()
+ out[ga_col("plus_di")] = plus_di
+ out[ga_col("minus_di")] = minus_di
+ out[ga_col("adx_trending")] = (out[ga_col("adx_14")] > GA_ADX_TREND_THRESHOLD).astype(int)
+
+ # Supertrend 방향 (ATR 밴드)
+ hl2 = (h + l) / 2
+ upper = hl2 + GA_SUPERTREND_ATR_MULT * atr
+ lower = hl2 - GA_SUPERTREND_ATR_MULT * atr
+ out[ga_col("supertrend_bull")] = (c > lower).astype(int)
+
+ # Linear regression slope 20
+ def _lin_slope(y: np.ndarray) -> float:
+ if len(y) < 2:
+ return 0.0
+ x = np.arange(len(y))
+ coef = np.polyfit(x, y, 1)
+ return float(coef[0])
+
+ out[ga_col("linreg_slope_20")] = c.rolling(GA_LINREG_WINDOW).apply(_lin_slope, raw=True)
+
+ def _lin_r2(y: np.ndarray) -> float:
+ if len(y) < 3:
+ return 0.0
+ x = np.arange(len(y))
+ coef = np.polyfit(x, y, 1)
+ pred = coef[0] * x + coef[1]
+ ss_res = ((y - pred) ** 2).sum()
+ ss_tot = ((y - y.mean()) ** 2).sum()
+ if ss_tot < 1e-12:
+ return 0.0
+ return float(1 - ss_res / ss_tot)
+
+ out[ga_col("linreg_r2_20")] = c.rolling(GA_LINREG_WINDOW).apply(_lin_r2, raw=True)
+
+ return out
+
+
+def general_analysis_indicator_columns() -> list[str]:
+ """스냅샷용 ga_ 지표 컬럼 목록."""
+ return [
+ "sma_5",
+ "sma_20",
+ "sma_60",
+ "close_vs_sma_20_pct",
+ "golden_cross",
+ "death_cross",
+ "atr_14",
+ "atr_pct",
+ "bb_squeeze",
+ "donchian_pos",
+ "cci_20",
+ "cci_oversold",
+ "cci_overbought",
+ "williams_r",
+ "williams_oversold",
+ "williams_overbought",
+ "roc_10",
+ "mfi_14",
+ "rsi_bull_div",
+ "rsi_bear_div",
+ "macd_bull_div",
+ "macd_bear_div",
+ "stoch_bull_div",
+ "stoch_bear_div",
+ "rsi_delta_1",
+ "macd_hist_delta_1",
+ "stoch_k_delta_1",
+ "keltner_pos",
+ "ao",
+ "ao_bull",
+ "ao_bear",
+ "hv_20",
+ "hv_percentile",
+ "ad_line",
+ "ad_slope_10",
+ "vol_ratio",
+ "obv_slope_10",
+ "obv_bull_div",
+ "obv_bear_div",
+ "cmf_20",
+ "close_vs_vwap_pct",
+ "adx_14",
+ "adx_trending",
+ "supertrend_bull",
+ "linreg_slope_20",
+ "linreg_r2_20",
+ "psar",
+ "psar_bull",
+ "psar_flip_bull",
+ "psar_flip_bear",
+ ]
diff --git a/deepcoin/analysis/general_analysis_patterns.py b/deepcoin/analysis/general_analysis_patterns.py
new file mode 100644
index 0000000..127c2d3
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_patterns.py
@@ -0,0 +1,302 @@
+"""
+general_analysis 차트·가격 패턴 (반전·지속·박스).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import GA_PATTERN_TOLERANCE_PCT, GA_PIVOT_ORDER
+from deepcoin.analysis.general_analysis_core import find_pivots, ga_col, last_row_dict
+
+
+def _pct_diff(a: float, b: float) -> float:
+ return abs(a - b) / max(abs(a), abs(b), 1e-9) * 100
+
+
+def general_analysis_detect_patterns(win: pd.DataFrame) -> dict[str, int | float | str | None]:
+ """
+ lookback 윈도우 마지막 봉 기준 패턴 라벨 (0/1 및 요약).
+
+ Args:
+ win: OHLCV (+ 지표 선택).
+
+ Returns:
+ ga_pattern_* 키 dict (접두사 없음, ga_col로 감쌈).
+ """
+ res: dict[str, int | float | str | None] = {
+ "pattern_double_top": 0,
+ "pattern_double_bottom": 0,
+ "pattern_head_shoulders": 0,
+ "pattern_inv_head_shoulders": 0,
+ "pattern_triangle_sym": 0,
+ "pattern_triangle_asc": 0,
+ "pattern_triangle_desc": 0,
+ "pattern_flag_bull": 0,
+ "pattern_flag_bear": 0,
+ "pattern_wedge_rising": 0,
+ "pattern_wedge_falling": 0,
+ "pattern_rectangle": 0,
+ "pattern_channel_up": 0,
+ "pattern_channel_down": 0,
+ "pattern_measured_move": 0,
+ "pattern_rounding_top": 0,
+ "pattern_rounding_bottom": 0,
+ "pattern_gap_up": 0,
+ "pattern_gap_down": 0,
+ "pattern_v_bottom": 0,
+ "pattern_spike_top": 0,
+ "pattern_triple_top": 0,
+ "pattern_triple_bottom": 0,
+ "pattern_cup_handle": 0,
+ "pattern_keystone_bull": 0,
+ "pattern_keystone_bear": 0,
+ "pattern_island_top": 0,
+ "pattern_island_bottom": 0,
+ "pattern_label": "none",
+ }
+ if win is None or len(win) < 20:
+ return res
+
+ h = win["High"].astype(float).values
+ l = win["Low"].astype(float).values
+ c = win["Close"].astype(float).values
+ peaks, troughs = find_pivots(h, l, order=GA_PIVOT_ORDER)
+ tol = GA_PATTERN_TOLERANCE_PCT
+
+ if len(peaks) >= 3:
+ p1, p2, p3 = peaks[-3], peaks[-2], peaks[-1]
+ if (
+ _pct_diff(h[p1], h[p2]) < tol
+ and _pct_diff(h[p2], h[p3]) < tol
+ and p1 < p2 < p3
+ ):
+ res["pattern_triple_top"] = 1
+ res["pattern_label"] = "triple_top"
+
+ if len(troughs) >= 3:
+ t1, t2, t3 = troughs[-3], troughs[-2], troughs[-1]
+ if (
+ _pct_diff(l[t1], l[t2]) < tol
+ and _pct_diff(l[t2], l[t3]) < tol
+ and t1 < t2 < t3
+ ):
+ res["pattern_triple_bottom"] = 1
+ res["pattern_label"] = "triple_bottom"
+
+ if len(peaks) >= 2:
+ p1, p2 = peaks[-2], peaks[-1]
+ if _pct_diff(h[p1], h[p2]) < tol:
+ res["pattern_double_top"] = 1
+ if res["pattern_label"] == "none":
+ res["pattern_label"] = "double_top"
+
+ if len(troughs) >= 2:
+ t1, t2 = troughs[-2], troughs[-1]
+ if _pct_diff(l[t1], l[t2]) < tol:
+ res["pattern_double_bottom"] = 1
+ res["pattern_label"] = "double_bottom"
+
+ if len(peaks) >= 3:
+ i, j, k = peaks[-3], peaks[-2], peaks[-1]
+ if h[j] > h[i] and h[j] > h[k] and _pct_diff(h[i], h[k]) < tol * 1.5:
+ res["pattern_head_shoulders"] = 1
+ res["pattern_label"] = "head_shoulders"
+
+ if len(troughs) >= 3:
+ i, j, k = troughs[-3], troughs[-2], troughs[-1]
+ if l[j] < l[i] and l[j] < l[k] and _pct_diff(l[i], l[k]) < tol * 1.5:
+ res["pattern_inv_head_shoulders"] = 1
+ res["pattern_label"] = "inv_head_shoulders"
+
+ n = len(win)
+ x = np.arange(n)
+ high_slope = np.polyfit(x, h, 1)[0]
+ low_slope = np.polyfit(x, l, 1)[0]
+ if high_slope < 0 and low_slope > 0:
+ res["pattern_triangle_sym"] = 1
+ if res["pattern_label"] == "none":
+ res["pattern_label"] = "triangle_sym"
+ if high_slope < 0 and low_slope > 0 and low_slope > abs(high_slope) * 0.5:
+ res["pattern_triangle_asc"] = 1
+ if high_slope < 0 and low_slope < 0 and abs(high_slope) > abs(low_slope) * 0.5:
+ res["pattern_triangle_desc"] = 1
+
+ rng_pct = (h.max() - l.min()) / max(c[-1], 1e-9) * 100
+ if rng_pct < 8 and abs(high_slope) < c[-1] * 0.0001:
+ res["pattern_rectangle"] = 1
+ if res["pattern_label"] == "none":
+ res["pattern_label"] = "rectangle"
+
+ leg = max(n // 3, 5)
+ if n > leg * 2:
+ first_move = (c[leg] - c[0]) / max(c[0], 1e-9) * 100
+ channel = (c[-1] - c[-leg]) / max(c[-leg], 1e-9) * 100
+ if first_move > 5 and abs(channel) < 3:
+ res["pattern_flag_bull"] = 1
+ res["pattern_label"] = "flag_bull"
+ if first_move < -5 and abs(channel) < 3:
+ res["pattern_flag_bear"] = 1
+ res["pattern_label"] = "flag_bear"
+
+ if high_slope > 0 and low_slope > 0:
+ res["pattern_wedge_rising"] = 1
+ if high_slope < 0 and low_slope < 0:
+ res["pattern_wedge_falling"] = 1
+
+ if high_slope > 0 and low_slope > 0:
+ res["pattern_channel_up"] = 1
+ if high_slope < 0 and low_slope < 0:
+ res["pattern_channel_down"] = 1
+
+ if len(c) >= 15:
+ mid = len(c) // 2
+ first_half = c[:mid].mean()
+ second_half = c[mid:].mean()
+ if c[0] > c[mid] * 1.08 and c[-1] > c[mid] * 1.05:
+ res["pattern_v_bottom"] = 1
+ res["pattern_label"] = "v_bottom"
+ if c[0] < c[-1] * 0.92 and c.max() > c[0] * 1.1:
+ res["pattern_spike_top"] = 1
+
+ o = win["Open"].astype(float).values
+ gap_ups: list[int] = []
+ gap_downs: list[int] = []
+ for i in range(1, min(30, n)):
+ if l[i] > h[i - 1]:
+ res["pattern_gap_up"] = 1
+ gap_ups.append(i)
+ if h[i] < l[i - 1]:
+ res["pattern_gap_down"] = 1
+ gap_downs.append(i)
+ for gi in gap_ups:
+ for gd in gap_downs:
+ if gd > gi and h[gi] < l[gd]:
+ res["pattern_island_top"] = 1
+ res["pattern_label"] = "island_top"
+ if gd > gi and l[gi] > h[gd]:
+ res["pattern_island_bottom"] = 1
+ res["pattern_label"] = "island_bottom"
+
+ # 키리스톤: 상단 수평 + 하단 상승(역키리스톤) 또는 하단 수평 + 상단 하락
+ if abs(high_slope) < c[-1] * 0.00005 and low_slope > 0:
+ res["pattern_keystone_bull"] = 1
+ if res["pattern_label"] == "none":
+ res["pattern_label"] = "keystone_bull"
+ if abs(low_slope) < c[-1] * 0.00005 and high_slope < 0:
+ res["pattern_keystone_bear"] = 1
+ if res["pattern_label"] == "none":
+ res["pattern_label"] = "keystone_bear"
+
+ # 컵앤핸들: 전반 U자 + 후반 15% 소폭 조정
+ if n >= 40:
+ cup_len = int(n * 0.65)
+ handle_len = max(int(n * 0.15), 5)
+ cup = c[:cup_len]
+ handle = c[-handle_len:]
+ rim = float(max(cup[0], cup[-1]))
+ bottom = float(cup.min())
+ depth = rim - bottom
+ if depth > rim * 0.08 and float(cup[-1]) > bottom + depth * 0.5:
+ handle_pull = float(handle.max() - handle.min())
+ if handle_pull < depth * 0.5 and float(c[-1]) >= rim * 0.98:
+ res["pattern_cup_handle"] = 1
+ res["pattern_label"] = "cup_handle"
+
+ if len(c) >= 30:
+ ma = pd.Series(c).rolling(10).mean()
+ if float(ma.iloc[-1]) > float(ma.iloc[-15]) > float(ma.iloc[-30]):
+ res["pattern_rounding_bottom"] = 1
+ if float(ma.iloc[-1]) < float(ma.iloc[-15]) < float(ma.iloc[-30]):
+ res["pattern_rounding_top"] = 1
+
+ if len(peaks) >= 2 and len(troughs) >= 2:
+ leg_h = h[peaks[-1]] - l[troughs[-1]]
+ if leg_h > 0 and c[-1] >= l[troughs[-1]] + leg_h * 0.9:
+ res["pattern_measured_move"] = 1
+
+ return res
+
+
+def general_analysis_pattern_snapshot(win: pd.DataFrame) -> dict[str, object]:
+ """패턴 dict → ga_pattern_* 컬럼명."""
+ raw = general_analysis_detect_patterns(win)
+ return {ga_col(k): v for k, v in raw.items()}
+
+
+def general_analysis_pattern_columns() -> list[str]:
+ return [
+ "pattern_double_top",
+ "pattern_double_bottom",
+ "pattern_head_shoulders",
+ "pattern_inv_head_shoulders",
+ "pattern_triangle_sym",
+ "pattern_triangle_asc",
+ "pattern_triangle_desc",
+ "pattern_flag_bull",
+ "pattern_flag_bear",
+ "pattern_wedge_rising",
+ "pattern_wedge_falling",
+ "pattern_rectangle",
+ "pattern_channel_up",
+ "pattern_channel_down",
+ "pattern_measured_move",
+ "pattern_rounding_top",
+ "pattern_rounding_bottom",
+ "pattern_gap_up",
+ "pattern_gap_down",
+ "pattern_v_bottom",
+ "pattern_spike_top",
+ "pattern_triple_top",
+ "pattern_triple_bottom",
+ "pattern_cup_handle",
+ "pattern_keystone_bull",
+ "pattern_keystone_bear",
+ "pattern_island_top",
+ "pattern_island_bottom",
+ "pattern_label",
+ ]
+
+
+def general_analysis_apply_patterns_to_bars(
+ df: pd.DataFrame,
+ interval: int,
+ tail_rows: int | None = None,
+) -> pd.DataFrame:
+ """
+ lookback 윈도우 패턴 라벨을 봉별 컬럼으로 채움 (최근 tail_rows만, 성능).
+
+ Args:
+ df: OHLCV (+ 선택적 지표).
+ interval: 분봉 간격.
+ tail_rows: None이면 전체(8천봉 이하) 또는 config tail.
+
+ Returns:
+ ga_pattern_* 컬럼이 추가된 DataFrame.
+ """
+ from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
+
+ out = df.copy()
+ lb = LOOKBACK_BARS.get(interval, 80)
+ keys = [k for k in general_analysis_pattern_columns() if k != "pattern_label"]
+ for k in keys:
+ out[ga_col(k)] = 0
+ out[ga_col("pattern_label")] = "none"
+
+ n = len(out)
+ if n < lb + 1:
+ return out
+
+ if tail_rows is None:
+ tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
+ start = max(lb, n - tail_rows)
+
+ for i in range(start, n):
+ win = out.iloc[i - lb : i]
+ det = general_analysis_detect_patterns(win)
+ idx = out.index[i]
+ for k, v in det.items():
+ out.at[idx, ga_col(k)] = v
+
+ return out
diff --git a/deepcoin/analysis/general_analysis_pipeline.py b/deepcoin/analysis/general_analysis_pipeline.py
new file mode 100644
index 0000000..b74b0d8
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_pipeline.py
@@ -0,0 +1,154 @@
+"""
+general_analysis 전체 파이프라인 (지표·캔들·한 봉 특징).
+"""
+
+from __future__ import annotations
+
+import pandas as pd
+
+from deepcoin.common.candle_features import compute_bar_features
+from deepcoin.analysis.general_analysis_candles import (
+ general_analysis_apply_candles,
+ general_analysis_candle_columns,
+)
+from deepcoin.analysis.general_analysis_chart import (
+ general_analysis_apply_chart_bars,
+ general_analysis_chart_columns,
+ general_analysis_chart_metrics,
+)
+from deepcoin.analysis.general_analysis_context import general_analysis_apply_context_features
+from deepcoin.analysis.general_analysis_harmonic import (
+ general_analysis_harmonic_columns,
+ general_analysis_harmonic_snapshot,
+)
+from deepcoin.analysis.general_analysis_volume import (
+ general_analysis_volume_columns,
+ general_analysis_volume_snapshot,
+)
+from deepcoin.analysis.general_analysis_core import ga_col, lookback_slice
+from deepcoin.analysis.general_analysis_indicators import (
+ general_analysis_apply_indicators,
+ general_analysis_indicator_columns,
+)
+from deepcoin.analysis.general_analysis_patterns import (
+ general_analysis_pattern_columns,
+ general_analysis_pattern_snapshot,
+)
+from deepcoin.analysis.general_analysis_wave import (
+ general_analysis_wave_columns,
+ general_analysis_wave_snapshot,
+)
+
+
+def general_analysis_enrich_bars(
+ df: pd.DataFrame,
+ interval: int | None = None,
+ *,
+ full_context: bool = True,
+) -> pd.DataFrame:
+ """
+ OHLCV → candle_features + ga 지표 + 캔들 + 차트 + (선택) lookback 컨텍스트.
+
+ Args:
+ df: raw OHLCV.
+ interval: 분봉 간격. full_context=True일 때 필수.
+ full_context: 패턴·VP·파동·하모닉 롤링 적용.
+
+ Returns:
+ 전체 특징 컬럼 DataFrame.
+ """
+ base = compute_bar_features(df)
+ out = general_analysis_apply_indicators(base)
+ out = general_analysis_apply_candles(out)
+ out = general_analysis_apply_chart_bars(out)
+ if full_context and interval is not None:
+ out = general_analysis_apply_context_features(out, interval)
+ return out
+
+
+def general_analysis_snapshot_at_bar(
+ enriched: pd.DataFrame,
+ ts: pd.Timestamp,
+ interval: int,
+) -> dict[str, object]:
+ """
+ 타점 시각 직전 완성봉 + lookback 패턴·파동·차트 메타.
+
+ Args:
+ enriched: general_analysis_enrich_bars 결과.
+ ts: 타점 시각.
+ interval: 분봉 간격.
+
+ Returns:
+ flat dict (ga_ 키 + legacy bb/rsi where present).
+ """
+ win = lookback_slice(enriched, interval, ts)
+ snap: dict[str, object] = {}
+ if win.empty:
+ return snap
+
+ row = win.iloc[-1]
+
+ legacy_cols = [
+ "bb_pos",
+ "RSI",
+ "macd_hist",
+ "stoch_k",
+ "stoch_d",
+ "macd_line",
+ "macd_signal",
+ "BB_Width",
+ ]
+ for c in legacy_cols:
+ if c in row.index and not pd.isna(row[c]):
+ snap[c] = float(row[c]) if isinstance(row[c], (int, float)) else row[c]
+
+ for c in general_analysis_indicator_columns():
+ col = ga_col(c)
+ if col in row.index:
+ v = row[col]
+ snap[col] = None if pd.isna(v) else v
+
+ for c in general_analysis_candle_columns():
+ col = ga_col(c)
+ if col in row.index:
+ snap[col] = int(row[col]) if not pd.isna(row[col]) else 0
+
+ pat_cols = [ga_col(c) for c in general_analysis_pattern_columns()]
+ if pat_cols and pat_cols[0] in enriched.columns:
+ for col in pat_cols:
+ if col in row.index:
+ snap[col] = row[col]
+ else:
+ snap.update(general_analysis_pattern_snapshot(win))
+
+ wave_cols = [ga_col(c) for c in general_analysis_wave_columns()]
+ if wave_cols and wave_cols[0] in enriched.columns:
+ for col in wave_cols:
+ if col in row.index:
+ snap[col] = row[col]
+ else:
+ snap.update(general_analysis_wave_snapshot(win))
+
+ snap.update(general_analysis_volume_snapshot(win))
+ snap.update(general_analysis_harmonic_snapshot(win))
+ snap.update(general_analysis_chart_metrics(win))
+
+ return snap
+
+
+def general_analysis_all_snapshot_keys() -> list[str]:
+ """CSV 헤더용 전체 키 목록 (간격 접두사 제외)."""
+ keys = list(legacy_snapshot_keys())
+ keys += [ga_col(c) for c in general_analysis_indicator_columns()]
+ keys += [ga_col(c) for c in general_analysis_candle_columns()]
+ keys += [ga_col(c) for c in general_analysis_pattern_columns()]
+ keys += [ga_col(c) for c in general_analysis_wave_columns()]
+ keys += [ga_col(c) for c in general_analysis_chart_columns()]
+ keys += [ga_col(c) for c in general_analysis_volume_columns()]
+ keys += [ga_col(c) for c in general_analysis_harmonic_columns()]
+ return keys
+
+
+def legacy_snapshot_keys() -> list[str]:
+ return ["bb_pos", "RSI", "macd_hist", "stoch_k", "stoch_d"]
diff --git a/deepcoin/analysis/general_analysis_report.py b/deepcoin/analysis/general_analysis_report.py
new file mode 100644
index 0000000..88b2640
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_report.py
@@ -0,0 +1,73 @@
+"""
+general_analysis HTML 요약 리포트.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_config import DEFAULT_OUTPUT_CSV, DEFAULT_OUTPUT_HTML
+
+
+def write_analysis_report(
+ csv_path: Path | str = DEFAULT_OUTPUT_CSV,
+ html_path: Path | str = DEFAULT_OUTPUT_HTML,
+) -> Path:
+ """
+ 스냅샷 CSV를 읽어 모듈별 컬럼 수·샘플 테이블 HTML 생성.
+
+ Returns:
+ HTML 경로.
+ """
+ df = pd.read_csv(csv_path)
+ html_out = Path(html_path)
+ html_out.parent.mkdir(parents=True, exist_ok=True)
+
+ modules = {
+ "지표 (ga_)": [c for c in df.columns if "_ga_" in c or c.startswith("ga_")],
+ "패턴": [c for c in df.columns if "ga_pattern_" in c],
+ "파동·구조": [c for c in df.columns if "ga_struct_" in c or "ga_elliott" in c or "ga_wyckoff" in c or "ga_fib_" in c],
+ "차트": [c for c in df.columns if "ga_chart_" in c],
+ "MTF 합성": [c for c in df.columns if "ga_align_" in c],
+ "레거시": [c for c in df.columns if c.endswith("_RSI") or c.endswith("_bb_pos")],
+ }
+
+ summary_rows = ""
+ for name, cols in modules.items():
+ summary_rows += f"| {name} | {len(cols)} |
"
+
+ sample = df.head(5)[
+ ["dt", "action", "price", "ga_align_timing_buy_score", "ga_align_mtf_conflict", "d1_RSI", "m3_RSI"]
+ ].to_html(index=False, classes="tbl") if "d1_RSI" in df.columns else df.head(3).to_html(index=False)
+
+ buy_mean = df[df["action"] == "buy"]["ga_align_timing_buy_score"].mean() if "ga_align_timing_buy_score" in df.columns else 0
+ sell_mean = df[df["action"] == "sell"]["ga_align_timing_sell_score"].mean() if "ga_align_timing_sell_score" in df.columns else 0
+
+ content = f"""
+
+general_analysis 실행 리포트
+
+general_analysis 실행 리포트
+타점 {len(df)}건 · 컬럼 {len(df.columns)}개 · CSV: {csv_path}
+모듈별 컬럼 수
+
+MTF 합성 평균
+
+- 매수 타점 timing_buy_score 평균: {buy_mean:.3f}
+- 매도 타점 timing_sell_score 평균: {sell_mean:.3f}
+
+샘플 5건
+{sample}
+전체 데이터: {csv_path}
+"""
+ html_out.write_text(content, encoding="utf-8")
+ print(f"리포트: {html_out}")
+ return html_out
diff --git a/deepcoin/analysis/general_analysis_runner.py b/deepcoin/analysis/general_analysis_runner.py
new file mode 100644
index 0000000..61aa599
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_runner.py
@@ -0,0 +1,71 @@
+"""
+general_analysis 실행 진입점.
+
+ python scripts/03_analyze_trades.py
+ python scripts/03_analyze_trades.py --limit 20 # 테스트용 타점 수 제한
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+
+from config import CHART_LOOKBACK_DAYS, SYMBOL
+from deepcoin.analysis.general_analysis_config import (
+ DEFAULT_OUTPUT_CSV,
+ DEFAULT_OUTPUT_HTML,
+ DEFAULT_TRADES_FILE,
+)
+from deepcoin.analysis.general_analysis_report import write_analysis_report
+from deepcoin.analysis.general_analysis_snapshot import export_trade_snapshots
+from deepcoin.ops.monitor import Monitor
+from deepcoin.data.mtf_bb import load_frames_from_db
+
+
+def main() -> None:
+ """ground truth 타점 MTF general_analysis 스냅샷 생성."""
+ parser = argparse.ArgumentParser(description="general_analysis MTF 타점 분석")
+ parser.add_argument("--limit", type=int, default=0, help="타점 수 제한 (0=전체)")
+ parser.add_argument("--trades", type=str, default=DEFAULT_TRADES_FILE)
+ parser.add_argument("--csv", type=str, default=DEFAULT_OUTPUT_CSV)
+ parser.add_argument("--html", type=str, default=DEFAULT_OUTPUT_HTML)
+ args = parser.parse_args()
+
+ from deepcoin.paths import REPORTS_ANALYSIS
+
+ trades_path = Path(args.trades)
+ data = json.loads(trades_path.read_text(encoding="utf-8"))
+ trades = data.get("trades") or []
+ if args.limit > 0:
+ trades = trades[: args.limit]
+ print(f"테스트 모드: 타점 {args.limit}건만")
+
+ print(f"=== general_analysis {SYMBOL} (lookback {CHART_LOOKBACK_DAYS}일) ===")
+ mon = Monitor(cooldown_file=None)
+ frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
+ if not frames:
+ raise RuntimeError("coins.db 데이터 없음")
+
+ # limit 시 임시 trades 파일
+ if args.limit > 0:
+ tmp = REPORTS_ANALYSIS / "_ga_trades_subset.json"
+ tmp.parent.mkdir(exist_ok=True)
+ subset = {**data, "trades": trades}
+ tmp.write_text(json.dumps(subset, ensure_ascii=False), encoding="utf-8")
+ trades_path = tmp
+
+ from deepcoin.analysis.general_analysis_snapshot import build_trade_mtf_snapshots
+
+ csv_path = Path(args.csv)
+ df = build_trade_mtf_snapshots(frames, trades)
+ csv_path.parent.mkdir(parents=True, exist_ok=True)
+ df.to_csv(csv_path, index=False, encoding="utf-8-sig")
+ print(f"저장: {csv_path} ({len(df)}행 × {len(df.columns)}열)")
+
+ write_analysis_report(csv_path, Path(args.html))
+ print("완료.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deepcoin/analysis/general_analysis_snapshot.py b/deepcoin/analysis/general_analysis_snapshot.py
new file mode 100644
index 0000000..c9386cd
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_snapshot.py
@@ -0,0 +1,100 @@
+"""
+general_analysis ground truth 타점 MTF 스냅샷 생성.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_align import general_analysis_mtf_scores
+from deepcoin.analysis.general_analysis_config import (
+ DEFAULT_OUTPUT_CSV,
+ DEFAULT_TRADES_FILE,
+ GENERAL_ANALYSIS_INTERVALS,
+)
+from deepcoin.analysis.general_analysis_core import interval_tf_prefix
+from deepcoin.analysis.general_analysis_pipeline import general_analysis_enrich_bars, general_analysis_snapshot_at_bar
+from deepcoin.ground_truth.ground_truth import load_ground_truth
+
+
+def _prefixed_snap(snap: dict[str, Any], interval: int) -> dict[str, Any]:
+ p = interval_tf_prefix(interval)
+ return {f"{p}_{k}": v for k, v in snap.items()}
+
+
+def build_trade_mtf_snapshots(
+ frames: dict[int, pd.DataFrame],
+ trades: list[dict[str, Any]],
+) -> pd.DataFrame:
+ """
+ 모든 타점에 대해 8개 간격 general_analysis 스냅샷.
+
+ Args:
+ frames: interval → OHLCV.
+ trades: ground_truth trades.
+
+ Returns:
+ wide DataFrame (1 row per trade).
+ """
+ enriched: dict[int, pd.DataFrame] = {}
+ for iv in GENERAL_ANALYSIS_INTERVALS:
+ raw = frames.get(iv)
+ if raw is None or raw.empty:
+ continue
+ print(f" [GA] {interval_tf_prefix(iv)} 봉 지표 계산 ({len(raw)}봉)...")
+ enriched[iv] = general_analysis_enrich_bars(raw, iv, full_context=True)
+
+ rows: list[dict[str, Any]] = []
+ for i, t in enumerate(sorted(trades, key=lambda x: x["dt"])):
+ ts = pd.Timestamp(t["dt"])
+ row: dict[str, Any] = {
+ "trade_idx": i,
+ "dt": t["dt"],
+ "action": t["action"],
+ "price": t["price"],
+ "weight": t.get("weight", 1.0),
+ "leg_id": t.get("leg_id", 0),
+ "memo": t.get("memo", ""),
+ }
+ flat: dict[str, Any] = {}
+ for iv in GENERAL_ANALYSIS_INTERVALS:
+ ef = enriched.get(iv)
+ if ef is None:
+ continue
+ snap = general_analysis_snapshot_at_bar(ef, ts, iv)
+ flat.update(_prefixed_snap(snap, iv))
+ row.update(flat)
+ row.update(general_analysis_mtf_scores(flat))
+ rows.append(row)
+ if (i + 1) % 50 == 0:
+ print(f" 타점 스냅샷 {i + 1}/{len(trades)}")
+
+ return pd.DataFrame(rows)
+
+
+def export_trade_snapshots(
+ frames: dict[int, pd.DataFrame],
+ trades_path: Path | str = DEFAULT_TRADES_FILE,
+ output_csv: Path | str = DEFAULT_OUTPUT_CSV,
+) -> Path:
+ """
+ CSV로 타점 MTF 스냅샷 저장.
+
+ Returns:
+ 저장 경로.
+ """
+ data = load_ground_truth(Path(trades_path))
+ if not data:
+ raise FileNotFoundError(f"정답 파일 없음: {trades_path}")
+ trades = data.get("trades") or []
+ print(f"타점 {len(trades)}건 × {len(GENERAL_ANALYSIS_INTERVALS)} TF general_analysis")
+ df = build_trade_mtf_snapshots(frames, trades)
+ out = Path(output_csv)
+ out.parent.mkdir(parents=True, exist_ok=True)
+ df.to_csv(out, index=False, encoding="utf-8-sig")
+ print(f"저장: {out} ({len(df)}행 × {len(df.columns)}열)")
+ return out
diff --git a/deepcoin/analysis/general_analysis_volume.py b/deepcoin/analysis/general_analysis_volume.py
new file mode 100644
index 0000000..6408b1f
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_volume.py
@@ -0,0 +1,92 @@
+"""
+general_analysis Volume Profile (POC, VAH, VAL).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import GA_VP_BINS, GA_VP_VALUE_AREA_PCT
+from deepcoin.analysis.general_analysis_core import ga_col
+
+
+def general_analysis_volume_profile(
+ win: pd.DataFrame,
+ bins: int | None = None,
+ value_area_pct: float | None = None,
+) -> dict[str, float | int]:
+ """
+ lookback 구간 가격-거래량 분포에서 POC·VAH·VAL 계산.
+
+ Args:
+ win: OHLCV.
+ bins: 가격 구간 수.
+ value_area_pct: value area 누적 비율 (기본 70%).
+
+ Returns:
+ ga_vp_* 키 dict (접두사 없음).
+ """
+ res: dict[str, float | int] = {
+ "vp_poc": 0.0,
+ "vp_vah": 0.0,
+ "vp_val": 0.0,
+ "vp_close_vs_poc_pct": 0.0,
+ "vp_in_value_area": 0,
+ }
+ if bins is None:
+ bins = GA_VP_BINS
+ if value_area_pct is None:
+ value_area_pct = GA_VP_VALUE_AREA_PCT
+
+ if win is None or len(win) < 10 or "Volume" not in win.columns:
+ return res
+
+ h = win["High"].astype(float).values
+ l = win["Low"].astype(float).values
+ c = win["Close"].astype(float).values
+ v = win["Volume"].astype(float).values
+ tp = (h + l + c) / 3.0
+
+ lo, hi = float(l.min()), float(h.max())
+ if hi <= lo:
+ return res
+
+ edges = np.linspace(lo, hi, bins + 1)
+ hist = np.zeros(bins, dtype=float)
+ for i in range(len(tp)):
+ idx = int(np.clip(np.digitize(tp[i], edges) - 1, 0, bins - 1))
+ hist[idx] += v[i]
+
+ if hist.sum() <= 0:
+ return res
+
+ poc_idx = int(np.argmax(hist))
+ poc = float((edges[poc_idx] + edges[poc_idx + 1]) / 2)
+ res["vp_poc"] = poc
+
+ order = np.argsort(hist)[::-1]
+ cum = 0.0
+ selected: list[int] = []
+ total = hist.sum()
+ for idx in order:
+ selected.append(int(idx))
+ cum += hist[idx]
+ if cum >= total * value_area_pct:
+ break
+ sel_min, sel_max = min(selected), max(selected)
+ res["vp_val"] = float(edges[sel_min])
+ res["vp_vah"] = float(edges[sel_max + 1])
+ res["vp_close_vs_poc_pct"] = float((c[-1] / poc - 1) * 100) if poc else 0.0
+ res["vp_in_value_area"] = int(res["vp_val"] <= c[-1] <= res["vp_vah"])
+
+ return res
+
+
+def general_analysis_volume_columns() -> list[str]:
+ return ["vp_poc", "vp_vah", "vp_val", "vp_close_vs_poc_pct", "vp_in_value_area"]
+
+
+def general_analysis_volume_snapshot(win: pd.DataFrame) -> dict[str, object]:
+ """Volume profile → ga_vp_*."""
+ return {ga_col(k): v for k, v in general_analysis_volume_profile(win).items()}
diff --git a/deepcoin/analysis/general_analysis_wave.py b/deepcoin/analysis/general_analysis_wave.py
new file mode 100644
index 0000000..0db968d
--- /dev/null
+++ b/deepcoin/analysis/general_analysis_wave.py
@@ -0,0 +1,205 @@
+"""
+general_analysis 파동·시장 구조 (다우, 엘리어트 라이트, 피보나치, Wyckoff 태그).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from deepcoin.analysis.general_analysis_core import find_pivots, ga_col
+
+
+def _fib_levels(low: float, high: float) -> dict[str, float]:
+ diff = high - low
+ return {
+ "fib_0": low,
+ "fib_382": low + diff * 0.382,
+ "fib_500": low + diff * 0.5,
+ "fib_618": low + diff * 0.618,
+ "fib_100": high,
+ "fib_1618": low + diff * 1.618,
+ }
+
+
+def general_analysis_wave_snapshot(win: pd.DataFrame) -> dict[str, object]:
+ """
+ lookback 윈도우 마지막 시점 파동·구조 스냅샷.
+
+ Args:
+ win: OHLCV.
+
+ Returns:
+ ga_wave_* / ga_struct_* / ga_fib_* dict.
+ """
+ res: dict[str, object] = {
+ "struct_trend": "range",
+ "struct_hh": 0,
+ "struct_hl": 0,
+ "struct_lh": 0,
+ "struct_ll": 0,
+ "struct_bos_bull": 0,
+ "struct_bos_bear": 0,
+ "struct_choch": 0,
+ "elliott_wave_count": 0,
+ "elliott_phase": "unknown",
+ "wyckoff_phase": "unknown",
+ "fib_near_level": "none",
+ "ichi_trend": "neutral",
+ "pitchfork_bias": "neutral",
+ "pitchfork_dist_pct": 0.0,
+ "wyckoff_spring": 0,
+ "wyckoff_utad": 0,
+ }
+ if win is None or len(win) < 15:
+ return {ga_col(k): v for k, v in res.items()}
+
+ h = win["High"].astype(float).values
+ l = win["Low"].astype(float).values
+ c = win["Close"].astype(float).values
+ peaks, troughs = find_pivots(h, l, order=2)
+
+ # Dow HH/HL/LH/LL
+ if len(peaks) >= 2 and len(troughs) >= 2:
+ hh = int(h[peaks[-1]] > h[peaks[-2]])
+ hl = int(l[troughs[-1]] > l[troughs[-2]])
+ lh = int(h[peaks[-1]] < h[peaks[-2]])
+ ll = int(l[troughs[-1]] < l[troughs[-2]])
+ res["struct_hh"] = hh
+ res["struct_hl"] = hl
+ res["struct_lh"] = lh
+ res["struct_ll"] = ll
+ if hh and hl:
+ res["struct_trend"] = "up"
+ elif lh and ll:
+ res["struct_trend"] = "down"
+ if hh and c[-1] > h[peaks[-2]]:
+ res["struct_bos_bull"] = 1
+ if ll and c[-1] < l[troughs[-2]]:
+ res["struct_bos_bear"] = 1
+ if (hh and ll) or (lh and hl):
+ res["struct_choch"] = 1
+
+ # Elliott lite: pivot count in window
+ swings = len(peaks) + len(troughs)
+ res["elliott_wave_count"] = swings
+ if swings >= 5:
+ res["elliott_phase"] = "impulse_late"
+ elif swings >= 3:
+ res["elliott_phase"] = "corrective"
+
+ # Wyckoff lite
+ vol = win["Volume"].astype(float).values if "Volume" in win.columns else np.ones(len(c))
+ vol_ma = vol[-20:].mean() if len(vol) >= 20 else vol.mean()
+ price_range = (h[-20:].max() - l[-20:].min()) / max(c[-1], 1e-9) * 100
+ if price_range < 6 and vol[-1] < vol_ma * 1.2:
+ res["wyckoff_phase"] = "accumulation"
+ elif price_range < 6 and vol[-1] > vol_ma * 1.5 and c[-1] > c[-5]:
+ res["wyckoff_phase"] = "distribution"
+
+ if price_range < 8 and l[-1] < l[-5] and c[-1] > c[-2] and vol[-1] > vol_ma * 1.3:
+ res["wyckoff_spring"] = 1
+ if price_range < 8 and h[-1] > h[-5] and c[-1] < c[-2] and vol[-1] > vol_ma * 1.3:
+ res["wyckoff_utad"] = 1
+
+ # Andrews Pitchfork (3피벗 중앙선 대비 종가 위치)
+ pivots = sorted([(i, h[i]) for i in peaks] + [(i, l[i]) for i in troughs])
+ if len(pivots) >= 3:
+ p0, p1, p2 = pivots[-3], pivots[-2], pivots[-1]
+ y0 = p0[1]
+ y_mid = (p1[1] + p2[1]) / 2
+ x0, x2 = p0[0], p2[0]
+ if x2 != x0:
+ slope = (y_mid - y0) / (x2 - x0)
+ y_line = y0 + slope * (len(c) - 1 - x0)
+ dist_pct = (c[-1] - y_line) / max(c[-1], 1e-9) * 100
+ res["pitchfork_dist_pct"] = round(float(dist_pct), 3)
+ if dist_pct > 0.5:
+ res["pitchfork_bias"] = "above"
+ elif dist_pct < -0.5:
+ res["pitchfork_bias"] = "below"
+
+ # Fibonacci
+ hi, lo = float(h.max()), float(l.min())
+ levels = _fib_levels(lo, hi)
+ price = float(c[-1])
+ for name, lvl in levels.items():
+ if abs(price - lvl) / max(price, 1e-9) * 100 < 1.5:
+ res["fib_near_level"] = name.replace("fib_", "")
+ break
+
+ if "ichi_cloud_top" in win.columns:
+ row = win.iloc[-1]
+ ct = float(row.get("ichi_cloud_top", np.nan))
+ cb = float(row.get("ichi_cloud_bottom", np.nan))
+ if not np.isnan(ct) and price > ct:
+ res["ichi_trend"] = "above_cloud"
+ elif not np.isnan(cb) and price < cb:
+ res["ichi_trend"] = "below_cloud"
+ else:
+ res["ichi_trend"] = "in_cloud"
+
+ return {ga_col(k): v for k, v in res.items()}
+
+
+def general_analysis_wave_columns() -> list[str]:
+ return [
+ "struct_trend",
+ "struct_hh",
+ "struct_hl",
+ "struct_lh",
+ "struct_ll",
+ "struct_bos_bull",
+ "struct_bos_bear",
+ "struct_choch",
+ "elliott_wave_count",
+ "elliott_phase",
+ "wyckoff_phase",
+ "fib_near_level",
+ "ichi_trend",
+ "pitchfork_bias",
+ "pitchfork_dist_pct",
+ "wyckoff_spring",
+ "wyckoff_utad",
+ ]
+
+
+def general_analysis_apply_wave_to_bars(
+ df: pd.DataFrame,
+ interval: int,
+ tail_rows: int | None = None,
+) -> pd.DataFrame:
+ """파동·구조 스냅샷을 최근 봉에 롤링 적용."""
+ from deepcoin.analysis.general_analysis_config import CONTEXT_TAIL_ROWS, LOOKBACK_BARS
+
+ out = df.copy()
+ lb = LOOKBACK_BARS.get(interval, 80)
+ for k in general_analysis_wave_columns():
+ col = ga_col(k)
+ if k == "struct_trend":
+ out[col] = "range"
+ elif k in ("elliott_phase", "wyckoff_phase"):
+ out[col] = "unknown"
+ elif k == "fib_near_level":
+ out[col] = "none"
+ elif k in ("ichi_trend", "pitchfork_bias"):
+ out[col] = "neutral"
+ elif k == "pitchfork_dist_pct":
+ out[col] = 0.0
+ else:
+ out[col] = 0
+
+ n = len(out)
+ if n < lb + 1:
+ return out
+ if tail_rows is None:
+ tail_rows = CONTEXT_TAIL_ROWS.get(interval, 5000)
+ start = max(lb, n - tail_rows)
+
+ for i in range(start, n):
+ snap = general_analysis_wave_snapshot(out.iloc[i - lb : i])
+ idx = out.index[i]
+ for k, v in snap.items():
+ out.at[idx, k] = v
+
+ return out
diff --git a/deepcoin/api/__init__.py b/deepcoin/api/__init__.py
new file mode 100644
index 0000000..bbc1ed0
--- /dev/null
+++ b/deepcoin/api/__init__.py
@@ -0,0 +1,5 @@
+"""외부 API 연동 (빗썸 등)."""
+
+from deepcoin.api.bithumb import HTS
+
+__all__ = ["HTS"]
diff --git a/HTS2.py b/deepcoin/api/bithumb.py
similarity index 97%
rename from HTS2.py
rename to deepcoin/api/bithumb.py
index eee6a30..c3d72de 100644
--- a/HTS2.py
+++ b/deepcoin/api/bithumb.py
@@ -1,4 +1,3 @@
-import os
import pandas as pd
import jwt
import uuid
@@ -15,13 +14,15 @@ class HTS:
bithumb = None
accessKey = ""
secretKey = ""
- apiUrl = "https://api.bithumb.com"
+ apiUrl = ""
def __init__(self):
+ from config import BITHUMB_ACCESS_KEY, BITHUMB_API_URL, BITHUMB_SECRET_KEY
+
self.bithumb = None
- self.accessKey = os.getenv("BITHUMB_ACCESS_KEY", "")
- self.secretKey = os.getenv("BITHUMB_SECRET_KEY", "")
- self.apiUrl = "https://api.bithumb.com"
+ self.accessKey = BITHUMB_ACCESS_KEY
+ self.secretKey = BITHUMB_SECRET_KEY
+ self.apiUrl = BITHUMB_API_URL.rstrip("/")
def append(self, stock, df=None, data_1=None):
if df is not None:
@@ -141,7 +142,7 @@ class HTS:
return df
def getTickerList(self):
- url = "https://api.bithumb.com/v1/market/all?isDetails=false"
+ url = f"{self.apiUrl}/v1/market/all?isDetails=false"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
@@ -149,7 +150,7 @@ class HTS:
return tickets
def getVirtual_asset_warning(self):
- url = "https://api.bithumb.com/v1/market/virtual_asset_warning"
+ url = f"{self.apiUrl}/v1/market/virtual_asset_warning"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
warning_list = response.json()
diff --git a/deepcoin/common/__init__.py b/deepcoin/common/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/candle_features.py b/deepcoin/common/candle_features.py
similarity index 67%
rename from candle_features.py
rename to deepcoin/common/candle_features.py
index 7366683..a816bb2 100644
--- a/candle_features.py
+++ b/deepcoin/common/candle_features.py
@@ -8,26 +8,21 @@ from __future__ import annotations
import numpy as np
import pandas as pd
-from config import ALL_INTERVALS, ENTRY_INTERVAL
-from indicators import add_bollinger, add_ichimoku
-from strategy import prepare_entry_df
-
-INTERVAL_LABELS: dict[int, str] = {
- 1: "m1",
- 3: "m3",
- 5: "m5",
- 10: "m10",
- 15: "m15",
- 30: "m30",
- 60: "m60",
- 240: "m240",
- 1440: "d1",
-}
+from config import (
+ ALL_INTERVALS,
+ BB_MIN_WIDTH_PCT,
+ DISPARITY_PERIODS,
+ ENTRY_INTERVAL,
+ INTERVAL_PREFIX,
+ STOCH_OVERBOUGHT,
+ STOCH_OVERSOLD,
+)
+from deepcoin.common.indicators import apply_bar_indicators, disparity_column
def interval_prefix(interval: int) -> str:
"""컬럼 접두사 (예: m3, d1)."""
- return INTERVAL_LABELS.get(interval, f"m{interval}")
+ return INTERVAL_PREFIX.get(interval, f"m{interval}")
def interval_display(interval: int) -> str:
@@ -73,6 +68,29 @@ BB_EVENT_FEATURES: tuple[str, ...] = (
"squeeze",
)
+MACD_STOCH_FEATURES: tuple[str, ...] = (
+ "macd_hist_positive",
+ "macd_hist_negative",
+ "macd_cross_up",
+ "macd_cross_down",
+ "stoch_oversold",
+ "stoch_overbought",
+ "stoch_cross_up",
+ "stoch_cross_down",
+)
+
+
+def _disparity_feature_names() -> tuple[str, ...]:
+ """기간별 이격도 과매수·과매도 불리언 컬럼명."""
+ names: list[str] = []
+ for p in DISPARITY_PERIODS:
+ names.append(f"disparity_{p}_oversold")
+ names.append(f"disparity_{p}_overbought")
+ return tuple(names)
+
+
+DISPARITY_FEATURES: tuple[str, ...] = _disparity_feature_names()
+
CANDLE_SHAPE_FEATURES: tuple[str, ...] = (
"body_strong",
"body_weak",
@@ -86,13 +104,15 @@ FEATURE_BOOL_COLS: tuple[str, ...] = (
BB_EVENT_FEATURES
+ BB_ZONE_FEATURES
+ ICHI_FEATURES
+ + MACD_STOCH_FEATURES
+ + DISPARITY_FEATURES
+ CANDLE_SHAPE_FEATURES
)
def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
- """단일 봉 DataFrame에 BB·일목·캔들 위치 특징을 추가합니다."""
- out = add_bollinger(add_ichimoku(prepare_entry_df(df.copy())))
+ """단일 봉 DataFrame에 BB·일목·MACD·스토캐스틱·캔들 위치 특징을 추가합니다."""
+ out = apply_bar_indicators(df.copy())
if len(out) < 2:
return out
@@ -129,7 +149,7 @@ def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
out["inside_band"] = ((c >= lower) & (c <= upper)).astype(int)
out["bb_pos_low"] = (pos < 0.2).astype(int)
out["bb_pos_high"] = (pos > 0.8).astype(int)
- out["squeeze"] = (out["BB_Width"] < 0.8).astype(int)
+ out["squeeze"] = (out["BB_Width"] < BB_MIN_WIDTH_PCT).astype(int)
ct = out["ichi_cloud_top"].astype(float)
cb = out["ichi_cloud_bottom"].astype(float)
@@ -157,6 +177,38 @@ def compute_bar_features(df: pd.DataFrame) -> pd.DataFrame:
out["bullish"] = (c > o).astype(int)
out["bearish"] = (c < o).astype(int)
+ if "macd_hist" in out.columns:
+ mh = out["macd_hist"].astype(float)
+ prev_mh = mh.shift(1)
+ ml = out["macd_line"].astype(float)
+ ms = out["macd_signal"].astype(float)
+ prev_ml = ml.shift(1)
+ prev_ms = ms.shift(1)
+ out["macd_hist_positive"] = (mh > 0).astype(int)
+ out["macd_hist_negative"] = (mh < 0).astype(int)
+ out["macd_cross_up"] = ((prev_ml <= prev_ms) & (ml > ms)).astype(int)
+ out["macd_cross_down"] = ((prev_ml >= prev_ms) & (ml < ms)).astype(int)
+
+ if "stoch_k" in out.columns:
+ sk = out["stoch_k"].astype(float)
+ sd = out["stoch_d"].astype(float)
+ prev_sk = sk.shift(1)
+ prev_sd = sd.shift(1)
+ out["stoch_oversold"] = (sk <= STOCH_OVERSOLD).astype(int)
+ out["stoch_overbought"] = (sk >= STOCH_OVERBOUGHT).astype(int)
+ out["stoch_cross_up"] = ((prev_sk <= prev_sd) & (sk > sd)).astype(int)
+ out["stoch_cross_down"] = ((prev_sk >= prev_sd) & (sk < sd)).astype(int)
+
+ from config import DISPARITY_OVERBOUGHT, DISPARITY_OVERSOLD
+
+ for p in DISPARITY_PERIODS:
+ col = disparity_column(p)
+ if col not in out.columns:
+ continue
+ d = out[col].astype(float)
+ out[f"disparity_{p}_oversold"] = (d <= DISPARITY_OVERSOLD).astype(int)
+ out[f"disparity_{p}_overbought"] = (d >= DISPARITY_OVERBOUGHT).astype(int)
+
return out
@@ -178,7 +230,7 @@ def describe_latest_position(df: pd.DataFrame, interval: int) -> dict:
elif int(row.get("ichi_below_cloud", 0)):
ichi_pos = "below_cloud"
- return {
+ snap: dict = {
"interval": interval,
"label": interval_display(interval),
"close": float(row["Close"]),
@@ -189,6 +241,31 @@ def describe_latest_position(df: pd.DataFrame, interval: int) -> dict:
"ichi_tk": "bull" if int(row.get("ichi_tk_bull", 0)) else "bear",
"ichi_cloud": "bull" if int(row.get("ichi_cloud_bull", 0)) else "bear",
}
+ if "macd_hist" in row.index and pd.notna(row["macd_hist"]):
+ snap["macd_hist"] = round(float(row["macd_hist"]), 4)
+ snap["macd_state"] = "bull" if float(row["macd_hist"]) > 0 else "bear"
+ if "stoch_k" in row.index and pd.notna(row["stoch_k"]):
+ sk = float(row["stoch_k"])
+ snap["stoch_k"] = round(sk, 1)
+ snap["stoch_d"] = round(float(row["stoch_d"]), 1)
+ if sk <= STOCH_OVERSOLD:
+ snap["stoch_zone"] = "oversold"
+ elif sk >= STOCH_OVERBOUGHT:
+ snap["stoch_zone"] = "overbought"
+ else:
+ snap["stoch_zone"] = "mid"
+ disp_vals: dict[int, float] = {}
+ for p in DISPARITY_PERIODS:
+ col = disparity_column(p)
+ if col in row.index and pd.notna(row[col]):
+ disp_vals[p] = round(float(row[col]), 2)
+ if disp_vals:
+ snap["disparity"] = disp_vals
+ primary_p = 20 if 20 in DISPARITY_PERIODS else DISPARITY_PERIODS[0]
+ snap["disparity_primary"] = disp_vals.get(
+ primary_p, next(iter(disp_vals.values()))
+ )
+ return snap
def _bb_event_label(row: pd.Series) -> str:
@@ -213,11 +290,20 @@ def _merge_interval_features(
) -> pd.DataFrame:
"""master_index 길이와 동일한 간격 특징만 반환."""
pick = [c for c in FEATURE_BOOL_COLS if c in feat.columns]
- extra = [
- c
- for c in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct")
- if c in feat.columns
- ]
+ numeric_cols = (
+ "bb_pos",
+ "body_ratio",
+ "lower_wick_ratio",
+ "ret_pct",
+ "bb_width_pct",
+ "macd_line",
+ "macd_signal",
+ "macd_hist",
+ "stoch_k",
+ "stoch_d",
+ "RSI",
+ ) + tuple(disparity_column(p) for p in DISPARITY_PERIODS)
+ extra = [c for c in numeric_cols if c in feat.columns]
if "bb_width_pct" not in feat.columns and "BB_Width" in feat.columns:
feat = feat.copy()
feat["bb_width_pct"] = feat["BB_Width"]
diff --git a/deepcoin/common/indicators.py b/deepcoin/common/indicators.py
new file mode 100644
index 0000000..e9b9463
--- /dev/null
+++ b/deepcoin/common/indicators.py
@@ -0,0 +1,315 @@
+"""
+볼린저 밴드·일목·MACD·스토캐스틱·RSI·이격도 계산 (모든 봉 간격 공용).
+"""
+
+from __future__ import annotations
+
+import numpy as np
+import pandas as pd
+
+from config import (
+ BB_PERIOD,
+ BB_STD,
+ DISPARITY_OVERBOUGHT,
+ DISPARITY_OVERSOLD,
+ DISPARITY_PERIODS,
+ MACD_FAST,
+ MACD_SIGNAL,
+ MACD_SLOW,
+ RSI_PERIOD,
+ STOCH_D_PERIOD,
+ STOCH_K_PERIOD,
+ STOCH_OVERBOUGHT,
+ STOCH_OVERSOLD,
+ STOCH_SMOOTH_K,
+ TREND_RANGE_MA_GAP_PCT,
+)
+
+Trend = str # "up" | "down" | "range"
+
+
+def add_bollinger(
+ df: pd.DataFrame,
+ period: int = BB_PERIOD,
+ std_mult: float = BB_STD,
+) -> pd.DataFrame:
+ """
+ 볼린저 밴드 컬럼을 추가합니다.
+
+ Args:
+ df: OHLCV DataFrame.
+ period: 중심선 기간.
+ std_mult: 표준편차 배수.
+
+ Returns:
+ MA, Upper, Lower, STD, bb_pos, BB_Width 가 추가된 DataFrame.
+ """
+ out = df.copy()
+ if "MA" not in out.columns:
+ out["MA"] = out["Close"].rolling(period).mean()
+ if "Upper" not in out.columns or "Lower" not in out.columns:
+ std = out["Close"].rolling(period).std()
+ out["STD"] = std
+ out["Upper"] = out["MA"] + std_mult * std
+ out["Lower"] = out["MA"] - std_mult * std
+ ma = out["MA"].replace(0, np.nan)
+ band = (out["Upper"] - out["Lower"]).replace(0, np.nan)
+ out["bb_pos"] = ((out["Close"] - out["Lower"]) / band).clip(0, 1)
+ out["BB_Width"] = band / ma * 100
+ return out
+
+
+def add_macd(
+ df: pd.DataFrame,
+ fast: int = MACD_FAST,
+ slow: int = MACD_SLOW,
+ signal_period: int = MACD_SIGNAL,
+) -> pd.DataFrame:
+ """
+ MACD(12,26,9) 라인·시그널·히스토그램을 추가합니다.
+
+ Args:
+ df: OHLCV (Close 필요).
+ fast: 단기 EMA 기간.
+ slow: 장기 EMA 기간.
+ signal_period: 시그널 EMA 기간.
+
+ Returns:
+ macd_line, macd_signal, macd_hist 컬럼이 추가된 DataFrame.
+ """
+ out = df.copy()
+ close = out["Close"].astype(float)
+ ema_fast = close.ewm(span=fast, adjust=False).mean()
+ ema_slow = close.ewm(span=slow, adjust=False).mean()
+ out["macd_line"] = ema_fast - ema_slow
+ out["macd_signal"] = out["macd_line"].ewm(span=signal_period, adjust=False).mean()
+ out["macd_hist"] = out["macd_line"] - out["macd_signal"]
+ return out
+
+
+def disparity_column(period: int) -> str:
+ """이격도 컬럼명 (예: disparity_20)."""
+ return f"disparity_{period}"
+
+
+def add_disparity(
+ df: pd.DataFrame,
+ periods: tuple[int, ...] | None = None,
+) -> pd.DataFrame:
+ """
+ 이격도 = (종가 / SMA(n)) × 100. 100이면 이평선과 동일 위치.
+
+ Args:
+ df: OHLCV (Close 필요).
+ periods: SMA 기간 목록. None이면 config.DISPARITY_PERIODS.
+
+ Returns:
+ disparity_{n} 컬럼이 추가된 DataFrame.
+ """
+ out = df.copy()
+ close = out["Close"].astype(float)
+ for p in periods or DISPARITY_PERIODS:
+ ma = close.rolling(p).mean()
+ out[disparity_column(p)] = (close / ma.replace(0, np.nan)) * 100.0
+ return out
+
+
+def disparity_zone(value: float | None) -> str:
+ """이격도 구간 라벨 (oversold / mid / overbought)."""
+ if value is None:
+ return "mid"
+ if value <= DISPARITY_OVERSOLD:
+ return "oversold"
+ if value >= DISPARITY_OVERBOUGHT:
+ return "overbought"
+ return "mid"
+
+
+def add_stochastic(
+ df: pd.DataFrame,
+ k_period: int = STOCH_K_PERIOD,
+ d_period: int = STOCH_D_PERIOD,
+ smooth_k: int = STOCH_SMOOTH_K,
+) -> pd.DataFrame:
+ """
+ 스토캐스틱 %K·%D를 추가합니다 (Slow Stochastic).
+
+ Args:
+ df: OHLCV (High, Low, Close 필요).
+ k_period: %K lookback.
+ d_period: %D SMA 기간.
+ smooth_k: %K SMA 평활 기간.
+
+ Returns:
+ stoch_k, stoch_d 컬럼이 추가된 DataFrame.
+ """
+ out = df.copy()
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+ lowest = l.rolling(k_period).min()
+ highest = h.rolling(k_period).max()
+ denom = (highest - lowest).replace(0, np.nan)
+ raw_k = ((c - lowest) / denom) * 100.0
+ out["stoch_k"] = raw_k.rolling(smooth_k).mean()
+ out["stoch_d"] = out["stoch_k"].rolling(d_period).mean()
+ return out
+
+
+def add_ichimoku(
+ df: pd.DataFrame,
+ tenkan: int = 9,
+ kijun: int = 26,
+ senkou_b_period: int = 52,
+) -> pd.DataFrame:
+ """
+ 일목균형표 라인·구름 위치 컬럼 추가 (해당 봉 시점, 미래 데이터 미사용).
+
+ Returns:
+ ichi_tenkan, ichi_kijun, ichi_span_a, ichi_span_b,
+ ichi_cloud_top, ichi_cloud_bottom
+ """
+ out = df.copy()
+ h = out["High"].astype(float)
+ l = out["Low"].astype(float)
+ c = out["Close"].astype(float)
+
+ out["ichi_tenkan"] = (h.rolling(tenkan).max() + l.rolling(tenkan).min()) / 2
+ out["ichi_kijun"] = (h.rolling(kijun).max() + l.rolling(kijun).min()) / 2
+ out["ichi_span_a"] = (out["ichi_tenkan"] + out["ichi_kijun"]) / 2
+ out["ichi_span_b"] = (h.rolling(senkou_b_period).max() + l.rolling(senkou_b_period).min()) / 2
+ out["ichi_cloud_top"] = np.maximum(out["ichi_span_a"], out["ichi_span_b"])
+ out["ichi_cloud_bottom"] = np.minimum(out["ichi_span_a"], out["ichi_span_b"])
+ return out
+
+
+def prepare_entry_df(data: pd.DataFrame) -> pd.DataFrame:
+ """
+ RSI·거래량 MA·BB 폭 등 보조 컬럼을 추가합니다.
+
+ Args:
+ data: BB(MA/Upper/Lower)가 계산된 OHLCV.
+
+ Returns:
+ RSI 등 컬럼이 추가된 DataFrame.
+ """
+ df = data.copy()
+ delta = df["Close"].diff()
+ gain = delta.where(delta > 0, 0.0).rolling(RSI_PERIOD).mean()
+ loss = (-delta.where(delta < 0, 0.0)).rolling(RSI_PERIOD).mean()
+ rs = gain / loss.replace(0, np.nan)
+ df["RSI"] = 100 - (100 / (1 + rs))
+ df["VolMA5"] = df["Volume"].rolling(5).mean()
+ if "MA" in df.columns and "Upper" in df.columns and "Lower" in df.columns:
+ ma = df["MA"].replace(0, np.nan)
+ df["BB_Width"] = (df["Upper"] - df["Lower"]) / ma * 100
+ return df
+
+
+def apply_bar_indicators(df: pd.DataFrame) -> pd.DataFrame:
+ """
+ 봉 분석·차트용 표준 지표 일괄 적용 (BB, 일목, RSI, MACD, 스토캐스틱, 이격도).
+
+ Args:
+ df: OHLCV DataFrame (datetime index).
+
+ Returns:
+ 모든 지표 컬럼이 붙은 DataFrame.
+ """
+ out = add_bollinger(df)
+ out = add_ichimoku(out)
+ out = prepare_entry_df(out)
+ out = add_disparity(out)
+ out = add_macd(out)
+ out = add_stochastic(out)
+ return out
+
+
+def latest_indicator_snapshot(df: pd.DataFrame) -> dict[str, float | str | None]:
+ """
+ 최신 봉의 BB·RSI·MACD·스토캐스틱 요약 (모니터·로그용).
+
+ Args:
+ df: apply_bar_indicators 적용된 DataFrame.
+
+ Returns:
+ 지표명→값 dict.
+ """
+ if df.empty:
+ return {}
+ row = df.iloc[-1]
+
+ def _f(col: str) -> float | None:
+ if col not in row.index or pd.isna(row[col]):
+ return None
+ return round(float(row[col]), 4)
+
+ macd_hist = _f("macd_hist")
+ stoch_k = _f("stoch_k")
+ stoch_d = _f("stoch_d")
+ stoch_zone = "mid"
+ if stoch_k is not None:
+ if stoch_k <= STOCH_OVERSOLD:
+ stoch_zone = "oversold"
+ elif stoch_k >= STOCH_OVERBOUGHT:
+ stoch_zone = "overbought"
+
+ macd_state = "neutral"
+ if macd_hist is not None:
+ macd_state = "bull" if macd_hist > 0 else "bear"
+
+ disp: dict[str, float | None] = {}
+ for p in DISPARITY_PERIODS:
+ col = disparity_column(p)
+ disp[col] = _f(col)
+ primary = disparity_column(DISPARITY_PERIODS[0]) if DISPARITY_PERIODS else None
+ disp_primary = disp.get(primary) if primary else None
+
+ return {
+ "bb_pos": _f("bb_pos"),
+ "rsi": _f("RSI"),
+ "disparity": disp,
+ "disparity_primary": disp_primary,
+ "disparity_zone": disparity_zone(disp_primary),
+ "macd_line": _f("macd_line"),
+ "macd_signal": _f("macd_signal"),
+ "macd_hist": macd_hist,
+ "macd_state": macd_state,
+ "stoch_k": stoch_k,
+ "stoch_d": stoch_d,
+ "stoch_zone": stoch_zone,
+ }
+
+
+def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
+ """
+ 일봉·1시간봉 기준 추세(up/down/range)를 반환합니다.
+
+ Args:
+ df_1d: 일봉 OHLCV+지표.
+ df_1h: 1시간봉 OHLCV+지표.
+
+ Returns:
+ 추세 문자열.
+ """
+ if len(df_1d) < 20 or len(df_1h) < 40:
+ return "range"
+
+ d_close = float(df_1d["Close"].iloc[-1])
+ d_ma20 = float(df_1d["MA20"].iloc[-1])
+ h_close = float(df_1h["Close"].iloc[-1])
+ h_ma20 = float(df_1h["MA20"].iloc[-1])
+ h_ma40 = float(df_1h["MA40"].iloc[-1])
+
+ if h_ma40 == 0:
+ return "range"
+
+ ma_gap_pct = abs(h_ma20 - h_ma40) / h_ma40 * 100
+ if ma_gap_pct < TREND_RANGE_MA_GAP_PCT:
+ return "range"
+
+ if d_close > d_ma20 and h_ma20 > h_ma40 and h_close > h_ma20:
+ return "up"
+ if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
+ return "down"
+ return "range"
diff --git a/deepcoin/data/__init__.py b/deepcoin/data/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/downloader.py b/deepcoin/data/downloader.py
similarity index 78%
rename from downloader.py
rename to deepcoin/data/downloader.py
index 879ba9e..a39f1ef 100644
--- a/downloader.py
+++ b/deepcoin/data/downloader.py
@@ -14,28 +14,30 @@ import pandas as pd
from dateutil.relativedelta import relativedelta
from config import (
+ BITHUMB_MINUTE_INTERVALS,
COIN_NAME,
+ DAILY_INTERVAL_MIN,
DB_PATH,
+ DOWNLOAD_BACKFILL_EXTRA_BARS,
+ DOWNLOAD_DAILY_EXTRA_DAYS,
DOWNLOAD_INTERVALS,
+ DOWNLOAD_MIN_INCREMENTAL_BARS,
DOWNLOAD_MONTHS,
DOWNLOAD_MONTHS_1M,
+ INCREMENTAL_OVERLAP_BARS,
KR_COINS,
SYMBOL,
)
-from monitor import Monitor
-
-BITHUMB_MINUTE_INTERVALS = {1, 3, 5, 10, 15, 30, 60, 240}
-# 증분 시 마지막 봉 재확인용 겹침 봉 수
-INCREMENTAL_OVERLAP_BARS = 3
+from deepcoin.ops.monitor import Monitor
def bong_count_for_months(interval_minutes: int, months: int) -> int:
"""N개월치 봉 개수(여유분 포함)."""
days = months * 30
- if interval_minutes >= 1440:
- return days + 20
+ if interval_minutes >= DAILY_INTERVAL_MIN:
+ return days + DOWNLOAD_DAILY_EXTRA_DAYS
bars_per_day = (24 * 60) // interval_minutes
- return days * bars_per_day + 200
+ return days * bars_per_day + DOWNLOAD_BACKFILL_EXTRA_BARS
def bong_count_since(
@@ -47,7 +49,7 @@ def bong_count_since(
last_ts = last_ts.tz_localize(None)
delta_min = max(0, (now - last_ts).total_seconds() / 60)
bars = int(delta_min / interval_minutes) + overlap + 10
- return max(bars, 50)
+ return max(bars, DOWNLOAD_MIN_INCREMENTAL_BARS)
def months_cutoff(months: int) -> pd.Timestamp:
@@ -111,6 +113,25 @@ def ensure_table(cursor, table_name: str) -> None:
)
+def get_earliest_timestamp(
+ symbol: str, interval: int, db_path: str = DB_PATH
+) -> pd.Timestamp | None:
+ """테이블에 저장된 해당 심볼의 가장 오래된 봉 시각."""
+ conn = sqlite3.connect(db_path)
+ cursor = conn.cursor()
+ table_name = f"{symbol}_{interval}"
+ ensure_table(cursor, table_name)
+ cursor.execute(
+ f"SELECT MIN(ymdhms) FROM {table_name} WHERE CODE = ?",
+ (symbol,),
+ )
+ row = cursor.fetchone()
+ conn.close()
+ if row and row[0]:
+ return pd.Timestamp(row[0])
+ return None
+
+
def get_last_timestamp(
symbol: str, interval: int, db_path: str = DB_PATH
) -> pd.Timestamp | None:
@@ -244,18 +265,68 @@ def append_data(
return len(records), skipped
+def backfill_before_earliest(
+ monitor: Monitor,
+ symbol: str,
+ interval: int,
+ months: int,
+) -> int:
+ """
+ DB 최초 봉보다 오래된 구간을 API로 채웁니다 (1년 적재 시 필요).
+
+ Returns:
+ 추가된 행 수.
+ """
+ months = months_for_interval(interval, months)
+ cutoff = months_cutoff(months)
+ earliest = get_earliest_timestamp(symbol, interval)
+ if earliest is None or earliest <= cutoff:
+ return 0
+
+ label = interval_label(interval)
+ # now부터 역순 수집이므로 cutoff까지 닿으려면 N개월 전체 봉 수가 필요
+ target = bong_count_for_months(interval, months)
+
+ print(
+ f" [백필] {label} — {cutoff.date()} ~ {earliest} "
+ f"(API 역수집 약 {target}봉)"
+ )
+
+ data = monitor.get_coin_more_data(
+ symbol, interval, bong_count=target, verbose=True
+ )
+ if data is None or data.empty:
+ print(" -> 백필 API 데이터 없음")
+ return 0
+
+ if not isinstance(data.index, pd.DatetimeIndex):
+ data.index = pd.to_datetime(data.index)
+ hist = data[(data.index >= cutoff) & (data.index < earliest)].copy()
+ if hist.empty:
+ print(" -> 백필 대상 구간 없음")
+ return 0
+
+ inserted, skipped = append_data(symbol, interval, hist, last_ts=None)
+ print(f" -> 백필 추가 {inserted}행 (스킵 {skipped})")
+ return inserted
+
+
def download_symbol(
monitor: Monitor,
symbol: str,
interval: int,
months: int,
) -> None:
- """한 간격의 봉을 API로 받아 증분 저장합니다."""
+ """한 간격의 봉을 API로 받아 증분·백필 저장합니다."""
months = months_for_interval(interval, months)
label = interval_label(interval)
- last_ts = get_last_timestamp(symbol, interval)
existing = get_row_count(symbol, interval)
+ if existing > 0:
+ backfill_before_earliest(monitor, symbol, interval, months)
+
+ last_ts = get_last_timestamp(symbol, interval)
+
if last_ts is None:
target = bong_count_for_months(interval, months)
mode = "초기 적재"
diff --git a/deepcoin/data/mtf_bb.py b/deepcoin/data/mtf_bb.py
new file mode 100644
index 0000000..8e87582
--- /dev/null
+++ b/deepcoin/data/mtf_bb.py
@@ -0,0 +1,47 @@
+"""
+coins.db에서 전 간격 봉 데이터를 로드합니다.
+"""
+
+from __future__ import annotations
+
+import pandas as pd
+
+from config import DOWNLOAD_INTERVALS, SYMBOL
+
+
+def interval_label(interval: int) -> str:
+ """봉 간격 표시 라벨."""
+ if interval >= 1440:
+ return "일봉"
+ return f"{interval}분"
+
+
+def load_frames_from_db(
+ monitor,
+ symbol: str,
+ lookback_days: int | None = None,
+) -> dict[int, pd.DataFrame]:
+ """
+ coins.db에서 DOWNLOAD_INTERVALS 전부 로드·지표 계산.
+
+ Args:
+ monitor: Monitor 인스턴스.
+ symbol: 코인 심볼.
+ lookback_days: 지정 시 간격별로 해당 일수만큼 DB에서 더 많이 읽습니다.
+
+ Returns:
+ 간격(분) → OHLCV+지표 DataFrame.
+ """
+ frames: dict[int, pd.DataFrame] = {}
+ for iv in DOWNLOAD_INTERVALS:
+ db_max = None
+ if lookback_days is not None:
+ db_max = monitor.db_row_limit_for_interval(iv, lookback_days)
+ df = monitor.get_coin_some_data(symbol, iv, db_max_rows=db_max)
+ if df is None or df.empty:
+ print(f" [{interval_label(iv)}] DB/API 데이터 없음 — 스킵")
+ continue
+ df = monitor.calculate_technical_indicators(df)
+ frames[iv] = df
+ print(f" [{interval_label(iv)}] {len(df)}봉 {df.index[0]} ~ {df.index[-1]}")
+ return frames
diff --git a/deepcoin/env_loader.py b/deepcoin/env_loader.py
new file mode 100644
index 0000000..2f4d7a9
--- /dev/null
+++ b/deepcoin/env_loader.py
@@ -0,0 +1,53 @@
+"""
+DeepCoin .env 로드 (프로젝트 루트 기준).
+
+config·HTS·스크립트 진입 전에 한 번 호출하면 cwd와 무관하게 동일한 설정을 사용합니다.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from deepcoin.paths import PROJECT_ROOT
+
+_ENV_LOADED = False
+ENV_FILE = PROJECT_ROOT / ".env"
+
+
+def load_project_env(*, override: bool = False) -> bool:
+ """
+ PROJECT_ROOT/.env 를 python-dotenv로 로드합니다.
+
+ Args:
+ override: True면 기존 OS 환경 변수를 .env 값으로 덮어씀.
+
+ Returns:
+ .env 파일이 존재해 로드 시도했으면 True, 없으면 False.
+ """
+ global _ENV_LOADED
+ if _ENV_LOADED and not override:
+ return ENV_FILE.is_file()
+
+ try:
+ from dotenv import load_dotenv
+ except ImportError:
+ _ENV_LOADED = True
+ return False
+
+ if ENV_FILE.is_file():
+ load_dotenv(ENV_FILE, override=override)
+ _ENV_LOADED = True
+ return True
+
+ _ENV_LOADED = True
+ return False
+
+
+def env_status() -> dict[str, str | bool]:
+ """디버그용 .env 상태."""
+ return {
+ "project_root": str(PROJECT_ROOT),
+ "env_file": str(ENV_FILE),
+ "env_exists": ENV_FILE.is_file(),
+ "loaded": _ENV_LOADED,
+ }
diff --git a/deepcoin/ground_truth/__init__.py b/deepcoin/ground_truth/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/deepcoin/ground_truth/ground_truth.py b/deepcoin/ground_truth/ground_truth.py
new file mode 100644
index 0000000..14881ae
--- /dev/null
+++ b/deepcoin/ground_truth/ground_truth.py
@@ -0,0 +1,1165 @@
+"""
+차트 조회 구간(기본 1년) 3분봉에서 최적 매수·매도 타점(정답 라벨)을 생성합니다.
+
+방법:
+ 1) ZigZag 피벗(스윙 고저) 추출
+ 2) split_buy_peak_sell: 저점 분할 매수 + 고점 1~2회 매도 (비중=삼각형 크기)
+ 3) ground_truth_trades.json 저장
+
+실행:
+ python scripts/02_ground_truth.py
+ python scripts/05_chart_truth.py
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+from config import (
+ CHART_LOOKBACK_DAYS,
+ ENTRY_INTERVAL,
+ GROUND_TRUTH_FILE,
+ GT_BUY_BB_MAX,
+ GT_BUY_MIN_BARS,
+ GT_BUY_MIN_SWING_PCT,
+ GT_INITIAL_CASH_KRW,
+ GT_MAX_BUYS_PER_LEG,
+ GT_MAX_ROUND_TRIPS,
+ TRADING_FEE_RATE,
+ GT_MAX_SELLS_PER_LEG,
+ GT_MIN_BARS_BETWEEN,
+ GT_MIN_LEG_PCT,
+ GT_MIN_SWING_PCT,
+ GT_PIVOT_ORDER,
+ GT_SELECTION_MODE,
+ GT_SELL_SPLIT_GAP_PCT,
+ SYMBOL,
+)
+from deepcoin.common.indicators import apply_bar_indicators, get_trend
+from deepcoin.data.mtf_bb import load_frames_from_db
+
+from deepcoin.paths import resolve_ground_truth_file
+
+DEFAULT_OUTPUT = resolve_ground_truth_file()
+
+
+@dataclass
+class Pivot:
+ """스윙 피벗 한 점."""
+
+ idx: int
+ ts: pd.Timestamp
+ kind: str # "trough" | "peak"
+ price: float
+
+
+@dataclass
+class TradePoint:
+ """정답 타점 1건."""
+
+ dt: str
+ action: str
+ price: float
+ memo: str
+ weight: float = 1.0
+ leg_id: int = 0
+ bb_pos: float | None = None
+ rsi: float | None = None
+ pivot_kind: str = ""
+ forward_return_pct: float | None = None
+
+
+def _local_extrema_indices(arr: np.ndarray, order: int, kind: str) -> np.ndarray:
+ """
+ order 양옆 구간에서 국소 최소/최대 인덱스를 반환합니다.
+
+ Args:
+ arr: 가격 배열.
+ order: 좌우 봉 수.
+ kind: "min" 또는 "max".
+
+ Returns:
+ 인덱스 ndarray.
+ """
+ n = len(arr)
+ if n < 2 * order + 1:
+ return np.array([], dtype=int)
+ out: list[int] = []
+ for i in range(order, n - order):
+ window = arr[i - order : i + order + 1]
+ if kind == "min" and arr[i] <= window.min():
+ out.append(i)
+ elif kind == "max" and arr[i] >= window.max():
+ out.append(i)
+ return np.array(out, dtype=int)
+
+
+def build_zigzag_pivots(
+ df: pd.DataFrame,
+ min_swing_pct: float = GT_MIN_SWING_PCT,
+ pivot_order: int = GT_PIVOT_ORDER,
+) -> list[Pivot]:
+ """
+ ZigZag 방식으로 스윙 저점·고점 피벗을 만듭니다.
+
+ Args:
+ df: OHLCV (index=datetime).
+ min_swing_pct: 피벗 확정 최소 가격 변동(%).
+ pivot_order: 국소 극값 탐색 반경(봉).
+
+ Returns:
+ 시간순 Pivot 리스트.
+ """
+ low = df["Low"].astype(float).values
+ high = df["High"].astype(float).values
+ index = df.index
+ min_ratio = min_swing_pct / 100.0
+
+ trough_idx = _local_extrema_indices(low, pivot_order, "min")
+ peak_idx = _local_extrema_indices(high, pivot_order, "max")
+
+ candidates: list[tuple[int, str, float]] = []
+ for i in trough_idx:
+ candidates.append((int(i), "trough", float(low[i])))
+ for i in peak_idx:
+ candidates.append((int(i), "peak", float(high[i])))
+ candidates.sort(key=lambda x: x[0])
+ if not candidates:
+ return []
+
+ pivots: list[Pivot] = []
+ last_kind: str | None = None
+ last_price = 0.0
+
+ for idx, kind, price in candidates:
+ if not pivots:
+ pivots.append(Pivot(idx, index[idx], kind, price))
+ last_kind = kind
+ last_price = price
+ continue
+
+ if kind == last_kind:
+ # 같은 방향이면 더 극단적인 쪽만 유지
+ if kind == "trough" and price < last_price:
+ pivots[-1] = Pivot(idx, index[idx], kind, price)
+ last_price = price
+ elif kind == "peak" and price > last_price:
+ pivots[-1] = Pivot(idx, index[idx], kind, price)
+ last_price = price
+ continue
+
+ move = abs(price - last_price) / max(last_price, 1e-9)
+ if move >= min_ratio:
+ pivots.append(Pivot(idx, index[idx], kind, price))
+ last_kind = kind
+ last_price = price
+
+ return pivots
+
+
+def _select_optimal_chain(
+ pivots: list[Pivot],
+ min_bars: int = GT_MIN_BARS_BETWEEN,
+ max_round_trips: int = GT_MAX_ROUND_TRIPS,
+ mode: str = GT_SELECTION_MODE,
+) -> list[Pivot]:
+ """
+ 피벗에서 정답 체인을 선택합니다.
+
+ Args:
+ pivots: ZigZag 피벗.
+ min_bars: 연속 체결 최소 봉 간격.
+ max_round_trips: 최대 라운드트립 수.
+ mode: "zigzag" 또는 "max_profit".
+
+ Returns:
+ 선택된 Pivot 부분열 (매수·매도 교대).
+ """
+ if len(pivots) < 2:
+ return []
+
+ if mode == "zigzag":
+ return _filter_alternating_pivots(pivots, min_bars, max_round_trips * 2)
+
+ if mode == "major_swings":
+ return _select_major_swings(
+ pivots,
+ min_bars=min_bars,
+ max_round_trips=max_round_trips,
+ min_leg_pct=GT_MIN_LEG_PCT,
+ )
+
+ intervals: list[tuple[int, int, float, Pivot, Pivot]] = []
+ for i, buy_p in enumerate(pivots):
+ if buy_p.kind != "trough":
+ continue
+ for j in range(i + 1, len(pivots)):
+ sell_p = pivots[j]
+ if sell_p.kind != "peak":
+ continue
+ if sell_p.idx - buy_p.idx < min_bars:
+ continue
+ if sell_p.price <= buy_p.price:
+ continue
+ profit = (sell_p.price - buy_p.price) / buy_p.price * 100.0
+ intervals.append((buy_p.idx, sell_p.idx, profit, buy_p, sell_p))
+
+ if not intervals:
+ return _filter_alternating_pivots(pivots, min_bars, max_round_trips * 2)
+
+ intervals.sort(key=lambda x: x[1])
+ m = len(intervals)
+ sell_bars = [iv[1] for iv in intervals]
+
+ def prev_non_overlap(k: int) -> int:
+ """매도 봉이 겹치지 않도록, 이전 구간의 매도 봉 < 현재 매수 봉."""
+ buy_bar = intervals[k][0]
+ lo, hi = 0, k - 1
+ ans = -1
+ while lo <= hi:
+ mid = (lo + hi) // 2
+ if sell_bars[mid] < buy_bar:
+ ans = mid
+ lo = mid + 1
+ else:
+ hi = mid - 1
+ return ans
+
+ pprev = [prev_non_overlap(k) for k in range(m)]
+
+ dp_val = [0.0] * m
+ dp_take = [False] * m
+ for k in range(m):
+ profit = intervals[k][2]
+ p_idx = pprev[k]
+ skip = profit
+ take = profit + (dp_val[p_idx] if p_idx >= 0 else 0.0)
+ if take >= skip:
+ dp_val[k] = take
+ dp_take[k] = True
+ else:
+ dp_val[k] = skip
+
+ chain_iv: list[tuple[int, int, float, Pivot, Pivot]] = []
+ k = m - 1
+ if m == 0:
+ return []
+ best_end = max(range(m), key=lambda i: dp_val[i])
+ k = best_end
+ while k >= 0 and len(chain_iv) < max_round_trips:
+ if dp_take[k]:
+ chain_iv.append(intervals[k])
+ k = pprev[k]
+ else:
+ k -= 1
+
+ chain_iv.reverse()
+ result: list[Pivot] = []
+ for _, _, _, bp, sp in chain_iv:
+ result.extend([bp, sp])
+ return result
+
+
+def _select_major_swings(
+ pivots: list[Pivot],
+ min_bars: int,
+ max_round_trips: int,
+ min_leg_pct: float,
+) -> list[Pivot]:
+ """
+ ZigZag 교대 체인에서 구간 수익이 min_leg_pct 이상인 매수·매도만 남깁니다.
+
+ 구간이 max_round_trips를 초과하면 비겹침 수익 합이 최대가 되도록 고릅니다.
+
+ Args:
+ pivots: ZigZag 피벗.
+ min_bars: 체결 간 최소 봉 수.
+ max_round_trips: 최대 라운드트립.
+ min_leg_pct: 한 구간 최소 수익률(%).
+
+ Returns:
+ 선택된 Pivot 리스트 (시간순).
+ """
+ chain = _filter_alternating_pivots(pivots, min_bars, len(pivots))
+ if len(chain) < 2:
+ return chain
+
+ legs: list[tuple[float, Pivot, Pivot, int, int]] = []
+ i = 0
+ while i < len(chain) - 1:
+ buy_p = chain[i]
+ sell_p = chain[i + 1]
+ if buy_p.kind == "trough" and sell_p.kind == "peak":
+ profit = (sell_p.price - buy_p.price) / max(buy_p.price, 1e-9) * 100.0
+ if profit >= min_leg_pct:
+ legs.append((profit, buy_p, sell_p, buy_p.idx, sell_p.idx))
+ i += 2
+ else:
+ i += 1
+
+ if not legs:
+ # 임계값 완화 후 재시도
+ return _select_major_swings(
+ pivots,
+ min_bars,
+ max_round_trips,
+ min_leg_pct=max(min_leg_pct * 0.6, 3.0),
+ )
+
+ if len(legs) <= max_round_trips:
+ out: list[Pivot] = []
+ for _, bp, sp, _, _ in legs:
+ out.extend([bp, sp])
+ return out
+
+ # 1년 라벨: 시간순 비겹침 구간 전부 사용 (상한으로 뒤쪽 월이 빠지지 않게 함)
+ if CHART_LOOKBACK_DAYS >= 300:
+ chosen: list[tuple[float, Pivot, Pivot, int, int]] = []
+ last_sell_bar = -1
+ for pr, bp, sp, lb, sb in sorted(legs, key=lambda x: x[3]):
+ if lb > last_sell_bar:
+ chosen.append((pr, bp, sp, lb, sb))
+ last_sell_bar = sb
+ result: list[Pivot] = []
+ for _pr, bp, sp, _lb, _sb in chosen:
+ result.extend([bp, sp])
+ return result
+
+ intervals = [(lb, sb, pr, bp, sp) for pr, bp, sp, lb, sb in legs]
+ intervals.sort(key=lambda x: x[1])
+ m = len(intervals)
+ sell_bars = [iv[1] for iv in intervals]
+
+ def prev_non_overlap(k: int) -> int:
+ buy_bar = intervals[k][0]
+ lo, hi = 0, k - 1
+ ans = -1
+ while lo <= hi:
+ mid = (lo + hi) // 2
+ if sell_bars[mid] < buy_bar:
+ ans = mid
+ lo = mid + 1
+ else:
+ hi = mid - 1
+ return ans
+
+ pprev = [prev_non_overlap(k) for k in range(m)]
+ dp_val = [0.0] * m
+ dp_take = [False] * m
+ for k in range(m):
+ profit = intervals[k][2]
+ p_idx = pprev[k]
+ take = profit + (dp_val[p_idx] if p_idx >= 0 else 0.0)
+ if take >= profit:
+ dp_val[k] = take
+ dp_take[k] = True
+ else:
+ dp_val[k] = profit
+
+ best_end = max(range(m), key=lambda i: dp_val[i])
+ k = best_end
+ chosen: list[tuple[float, Pivot, Pivot, int, int]] = []
+ while k >= 0 and len(chosen) < max_round_trips:
+ if dp_take[k]:
+ chosen.append(intervals[k])
+ k = pprev[k]
+ else:
+ k -= 1
+ chosen.reverse()
+ result: list[Pivot] = []
+ for _lb, _sb, _pr, bp, sp in chosen:
+ result.extend([bp, sp])
+ return result
+
+
+def _filter_alternating_pivots(
+ pivots: list[Pivot],
+ min_bars: int,
+ max_points: int,
+) -> list[Pivot]:
+ """ZigZag 피벗을 간격·교대 규칙으로만 줄입니다."""
+ filtered: list[Pivot] = []
+ for p in pivots:
+ if filtered and p.idx - filtered[-1].idx < min_bars:
+ continue
+ if filtered and p.kind == filtered[-1].kind:
+ if p.kind == "trough" and p.price < filtered[-1].price:
+ filtered[-1] = p
+ elif p.kind == "peak" and p.price > filtered[-1].price:
+ filtered[-1] = p
+ continue
+ filtered.append(p)
+ if filtered and filtered[0].kind == "peak":
+ filtered = filtered[1:]
+ if filtered and filtered[-1].kind == "trough":
+ filtered = filtered[:-1]
+ return filtered[:max_points]
+
+
+def _bb_context(row: pd.Series) -> tuple[float | None, float | None, float | None]:
+ """봉의 BB %B, RSI, 이격도(20 기본)."""
+ from config import DISPARITY_PERIODS
+ from deepcoin.common.indicators import disparity_column
+
+ bb = None
+ if "bb_pos" in row.index and pd.notna(row["bb_pos"]):
+ bb = round(float(row["bb_pos"]), 3)
+ rsi = None
+ if "RSI" in row.index and pd.notna(row["RSI"]):
+ rsi = round(float(row["RSI"]), 1)
+ disp = None
+ primary_p = 20 if 20 in DISPARITY_PERIODS else DISPARITY_PERIODS[0]
+ dcol = disparity_column(primary_p)
+ if dcol in row.index and pd.notna(row[dcol]):
+ disp = round(float(row[dcol]), 1)
+ return bb, rsi, disp
+
+
+def _memo_for_trade(
+ action: str,
+ pivot: Pivot,
+ bb_pos: float | None,
+ rsi: float | None,
+ disparity: float | None,
+ forward_pct: float | None,
+) -> str:
+ """타점 해석 메모."""
+ zone = "중단"
+ if bb_pos is not None:
+ if bb_pos < 0.25:
+ zone = "밴드 하단"
+ elif bb_pos > 0.75:
+ zone = "밴드 상단"
+ parts = [
+ f"ZigZag {pivot.kind}",
+ zone,
+ ]
+ if rsi is not None:
+ parts.append(f"RSI {rsi}")
+ if disparity is not None:
+ parts.append(f"D.I.{disparity}")
+ if forward_pct is not None and action == "buy":
+ parts.append(f"다음 매도까지 +{forward_pct:.1f}%")
+ elif forward_pct is not None and action == "sell":
+ parts.append(f"직전 매수 대비 +{forward_pct:.1f}%")
+ return " · ".join(parts)
+
+
+def _bar_index(df: pd.DataFrame, ts: pd.Timestamp) -> int:
+ """타임스탬프의 정수 봉 위치."""
+ loc = df.index.get_loc(ts if ts in df.index else df.index[df.index.get_indexer([ts], method="nearest")[0]])
+ if isinstance(loc, slice):
+ return int(loc.start or 0)
+ if hasattr(loc, "__len__") and not isinstance(loc, int):
+ return int(loc[-1])
+ return int(loc)
+
+
+def _row_at_ts(df: pd.DataFrame, ts: pd.Timestamp) -> pd.Series:
+ """타임스탬프에 해당하는 봉 1행."""
+ loc = ts if ts in df.index else df.index[df.index.get_indexer([ts], method="nearest")[0]]
+ row = df.loc[loc]
+ if isinstance(row, pd.DataFrame):
+ row = row.iloc[-1]
+ return row
+
+
+def _normalize_weights(scores: list[float]) -> list[float]:
+ """비중 점수를 합 1로 정규화."""
+ total = sum(scores)
+ if total <= 0:
+ n = len(scores)
+ return [1.0 / n] * n if n else []
+ return [s / total for s in scores]
+
+
+def _collect_buy_troughs(
+ df: pd.DataFrame,
+ buy_pivots: list[Pivot],
+ start: pd.Timestamp,
+ end: pd.Timestamp,
+ min_bars: int,
+ max_buys: int = GT_MAX_BUYS_PER_LEG,
+) -> list[Pivot]:
+ """
+ 매도 전 구간의 ZigZag 저점(trough)을 모읍니다.
+
+ BB 하단이면서 구간 최저에 가까운 저점 1건만 추가 보완합니다.
+ """
+ out: list[Pivot] = []
+ for p in buy_pivots:
+ if start < p.ts < end:
+ out.append(p)
+
+ if "bb_pos" in df.columns and out:
+ seg = df[(df.index > start) & (df.index < end)]
+ if not seg.empty and "bb_pos" in seg.columns:
+ bb_seg = seg[seg["bb_pos"] <= GT_BUY_BB_MAX]
+ if not bb_seg.empty:
+ loc = bb_seg["Low"].astype(float).idxmin()
+ idx = _bar_index(df, loc)
+ if all(abs(idx - p.idx) >= min_bars for p in out):
+ out.append(
+ Pivot(idx, loc, "trough", float(bb_seg.loc[loc, "Low"]))
+ )
+
+ out.sort(key=lambda x: x.ts)
+ filtered: list[Pivot] = []
+ for p in out:
+ if filtered and p.idx - filtered[-1].idx < min_bars:
+ if p.price < filtered[-1].price:
+ filtered[-1] = p
+ continue
+ filtered.append(p)
+
+ if len(filtered) > max_buys:
+ # 가격이 낮은(저점) 순으로 max_buys만 유지 후 시간순
+ filtered.sort(key=lambda x: x.price)
+ filtered = sorted(filtered[:max_buys], key=lambda x: x.ts)
+ return filtered
+
+
+def _peak_sell_points(
+ df: pd.DataFrame,
+ peak: Pivot,
+ max_splits: int,
+ split_gap_pct: float,
+) -> list[tuple[Pivot, float]]:
+ """
+ 고점에서 1회 또는 분할 매도 시점·비중.
+
+ Returns:
+ (피벗, 비중) 리스트. 비중 합 = 1.0.
+ """
+ row = _row_at_ts(df, peak.ts)
+ main_price = float(row["High"]) if "High" in row else peak.price
+ main = Pivot(peak.idx, peak.ts, "peak", main_price)
+
+ if max_splits < 2:
+ return [(main, 1.0)]
+
+ seg = df.iloc[peak.idx : peak.idx + 80]
+ if len(seg) < 5:
+ return [(main, 1.0)]
+
+ sub_peaks: list[Pivot] = []
+ highs = seg["High"].astype(float).values
+ for j in range(2, len(seg) - 2):
+ if highs[j] >= highs[j - 2 : j + 3].max():
+ px = float(highs[j])
+ if abs(px - main_price) / max(main_price, 1e-9) * 100 <= split_gap_pct:
+ sub_peaks.append(
+ Pivot(peak.idx + j, seg.index[j], "peak", px)
+ )
+
+ if not sub_peaks:
+ return [(main, 1.0)]
+
+ second = max(sub_peaks, key=lambda x: x.price)
+ if second.ts == main.ts:
+ return [(main, 1.0)]
+ return [(main, 0.65), (second, 0.35)]
+
+
+def build_split_buy_peak_sell_trades(
+ df: pd.DataFrame,
+ raw_pivots: list[Pivot],
+ sell_peaks: list[Pivot],
+ buy_min_bars: int = GT_BUY_MIN_BARS,
+) -> list[TradePoint]:
+ """
+ 저점 분할 매수 + 고점 1~2회 매도 정답 타점.
+
+ Args:
+ df: 지표 포함 3분봉.
+ raw_pivots: ZigZag 피벗(매수 탐지용, 낮은 스윙%).
+ sell_peaks: 고점 매도 기준 피벗(major swing).
+ buy_min_bars: 분할 매수 최소 간격(봉).
+
+ Returns:
+ TradePoint 리스트.
+ """
+ buy_pivots = build_zigzag_pivots(
+ df, min_swing_pct=GT_BUY_MIN_SWING_PCT, pivot_order=GT_PIVOT_ORDER
+ )
+ buy_pivots = [p for p in buy_pivots if p.kind == "trough"]
+
+ sell_peaks = sorted(sell_peaks, key=lambda x: x.ts)
+ trades: list[TradePoint] = []
+ prev_sell_ts = df.index[0]
+
+ for leg_id, peak in enumerate(sell_peaks):
+ troughs = _collect_buy_troughs(df, buy_pivots, prev_sell_ts, peak.ts, buy_min_bars)
+ if troughs:
+ scores = [1.0 / max(t.price, 1e-9) for t in troughs]
+ weights = _normalize_weights(scores)
+ for t, w in zip(troughs, weights):
+ row = _row_at_ts(df, t.ts)
+ bb_pos, rsi, disp = _bb_context(row)
+ price = float(row["Low"]) if "Low" in row else t.price
+ pct = (peak.price - price) / max(price, 1e-9) * 100.0
+ trades.append(
+ TradePoint(
+ dt=t.ts.strftime("%Y-%m-%d %H:%M:%S"),
+ action="buy",
+ price=round(price, 2),
+ weight=round(w, 3),
+ leg_id=leg_id,
+ memo=(
+ f"저점 분할 매수 · 비중 {w*100:.0f}% · {len(troughs)}회 "
+ f"· BB하단 · leg#{leg_id}"
+ ),
+ bb_pos=bb_pos,
+ rsi=rsi,
+ pivot_kind="trough",
+ forward_return_pct=round(pct, 2),
+ )
+ )
+
+ sell_pts = _peak_sell_points(
+ df, peak, GT_MAX_SELLS_PER_LEG, GT_SELL_SPLIT_GAP_PCT
+ )
+ leg_avg = (
+ sum(t.price * t.weight for t in trades if t.leg_id == leg_id and t.action == "buy")
+ / max(
+ sum(t.weight for t in trades if t.leg_id == leg_id and t.action == "buy"),
+ 1e-9,
+ )
+ )
+ for sp, w in sell_pts:
+ row = _row_at_ts(df, sp.ts)
+ bb_pos, rsi, disp = _bb_context(row)
+ price = float(row["High"]) if "High" in row else sp.price
+ ret = (price - leg_avg) / max(leg_avg, 1e-9) * 100.0 if leg_avg > 0 else None
+ n_sell = len(sell_pts)
+ trades.append(
+ TradePoint(
+ dt=sp.ts.strftime("%Y-%m-%d %H:%M:%S"),
+ action="sell",
+ price=round(price, 2),
+ weight=round(w, 3),
+ leg_id=leg_id,
+ memo=(
+ f"고점 매도 · 비중 {w*100:.0f}% · "
+ f"{'분할' if n_sell > 1 else '1회'} · leg#{leg_id}"
+ ),
+ bb_pos=bb_pos,
+ rsi=rsi,
+ pivot_kind="peak",
+ forward_return_pct=round(ret, 2) if ret is not None else None,
+ )
+ )
+
+ prev_sell_ts = peak.ts
+
+ # 마지막 매도 이후 ~ 기간 끝: 미청산 구간 분할 매수만
+ if sell_peaks:
+ last_peak = sell_peaks[-1]
+ troughs = _collect_buy_troughs(
+ df, buy_pivots, last_peak.ts, df.index[-1], buy_min_bars
+ )
+ leg_id = len(sell_peaks)
+ if troughs:
+ weights = _normalize_weights([1.0 / max(t.price, 1e-9) for t in troughs])
+ for t, w in zip(troughs, weights):
+ row = _row_at_ts(df, t.ts)
+ bb_pos, rsi, disp = _bb_context(row)
+ price = float(row["Low"]) if "Low" in row else t.price
+ trades.append(
+ TradePoint(
+ dt=t.ts.strftime("%Y-%m-%d %H:%M:%S"),
+ action="buy",
+ price=round(price, 2),
+ weight=round(w, 3),
+ leg_id=leg_id,
+ memo=f"저점 분할 매수(미청산) · 비중 {w*100:.0f}%",
+ bb_pos=bb_pos,
+ rsi=rsi,
+ pivot_kind="trough",
+ )
+ )
+
+ trades.sort(key=lambda t: t.dt)
+ return trades
+
+
+def pivots_to_trades(
+ pivots: list[Pivot],
+ df: pd.DataFrame,
+) -> list[TradePoint]:
+ """
+ 피벗을 매수·매도 정답 타점으로 변환합니다.
+
+ Args:
+ pivots: 선택된 피벗.
+ df: 지표가 포함된 3분봉.
+
+ Returns:
+ TradePoint 리스트.
+ """
+ trades: list[TradePoint] = []
+ last_buy_price: float | None = None
+
+ for i, p in enumerate(pivots):
+ loc = (
+ p.ts
+ if p.ts in df.index
+ else df.index[df.index.get_indexer([p.ts], method="nearest")[0]]
+ )
+ row = df.loc[loc]
+ if isinstance(row, pd.DataFrame):
+ row = row.iloc[-1]
+
+ bb_pos, rsi, disp = _bb_context(row)
+ forward_pct: float | None = None
+
+ if p.kind == "trough":
+ action = "buy"
+ price = float(row["Low"]) if "Low" in row else p.price
+ if i + 1 < len(pivots) and pivots[i + 1].kind == "peak":
+ forward_pct = (pivots[i + 1].price - price) / max(price, 1e-9) * 100.0
+ last_buy_price = price
+ else:
+ action = "sell"
+ price = float(row["High"]) if "High" in row else p.price
+ if last_buy_price:
+ forward_pct = (price - last_buy_price) / max(last_buy_price, 1e-9) * 100.0
+ last_buy_price = None
+
+ trades.append(
+ TradePoint(
+ dt=p.ts.strftime("%Y-%m-%d %H:%M:%S"),
+ action=action,
+ price=round(price, 2),
+ weight=1.0,
+ memo=_memo_for_trade(action, p, bb_pos, rsi, disp, forward_pct),
+ bb_pos=bb_pos,
+ rsi=rsi,
+ pivot_kind=p.kind,
+ forward_return_pct=round(forward_pct, 2) if forward_pct is not None else None,
+ )
+ )
+
+ return trades
+
+
+def generate_ground_truth(
+ df_3m: pd.DataFrame,
+ df_1d: pd.DataFrame | None = None,
+ df_1h: pd.DataFrame | None = None,
+ min_swing_pct: float = GT_MIN_SWING_PCT,
+ pivot_order: int = GT_PIVOT_ORDER,
+ min_bars: int = GT_MIN_BARS_BETWEEN,
+ max_round_trips: int = GT_MAX_ROUND_TRIPS,
+ selection_mode: str = GT_SELECTION_MODE,
+) -> dict[str, Any]:
+ """
+ 3분봉 구간에서 정답 타점 JSON 구조를 생성합니다.
+
+ Args:
+ df_3m: 3분 OHLCV.
+ df_1d: 일봉 (추세 메모용, 선택).
+ df_1h: 1시간봉 (추세 메모용, 선택).
+ min_swing_pct: ZigZag 최소 스윙(%).
+ pivot_order: 국소 극값 반경.
+ min_bars: 체결 간 최소 봉 수.
+ max_round_trips: 최대 라운드트립.
+ selection_mode: zigzag | max_profit.
+
+ Returns:
+ ground_truth_trades.json 에 넣을 dict.
+ """
+ df = apply_bar_indicators(df_3m.sort_index().copy())
+ if df.empty:
+ raise ValueError("3분봉 데이터가 비어 있습니다.")
+
+ raw_pivots = build_zigzag_pivots(df, min_swing_pct=min_swing_pct, pivot_order=pivot_order)
+
+ if selection_mode == "split_buy_peak_sell":
+ selected = _select_optimal_chain(
+ raw_pivots,
+ min_bars=min_bars,
+ max_round_trips=max_round_trips,
+ mode="major_swings",
+ )
+ sell_peaks = [p for p in selected if p.kind == "peak"]
+ trades = build_split_buy_peak_sell_trades(df, raw_pivots, sell_peaks)
+ method = "split_buy_at_troughs + peak_sell_1or2"
+ else:
+ selected = _select_optimal_chain(
+ raw_pivots,
+ min_bars=min_bars,
+ max_round_trips=max_round_trips,
+ mode=selection_mode,
+ )
+ trades = pivots_to_trades(selected, df)
+ method = "zigzag_pivot + max_profit_chain"
+
+ trend = "range"
+ if df_1d is not None and df_1h is not None:
+ trend = get_trend(df_1d, df_1h)
+
+ round_trips = len({t.leg_id for t in trades if t.action == "sell"})
+ buy_count = sum(1 for t in trades if t.action == "buy")
+ sell_count = sum(1 for t in trades if t.action == "sell")
+ total_ret = sum(
+ t.forward_return_pct or 0.0 for t in trades if t.action == "sell"
+ )
+
+ trades.sort(key=lambda t: t.dt)
+ last_close = float(df["Close"].iloc[-1])
+ pnl = simulate_truth_portfolio(
+ [asdict(t) for t in trades],
+ initial_cash=GT_INITIAL_CASH_KRW,
+ fee_rate=TRADING_FEE_RATE,
+ last_price=last_close,
+ )
+
+ return {
+ "name": "ground_truth_split_buy_peak_sell",
+ "method": method,
+ "symbol": SYMBOL,
+ "interval_min": ENTRY_INTERVAL,
+ "lookback_days": CHART_LOOKBACK_DAYS,
+ "period_start": str(df.index[0]),
+ "period_end": str(df.index[-1]),
+ "trend_at_end": trend,
+ "params": {
+ "min_swing_pct": min_swing_pct,
+ "pivot_order": pivot_order,
+ "min_bars_between": min_bars,
+ "max_round_trips": max_round_trips,
+ "selection_mode": selection_mode,
+ "buy_min_swing_pct": GT_BUY_MIN_SWING_PCT,
+ "buy_bb_max": GT_BUY_BB_MAX,
+ "max_sells_per_leg": GT_MAX_SELLS_PER_LEG,
+ },
+ "summary": {
+ "pivot_candidates": len(raw_pivots),
+ "sell_peaks": len([p for p in selected if p.kind == "peak"]) if selected else 0,
+ "trade_count": len(trades),
+ "buy_count": buy_count,
+ "sell_count": sell_count,
+ "round_trips": round_trips,
+ "sum_sell_leg_return_pct": round(total_ret, 2),
+ **pnl,
+ },
+ "note": (
+ "저점 분할 매수(삼각형 크기=비중), 고점 1~2회 매도. "
+ "사후 라벨·캘리브레이션용."
+ ),
+ "trades": [asdict(t) for t in trades],
+ }
+
+
+def _truth_simulation_rows(trades: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ """TradePoint/dict 리스트를 시간순 dict 행으로 정규화."""
+ return sorted(
+ [t if isinstance(t, dict) else asdict(t) for t in trades],
+ key=lambda x: x["dt"],
+ )
+
+
+def simulate_truth_portfolio_steps(
+ trades: list[dict[str, Any]] | list[TradePoint],
+ initial_cash: float = GT_INITIAL_CASH_KRW,
+ fee_rate: float = TRADING_FEE_RATE,
+) -> list[dict[str, Any]]:
+ """
+ 체결마다 현금·보유·총평가(현금+보유×체결가) 스냅샷을 반환합니다.
+
+ Args:
+ trades: JSON trades 또는 TradePoint 리스트.
+ initial_cash: 시작 원화.
+ fee_rate: 매수·매도 수수료율.
+
+ Returns:
+ 체결 시각순 스냅샷 dict 리스트 (total_asset_krw, cash_krw, holding_qty 등).
+ """
+ rows = _truth_simulation_rows(trades)
+ cash = float(initial_cash)
+ qty = 0.0
+ leg_budget = 0.0
+ current_leg: int | None = None
+ sell_leg: int | None = None
+ sell_base_qty = 0.0
+ steps: list[dict[str, Any]] = []
+
+ for t in rows:
+ action = t["action"]
+ price = float(t["price"])
+ weight = float(t.get("weight", 1.0))
+ leg_id = int(t.get("leg_id", 0))
+
+ if action == "buy":
+ if leg_id != current_leg:
+ current_leg = leg_id
+ leg_budget = cash
+ sell_leg = None
+ amount = leg_budget * weight
+ if amount <= 0:
+ continue
+ fee = amount * fee_rate
+ spend = amount + fee
+ if spend > cash:
+ amount = max(cash / (1.0 + fee_rate), 0.0)
+ fee = amount * fee_rate
+ spend = amount + fee
+ cash -= spend
+ if price > 0:
+ qty += amount / price
+
+ elif action == "sell" and qty > 0:
+ if leg_id != sell_leg:
+ sell_leg = leg_id
+ sell_base_qty = qty
+ sell_qty = min(sell_base_qty * weight, qty)
+ if sell_qty <= 0:
+ continue
+ gross = sell_qty * price
+ fee = gross * fee_rate
+ cash += gross - fee
+ qty -= sell_qty
+ if qty < 1e-12:
+ qty = 0.0
+
+ total_asset = cash + qty * price
+ steps.append(
+ {
+ "dt": t["dt"],
+ "action": action,
+ "price": price,
+ "weight": weight,
+ "leg_id": leg_id,
+ "cash_krw": round(cash, 0),
+ "holding_qty": round(qty, 4),
+ "total_asset_krw": round(total_asset, 0),
+ }
+ )
+
+ return steps
+
+
+def simulate_truth_portfolio(
+ trades: list[dict[str, Any]] | list[TradePoint],
+ initial_cash: float = GT_INITIAL_CASH_KRW,
+ fee_rate: float = TRADING_FEE_RATE,
+ last_price: float | None = None,
+) -> dict[str, Any]:
+ """
+ 분할 매수·매도를 시간순으로 적용한 뒤, 초기·기말 총평가로 수익을 계산합니다.
+
+ - 초기 총평가 = initial_cash (전액 현금, 보유 0).
+ - 매수/매도마다 그 시점 현금·보유 수량을 갱신 (분할 비중 weight 반영).
+ - 기말 총평가 = 현금 + 보유수량 × mark_price(미청산은 종가 평가).
+ - 수익금 = 기말 총평가 − 초기 총평가.
+ - 수익률(%) = 수익금 / 초기 총평가 × 100.
+
+ 분할 매도: 같은 leg의 첫 매도 시점 보유량 기준으로 weight 합이 1이 되도록 매도
+ (0.65+0.35를 남은 수량에 연속 적용하지 않음).
+
+ Args:
+ trades: JSON trades 또는 TradePoint 리스트.
+ initial_cash: 시작 원화 (기본 100만).
+ fee_rate: 매수·매도 각각 적용 수수료율.
+ last_price: 미청산 평가용 종가. None이면 마지막 체결가.
+
+ Returns:
+ initial_cash, final_asset, pnl_krw, pnl_pct, total_fees, holding_qty 등.
+ """
+ rows = _truth_simulation_rows(trades)
+ cash = float(initial_cash)
+ qty = 0.0
+ total_fees = 0.0
+ leg_budget = 0.0
+ current_leg: int | None = None
+ sell_leg: int | None = None
+ sell_base_qty = 0.0
+ last_trade_price = last_price
+
+ for t in rows:
+ action = t["action"]
+ price = float(t["price"])
+ weight = float(t.get("weight", 1.0))
+ leg_id = int(t.get("leg_id", 0))
+ last_trade_price = price
+
+ if action == "buy":
+ if leg_id != current_leg:
+ current_leg = leg_id
+ leg_budget = cash
+ sell_leg = None
+ amount = leg_budget * weight
+ if amount <= 0:
+ continue
+ fee = amount * fee_rate
+ spend = amount + fee
+ if spend > cash:
+ amount = max(cash / (1.0 + fee_rate), 0.0)
+ fee = amount * fee_rate
+ spend = amount + fee
+ cash -= spend
+ total_fees += fee
+ if price > 0:
+ qty += amount / price
+
+ elif action == "sell" and qty > 0:
+ if leg_id != sell_leg:
+ sell_leg = leg_id
+ sell_base_qty = qty
+ sell_qty = min(sell_base_qty * weight, qty)
+ if sell_qty <= 0:
+ continue
+ gross = sell_qty * price
+ fee = gross * fee_rate
+ cash += gross - fee
+ total_fees += fee
+ qty -= sell_qty
+ if qty < 1e-12:
+ qty = 0.0
+
+ mark_price = float(last_price if last_price is not None else last_trade_price or 0)
+ holding_value = qty * mark_price
+ final_asset = cash + holding_value
+ pnl_krw = final_asset - initial_cash
+ pnl_pct = pnl_krw / initial_cash * 100.0 if initial_cash else 0.0
+
+ return {
+ "initial_cash_krw": round(initial_cash, 0),
+ "final_asset_krw": round(final_asset, 0),
+ "pnl_krw": round(pnl_krw, 0),
+ "pnl_pct": round(pnl_pct, 2),
+ "total_fees_krw": round(total_fees, 0),
+ "cash_krw": round(cash, 0),
+ "holding_qty": round(qty, 6),
+ "holding_value_krw": round(holding_value, 0),
+ "mark_price": round(mark_price, 2),
+ "fee_rate": fee_rate,
+ }
+
+
+def save_ground_truth(data: dict[str, Any], path: Path = DEFAULT_OUTPUT) -> Path:
+ """정답 JSON 저장."""
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
+ return path
+
+
+def load_ground_truth(path: Path = DEFAULT_OUTPUT) -> dict[str, Any] | None:
+ """정답 JSON 로드."""
+ if not path.exists():
+ return None
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _report_month_gaps(trades: list[dict[str, Any]]) -> list[str]:
+ """거래가 없는 연속 월(YYYY-MM) 목록."""
+ if not trades:
+ return []
+ from collections import Counter
+
+ months = sorted({t["dt"][:7] for t in trades})
+ gaps: list[str] = []
+ y1, m1 = map(int, months[0].split("-"))
+ for label in months[1:]:
+ y2, m2 = map(int, label.split("-"))
+ gap = (y2 - y1) * 12 + (m2 - m1)
+ if gap > 1:
+ gaps.append(f"{months[months.index(label) - 1]} → {label} ({gap - 1}개월 공백)")
+ y1, m1 = y2, m2
+ return gaps
+
+
+def print_ground_truth_report(data: dict[str, Any]) -> None:
+ """터미널 요약 출력."""
+ s = data.get("summary", {})
+ trades = data.get("trades") or []
+ print(f"\n[정답 타점] {data.get('period_start')} ~ {data.get('period_end')}")
+ print(
+ f" 피벗 {s.get('pivot_candidates')} | 매수 {s.get('buy_count')} / 매도 {s.get('sell_count')} "
+ f"| leg {s.get('round_trips')}"
+ )
+ print(f" 매도 수익 합(참고): {s.get('sum_sell_leg_return_pct')}%")
+ if s.get("initial_cash_krw"):
+ print(
+ f" 시뮬(시작 ₩{s['initial_cash_krw']:,.0f}): "
+ f"최종 ₩{s['final_asset_krw']:,.0f} | "
+ f"수익 ₩{s['pnl_krw']:+,.0f} ({s['pnl_pct']:+.2f}%) | "
+ f"수수료 ₩{s['total_fees_krw']:,.0f}"
+ )
+ if s.get("holding_qty", 0) > 0:
+ print(
+ f" 미청산: {s['holding_qty']}개 "
+ f"(평가 ₩{s['holding_value_krw']:,.0f}, 종가 ₩{s['mark_price']:,.0f})"
+ )
+ print(f" 파라미터: {data.get('params')}")
+ from collections import Counter
+
+ by_month = Counter(t["dt"][:7] for t in trades)
+ print(f" 월별 타점: {', '.join(f'{m}({c})' for m, c in sorted(by_month.items()))}")
+ gaps = _report_month_gaps(trades)
+ if gaps:
+ print(f" 경고 — 거래 공백 월: {'; '.join(gaps)}")
+ else:
+ print(" 월별 공백: 없음 (연속 커버)")
+ show = trades if len(trades) <= 40 else trades[:20] + trades[-10:]
+ if len(trades) > 40:
+ print(f" (타점 {len(trades)}건 — 앞 20·뒤 10건만 표시)")
+ for t in show:
+ mark = "매수" if t["action"] == "buy" else "매도"
+ w = float(t.get("weight", 1.0))
+ ret = t.get("forward_return_pct")
+ ret_s = f" (+{ret}%)" if ret is not None else ""
+ print(
+ f" [{mark}] {t['dt'][:16]} ₩{t['price']:,.0f} "
+ f"비중{w*100:.0f}%{ret_s} {t.get('memo', '')}"
+ )
+
+
+def run_from_db(monitor=None, output: Path = DEFAULT_OUTPUT) -> dict[str, Any]:
+ """
+ coins.db에서 CHART_LOOKBACK_DAYS 구간을 읽어 정답을 생성·저장합니다.
+
+ Args:
+ monitor: Monitor 인스턴스. None이면 새로 생성.
+ output: 저장 경로.
+
+ Returns:
+ 생성된 dict.
+ """
+ from config import TREND_INTERVAL_1D, TREND_INTERVAL_1H
+ from monitor import Monitor
+
+ mon = monitor or Monitor(cooldown_file=None)
+ print(f"정답 생성: 최근 {CHART_LOOKBACK_DAYS}일 3분봉")
+ frames = load_frames_from_db(mon, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
+ df_3m = frames.get(ENTRY_INTERVAL)
+ if df_3m is None or df_3m.empty:
+ raise RuntimeError("3분봉 없음. python scripts/01_download.py 실행 후 재시도.")
+
+ df_1d = frames.get(TREND_INTERVAL_1D)
+ if df_1d is None or df_1d.empty:
+ df_1d = df_3m
+ df_1h = frames.get(TREND_INTERVAL_1H)
+ if df_1h is None or df_1h.empty:
+ df_1h = df_3m
+
+ data = generate_ground_truth(df_3m, df_1d, df_1h)
+ save_ground_truth(data, output)
+ print(f"저장: {output}")
+ print_ground_truth_report(data)
+ return data
+
+
+def main() -> None:
+ """CLI: 정답 JSON 생성."""
+ run_from_db()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deepcoin/matching/README.md b/deepcoin/matching/README.md
new file mode 100644
index 0000000..22a07e3
--- /dev/null
+++ b/deepcoin/matching/README.md
@@ -0,0 +1,15 @@
+# Phase 04 — Matching
+
+Ground Truth 매수·매도 타점의 MTF 스냅샷(`docs/03_analysis/general_analysis_trades.csv`)과
+실시간·최근 봉 상태를 비교해 **가장 근접한 기술적 프로파일** 및 **진입·청산 규칙**을 선택합니다.
+
+예정 산출물:
+
+- `docs/04_matching/rule_candidates.json`
+- `docs/04_matching/similarity_report.html`
+
+실행 (스텁):
+
+```bash
+python scripts/04_match_rules.py
+```
diff --git a/deepcoin/matching/__init__.py b/deepcoin/matching/__init__.py
new file mode 100644
index 0000000..a05f34c
--- /dev/null
+++ b/deepcoin/matching/__init__.py
@@ -0,0 +1,3 @@
+"""
+04단계: Ground Truth에 근접한 기술적 상태·규칙 선택 (예정).
+"""
diff --git a/deepcoin/matching/match_rules.py b/deepcoin/matching/match_rules.py
new file mode 100644
index 0000000..3a2b81d
--- /dev/null
+++ b/deepcoin/matching/match_rules.py
@@ -0,0 +1,31 @@
+"""
+04단계 스텁: GT 스냅샷과 현재 상태 유사도·규칙 후보 (구현 예정).
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from deepcoin.paths import REPORTS_ANALYSIS, REPORTS_MATCHING, resolve_ground_truth_file
+
+
+def run_match_stub() -> Path:
+ """
+ 입력 파일 존재 여부만 확인하고 04단계 안내를 출력합니다.
+
+ Returns:
+ matching 리포트 디렉터리.
+ """
+ REPORTS_MATCHING.mkdir(parents=True, exist_ok=True)
+ gt = resolve_ground_truth_file()
+ csv = REPORTS_ANALYSIS / "general_analysis_trades.csv"
+ print("=== Phase 04 Matching (stub) ===")
+ print(f" ground truth: {gt} ({'OK' if gt.is_file() else 'MISSING'})")
+ print(f" analysis csv: {csv} ({'OK' if csv.is_file() else 'MISSING — run scripts/03_analyze_trades.py'})")
+ print(f" output dir: {REPORTS_MATCHING}")
+ print(" 구현 예정: 유사도·규칙 선택")
+ return REPORTS_MATCHING
+
+
+if __name__ == "__main__":
+ run_match_stub()
diff --git a/deepcoin/ops/__init__.py b/deepcoin/ops/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/monitor.py b/deepcoin/ops/monitor.py
similarity index 60%
rename from monitor.py
rename to deepcoin/ops/monitor.py
index e704502..dc85491 100644
--- a/monitor.py
+++ b/deepcoin/ops/monitor.py
@@ -1,5 +1,5 @@
import pandas as pd
-from HTS2 import HTS
+from deepcoin.api.bithumb import HTS
from dateutil.relativedelta import relativedelta
from datetime import datetime
import sqlite3
@@ -17,15 +17,14 @@ import numpy as np
import os
from config import *
-import strategy
class Monitor(HTS):
- """WLD 코인 모니터링 및 매매 실행."""
+ """WLD 코인 데이터·지표·시장 상태 출력."""
last_signal = None
cooldown_file = None
- def __init__(self, cooldown_file='coins_buy_time.json') -> None:
+ def __init__(self, cooldown_file: str | None = None) -> None:
HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {}
@@ -130,12 +129,12 @@ class Monitor(HTS):
payload = header + "\n"
for i, message in enumerate(message_list):
payload += message
- if i + 1 % 20 == 0:
- pool = Pool(12)
+ if i + 1 % MONITOR_TELEGRAM_BATCH_SIZE == 0:
+ pool = Pool(MONITOR_POOL_WORKERS)
pool.map(self._send_coin_msg, [payload])
payload = ''
- if len(message_list) % 20 != 0:
- pool = Pool(12)
+ if len(message_list) % MONITOR_TELEGRAM_BATCH_SIZE != 0:
+ pool = Pool(MONITOR_POOL_WORKERS)
pool.map(self._send_coin_msg, [payload])
# ------------- Indicators -------------
@@ -143,8 +142,8 @@ class Monitor(HTS):
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
normalized_data = data.copy()
for column in columns_to_normalize:
- min_val = data[column].rolling(window=20).min()
- max_val = data[column].rolling(window=20).max()
+ min_val = data[column].rolling(window=MONITOR_NORM_WINDOW).min()
+ max_val = data[column].rolling(window=MONITOR_NORM_WINDOW).max()
denominator = max_val - min_val
normalized_data[f'{column}_Norm'] = np.where(
denominator != 0,
@@ -167,154 +166,49 @@ class Monitor(HTS):
# 지표 다시 계산
inv = self.normalize_data(inv)
- inv['MA5'] = inv['Close'].rolling(window=5).mean()
- inv['MA20'] = inv['Close'].rolling(window=20).mean()
- inv['MA40'] = inv['Close'].rolling(window=40).mean()
- inv['MA120'] = inv['Close'].rolling(window=120).mean()
- inv['MA200'] = inv['Close'].rolling(window=200).mean()
- inv['MA240'] = inv['Close'].rolling(window=240).mean()
- inv['MA720'] = inv['Close'].rolling(window=720).mean()
- inv['MA1440'] = inv['Close'].rolling(window=1440).mean()
- inv['Deviation5'] = (inv['Close'] / inv['MA5']) * 100
- inv['Deviation20'] = (inv['Close'] / inv['MA20']) * 100
- inv['Deviation40'] = (inv['Close'] / inv['MA40']) * 100
- inv['Deviation120'] = (inv['Close'] / inv['MA120']) * 100
- inv['Deviation200'] = (inv['Close'] / inv['MA200']) * 100
- inv['Deviation240'] = (inv['Close'] / inv['MA240']) * 100
- inv['Deviation720'] = (inv['Close'] / inv['MA720']) * 100
- inv['Deviation1440'] = (inv['Close'] / inv['MA1440']) * 100
- inv['golden_cross'] = (inv['MA5'] > inv['MA20']) & (inv['MA5'].shift(1) <= inv['MA20'].shift(1))
- inv['MA'] = inv['Close'].rolling(window=20).mean()
- inv['STD'] = inv['Close'].rolling(window=20).std()
- inv['Upper'] = inv['MA'] + (2 * inv['STD'])
- inv['Lower'] = inv['MA'] - (2 * inv['STD'])
+ for w in MONITOR_MA_WINDOWS:
+ inv[f"MA{w}"] = inv["Close"].rolling(window=w).mean()
+ inv[f"Deviation{w}"] = (inv["Close"] / inv[f"MA{w}"]) * 100
+ if len(MONITOR_MA_WINDOWS) >= 2:
+ w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1]
+ inv["golden_cross"] = (inv[f"MA{w_fast}"] > inv[f"MA{w_slow}"]) & (
+ inv[f"MA{w_fast}"].shift(1) <= inv[f"MA{w_slow}"].shift(1)
+ )
+ inv["MA"] = inv["Close"].rolling(window=BB_PERIOD).mean()
+ inv["STD"] = inv["Close"].rolling(window=BB_PERIOD).std()
+ inv["Upper"] = inv["MA"] + (BB_STD * inv["STD"])
+ inv["Lower"] = inv["MA"] - (BB_STD * inv["STD"])
return inv
def calculate_technical_indicators(self, data: pd.DataFrame) -> pd.DataFrame:
data = self.normalize_data(data)
- data['MA5'] = data['Close'].rolling(window=5).mean()
- data['MA20'] = data['Close'].rolling(window=20).mean()
- data['MA40'] = data['Close'].rolling(window=40).mean()
- data['MA120'] = data['Close'].rolling(window=120).mean()
- data['MA200'] = data['Close'].rolling(window=200).mean()
- data['MA240'] = data['Close'].rolling(window=240).mean()
- data['MA720'] = data['Close'].rolling(window=720).mean()
- data['MA1440'] = data['Close'].rolling(window=1440).mean()
- data['Deviation5'] = (data['Close'] / data['MA5']) * 100
- data['Deviation20'] = (data['Close'] / data['MA20']) * 100
- data['Deviation40'] = (data['Close'] / data['MA40']) * 100
- data['Deviation120'] = (data['Close'] / data['MA120']) * 100
- data['Deviation200'] = (data['Close'] / data['MA200']) * 100
- data['Deviation240'] = (data['Close'] / data['MA240']) * 100
- data['Deviation720'] = (data['Close'] / data['MA720']) * 100
- data['Deviation1440'] = (data['Close'] / data['MA1440']) * 100
- data['golden_cross'] = (data['MA5'] > data['MA20']) & (data['MA5'].shift(1) <= data['MA20'].shift(1))
- data['MA'] = data['Close'].rolling(window=20).mean()
- data['STD'] = data['Close'].rolling(window=20).std()
- data['Upper'] = data['MA'] + (2 * data['STD'])
- data['Lower'] = data['MA'] - (2 * data['STD'])
+ for w in MONITOR_MA_WINDOWS:
+ data[f"MA{w}"] = data["Close"].rolling(window=w).mean()
+ data[f"Deviation{w}"] = (data["Close"] / data[f"MA{w}"]) * 100
+ if len(MONITOR_MA_WINDOWS) >= 2:
+ w_fast, w_slow = MONITOR_MA_WINDOWS[0], MONITOR_MA_WINDOWS[1]
+ data["golden_cross"] = (data[f"MA{w_fast}"] > data[f"MA{w_slow}"]) & (
+ data[f"MA{w_fast}"].shift(1) <= data[f"MA{w_slow}"].shift(1)
+ )
+ data["MA"] = data["Close"].rolling(window=BB_PERIOD).mean()
+ data["STD"] = data["Close"].rolling(window=BB_PERIOD).std()
+ data["Upper"] = data["MA"] + (BB_STD * data["STD"])
+ data["Lower"] = data["MA"] - (BB_STD * data["STD"])
+ from deepcoin.common.indicators import add_macd, add_stochastic
+
+ data = add_macd(data)
+ data = add_stochastic(data)
return data
- # ------------- Strategy (strategy.py에 구현) -------------
- def annotate_signals(self, symbol: str, data: pd.DataFrame, simulation: bool | None = None) -> pd.DataFrame:
- """strategy.annotate_signals에 위임."""
- return strategy.annotate_signals(
- symbol, data, simulation=simulation, config=strategy.ACTIVE_CONFIG
- )
-
- def _is_in_cooldown(self, symbol: str, side: str) -> bool:
- """매수/매도 쿨다운 여부."""
- if self.cooldown_file is None:
- return False
- last_dt = self.buy_cooldown.get(symbol, {}).get(side, {}).get("datetime")
- if not last_dt:
- return False
- limit = BUY_COOLDOWN_SEC if side == "buy" else SELL_COOLDOWN_SEC
- elapsed = (datetime.now() - last_dt).total_seconds()
- if elapsed < limit:
- print(f"{symbol}: {side} 쿨다운 중 (남은 시간: {limit - elapsed:.0f}초)")
- return True
- return False
-
- def _record_trade(self, symbol: str, side: str, signal: str) -> None:
- """매매 기록 저장."""
- if self.cooldown_file is None:
- return
- current_time = datetime.now()
- self.last_signal[symbol] = signal
- self.buy_cooldown.setdefault(symbol, {})[side] = {
- "datetime": current_time,
- "signal": signal,
- }
- self._save_buy_cooldown()
-
- def execute_trade_signal(
- self,
- symbol: str,
- trade: strategy.TradeSignal,
- balances: dict | None = None,
- ) -> bool:
- """TradeSignal 1건에 대해 현물 매수 또는 매도를 실행합니다."""
- try:
- coin_name = KR_COINS.get(symbol, symbol)
- signal_name = trade.signal
- close = trade.close
-
- if trade.action == "sell":
- if self._is_in_cooldown(symbol, "sell"):
- return False
- available = 0.0
- if balances and symbol in balances:
- available = float(balances[symbol].get("balance", 0))
- if available <= 0:
- print(f"{symbol}: 매도 신호({signal_name}) — 보유 없음, 스킵")
- return False
- sell_amount = available * strategy.get_sell_ratio(symbol, signal_name)
- if sell_amount <= 0:
- return False
- self.sellCoinMarket(symbol, 0, sell_amount)
- self._record_trade(symbol, "sell", signal_name)
- print(f"{coin_name} ({symbol}) [매도 {signal_name}] ₩{close:.4f}, 수량 {sell_amount:.6f}")
- self.sendMsg(
- f"[KRW-COIN]\n• 매도 {coin_name} ({symbol}): {signal_name} ₩{close:.4f}"
- )
- return True
-
- if self._is_in_cooldown(symbol, "buy"):
- return False
- buy_amount = strategy.get_buy_amount(
- symbol, signal_name, close, trend=trade.trend
- )
- if strategy.should_double_buy(symbol, signal_name, pd.DataFrame()):
- buy_amount *= 2
- executed = self.buyCoinMarket(symbol, buy_amount)
- self._record_trade(symbol, "buy", signal_name)
- print(
- f"{coin_name} ({symbol}) [매수 {signal_name}] ₩{close:.4f} "
- f"({buy_amount} KRW, 추세={trade.trend})"
- )
- self.sendMsg(
- self.format_message(
- symbol, coin_name, close, signal_name, executed or buy_amount
- )
- )
- return True
- except Exception as e:
- print(f"Error trading {symbol}: {str(e)}")
- return False
-
- def process_wld_mtf(self, symbol: str, balances: dict | None = None) -> None:
+ def process_wld_market_status(self, symbol: str) -> None:
"""
- WLD: 전 봉(1~1440분) BB·일목 위치 조합 매매.
-
- USE_DISCOVERED_LIVE=True: discovered_rules.json + combination 특징
- False: mtf_bb_policy.json BB MTF
+ WLD: 전 봉 BB·일목 위치·추세만 출력 (자동 매매 없음).
"""
- from config import USE_DISCOVERED_LIVE
- from mtf_bb import load_frames_from_db, load_policy, print_latest_states
- from candle_features import describe_latest_position
+ from deepcoin.common.candle_features import describe_latest_position
+ from deepcoin.common.indicators import get_trend
+ from deepcoin.data.mtf_bb import load_frames_from_db
try:
frames = load_frames_from_db(self, symbol)
@@ -329,43 +223,29 @@ class Monitor(HTS):
if df_1h is None or df_1h.empty:
df_1h = frames.get(ENTRY_INTERVAL)
- trend = strategy.get_trend(df_1d, df_1h)
- print(f"{symbol} 추세: {trend}")
+ trend = get_trend(df_1d, df_1h)
+ print(f"{symbol} 추세(참고): {trend}")
print("--- 봉별 BB·일목 위치 ---")
for iv in sorted(frames.keys()):
pos = describe_latest_position(frames[iv], iv)
+ macd_s = ""
+ if pos.get("macd_hist") is not None:
+ macd_s = f" | MACD {pos.get('macd_state', '-')} h={pos['macd_hist']}"
+ stoch_s = ""
+ if pos.get("stoch_k") is not None:
+ stoch_s = (
+ f" | Stoch K={pos['stoch_k']} D={pos.get('stoch_d')} "
+ f"{pos.get('stoch_zone', '')}"
+ )
+ disp_s = ""
+ if pos.get("disparity"):
+ parts = [f"{p}={v:.1f}" for p, v in sorted(pos["disparity"].items())]
+ disp_s = " | D.I. " + " ".join(parts)
print(
f" {pos['label']:>6} | BB {pos['bb_zone']} {pos['bb_state']:>16} | "
f"일목 {pos['ichi_position']} TK={pos['ichi_tk']}"
+ f"{macd_s}{stoch_s}{disp_s}"
)
-
- if USE_DISCOVERED_LIVE:
- print("모드: 전봉 BB·일목 조합 (discovered_rules)")
- trade = strategy.evaluate_discovered_live(
- symbol, frames, df_1d, df_1h, balances or {}
- )
- else:
- policy = load_policy() or strategy.ACTIVE_MTF_POLICY
- cfg = strategy.ACTIVE_CONFIG
- print_latest_states(frames, cfg)
- print(
- f"MTF 정책: {policy.name} | 매수={policy.buy_interval}분 | "
- f"매도={policy.sell_interval}분"
- )
- entry = frames.get(ENTRY_INTERVAL)
- trade = strategy.evaluate(
- symbol,
- entry if entry is not None else frames[policy.buy_interval],
- df_1h,
- df_1d,
- config=cfg,
- frames=frames,
- policy=policy,
- )
- if trade is None:
- print("신호 없음")
- return
- self.execute_trade_signal(symbol, trade, balances=balances)
except Exception as e:
print(f"Error processing {symbol}: {str(e)}")
@@ -376,8 +256,8 @@ class Monitor(HTS):
balances: dict | None = None,
use_inverse: bool = False,
) -> None:
- """하위 호환: MTF 전략으로 위임 (use_inverse 무시)."""
- self.process_wld_mtf(symbol, balances=balances)
+ """하위 호환: 시장 상태 출력으로 위임."""
+ self.process_wld_market_status(symbol)
def load_balances_dict(self) -> dict:
"""getBalances() 결과를 currency 키 dict로 변환."""
@@ -414,19 +294,36 @@ class Monitor(HTS):
return message
# ------------- Data fetch -------------
- def get_coin_data(self, symbol: str, interval: int = 60, to: str | None = None, retries: int = 3) -> pd.DataFrame | None:
+ def get_coin_data(
+ self,
+ symbol: str,
+ interval: int = MONITOR_DEFAULT_INTERVAL,
+ to: str | None = None,
+ retries: int = MONITOR_API_RETRIES,
+ ) -> pd.DataFrame | None:
+ base = BITHUMB_API_URL.rstrip("/")
+ count = BITHUMB_API_CANDLE_COUNT
for attempt in range(retries):
try:
if to is None:
- if interval == 1440:
- url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200").format(symbol)
+ if interval >= DAILY_INTERVAL_MIN:
+ url = f"{base}/v1/candles/days?market=KRW-{symbol}&count={count}"
else:
- url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200").format(interval, symbol)
+ url = (
+ f"{base}/v1/candles/minutes/{interval}"
+ f"?market=KRW-{symbol}&count={count}"
+ )
else:
- if interval == 1440:
- url = ("https://api.bithumb.com/v1/candles/days?market=KRW-{}&count=200&to={}").format(symbol, to)
+ if interval >= DAILY_INTERVAL_MIN:
+ url = (
+ f"{base}/v1/candles/days?market=KRW-{symbol}"
+ f"&count={count}&to={to}"
+ )
else:
- url = ("https://api.bithumb.com/v1/candles/minutes/{}?market=KRW-{}&count=200&to={}").format(interval, symbol, to)
+ url = (
+ f"{base}/v1/candles/minutes/{interval}"
+ f"?market=KRW-{symbol}&count={count}&to={to}"
+ )
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
@@ -447,11 +344,11 @@ class Monitor(HTS):
if not data.empty:
return data
print(f"No data received for {symbol}, attempt {attempt + 1}")
- time.sleep(0.5)
+ time.sleep(MONITOR_SLEEP_AFTER_REQUEST_SEC)
except Exception as e:
print(f"Attempt {attempt + 1} failed for {symbol}: {str(e)}")
if attempt < retries - 1:
- time.sleep(5)
+ time.sleep(MONITOR_SLEEP_RATE_LIMIT_SEC)
continue
return None
@@ -459,7 +356,7 @@ class Monitor(HTS):
self,
symbol: str,
interval: int,
- bong_count: int = 3000,
+ bong_count: int = MONITOR_API_BONG_COUNT,
verbose: bool = False,
) -> pd.DataFrame:
"""
@@ -488,8 +385,8 @@ class Monitor(HTS):
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
label = "일봉" if interval >= 1440 else f"{interval}분"
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}봉")
- time.sleep(0.3)
- to = to - relativedelta(minutes=interval * 200)
+ time.sleep(MONITOR_SLEEP_BETWEEN_CHUNKS_SEC)
+ to = to - relativedelta(minutes=interval * MONITOR_API_CHUNK_BARS)
if data is None or data.empty:
return pd.DataFrame()
data = data.set_index("datetime")
@@ -498,13 +395,38 @@ class Monitor(HTS):
data["datetime"] = data.index
return data
+ @staticmethod
+ def db_row_limit_for_interval(interval: int, lookback_days: int) -> int:
+ """
+ lookback_days 구간 + 지표 워밍업을 담을 SQLite LIMIT(봉 개수)을 계산합니다.
+
+ Args:
+ interval: 봉 간격(분). 1440이면 일봉.
+ lookback_days: 과거 조회 일수.
+
+ Returns:
+ LIMIT에 넣을 최대 행 수.
+ """
+ if interval >= DAILY_INTERVAL_MIN:
+ return max(
+ lookback_days + DB_ROW_DAILY_PADDING_DAYS,
+ DB_ROW_MIN_DAILY_BARS,
+ )
+ bars_per_day = max((24 * 60) // max(interval, 1), 1)
+ return bars_per_day * lookback_days + DB_ROW_WARMUP_BARS
+
def get_coin_saved_data(
- self, symbol: str, interval: int, data: pd.DataFrame, db_path: str = "coins.db"
+ self,
+ symbol: str,
+ interval: int,
+ data: pd.DataFrame,
+ db_path: str = DB_PATH,
+ max_rows: int = DB_READ_LIMIT_DEFAULT,
) -> pd.DataFrame:
"""
coins.db에서 저장된 봉을 읽고, API로 받은 최신 봉을 DB에 반영합니다.
- downloader.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
+ scripts/01_download.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
@@ -548,7 +470,7 @@ class Monitor(HTS):
cursor.execute(
f"SELECT Open, Close, High, Low, Volume, ymdhms AS datetime "
f"FROM (SELECT Open, Close, High, Low, Volume, ymdhms "
- f"FROM {table_name} ORDER BY ymdhms DESC LIMIT 7000) "
+ f"FROM {table_name} ORDER BY ymdhms DESC LIMIT {int(max_rows)}) "
f"ORDER BY datetime"
)
result = cursor.fetchall()
@@ -569,11 +491,13 @@ class Monitor(HTS):
df["datetime"] = df.index
return df
- def get_coin_some_data(self, symbol: str, interval: int) -> pd.DataFrame:
+ def get_coin_some_data(
+ self, symbol: str, interval: int, db_max_rows: int | None = None
+ ) -> pd.DataFrame:
"""
WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
- DB가 비어 있으면 API·1분봉만 사용합니다. 과거 적재는 downloader.py 실행.
+ DB가 비어 있으면 API·1분봉만 사용합니다. 과거 적재는 scripts/01_download.py 실행.
"""
data = self.get_coin_data(symbol, interval)
if data is None or data.empty:
@@ -584,7 +508,10 @@ class Monitor(HTS):
data_1 = data_1.copy()
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
- saved_data = self.get_coin_saved_data(symbol, interval, data)
+ row_limit = DB_READ_LIMIT_DEFAULT if db_max_rows is None else int(db_max_rows)
+ saved_data = self.get_coin_saved_data(
+ symbol, interval, data, max_rows=row_limit
+ )
parts = [data]
if saved_data is not None and not saved_data.empty:
parts.append(saved_data)
diff --git a/deepcoin/ops/monitor_coin.py b/deepcoin/ops/monitor_coin.py
new file mode 100644
index 0000000..4434016
--- /dev/null
+++ b/deepcoin/ops/monitor_coin.py
@@ -0,0 +1,34 @@
+"""
+WLD(월드코인) 실시간 모니터 — BB·일목 위치·추세 출력 (자동 매매 없음).
+"""
+
+from datetime import datetime
+import time
+
+from config import COIN_NAME, MONITOR_LOOP_SLEEP_SEC, SYMBOL
+from deepcoin.ops.monitor import Monitor
+
+
+class MonitorCoin(Monitor):
+ """WLD 시장 상태 주기 출력."""
+
+ def monitor_wld(self) -> None:
+ """전 봉 BB·일목·추세를 콘솔에 출력합니다."""
+ print(
+ "[{}] {} ({})".format(
+ datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+ COIN_NAME,
+ SYMBOL,
+ )
+ )
+ self.process_wld_market_status(SYMBOL)
+
+ def run_schedule(self) -> None:
+ """MONITOR_LOOP_SLEEP_SEC 간격으로 상태를 출력합니다."""
+ while True:
+ self.monitor_wld()
+ time.sleep(MONITOR_LOOP_SLEEP_SEC)
+
+
+if __name__ == "__main__":
+ MonitorCoin(cooldown_file=None).run_schedule()
diff --git a/deepcoin/ops/simulation.py b/deepcoin/ops/simulation.py
new file mode 100644
index 0000000..536383c
--- /dev/null
+++ b/deepcoin/ops/simulation.py
@@ -0,0 +1,595 @@
+"""
+WLD 볼린저 밴드 차트.
+
+ python scripts/05_chart_bb.py
+ python scripts/05_chart_truth.py
+ python scripts/02_ground_truth.py
+"""
+
+from __future__ import annotations
+
+import sys
+import webbrowser
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+import plotly.graph_objs as go
+from plotly.subplots import make_subplots
+
+from config import (
+ CHART_LOOKBACK_DAYS,
+ COIN_NAME,
+ DISPARITY_OVERBOUGHT,
+ DISPARITY_OVERSOLD,
+ DISPARITY_PERIODS,
+ ENTRY_INTERVAL,
+ GROUND_TRUTH_FILE,
+ GT_INITIAL_CASH_KRW,
+ GT_MARKER_SIZE_MAX,
+ GT_MARKER_SIZE_MIN,
+ MACD_FAST,
+ MACD_SIGNAL,
+ MACD_SLOW,
+ STOCH_D_PERIOD,
+ STOCH_K_PERIOD,
+ SYMBOL,
+ TRADING_FEE_RATE,
+ TREND_INTERVAL_1D,
+ TREND_INTERVAL_1H,
+)
+from deepcoin.common.indicators import apply_bar_indicators, disparity_column, get_trend
+from deepcoin.ops.monitor import Monitor
+from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
+
+from deepcoin.paths import CHART_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file
+
+OUTPUT_HTML = CHART_BB_HTML
+TRUTH_HTML = CHART_TRUTH_HTML
+GROUND_TRUTH_PATH = resolve_ground_truth_file()
+REPORT_DIR = CHART_BB_HTML.parent
+
+
+def interval_chart_label(interval_min: int) -> str:
+ """차트 제목용 봉 라벨."""
+ if interval_min >= 1440:
+ return "일봉"
+ return f"{interval_min}분봉"
+
+
+def _marker_sizes(trades: list[dict], action: str) -> list[float]:
+ """비중(weight, 0~1)에 비례한 삼각형 크기."""
+ pts = [t for t in trades if t.get("action") == action]
+ if not pts:
+ return []
+ lo, hi = float(GT_MARKER_SIZE_MIN), float(GT_MARKER_SIZE_MAX)
+ return [
+ lo + (hi - lo) * min(max(float(t.get("weight", 1.0)), 0.05), 1.0)
+ for t in pts
+ ]
+
+
+def _add_truth_markers(fig, trades: list[dict], row: int = 1) -> None:
+ """정답 매수·매도 마커 (삼각형 크기 = 비중)."""
+ for action, color, symbol, label in [
+ ("buy", "#16a34a", "triangle-up", "정답 매수"),
+ ("sell", "#dc2626", "triangle-down", "정답 매도"),
+ ]:
+ pts = [t for t in trades if t.get("action") == action]
+ if not pts:
+ continue
+ sizes = _marker_sizes(trades, action)
+ fig.add_trace(
+ go.Scatter(
+ x=[pd.Timestamp(t["dt"]) for t in pts],
+ y=[t["price"] for t in pts],
+ mode="markers",
+ name=label,
+ legendgroup=label,
+ marker=dict(
+ symbol=symbol,
+ size=sizes,
+ sizemode="diameter",
+ color=color,
+ line=dict(width=1.5, color="#111"),
+ ),
+ hovertext=[
+ f"{label}
{t['dt'][:16]}
₩{t['price']:,.0f}"
+ f"
비중 {float(t.get('weight', 1))*100:.0f}%"
+ f"
{t.get('memo', '')}"
+ for t in pts
+ ],
+ hovertemplate="%{hovertext}",
+ ),
+ row=row,
+ col=1,
+ )
+
+
+def build_chart_html(
+ df: pd.DataFrame,
+ trend: str,
+ interval_min: int = ENTRY_INTERVAL,
+ note: str = "",
+ truth_trades: list[dict] | None = None,
+ title_suffix: str = "BB 차트",
+ pnl_summary: dict | None = None,
+) -> str:
+ """BB·이격도·RSI·MACD·스토캐스틱·거래량 차트 HTML."""
+ df = apply_bar_indicators(df.copy())
+ iv_label = interval_chart_label(interval_min)
+ close_last = float(df["Close"].iloc[-1])
+ bb_pos = None
+ if "bb_pos" in df.columns and pd.notna(df["bb_pos"].iloc[-1]):
+ bb_pos = float(df["bb_pos"].iloc[-1])
+
+ disp_title = "이격도 " + ",".join(str(p) for p in DISPARITY_PERIODS)
+ fig = make_subplots(
+ rows=6,
+ cols=1,
+ shared_xaxes=True,
+ vertical_spacing=0.03,
+ row_heights=[0.42, 0.11, 0.11, 0.11, 0.13, 0.12],
+ subplot_titles=(
+ f"{COIN_NAME} ({SYMBOL}) {iv_label}",
+ disp_title,
+ f"Stochastic ({STOCH_K_PERIOD},{STOCH_D_PERIOD})",
+ "RSI (14)",
+ f"MACD ({MACD_FAST},{MACD_SLOW},{MACD_SIGNAL})",
+ "거래량",
+ ),
+ )
+ disp_colors = ("#0d9488", "#7c3aed", "#ca8a04")
+
+ fig.add_trace(
+ go.Candlestick(
+ x=df.index,
+ open=df["Open"],
+ high=df["High"],
+ low=df["Low"],
+ close=df["Close"],
+ name=f"{iv_label} 캔들",
+ increasing_line_color="#ef4444",
+ decreasing_line_color="#3b82f6",
+ ),
+ row=1,
+ col=1,
+ )
+ if "MA" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["MA"],
+ name="BB 중심",
+ line=dict(color="#64748b", width=1, dash="dot"),
+ ),
+ row=1,
+ col=1,
+ )
+ if "Upper" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["Upper"],
+ name="BB 상단",
+ line=dict(color="#94a3b8", width=1),
+ ),
+ row=1,
+ col=1,
+ )
+ if "Lower" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["Lower"],
+ name="BB 하단",
+ line=dict(color="#94a3b8", width=1),
+ ),
+ row=1,
+ col=1,
+ )
+
+ if truth_trades:
+ _add_truth_markers(fig, truth_trades, row=1)
+
+ disp_row = 2
+ for i, p in enumerate(DISPARITY_PERIODS):
+ col = disparity_column(p)
+ if col not in df.columns:
+ continue
+ color = disp_colors[i % len(disp_colors)]
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df[col],
+ name=f"D.I. {p}",
+ line=dict(color=color, width=1),
+ ),
+ row=disp_row,
+ col=1,
+ )
+ if any(disparity_column(p) in df.columns for p in DISPARITY_PERIODS):
+ fig.add_hline(
+ y=100, line_dash="solid", line_color="#64748b", row=disp_row, col=1
+ )
+ fig.add_hline(
+ y=DISPARITY_OVERBOUGHT,
+ line_dash="dot",
+ line_color="#ef4444",
+ row=disp_row,
+ col=1,
+ )
+ fig.add_hline(
+ y=DISPARITY_OVERSOLD,
+ line_dash="dot",
+ line_color="#16a34a",
+ row=disp_row,
+ col=1,
+ )
+
+ stoch_row = 3
+ if "stoch_k" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["stoch_k"],
+ name="Stoch %K",
+ line=dict(color="#0ea5e9", width=1),
+ ),
+ row=stoch_row,
+ col=1,
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["stoch_d"],
+ name="Stoch %D",
+ line=dict(color="#f97316", width=1),
+ ),
+ row=stoch_row,
+ col=1,
+ )
+ fig.add_hline(y=80, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
+ fig.add_hline(y=20, line_dash="dot", line_color="#9ca3af", row=stoch_row, col=1)
+
+ rsi_row = 4
+ if "RSI" in df.columns:
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["RSI"],
+ name="RSI",
+ line=dict(color="#7c3aed"),
+ ),
+ row=rsi_row,
+ col=1,
+ )
+ fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
+ fig.add_hline(y=30, line_dash="dot", line_color="#9ca3af", row=rsi_row, col=1)
+
+ macd_row = 5
+ vol_row = 6
+ if "macd_hist" in df.columns:
+ colors = np.where(df["macd_hist"].astype(float) >= 0, "#ef4444", "#3b82f6")
+ fig.add_trace(
+ go.Bar(
+ x=df.index,
+ y=df["macd_hist"],
+ name="MACD Hist",
+ marker_color=colors,
+ ),
+ row=macd_row,
+ col=1,
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["macd_line"],
+ name="MACD",
+ line=dict(color="#2563eb", width=1),
+ ),
+ row=macd_row,
+ col=1,
+ )
+ fig.add_trace(
+ go.Scatter(
+ x=df.index,
+ y=df["macd_signal"],
+ name="Signal",
+ line=dict(color="#ea580c", width=1, dash="dot"),
+ ),
+ row=macd_row,
+ col=1,
+ )
+
+ fig.add_trace(
+ go.Bar(
+ x=df.index,
+ y=df["Volume"],
+ name="Volume",
+ marker_color="#cbd5e1",
+ ),
+ row=vol_row,
+ col=1,
+ )
+
+ fig.update_layout(
+ height=1180,
+ template="plotly_white",
+ xaxis_rangeslider_visible=False,
+ legend=dict(orientation="h", y=1.05, x=0),
+ margin=dict(l=60, r=30, t=90, b=40),
+ )
+ fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
+ fig.update_yaxes(title_text="이격도", row=2, col=1)
+ fig.update_yaxes(title_text="Stoch", row=3, col=1, range=[0, 100])
+ fig.update_yaxes(title_text="RSI", row=4, col=1, range=[0, 100])
+ fig.update_yaxes(title_text="MACD", row=5, col=1)
+
+ chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
+ note_html = f"{note}
" if note else ""
+ bb_pos_txt = f"{bb_pos:.2f}" if bb_pos is not None else "-"
+ pnl = pnl_summary or {}
+ if truth_trades and not pnl:
+ from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio
+
+ pnl = simulate_truth_portfolio(
+ truth_trades,
+ initial_cash=GT_INITIAL_CASH_KRW,
+ fee_rate=TRADING_FEE_RATE,
+ last_price=close_last,
+ )
+ trade_rows = ""
+ if truth_trades:
+ from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio_steps
+
+ steps = simulate_truth_portfolio_steps(
+ truth_trades,
+ initial_cash=GT_INITIAL_CASH_KRW,
+ fee_rate=TRADING_FEE_RATE,
+ )
+ step_key = {
+ (s["dt"], s["action"], float(s["price"]), float(s["weight"])): s
+ for s in steps
+ }
+ sorted_trades = sorted(truth_trades, key=lambda x: x["dt"])
+ trade_rows += f"""
+
+ | 시작 |
+ - |
+ - |
+ - |
+ ₩{GT_INITIAL_CASH_KRW:,.0f} |
+ 초기 현금 (보유 0) |
+
"""
+ for t in sorted_trades:
+ cls = "buy" if t["action"] == "buy" else "sell"
+ mark = "매수" if t["action"] == "buy" else "매도"
+ ret = t.get("forward_return_pct")
+ ret_s = f" (+{ret}%)" if ret is not None else ""
+ w = float(t.get("weight", 1.0))
+ key = (t["dt"], t["action"], float(t["price"]), w)
+ step = step_key.get(key)
+ if step:
+ total_s = f"₩{step['total_asset_krw']:,.0f}"
+ hold_s = f" (현금 ₩{step['cash_krw']:,.0f} + 코인 {step['holding_qty']:,.2f}개)"
+ else:
+ total_s = "-"
+ hold_s = ""
+ trade_rows += f"""
+
+ | {t['dt'][:16]} |
+ {mark} |
+ {w*100:.0f}% |
+ ₩{t['price']:,.0f}{ret_s} |
+ {total_s}{hold_s} |
+ {t.get('memo', '')} |
+
"""
+ trade_table = ""
+ if truth_trades:
+ if not trade_rows:
+ trade_rows = "| 타점 없음 |
"
+ mark_note = ""
+ if pnl.get("mark_price"):
+ mark_note = (
+ f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가."
+ )
+ trade_table = f"""
+ 정답 타점 (ground_truth)
+ 삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회.
+ 총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}
+
+ | 시각 | 구분 | 비중 | 가격 | 총 평가금액 | 해석 |
+ {trade_rows}
+
"""
+
+ pnl_cards = ""
+ if truth_trades and pnl.get("initial_cash_krw") is not None:
+ pnl_cards = f"""
+ 시작₩{pnl['initial_cash_krw']:,.0f}
+ 최종 자산₩{pnl['final_asset_krw']:,.0f}
+ 수익금₩{pnl['pnl_krw']:+,.0f}
+ 수익률{pnl['pnl_pct']:+.2f}%
+ 수수료₩{pnl['total_fees_krw']:,.0f}
"""
+ if pnl.get("holding_qty", 0) > 0:
+ pnl_cards += f"""
+ 미청산{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})
"""
+
+ return f"""
+
+
+
+ {SYMBOL} {title_suffix}
+
+
+
+ {COIN_NAME} ({SYMBOL}) {title_suffix}
+ 추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}
+ {note_html}
+ ▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.
+
+
종가₩{close_last:,.2f}
+
BB %B{bb_pos_txt}
+
정답 타점{len(truth_trades) if truth_trades else 0}건
+ {pnl_cards}
+
+ {chart_html}
+ {trade_table}
+
+"""
+
+
+def _frames_to_mtf(
+ frames: dict[int, pd.DataFrame],
+) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
+ """전 간격 frames에서 1d/1h/3m 추출."""
+ df_3m = frames.get(ENTRY_INTERVAL)
+ if df_3m is None or df_3m.empty:
+ raise ValueError(f"{ENTRY_INTERVAL}분봉 데이터 없음")
+ df_1d = frames.get(TREND_INTERVAL_1D)
+ if df_1d is None or df_1d.empty:
+ df_1d = df_3m
+ df_1h = frames.get(TREND_INTERVAL_1H)
+ if df_1h is None or df_1h.empty:
+ df_1h = df_3m
+ return df_1d, df_1h, df_3m
+
+
+def load_chart_frames() -> dict[int, pd.DataFrame] | None:
+ """coins.db 전 간격 로드. 부족 시 None."""
+ monitor = Monitor(cooldown_file=None)
+ print(f"DB 조회: 최근 {CHART_LOOKBACK_DAYS}일 (CHART_LOOKBACK_DAYS)")
+ frames = load_frames_from_db(monitor, SYMBOL, lookback_days=CHART_LOOKBACK_DAYS)
+ if ENTRY_INTERVAL not in frames:
+ print("coins.db 데이터 부족. python scripts/01_download.py 실행 후 재시도.")
+ return None
+ return frames
+
+
+def run_ground_truth_chart(open_browser: bool = True) -> Path:
+ """
+ 정답 타점을 생성·저장하고 마커가 포함된 HTML 차트를 만듭니다.
+
+ Args:
+ open_browser: True면 브라우저로 HTML을 엽니다.
+
+ Returns:
+ HTML 파일 경로.
+ """
+ from deepcoin.ground_truth.ground_truth import run_from_db
+
+ data = run_from_db()
+ frames = load_chart_frames()
+ if frames is None:
+ raise RuntimeError("차트 데이터 로드 실패")
+
+ df_1d, df_1h, df_3m = _frames_to_mtf(frames)
+ trend = get_trend(df_1d, df_1h)
+ df_chart = apply_bar_indicators(df_3m)
+ trades = data.get("trades") or []
+
+ summary = data.get("summary") or {}
+ html = build_chart_html(
+ df_chart,
+ trend,
+ note=data.get("note", ""),
+ truth_trades=trades,
+ title_suffix=f"정답 타점 ({CHART_LOOKBACK_DAYS}일)",
+ pnl_summary=summary if summary.get("pnl_krw") is not None else None,
+ )
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
+ TRUTH_HTML.write_text(html, encoding="utf-8")
+ print(f"HTML: {TRUTH_HTML}")
+ if open_browser:
+ webbrowser.open(TRUTH_HTML.resolve().as_uri())
+ return TRUTH_HTML
+
+
+def run_chart(open_browser: bool = True) -> Path:
+ """
+ 3분봉 BB 차트 HTML을 생성합니다.
+
+ Args:
+ open_browser: True면 기본 브라우저로 HTML을 엽니다.
+
+ Returns:
+ 저장된 HTML 경로.
+ """
+ frames = load_chart_frames()
+ if frames is None:
+ raise RuntimeError("차트 데이터 로드 실패")
+
+ df_1d, df_1h, df_3m = _frames_to_mtf(frames)
+ trend = get_trend(df_1d, df_1h)
+ df_chart = apply_bar_indicators(df_3m)
+ print(f"\n추세(참고): {trend}")
+ print(f"3분: {df_chart.index[0]} ~ {df_chart.index[-1]} ({len(df_chart)}봉)")
+
+ html = build_chart_html(
+ df_chart,
+ trend,
+ note="자동 매수·매도 전략은 사용하지 않습니다.",
+ )
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
+ OUTPUT_HTML.write_text(html, encoding="utf-8")
+ print(f"HTML: {OUTPUT_HTML}")
+ if open_browser:
+ webbrowser.open(OUTPUT_HTML.resolve().as_uri())
+ return OUTPUT_HTML
+
+
+def print_usage() -> None:
+ print(
+ """
+DeepCoin simulation.py
+
+ python simulation.py
+ WLD 3분봉 BB 차트 → docs/charts/wld_bb_chart.html
+
+ python simulation.py truth
+ 정답 타점 생성 → ground_truth_trades.json
+ 차트 → docs/02_ground_truth/wld_ground_truth_chart.html
+"""
+ )
+
+
+def main() -> None:
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
+ print_usage()
+ return
+ if len(sys.argv) > 1 and sys.argv[1] in ("truth", "ground-truth", "gt"):
+ print("=" * 60)
+ print("정답 타점 생성 + 차트")
+ print("=" * 60)
+ run_ground_truth_chart()
+ print("\n완료.")
+ return
+ if len(sys.argv) > 1:
+ print(f"알 수 없는 옵션: {sys.argv[1]}\n")
+ print_usage()
+ return
+ print("=" * 60)
+ print("WLD BB 차트 (매매 전략 없음)")
+ print("=" * 60)
+ run_chart()
+ print("\n완료.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/deepcoin/paths.py b/deepcoin/paths.py
new file mode 100644
index 0000000..b20befe
--- /dev/null
+++ b/deepcoin/paths.py
@@ -0,0 +1,95 @@
+"""
+DeepCoin 프로젝트 경로 (data + docs 통합).
+
+docs/
+ reference/ 가이드·기법 명세 (Git 추적)
+ 02_ground_truth/ … 05_ops/, charts/ 단계별 산출물 (로컬 재생성, Git 제외)
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+# DeepCoin/ (이 파일: DeepCoin/deepcoin/paths.py)
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+
+# --- data ---
+DATA_DIR = PROJECT_ROOT / "data"
+DB_DIR = DATA_DIR
+GROUND_TRUTH_DIR = DATA_DIR / "ground_truth"
+OPS_STATE_DIR = DATA_DIR / "ops"
+COOLDOWN_FILE = OPS_STATE_DIR / "coins_buy_time.json"
+
+# --- docs (reference + 단계별 산출물) ---
+DOCS_DIR = PROJECT_ROOT / "docs"
+DOCS_REFERENCE_DIR = DOCS_DIR / "reference"
+
+DOCS_CHARTS = DOCS_DIR / "charts"
+DOCS_GROUND_TRUTH = DOCS_DIR / "02_ground_truth"
+DOCS_ANALYSIS = DOCS_DIR / "03_analysis"
+DOCS_MATCHING = DOCS_DIR / "04_matching"
+DOCS_OPS = DOCS_DIR / "05_ops"
+
+ANALYSIS_TRADES_CSV = DOCS_ANALYSIS / "general_analysis_trades.csv"
+ANALYSIS_REPORT_HTML = DOCS_ANALYSIS / "general_analysis_report.html"
+ANALYSIS_CAPABILITY_HTML = DOCS_ANALYSIS / "general_analysis_capability.html"
+ANALYSIS_LATEST_DIR = DOCS_ANALYSIS / "latest"
+
+CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html"
+CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
+
+# 하위 호환 (구 reports/ 이름)
+REPORTS_DIR = DOCS_DIR
+REPORTS_CHARTS = DOCS_CHARTS
+REPORTS_GROUND_TRUTH = DOCS_GROUND_TRUTH
+REPORTS_ANALYSIS = DOCS_ANALYSIS
+REPORTS_MATCHING = DOCS_MATCHING
+REPORTS_OPS = DOCS_OPS
+
+
+def resolve_db_path() -> Path:
+ """존재하는 coins.db 경로."""
+ candidates = [
+ DATA_DIR / "coins.db",
+ PROJECT_ROOT / "coins.db",
+ ]
+ for p in candidates:
+ if p.is_file():
+ return p
+ return DATA_DIR / "coins.db"
+
+
+def resolve_ground_truth_file() -> Path:
+ """존재하는 ground_truth_trades.json 경로."""
+ name = os.getenv("GROUND_TRUTH_FILE", "ground_truth_trades.json")
+ p = Path(name)
+ if p.is_absolute():
+ return p
+ candidates = [
+ GROUND_TRUTH_DIR / "ground_truth_trades.json",
+ PROJECT_ROOT / "ground_truth_trades.json",
+ PROJECT_ROOT / name,
+ ]
+ for c in candidates:
+ if c.is_file():
+ return c
+ return GROUND_TRUTH_DIR / "ground_truth_trades.json"
+
+
+def ensure_dirs() -> None:
+ """단계별 출력·가이드 디렉터리 생성."""
+ for d in (
+ DATA_DIR,
+ GROUND_TRUTH_DIR,
+ OPS_STATE_DIR,
+ DOCS_DIR,
+ DOCS_REFERENCE_DIR,
+ DOCS_CHARTS,
+ DOCS_GROUND_TRUTH,
+ DOCS_ANALYSIS,
+ DOCS_MATCHING,
+ DOCS_OPS,
+ ANALYSIS_LATEST_DIR,
+ ):
+ d.mkdir(parents=True, exist_ok=True)
diff --git a/discovered_rules.json b/discovered_rules.json
deleted file mode 100644
index da1a2e4..0000000
--- a/discovered_rules.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "name": "discovered_best",
- "buy_all": [
- "m3:cross_up_lower",
- "m60:ichi_tk_bull",
- "d1:!ichi_below_cloud",
- "m3:bb_zone_low",
- "m3:ichi_above_cloud"
- ],
- "buy_any": [],
- "sell_all": [
- "m60:cross_up_upper"
- ],
- "sell_stop": [
- "m3:cross_down_lower"
- ],
- "train_return_pct": 0.7385912698412721,
- "test_return_pct": 0.0,
- "full_return_pct": 0.7385912698412721,
- "trade_count": 2
-}
\ No newline at end of file
diff --git a/docs/LOGOS_STRATEGY.md b/docs/LOGOS_STRATEGY.md
deleted file mode 100644
index 811f7f9..0000000
--- a/docs/LOGOS_STRATEGY.md
+++ /dev/null
@@ -1,223 +0,0 @@
-# 로고스(Logos) 매매 타점 전략 설계
-
-BB predicate 탐색(`discovered_rules`)과 **분리**된, 차트 구조·추세·과열을 우선하는 **3분 현물** 전략입니다.
-수동 타점(`logos_trades.json`)은 이 전략의 **교사 데이터(벤치마크)** 로 사용합니다.
-
----
-
-## Plan (계획)
-
-### 목적
-
-- **저점 근처 매수**, **고점·과열에서 매도**, **추격 매수·바닥 매도**를 시스템적으로 배제한다.
-- 3분봉 실행, 1시간·일봉으로 **방향 필터**만 거는 MTF 구조를 유지한다.
-- 체결은 **엣지(전환) 1회** + **쿨다운·최소 봉 간격**으로 휩소를 줄인다.
-
-### 목표 KPI (백테스트·실거래 공통)
-
-| KPI | 목표 |
-|-----|------|
-| 매도가 ≥ 직전 매수가 비율 | 60% 이상 |
-| 월 거래 횟수 (3분 WLD) | 8~40회 (과다 체결 방지) |
-| 최대 연속 손실 매도 | 3회 이하 |
-| 수동 로고스 타점 일치율 | ±3봉(9분), ±2% 가격 이내 50% 이상 (캘리브레이션) |
-
-### 전략 원칙 (5가지)
-
-1. **구조 우선**: 지표 한 개보다 «바닥 형성 → 눌림 → 가속 → 과열» 순서를 본다.
-2. **가치 매수만**: 하단 돌파·망치·밴드 하단 구간. 상단 구간 단독 매수 금지.
-3. **익절은 강도로**: 밴드 상단 돌파만으로 팔지 않고, **위치·캔들·RSI·거래량**이 맞을 때만.
-4. **추세 보유**: 눌림(예: 378)은 **상위 추세가 살아 있으면** 버틴다.
-5. **현물 순환**: 공매도 없음. 매도 = 보유 청산 또는 익절 후 재진입 대기.
-
----
-
-## Do (실행 구조)
-
-### 아키텍처 (4계층)
-
-```mermaid
-flowchart TB
- subgraph L1 [L1 레짐 1D/1H]
- R1[추세: up / range / down]
- R2[일목: 구름 위·아래]
- end
- subgraph L2 [L2 구조 3m/15m/60m]
- S1[스윙 고저 pivot]
- S2[고저점 상승 HL / 하락 LH]
- end
- subgraph L3 [L3 트리거 3m]
- B1[매수 A: 바닥·반등]
- B2[매수 B: 추세 눌림]
- X1[매도: 익절·과열]
- X2[매도: 손절]
- end
- subgraph L4 [L4 체결]
- E1[엣지 트리거]
- E2[쿨다운·최소봉격]
- end
- L1 --> L2 --> L3 --> L4
-```
-
-### L1. 레짐 필터 (1D + 1H)
-
-| 레짐 | 매수 | 매도(익절) |
-|------|------|------------|
-| **up** | A·B 허용 | 전량·분할 허용 |
-| **range** | A만 (바닥형), B 제한 | 분할 위주 |
-| **down** | A만 소량, B 금지 | 보유 시 손절·약익절만 |
-
-판별(기존 `strategy.get_trend` 활용):
-
-- 1H·1D MA 정배열/역배열 + 갭
-- 보조: 일봉 `!ichi_below_cloud` (매수), 구름 아래 매수 금지
-
-### L2. 구조 (스윙)
-
-| 간격 | pivot order (3분봉 개수) | 용도 |
-|------|--------------------------|------|
-| 3m | 40 (~2시간) | 주요 고저 |
-| 3m | 15 (~45분) | 보조 눌림 |
-| 15m | 20 | 중기 저항 |
-
-정의:
-
-- **스윙 저점**: 좌우 `order` 봉보다 `Low`가 낮은 봉
-- **스윙 고점**: 좌우 `order` 봉보다 `High`가 높은 봉
-- **HL(상승)**: 직전 스윙 저점 < 현재 스윙 저점
-- **과열 고점**: 종가가 120봉 최고가 대비 97% 이상
-
-### L3-A. 매수 트리거
-
-#### A. 바닥·투매 종료 (5/18 340 유형)
-
-**엣지**로만 진입. 아래 **모두** 충족:
-
-| # | 조건 |
-|---|------|
-| A1 | 3m `bb_pos` < 0.35 또는 `bb_zone_bottom` |
-| A2 | `cross_up_lower` **또는** `hammer` (전봉 대비 신규) |
-| A3 | RSI(14) < 42 **그리고** 전봉 대비 RSI 상승 |
-| A4 | 120봉 최저가 대비 종가 ≤ 103% (진정한 바닥권) |
-| A5 | 레짐 ≠ down 또는 일봉 구름 아래 아님 |
-
-**금지**: `bb_pos` ≥ 0.55 단독, `bb_zone_top`, 당일 `shooting_star` + 고점권
-
-#### B. 추세 눌림 재진입 (5/23 392, 5/25 427 유형)
-
-**엣지** + 레짐 **up**:
-
-| # | 조건 |
-|---|------|
-| B1 | 1H 추세 up, 3m `bb_zone_low` 또는 `cross_up_lower` |
-| B2 | 직전 스윙 고점 대비 3~12% 조정(눌림 깊이) |
-| B3 | 3m `bb_pos` < 0.50 |
-| B4 | 15m `ichi_tk_bull` 또는 전환선 지지 |
-| B5 | 마지막 매도 후 ≥ 20봉(1시간) |
-
-**금지**: 450+ 구간 «급등 후 재추격»(5/26 04:00 유형) — `bb_pos` > 0.65 신규 매수 차단
-
-### L3-B. 매도 트리거
-
-#### C. 구조적 익절 (5/23 00:30, 5/24 464 유형)
-
-**엣지** + 보유 중:
-
-| # | 조건 |
-|---|------|
-| C1 | 3m **스윙 고점** 확정(피벗) **또는** `cross_up_upper` |
-| C2 | `bb_pos` ≥ **0.65** (저점 익절 방지) |
-| C3 | **NOT** (`hammer` OR `bb_zone_bottom` 동일 봉) |
-| C4 | 종가 ≥ BB 중심(MA) |
-
-분할: 1차 C만 충족 시 50% 익절, 2차 조건(D) 시 나머지 (구현 옵션).
-
-#### D. 과열·불꽃 익절 (5/26 603 유형)
-
-| # | 조건 |
-|---|------|
-| D1 | 3m `bb_pos` ≥ 0.90 **또는** 20봉 누적 상승률 ≥ 8% |
-| D2 | 거래량 > 20봉 평균 × 1.5 |
-| D3 | `shooting_star` 또는 윗꼬리 비율 > 0.45 |
-
-→ **전량 매도** 우선.
-
-#### E. 손절 (필수)
-
-| # | 조건 |
-|---|------|
-| E1 | 3m `cross_down_lower` **엣지** |
-| E2 | 또는 매수가 대비 -3% (설정값) |
-
-**금지**: `bb_pos` < 0.40 에서 `cross_up_upper` 단독 매도 (5/26 01:48 유형)
-
-### L4. 체결 규칙 (공통)
-
-| 항목 | 기본값 | 설명 |
-|------|--------|------|
-| `SIGNAL_EDGE_ONLY` | true | False→True 봉만 |
-| `TRADE_MIN_GAP_BARS` | 5 | 체결 후 15분 |
-| `BUY_COOLDOWN_SEC` | 1800 | 매수 간 30분 |
-| `SELL_COOLDOWN_SEC` | 900 | 매도 간 15분 |
-| `SELL_MIN_BB_POS` | 0.40 | 이보다 낮으면 C 매도 금지 |
-| `BUY_MAX_BB_POS_CHASE` | 0.55 | 이보다 높으면 value 트리거 없이 매수 금지 |
-
-우선순위(보유 중): **E 손절 > D 과열 > C 익절**
-
----
-
-## 수동 타점과의 매핑
-
-| 수동 타점 | 전략 분류 | 자동화 핵심 조건 |
-|-----------|-----------|------------------|
-| 5/18 340 매수 | A 바닥 | A1~A4 + hammer |
-| 5/23 00:30 445 매도 | C+D | pivot 고점 + bb_pos≥0.65 |
-| 5/23 392 매수 | B 눌림 | up + bb_zone_low + HL |
-| 5/24 464 매도 | C | cross_up_upper + 과열 |
-| 5/25 427 매수 | B | 급등 전 마지막 눌림, bb_pos<0.5 |
-| 5/26 603 매도 | D | bb_pos·거래량·급등률 |
-| 5/28 420 매수 | A′ 급락 바닥 | 1일 -20% 후 첫 3m 저점 |
-| 5/29 441 매도 | C | 반등 실패·중심선 이탈 |
-
----
-
-## Check (검토)
-
-### 백테스트 절차
-
-1. `python simulation.py` — 로고스 전략 체결 HTML (`reports/wld_bb_simulation.html`)
-2. `python simulation.py benchmark` — 수동 정답 참고 (`reports/wld_logos_benchmark.html`)
-
-### 리스크
-
-| 리스크 | 완화 |
-|--------|------|
-| 눌림 구간 손실 확대 | E 손절, down 레짐 매수 축소 |
-| 급등 후 재매수 | `BUY_MAX_BB_POS_CHASE` |
-| 바닥에서 익절 | `SELL_MIN_BB_POS` + 망치 동봉 매도 금지 |
-| 거래 과다 | 엣지 + 쿨다운 + pivot 최소 간격 |
-
----
-
-## Act (개선)
-
-### 자동 전략 (코드 고정)
-
-벤치마크 1위였던 S9 로직을 `logos_strategy.py` 상단 상수로만 유지합니다.
-`logos_best_policy.json`, `LogosPolicy`, `logos-fit` 제거.
-
-1. **Phase 3**: `USE_LOGOS_LIVE` 실거래 스위치
-2. **Phase 4**: 눌림/바닥 분기 강화
-
----
-
-## 코드 위치
-
-| 파일 | 역할 |
-|------|------|
-| `docs/LOGOS_STRATEGY.md` | 본 설계서 |
-| `logos_strategy.py` | 신호·체결 엔진 |
-| `logos_trades.json` | 수동 벤치마크 |
-| `logos_chart.py` | `simulation.py` 와 동일 진입 |
-| `simulation.py` | 기본 실행 = 로고스 전략 HTML |
-| `simulation.py benchmark` | 수동 정답 참고 HTML |
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..4775846
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,12 @@
+# docs
+
+| 경로 | 용도 | Git |
+|------|------|-----|
+| [reference/](reference/) | 로드맵·구조·GT 가이드·기법 명세 (`trade_anaysis.html`) | 추적 |
+| [02_ground_truth/](02_ground_truth/) | 정답 차트 HTML | 제외 (재생성) |
+| [03_analysis/](03_analysis/) | enrich CSV·GT 스냅샷·점검 HTML | 제외 |
+| [04_matching/](04_matching/) | 규칙·유사도 (예정) | 제외 |
+| [05_ops/](05_ops/) | 운영 로그 (예정) | 제외 |
+| [charts/](charts/) | BB 등 차트 HTML | 제외 |
+
+실행·경로 상세: [reference/STRUCTURE.md](reference/STRUCTURE.md)
diff --git a/docs/reference/GROUND_TRUTH.md b/docs/reference/GROUND_TRUTH.md
new file mode 100644
index 0000000..ace18b9
--- /dev/null
+++ b/docs/reference/GROUND_TRUTH.md
@@ -0,0 +1,33 @@
+# 정답 타점 (Ground Truth)
+
+1달(기본 45일) 3분봉 구간에서 **사후적 최적 스윙** 매수·매도 라벨을 만듭니다.
+실시간 매매 전략이 아니라, 이후 전략 검증·학습용 **정답 데이터**입니다.
+
+## Plan
+
+- **목적**: 차트 상 의미 있는 저점 매수·고점 매도를 JSON으로 고정
+- **방법**: 고점(major swing)에서 1~2회 매도 · 저점(ZigZag+BB)에서 분할 매수 · 삼각형 크기=비중
+
+## Do
+
+```bash
+python scripts/02_ground_truth.py # ground_truth_trades.json
+python scripts/05_chart_truth.py # JSON + HTML 차트
+```
+
+## Check
+
+| 환경 변수 | 기본 | 설명 |
+|-----------|------|------|
+| `CHART_LOOKBACK_DAYS` | 365 | 조회 일수 (`.env` 기본 1년) |
+| `GT_MIN_SWING_PCT` | 4.0 | ZigZag 최소 스윙(%) |
+| `GT_PIVOT_ORDER` | 20 | 국소 극값 반경(봉) |
+| `GT_MIN_BARS_BETWEEN` | 30 | 체결 간격(3분봉 30봉=90분) |
+| `GT_MIN_LEG_PCT` | 8.0 | 한 구간 최소 수익(%) |
+| `GT_MAX_ROUND_TRIPS` | 24 | 최대 라운드트립 |
+| `GT_SELECTION_MODE` | split_buy_peak_sell | `split_buy_peak_sell` 등 (`.env` 참고) |
+
+## Act
+
+- JSON 수동 수정 후 `scripts/05_chart_truth.py` 재실행으로 차트 갱신
+- 파라미터 조정으로 타점 수·크기 튜닝
diff --git a/docs/reference/ROADMAP.md b/docs/reference/ROADMAP.md
new file mode 100644
index 0000000..938e527
--- /dev/null
+++ b/docs/reference/ROADMAP.md
@@ -0,0 +1,29 @@
+# DeepCoin 로드맵 (WLD)
+
+## 완료
+
+| 단계 | 내용 | 실행 |
+|------|------|------|
+| 01 데이터 | 1년치 3분~일봉 `coins.db` 적재 | `python scripts/01_download.py` |
+| 02 Ground Truth | 매수·매도 정답 타점 JSON | `python scripts/02_ground_truth.py` |
+| 03 분석 준비 | 8TF 기술적 지표·패턴 enrich | `python scripts/03_analyze_enrich.py` |
+
+## 진행 예정
+
+| 단계 | 내용 | 패키지 | 실행 (예정) |
+|------|------|--------|-------------|
+| 03b | GT 타점 3분~일봉 기술적 상태 분석 (CLI 준비, 전량 CSV 재실행 필요) | `deepcoin/analysis/` | `python scripts/03_analyze_trades.py` |
+| 04 | GT에 가장 근접한 기술적 상태 선택 | `deepcoin/matching/` | `python scripts/04_match_rules.py` |
+| 05 | 1분 단위 상태 확인·실거래 | `deepcoin/ops/` | `python scripts/05_run_monitor.py` |
+
+## 디렉터리
+
+구조: [STRUCTURE.md](STRUCTURE.md)
+
+```text
+scripts/01~05_*.py 단계별 CLI
+data/ coins.db, ground_truth/, ops/
+docs/reference/ 가이드·명세
+docs/02~05, charts/ 단계별 산출물 (HTML·CSV)
+deepcoin/ 단계별 Python 패키지
+```
diff --git a/docs/reference/STRUCTURE.md b/docs/reference/STRUCTURE.md
new file mode 100644
index 0000000..cccf414
--- /dev/null
+++ b/docs/reference/STRUCTURE.md
@@ -0,0 +1,52 @@
+# DeepCoin 프로젝트 구조
+
+단계별 PM 기준으로 디렉터리·진입점을 정리합니다. **실행은 `scripts/`만 사용**합니다.
+
+**문서·산출물은 `docs/` 한 곳**에서 관리합니다.
+
+- `docs/reference/` — 사람이 읽는 가이드·명세 (Git 추적)
+- `docs/02_*` ~ `docs/charts/` — 스크립트가 만든 HTML·CSV (로컬 재생성)
+
+## 단계 ↔ 실행 ↔ 산출물
+
+| 단계 | 목적 | 스크립트 | 패키지 | 산출물 |
+|------|------|----------|--------|--------|
+| 01 | 1년치 봉 적재 | `scripts/01_download.py` | `deepcoin/data/` | `data/coins.db` |
+| 02 | Ground Truth 타점 | `scripts/02_ground_truth.py` | `deepcoin/ground_truth/` | `data/ground_truth/*.json`, `docs/02_ground_truth/` |
+| 03 | 8TF 지표 enrich | `scripts/03_analyze_enrich.py` | `deepcoin/analysis/` | `docs/03_analysis/latest/` |
+| 03b | GT 타점 MTF 스냅샷 | `scripts/03_analyze_trades.py` | `deepcoin/analysis/` | `docs/03_analysis/general_analysis_trades.csv` |
+| 04 | GT 근접 규칙 선택 | `scripts/04_match_rules.py` | `deepcoin/matching/` | `docs/04_matching/` (예정) |
+| 05 | 차트·1분 모니터 | `scripts/05_chart_*.py`, `05_run_monitor.py` | `deepcoin/ops/` | `docs/charts/`, `docs/05_ops/` |
+
+## 디렉터리 트리
+
+```text
+DeepCoin/
+├── .env, config.py
+├── scripts/ # 단계별 CLI
+├── deepcoin/ # 로직 패키지
+├── data/ # DB, GT JSON, ops 상태
+└── docs/
+ ├── README.md
+ ├── reference/ # 가이드 (Git)
+ │ ├── ROADMAP.md
+ │ ├── STRUCTURE.md
+ │ ├── GROUND_TRUTH.md
+ │ └── trade_anaysis.html
+ ├── 02_ground_truth/ # 산출물
+ ├── 03_analysis/
+ ├── 04_matching/
+ ├── 05_ops/
+ └── charts/
+```
+
+## `deepcoin/analysis/` 모듈 역할
+
+| 파일 | 역할 |
+|------|------|
+| `general_analysis_enrich_runner.py` | 03 enrich |
+| `general_analysis_runner.py` | 03b GT 스냅샷 |
+| `general_analysis_pipeline.py` | 타점별 MTF 조립 |
+| `general_analysis_*` (기타) | 지표·패턴·리포트 |
+
+구 `reports/` 폴더는 `docs/`로 통합되었습니다. 코드의 `REPORTS_*` 이름은 `paths.py`에서 `docs/*`로 연결되는 별칭입니다.
diff --git a/docs/reference/trade_anaysis.html b/docs/reference/trade_anaysis.html
new file mode 100644
index 0000000..f12dd00
--- /dev/null
+++ b/docs/reference/trade_anaysis.html
@@ -0,0 +1,394 @@
+
+
+
+
+ MTF 기술적 분석 기법 목록 (trade_anaysis)
+
+
+
+
+MTF 기술적 분석 기법 목록 및 구현 상태
+
+ 목적: 3분~일봉 OHLCV 유입 시 모든 기법을 봉 단위로 검증 (매수·매도 타점과 무관) ·
+ 간격: 3, 5, 10, 15, 30, 60, 240분 + 일봉(1440) ·
+ 데이터: data/coins.db (WLD) ·
+ 문서: docs/reference/trade_anaysis.html
+
+
+
+
구현 상태 범례
+
+ - 완료 —
general_analysis_enrich_bars(df, interval) CSV 컬럼 또는 기존 Plotly 차트.
+ - 부분 — Plotly/HTML UI만 없음. 수치·플래그는 CSV에 존재.
+ - 미구현 — 전용 UI·리포트 페이지 없음 (아래 2건).
+ - 구현완료·전량재실행 — 코드·CLI는 준비됨. 산출 CSV가 GT 전체를 반영하려면
--limit 없이 재실행.
+
+
+
+
+
실행 및 산출물
+
+ - 03 enrich (권장):
python scripts/03_analyze_enrich.py
+ · 모듈: deepcoin/analysis/general_analysis_enrich_runner.py
+ - 산출:
docs/03_analysis/latest/m3_latest.csv … d1_latest.csv
+ (간격당 약 247컬럼, 최근 N봉 — GA_DEFAULT_TAIL_EXPORT 기본 200, --tail-export로 변경)
+ - 점검:
docs/03_analysis/general_analysis_capability.html
+ - 03b GT 타점 MTF:
python scripts/03_analyze_trades.py
+ → docs/03_analysis/general_analysis_trades.csv
+ · 리포트: docs/03_analysis/general_analysis_report.html
+ - 주의:
--limit N은 테스트용. 전체 GT(약 450타점) 반영 시 --limit 없이 실행.
+
+
+
+
+
분석 시점 정의
+
+ - 봉 분석: 각 간격의 완성봉 시계열에 지표·패턴 컬럼 부여 (전 봉 + lookback 롤링).
+ - 타점 분석(선택):
dt 직전 완성봉만 사용 (merge_asof backward).
+ - 3분봉 lookback 롤링은 성능상 최근 6000봉 구간만 패턴·VP·파동·하모닉 갱신 (
CONTEXT_TAIL_ROWS).
+
+
+
+1. DB 보유 간격
+
+
+
+ | 간격 | 3분 | 5분 | 10분 | 15분 |
+ 30분 | 60분 | 240분 | 일봉 |
+
+
+
+
+ | enrich 대상 |
+ O | O | O | O |
+ O | O | O | O |
+
+
+ | DB 적재 |
+ O | O | O | O |
+ O | O | O | O |
+
+
+ | 대략 기간 |
+ ~12개월 | ~12개월 | ~12개월 | ~12개월 |
+ ~12개월 | ~12개월 | ~12개월 | ~12개월 |
+
+
+ | 비고 |
+ 1분봉은 DB 6개월만 있어 본 문서 범위 제외. |
+
+
+
+
+2. 파이프라인
+
+
+ - 입력:
mtf_bb.load_frames_from_db() — 8개 간격 OHLCV
+ - enrich: 완료
general_analysis_enrich_bars(raw, interval, full_context=True)
+ - 모듈 순서:
candle_features → indicators → candles → chart → context(patterns/wave/volume/harmonic)
+ - MTF 합성: 완료
general_analysis_mtf_vote_latest(), ga_align_*
+ - 시각화: 부분
scripts/05_chart_*.py 3분 6패널 · 8TF 타일·타점 미니차트 UI 미구현
+
+
+
+3. 차트 분석 (Chart Analysis)
+
+
+ | 방법 | 설명 | 구현 | 주요 컬럼 / 모듈 |
+
+
+
+ | 캔들차트 (Candlestick) |
+ OHLC + BB·일목 오버레이 |
+ 완료 |
+ scripts/05_chart_*.py, ga_chart_type_candle |
+
+
+ | 선차트 (Line) |
+ 종가·MA·MACD 등 시계열 |
+ 완료 UI |
+ ga_chart_line_slope, ga_chart_line_slope_1 · Plotly 전용 선차트 없음 |
+
+
+ | 바차트 (Bar / OHLC Bar) |
+ 봉 범위·거래량 스파이크 |
+ 완료 UI |
+ ga_chart_bar_range_pct, ga_chart_vol_spike |
+
+
+ | Heikin-Ashi |
+ 노이즈 완화 캔들 |
+ 완료 |
+ ga_ha_*, ga_chart_ha_trend |
+
+
+ | Renko |
+ ATR 브릭 방향 |
+ 완료 |
+ ga_chart_renko_dir, ga_chart_renko_up, ga_chart_renko_brick_up_ratio |
+
+
+ | Point & Figure |
+ 박스 크기 기준 X/O 열 |
+ 완료 |
+ ga_chart_pnf_col |
+
+
+ | 멀티 패널 (6패널) |
+ BB·이격·Stoch·RSI·MACD |
+ 완료 |
+ scripts/05_chart_*.py 3분 |
+
+
+ | MTF 타일 (Small Multiples) |
+ 8TF 나란히 Plotly |
+ 미구현 |
+ CSV 8TF 컬럼으로 대체 · docs/03_analysis/latest/*_latest.csv |
+
+
+
+
+4. 패턴 분석 (Pattern Analysis)
+lookback 윈도우(LOOKBACK_BARS) 마지막 봉 기준. 롤링 적용: general_analysis_apply_context_features.
+
+4.1 반전 패턴
+
+
+ | 패턴 | 구현 | 컬럼 | 권장 TF |
+
+
+ | 헤드앤숄더 / 역H&S | 완료 | ga_pattern_head_shoulders, ga_pattern_inv_head_shoulders | 60분~일봉 |
+ | 쌍봉 / 쌍바닥 | 완료 | ga_pattern_double_top, ga_pattern_double_bottom | 30분~일봉 |
+ | 트리플 탑/바닼 | 완료 | ga_pattern_triple_top, ga_pattern_triple_bottom | 60분~일봉 |
+ | V자 반등 / 스파이크 | 완료 | ga_pattern_v_bottom, ga_pattern_spike_top | 5~60분 |
+ | 둥근 천장/바닼 | 완료 | ga_pattern_rounding_top, ga_pattern_rounding_bottom | 일봉 |
+ | 플래티어 (Rectangle) | 완료 | ga_pattern_rectangle | 15분~240분 |
+ | 갭 / 아일랜드 | 완료 | ga_pattern_gap_up/down, ga_pattern_island_top/bottom | 60분~일봉 |
+ | 키리스톤 / 역키리스톤 | 완료 | ga_pattern_keystone_bull, ga_pattern_keystone_bear | 30분~일봉 |
+
+
+
+4.2 지속 패턴
+
+
+ | 패턴 | 구현 | 컬럼 | 권장 TF |
+
+
+ | 삼각수렴 (대칭/상승/하락) | 완료 | ga_pattern_triangle_sym/asc/desc | 15분~240분 |
+ | 깃발 / 페넌트 | 완료 | ga_pattern_flag_bull, ga_pattern_flag_bear | 5~60분 |
+ | 웨지 | 완료 | ga_pattern_wedge_rising, ga_pattern_wedge_falling | 15분~60분 |
+ | 채널 | 완료 | ga_pattern_channel_up, ga_pattern_channel_down | 전 TF |
+ | 박스권 + BB 스퀴즈 | 완료 | ga_pattern_rectangle, ga_bb_squeeze | 5~60분 |
+ | 컵앤핸들 | 완료 | ga_pattern_cup_handle | 일봉 |
+ | 측정된 움직임 | 완료 | ga_pattern_measured_move | 30분~일봉 |
+ | 패턴 요약 라벨 | 완료 | ga_pattern_label | 전 TF |
+
+
+
+4.3 캔들 패턴
+
+
+ | 패턴 | 구현 | 컬럼 | 권장 TF |
+
+
+ | 해머 / 유성 / 도지 | 완료 | ga_hammer, ga_shooting_star, ga_doji + candle_features | 3~60분 |
+ | 장악형 | 완료 | ga_bullish_engulfing, ga_bearish_engulfing | 5~60분 |
+ | 샛별형 | 완료 | ga_morning_star, ga_evening_star | 15분~일봉 |
+ | 삼병 / 삼까마귀 | 완료 | ga_three_white_soldiers, ga_three_black_crows | 15분~60분 |
+ | 피보나치 되돌림 근접 | 완료 | ga_fib_near_level | 30분~일봉 |
+
+
+
+5. 기술적 지표 (Technical Indicators)
+전 봉 시계열 컬럼. 레거시: RSI, bb_pos, macd_*, stoch_* 등.
+
+5.1 추세
+
+
+ | 지표 | 구현 | 컬럼 |
+
+
+ | SMA / EMA / 골든·데드크로스 | 완료 | ga_sma_*, ga_golden_cross, ga_death_cross |
+ | MACD | 완료 | macd_line, macd_signal, macd_hist |
+ | 이격도 | 완료 | indicators DI 5/20/60 |
+ | ADX (+DI/-DI) | 완료 | ga_adx_14, ga_plus_di, ga_minus_di |
+ | Parabolic SAR | 완료 | ga_psar, ga_psar_bull, ga_psar_flip_bull/bear |
+ | Ichimoku | 완료 | indicators + ga_ichi_trend |
+ | Linear Regression | 완료 | ga_linreg_slope_20, ga_linreg_r2_20 |
+ | VWAP | 완료 | ga_vwap, ga_close_vs_vwap_pct (누적 VWAP) |
+ | Supertrend | 완료 | ga_supertrend_bull |
+
+
+
+5.2 모멘텀
+
+
+ | 지표 | 구현 | 컬럼 |
+
+
+ | RSI | 완료 | RSI, ga_rsi_delta_1 |
+ | 스토캐스틱 | 완료 | stoch_k, stoch_d, ga_stoch_k_delta_1 |
+ | CCI | 완료 | ga_cci_20, ga_cci_oversold/overbought |
+ | Williams %R | 완료 | ga_williams_r, ga_williams_oversold/overbought |
+ | ROC | 완료 | ga_roc_10 |
+ | MFI | 완료 | ga_mfi_14 |
+ | Awesome Oscillator | 완료 | ga_ao, ga_ao_bull, ga_ao_bear |
+ | RSI / MACD / Stoch 다이버전스 | 완료 | ga_rsi_*_div, ga_macd_*_div, ga_stoch_*_div |
+
+
+
+5.3 변동성
+
+
+ | 지표 | 구현 | 컬럼 |
+
+
+ | 볼린저 밴드 | 완료 | bb_pos, ga_bb_width_pct, zone (candle_features) |
+ | ATR | 완료 | ga_atr_14, ga_atr_pct |
+ | Keltner Channel | 완료 | ga_keltner_mid/upper/lower, ga_keltner_pos |
+ | Donchian Channel | 완료 | ga_donchian_pos |
+ | Historical Volatility | 완료 | ga_hv_20, ga_hv_percentile |
+ | BB Squeeze | 완료 | ga_bb_squeeze |
+
+
+
+5.4 거래량
+
+
+ | 지표 | 구현 | 컬럼 |
+
+
+ | OBV + 다이버전스 | 완료 | ga_obv, ga_obv_slope_10, ga_obv_*_div |
+ | Volume MA ratio | 완료 | ga_vol_ratio, ga_vol_ma20 |
+ | VWAP deviation | 완료 | ga_close_vs_vwap_pct |
+ | Accumulation/Distribution | 완료 | ga_ad_line, ga_ad_slope_10 |
+ | Chaikin Money Flow | 완료 | ga_cmf_20 |
+ | Volume Profile | 완료 | ga_vp_poc, ga_vp_vah, ga_vp_val, ga_vp_in_value_area |
+
+
+
+6. 파동·시장 구조
+
+
+ | 이론 | 구현 | 컬럼 | 비고 |
+
+
+ | 다우 이론 (HH/HL/LH/LL) | 완료 | ga_struct_* | 피벗 기반 |
+ | 시장 구조 (BOS/CHoCH) | 완료 | ga_struct_bos_*, ga_struct_choch | |
+ | 엘리어트 파동 | 완료 | ga_elliott_wave_count, ga_elliott_phase | 라이트(스윙 수·단계) |
+ | Wyckoff | 완료 | ga_wyckoff_phase, ga_wyckoff_spring, ga_wyckoff_utad | accumulation/distribution + spring/UTAD |
+ | 일목 (구름) | 완료 | ga_ichi_trend | |
+ | 피보나치 | 완료 | ga_fib_near_level | 0/382/500/618/100/1618 |
+ | 하모닉 (Gartley/Bat) | 완료 | ga_harmonic_gartley, ga_harmonic_bat, ga_harmonic_label | 5피벗 비율 |
+ | 앤더류 피치포크 | 완료 | ga_pitchfork_bias, ga_pitchfork_dist_pct | 3피벗 중앙선 |
+
+
+
+7. MTF 합성
+
+
+ | 방법 | 구현 | 컬럼 / 함수 |
+
+
+ | TF 가중 투표 | 완료 | general_analysis_mtf_vote_latest() → ga_vote_timing_buy/sell, ga_vote_trend_score |
+ | 정렬 점수 (RSI) | 완료 | ga_align_timing_buy_score, ga_align_timing_sell_score |
+ | 상위 TF 추세 필터 | 완료 | ga_align_trend_score, TF별 ga_struct_trend |
+ | MTF 충돌 태그 | 완료 | ga_align_mtf_conflict |
+ | 봉 간 Δ (T vs T-1) | 완료 | ga_rsi_delta_1, ga_macd_hist_delta_1, ga_stoch_k_delta_1 |
+
+
+
+8. 구현 단계 (현황)
+
+
+ | 단계 | 내용 | 산출물 | 구현 |
+
+
+ | P0 | 8TF 봉 enrich + latest CSV | docs/03_analysis/latest/*_latest.csv | 완료 |
+ | P1 | 기법 점검 HTML | docs/03_analysis/general_analysis_capability.html | 완료 |
+ | P2 | 전 지표·거래량·변동성 | general_analysis_indicators.py | 완료 |
+ | P3 | 전 패턴·캔들 | general_analysis_patterns.py, candles.py | 완료 |
+ | P4 | 파동·VP·하모닉·MTF | wave, volume, harmonic, align | 완료 |
+ | P5 | GT 타점 wide CSV (03b) | docs/03_analysis/general_analysis_trades.csv | 구현완료·전량재실행 |
+ | P6 | 8TF Plotly 타일 · 타점 미니차트 | trade_detail.html | 미구현 |
+ | — | 04 규칙 매칭 · 05 1분 운영 | scripts/04_match_rules.py, 05_run_monitor.py | 로드맵 예정 |
+
+
+
+9. 코드베이스 매핑
+
+
+ | 모듈 | 역할 |
+
+
+ general_analysis_enrich_runner.py | 8TF enrich 로직 · CLI: scripts/03_analyze_enrich.py |
+ general_analysis_pipeline.py | enrich_bars, snapshot_at_bar |
+ general_analysis_indicators.py | 추세·모멘텀·변동성·거래량·SAR·Keltner·AO·HV·Δ |
+ general_analysis_candles.py | Heikin-Ashi, 단일·복수 봉 패턴 |
+ general_analysis_chart.py | Renko, P&F, 선·바 파생 |
+ general_analysis_patterns.py | 반전·지속 패턴 + 롤링 적용 |
+ general_analysis_wave.py | 구조·엘리어트·Wyckoff·피보나치·피치포크 |
+ general_analysis_volume.py | Volume Profile POC/VAH/VAL |
+ general_analysis_harmonic.py | Gartley, Bat |
+ general_analysis_context.py | lookback 롤링 일괄 (patterns/wave/vp/harmonic) |
+ general_analysis_align.py | ga_align_*, ga_vote_* |
+ general_analysis_runner.py | GT 타점 wide CSV · CLI: scripts/03_analyze_trades.py |
+ indicators.py / candle_features.py | BB, 일목, RSI, MACD, Stoch, 이격도, zone |
+ scripts/05_chart_*.py | 3분 6패널 · ground truth 차트 |
+
+
+
+10. 구현 집계
+
+
+ | 구분 | 완료 | 부분 (UI만) | 미구현 |
+
+
+ | 차트 분석 (§3) | 7 | 2 (선·바 Plotly) | 1 (MTF 타일) |
+ | 반전 패턴 (§4.1) | 8 | 0 | 0 |
+ | 지속 패턴 (§4.2) | 8 | 0 | 0 |
+ | 캔들 패턴 (§4.3) | 5 | 0 | 0 |
+ | 추세 지표 (§5.1) | 9 | 0 | 0 |
+ | 모멘텀 (§5.2) | 8 | 0 | 0 |
+ | 변동성 (§5.3) | 6 | 0 | 0 |
+ | 거래량 (§5.4) | 6 | 0 | 0 |
+ | 파동·구조 (§6) | 8 | 0 | 0 |
+ | MTF 합성 (§7) | 5 | 0 | 0 |
+ | 합계 | 70 | 2 | 1 |
+
+
+「부분」= CSV 수치는 있으나 전용 Plotly UI 없음. P5 「구현완료·전량재실행」= 코드·CLI 준비됨, GT 450건 전체 wide CSV는 03_analyze_trades.py를 --limit 없이 실행해 갱신.
+
+
+ 문서 버전: 2026-05-30 (프로젝트 구조·CLI 동기화) · DeepCoin / WLD ·
+ docs/reference/trade_anaysis.html ·
+ DB: data/coins.db · GT: data/ground_truth/ground_truth_trades.json ·
+ enrich: python scripts/03_analyze_enrich.py ·
+ 타점: python scripts/03_analyze_trades.py ·
+ 약 247컬럼/TF · tail 기본 200봉 ·
+ UI 미구현: MTF 타일 Plotly, trade_detail.html ·
+ 다음: 04 매칭, 05 운영 — docs/reference/ROADMAP.md
+
+
+
+
diff --git a/indicators.py b/indicators.py
deleted file mode 100644
index 0169bc0..0000000
--- a/indicators.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-볼린저 밴드·일목균형표 계산 (모든 봉 간격 공용).
-"""
-
-from __future__ import annotations
-
-import numpy as np
-import pandas as pd
-
-from config import BB_PERIOD, BB_STD
-
-
-def add_bollinger(
- df: pd.DataFrame,
- period: int = BB_PERIOD,
- std_mult: float = BB_STD,
-) -> pd.DataFrame:
- """MA, Upper, Lower, BB_Width, bb_pos 컬럼 추가."""
- out = df.copy()
- if "MA" not in out.columns:
- out["MA"] = out["Close"].rolling(period).mean()
- if "Upper" not in out.columns or "Lower" not in out.columns:
- std = out["Close"].rolling(period).std()
- out["STD"] = std
- out["Upper"] = out["MA"] + std_mult * std
- out["Lower"] = out["MA"] - std_mult * std
- ma = out["MA"].replace(0, np.nan)
- band = (out["Upper"] - out["Lower"]).replace(0, np.nan)
- out["bb_pos"] = ((out["Close"] - out["Lower"]) / band).clip(0, 1)
- out["BB_Width"] = band / ma * 100
- return out
-
-
-def add_ichimoku(
- df: pd.DataFrame,
- tenkan: int = 9,
- kijun: int = 26,
- senkou_b_period: int = 52,
-) -> pd.DataFrame:
- """
- 일목균형표 라인·구름 위치 컬럼 추가 (해당 봉 시점, 미래 데이터 미사용).
-
- Returns:
- ichi_tenkan, ichi_kijun, ichi_span_a, ichi_span_b,
- ichi_cloud_top, ichi_cloud_bottom
- """
- out = df.copy()
- h = out["High"].astype(float)
- l = out["Low"].astype(float)
- c = out["Close"].astype(float)
-
- out["ichi_tenkan"] = (h.rolling(tenkan).max() + l.rolling(tenkan).min()) / 2
- out["ichi_kijun"] = (h.rolling(kijun).max() + l.rolling(kijun).min()) / 2
- out["ichi_span_a"] = (out["ichi_tenkan"] + out["ichi_kijun"]) / 2
- out["ichi_span_b"] = (h.rolling(senkou_b_period).max() + l.rolling(senkou_b_period).min()) / 2
- out["ichi_cloud_top"] = np.maximum(out["ichi_span_a"], out["ichi_span_b"])
- out["ichi_cloud_bottom"] = np.minimum(out["ichi_span_a"], out["ichi_span_b"])
- return out
diff --git a/logos_chart.py b/logos_chart.py
deleted file mode 100644
index 8786dfb..0000000
--- a/logos_chart.py
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/usr/bin/env python3
-"""
-로고스 전략 백테스트·HTML (simulation.py 와 동일).
-
- python logos_chart.py
-"""
-
-from __future__ import annotations
-
-from simulation import run_full_pipeline
-
-
-def main() -> None:
- run_full_pipeline()
-
-
-if __name__ == "__main__":
- main()
diff --git a/logos_strategy.py b/logos_strategy.py
deleted file mode 100644
index b1c4cde..0000000
--- a/logos_strategy.py
+++ /dev/null
@@ -1,349 +0,0 @@
-"""
-로고스(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
diff --git a/logos_trades.json b/logos_trades.json
deleted file mode 100644
index 1e87827..0000000
--- a/logos_trades.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "name": "logos_discretionary",
- "author": "Logos (직관·차트 해석)",
- "symbol": "WLD",
- "interval_min": 3,
- "note": "BB/탐색 규칙 미사용. 3분봉 가격·추세·거래량 구조를 보고 선별한 대표 타점입니다.",
- "trades": [
- {
- "dt": "2026-05-18 08:39:00",
- "action": "buy",
- "price": 340,
- "memo": "구간 최저·투매 종료. 이후 고점 갱신 전환"
- },
- {
- "dt": "2026-05-23 00:30:00",
- "action": "sell",
- "price": 445,
- "memo": "1차 파동 단기 고점(445). 378 눌림 견디고 새벽 과열 구간 익절"
- },
- {
- "dt": "2026-05-23 16:48:00",
- "action": "buy",
- "price": 392,
- "memo": "상승 추세 눌림·저점 상승 확인 후 재진입"
- },
- {
- "dt": "2026-05-24 22:45:00",
- "action": "sell",
- "price": 464,
- "memo": "단기 과열·윗꼬리 구간, 1차 분할 익절"
- },
- {
- "dt": "2026-05-25 06:42:00",
- "action": "buy",
- "price": 427,
- "memo": "26일 급등 전 마지막 눌림. 450대 재매수는 추격이라 보류"
- },
- {
- "dt": "2026-05-26 21:30:00",
- "action": "sell",
- "price": 603,
- "memo": "거래량·각도 과열 끝자락. 전량 익절(01:48 저가 매도 회피)"
- },
- {
- "dt": "2026-05-28 23:39:00",
- "action": "buy",
- "price": 420,
- "memo": "급락 직후 첫 바닥(당일 3분 저점). 소량·단기"
- },
- {
- "dt": "2026-05-29 10:09:00",
- "action": "sell",
- "price": 441,
- "memo": "반등 첫 저항·전일 하락 중충 돌파 실패 구간"
- }
- ]
-}
diff --git a/monitor_coin.py b/monitor_coin.py
deleted file mode 100644
index 87b8f86..0000000
--- a/monitor_coin.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""
-WLD(월드코인) 실시간 모니터 — 전 봉 BB·일목 조합 (discovered_rules).
-
-전략: strategy.py / rule_discovery.py
-"""
-
-from datetime import datetime
-import time
-
-from config import COIN_NAME, COOLDOWN_FILE, MONITOR_LOOP_SLEEP_SEC, SYMBOL
-from monitor import Monitor
-
-
-class MonitorCoin(Monitor):
- """WLD 모니터링 및 매매 실행."""
-
- def __init__(self, cooldown_file: str = COOLDOWN_FILE) -> None:
- super().__init__(cooldown_file)
-
- def monitor_wld(self) -> None:
- """일봉·1시간 추세 + 3분 신호로 현물 매수/매도."""
- balances = self.load_balances_dict()
- print(
- "[{}] {} ({})".format(
- datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
- COIN_NAME,
- SYMBOL,
- )
- )
- self.process_wld_mtf(SYMBOL, balances=balances)
-
- def run_schedule(self) -> None:
- while True:
- self.monitor_wld()
- time.sleep(MONITOR_LOOP_SLEEP_SEC)
-
-
-if __name__ == "__main__":
- MonitorCoin().run_schedule()
diff --git a/mtf_bb.py b/mtf_bb.py
deleted file mode 100644
index b2b06a7..0000000
--- a/mtf_bb.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""
-봉 간격별 볼린저밴드 상태 분석 및 최적 매수/매도 봉 추천.
-
-기본 규칙(모든 봉 동일):
- - 매수: 하단 밴드 상향 돌파
- - 매도: 상단 밴드 상향 돌파
- - 손절(선택): 하단 재이탈
-"""
-
-from __future__ import annotations
-
-import json
-from dataclasses import asdict, dataclass
-from pathlib import Path
-
-import pandas as pd
-
-from config import DOWNLOAD_INTERVALS, SYMBOL
-from strategy import (
- ACTIVE_CONFIG,
- StrategyConfig,
- annotate_interval_signals,
- get_latest_bb_state,
- MtfBbPolicy,
-)
-
-POLICY_FILE = Path(__file__).parent / "mtf_bb_policy.json"
-
-# 매수/매도 트리거 실행 후보 (긴 봉은 확인용만)
-EXECUTION_INTERVAL_CANDIDATES: tuple[int, ...] = (3, 10, 15, 30, 60)
-DEFAULT_CONFIRM_INTERVALS: tuple[int, ...] = (60, 1440)
-
-
-@dataclass
-class IntervalBacktestSummary:
- """봉 간격별 백테스트 요약."""
-
- interval: int
- label: str
- return_pct: float
- trade_count: int
- buy_count: int
- sell_count: int
- final_asset: float
-
-
-def interval_label(interval: int) -> str:
- if interval >= 1440:
- return "일봉"
- return f"{interval}분"
-
-
-def load_frames_from_db(monitor, symbol: str) -> dict[int, pd.DataFrame]:
- """coins.db에서 DOWNLOAD_INTERVALS 전부 로드·지표 계산."""
- frames: dict[int, pd.DataFrame] = {}
- for iv in DOWNLOAD_INTERVALS:
- df = monitor.get_coin_some_data(symbol, iv)
- if df is None or df.empty:
- print(f" [{interval_label(iv)}] DB/API 데이터 없음 — 스킵")
- continue
- df = monitor.calculate_technical_indicators(df)
- frames[iv] = df
- print(f" [{interval_label(iv)}] {len(df)}봉 {df.index[0]} ~ {df.index[-1]}")
- return frames
-
-
-def print_latest_states(frames: dict[int, pd.DataFrame], cfg: StrategyConfig) -> None:
- """각 봉의 최신 BB 상태 출력."""
- print("\n--- 봉별 최신 BB 상태 ---")
- for iv in sorted(frames.keys()):
- st = get_latest_bb_state(frames[iv], cfg)
- df = frames[iv]
- close = df["Close"].iloc[-1]
- print(
- f" {interval_label(iv):>6} ({iv:>4}분) | {st:20} | 종가 {close:,.2f} | "
- f"L={df['Lower'].iloc[-1]:,.2f} U={df['Upper'].iloc[-1]:,.2f}"
- )
-
-
-def backtest_interval(
- frames: dict[int, pd.DataFrame],
- interval: int,
- cfg: StrategyConfig,
-) -> IntervalBacktestSummary | None:
- """한 간격만으로 BB 매매 백테스트."""
- from simulation import run_backtest
-
- if interval not in frames:
- return None
- df = annotate_interval_signals(SYMBOL, frames[interval].copy(), config=cfg)
- # 단일 봉 비교이므로 추세 필터 없이 동일 df를 HTF로 전달
- res = run_backtest(df, frames[interval], frames[interval], config_name=f"{interval}분")
- buys = sum(1 for t in res.trades if t.action == "매수")
- sells = sum(1 for t in res.trades if t.action == "매도")
- return IntervalBacktestSummary(
- interval=interval,
- label=interval_label(interval),
- return_pct=res.total_return_pct,
- trade_count=res.trade_count,
- buy_count=buys,
- sell_count=sells,
- final_asset=res.final_asset,
- )
-
-
-def recommend_policy(
- summaries: list[IntervalBacktestSummary],
- frames: dict[int, pd.DataFrame],
-) -> MtfBbPolicy:
- """
- 백테스트 결과로 매수/매도 실행 봉과 확인용 상위 봉을 추천합니다.
-
- - 매수/매도 실행: 수익률 1위 간격 (동일하면 더 긴 봉 우선)
- - 확인 봉: 실행 봉보다 긴 간격 중 가장 가까운 2개
- """
- if not summaries:
- return MtfBbPolicy()
-
- exec_pool = [s for s in summaries if s.interval in EXECUTION_INTERVAL_CANDIDATES]
- if not exec_pool:
- exec_pool = list(summaries)
-
- ranked = sorted(
- exec_pool,
- key=lambda s: (s.return_pct, s.trade_count, -s.interval),
- reverse=True,
- )
- best = ranked[0]
- buy_iv = sell_iv = best.interval
- if best.trade_count == 0:
- buy_iv = sell_iv = min(
- (iv for iv in frames if iv in EXECUTION_INTERVAL_CANDIDATES),
- default=min(frames.keys()),
- )
-
- longer = sorted([iv for iv in frames if iv > buy_iv])
- confirm = tuple(iv for iv in DEFAULT_CONFIRM_INTERVALS if iv in frames and iv > buy_iv)
- if not confirm:
- confirm = tuple(longer[-2:]) if len(longer) >= 2 else tuple(longer)
- if not confirm and len(longer) == 1:
- confirm = (longer[0],)
-
- return MtfBbPolicy(
- buy_interval=buy_iv,
- sell_interval=sell_iv,
- buy_confirm_intervals=confirm,
- sell_confirm_intervals=confirm[:1] if confirm else (),
- name=f"auto_{interval_label(buy_iv)}_buy_{interval_label(sell_iv)}_sell",
- )
-
-
-def run_interval_comparison(monitor) -> tuple[MtfBbPolicy, list[IntervalBacktestSummary]]:
- """모든 봉 간격 BB 백테스트 후 정책 추천."""
- cfg = StrategyConfig(
- name="봉별_BB_기본",
- use_mtf=False,
- use_regime_switch=False,
- use_rsi_filter=False,
- use_volume_filter=False,
- use_squeeze_filter=False,
- use_stop_loss=True,
- )
- print(f"\n{'='*72}")
- print("봉 간격별 BB 매매 비교 (하단↑매수 / 상단↑매도 / 수수료 반영)")
- print(f"{'='*72}")
-
- frames = load_frames_from_db(monitor, SYMBOL)
- if not frames:
- raise RuntimeError("로드된 봉 데이터가 없습니다. downloader.py 먼저 실행하세요.")
-
- print_latest_states(frames, cfg)
-
- summaries: list[IntervalBacktestSummary] = []
- for iv in sorted(frames.keys()):
- s = backtest_interval(frames, iv, cfg)
- if s:
- summaries.append(s)
-
- summaries.sort(key=lambda x: x.return_pct, reverse=True)
- print(f"\n{'순위':<4} {'봉':>8} {'수익률':>9} {'거래':>6} {'매수':>5} {'매도':>5}")
- print("-" * 45)
- for i, s in enumerate(summaries, 1):
- print(
- f"{i:<4} {s.label:>8} {s.return_pct:>+8.2f}% "
- f"{s.trade_count:>6} {s.buy_count:>5} {s.sell_count:>5}"
- )
-
- policy = recommend_policy(summaries, frames)
- print(f"\n추천 정책:")
- print(f" 매수 실행 봉: {interval_label(policy.buy_interval)}")
- print(f" 매도 실행 봉: {interval_label(policy.sell_interval)}")
- print(f" 매수 확인 봉: {[interval_label(i) for i in policy.buy_confirm_intervals]}")
- print(f" 매도 확인 봉: {[interval_label(i) for i in policy.sell_confirm_intervals]}")
- print(f"{'='*72}\n")
- return policy, summaries
-
-
-def save_policy(policy: MtfBbPolicy, path: Path = POLICY_FILE) -> None:
- """추천 정책을 JSON으로 저장."""
- path.write_text(json.dumps(asdict(policy), ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def load_policy(path: Path = POLICY_FILE) -> MtfBbPolicy | None:
- """저장된 정책 로드."""
- if not path.exists():
- return None
- data = json.loads(path.read_text(encoding="utf-8"))
- return MtfBbPolicy(
- buy_interval=int(data["buy_interval"]),
- sell_interval=int(data["sell_interval"]),
- buy_confirm_intervals=tuple(data.get("buy_confirm_intervals", [])),
- sell_confirm_intervals=tuple(data.get("sell_confirm_intervals", [])),
- name=data.get("name", "loaded"),
- )
-
-
-def apply_policy(policy: MtfBbPolicy) -> None:
- """strategy.ACTIVE_MTF_POLICY 에 반영."""
- import strategy as st
-
- st.ACTIVE_MTF_POLICY = policy
- print(f"ACTIVE_MTF_POLICY 적용: {policy.name}")
diff --git a/mtf_bb_policy.json b/mtf_bb_policy.json
deleted file mode 100644
index 0915f0a..0000000
--- a/mtf_bb_policy.json
+++ /dev/null
@@ -1,11 +0,0 @@
-{
- "buy_interval": 60,
- "sell_interval": 60,
- "buy_confirm_intervals": [
- 1440
- ],
- "sell_confirm_intervals": [
- 1440
- ],
- "name": "auto_60분_buy_60분_sell"
-}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 4b9f6af..7763945 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,5 +3,6 @@ numpy
PyJWT
requests
python-dateutil
+python-dotenv>=1.0.0
python-telegram-bot
plotly
diff --git a/rule_discovery.py b/rule_discovery.py
deleted file mode 100644
index eb1cd66..0000000
--- a/rule_discovery.py
+++ /dev/null
@@ -1,859 +0,0 @@
-"""
-모든 봉·캔들 특징 행렬에서 매수/매도 규칙을 탐색합니다 (인과적 백테스트).
-
- python simulation.py discover
-"""
-
-from __future__ import annotations
-
-import json
-import random
-from dataclasses import asdict, dataclass, field
-from pathlib import Path
-
-import numpy as np
-import pandas as pd
-
-from candle_features import (
- FEATURE_BOOL_COLS,
- build_master_feature_matrix,
- interval_prefix,
-)
-from config import (
- BUY_COOLDOWN_SEC,
- BUY_MAX_BB_POS_CHASE,
- DISCOVER_MAX_TRADES,
- DISCOVER_TRADE_PENALTY_PCT,
- DOWNLOAD_INTERVALS,
- ENTRY_INTERVAL,
- SELL_COOLDOWN_SEC,
- SELL_MIN_BB_POS,
- SIGNAL_EDGE_ONLY,
- SIM_INITIAL_CASH_KRW,
- SIM_MIN_ORDER_KRW,
- SYMBOL,
- TRADE_MIN_GAP_BARS,
- TRADING_FEE_RATE,
-)
-from strategy import (
- SIGNAL_BUY_LOWER,
- SIGNAL_SELL_STOP,
- SIGNAL_SELL_UPPER,
-)
-
-RULES_FILE = Path(__file__).parent / "discovered_rules.json"
-
-# 탐색에 쓸 특징 (불리언 컬럼)
-SEARCH_FEATURES: tuple[str, ...] = FEATURE_BOOL_COLS
-
-# 상위 봉 과열·하락 차단용 부정 조건 후보
-NEG_BLOCK_FEATURES: tuple[str, ...] = (
- "cross_up_upper",
- "above_upper",
- "cross_down_lower",
- "shooting_star",
- "ichi_above_cloud",
- "bb_zone_top",
- "bb_pos_high",
-)
-
-# 탐색 규칙 적용 시 항상 매수 차단 (상단 돌파·과열)
-BUY_SAFETY_BLOCK: tuple[str, ...] = (
- "m3:above_upper",
- "m3:cross_up_upper",
- "m10:above_upper",
- "m10:cross_up_upper",
-)
-
-# 연속 봉에서 오래 참 → 엣지 없으면 과다 체결
-LEVEL_STATE_FEATURES: tuple[str, ...] = (
- "below_lower",
- "above_upper",
- "inside_band",
- "bb_zone_bottom",
- "bb_zone_top",
- "bb_pos_low",
- "bb_pos_high",
- "ichi_price_above_tenkan",
- "ichi_price_below_tenkan",
-)
-
-
-@dataclass
-class DiscoveredRules:
- """탐색된 매수/매도 규칙 (모든 봉 특징 조합)."""
-
- name: str = "discovered"
- buy_all: list[str] = field(default_factory=list)
- buy_any: list[list[str]] = field(default_factory=list)
- sell_all: list[str] = field(default_factory=list)
- sell_stop: list[str] = field(default_factory=list)
- train_return_pct: float = 0.0
- test_return_pct: float = 0.0
- full_return_pct: float = 0.0
- trade_count: int = 0
-
-
-def predicate_column(key: str) -> tuple[str, bool]:
- """
- 'm60:cross_up_lower' / 'd1:!above_upper' -> (컬럼명, negated).
- """
- if ":" not in key:
- raise ValueError(f"잘못된 predicate: {key}")
- prefix, rest = key.split(":", 1)
- neg = rest.startswith("!")
- feat = rest[1:] if neg else rest
- return f"{prefix}_{feat}", neg
-
-
-def _predicate_feature(key: str) -> str:
- """predicate에서 특징명만 추출 (! 제외)."""
- rest = key.split(":", 1)[1]
- return rest[1:] if rest.startswith("!") else rest
-
-
-def is_level_state_predicate(key: str) -> bool:
- """한번 참이면 여러 봉 연속 참인 상태형 조건."""
- return _predicate_feature(key) in LEVEL_STATE_FEATURES
-
-
-def is_weak_sell_predicate(key: str) -> bool:
- """
- !cross_* / !below_* 등 — 대부분의 봉에서 참이라 매도가 과다해짐.
- """
- if ":" not in key:
- return False
- rest = key.split(":", 1)[1]
- if not rest.startswith("!"):
- return False
- feat = rest[1:]
- if feat.startswith("cross_"):
- return True
- return feat in ("below_lower", "above_upper", "inside_band")
-
-
-def is_blocked_buy_predicate(key: str) -> bool:
- """진입(3분) 봉의 상태형 매수 조건은 탐색에서 제외."""
- pfx = interval_prefix(ENTRY_INTERVAL)
- return key.startswith(f"{pfx}:") and is_level_state_predicate(key)
-
-
-# 고점 추격 매수(상단 구간·과열) — 탐색·체결에서 제외
-CHASE_BUY_FEATURES: tuple[str, ...] = (
- "bb_zone_top",
- "bb_zone_high",
- "bb_pos_high",
- "above_upper",
- "cross_up_upper",
-)
-
-# 저점·반등 매수 트리거
-VALUE_BUY_FEATURES: tuple[str, ...] = (
- "cross_up_lower",
- "bb_zone_bottom",
- "bb_zone_low",
- "hammer",
- "bb_pos_low",
- "ichi_tk_cross_up",
- "cross_down_lower",
-)
-
-
-def is_chase_buy_predicate(key: str) -> bool:
- """밴드 상단·고점 추격 매수 조건."""
- if ":" not in key:
- return False
- rest = key.split(":", 1)[1]
- if rest.startswith("!"):
- return False
- return _predicate_feature(key) in CHASE_BUY_FEATURES
-
-
-def is_value_buy_predicate(key: str) -> bool:
- """하단 돌파·반등형 매수 조건."""
- if ":" not in key:
- return False
- rest = key.split(":", 1)[1]
- if rest.startswith("!"):
- return False
- return _predicate_feature(key) in VALUE_BUY_FEATURES
-
-
-def _entry_bb_pos_col() -> str:
- return f"{interval_prefix(ENTRY_INTERVAL)}_bb_pos"
-
-
-def discover_score(return_pct: float, trade_count: int) -> float:
- """탐색 목적함수: 수익률 − 과다 거래 패널티."""
- excess = max(0, trade_count - DISCOVER_MAX_TRADES)
- return return_pct - excess * DISCOVER_TRADE_PENALTY_PCT
-
-
-def _rising_edge(mask: np.ndarray, i: int) -> bool:
- """i번째 봉에서 조건이 새로 참이 됐는지."""
- if not bool(mask[i]):
- return False
- if i == 0:
- return True
- return not bool(mask[i - 1])
-
-
-def _trigger_at(mask: np.ndarray, i: int, edge_only: bool = SIGNAL_EDGE_ONLY) -> bool:
- if edge_only:
- return _rising_edge(mask, i)
- return bool(mask[i])
-
-
-def _mask_for_keys(matrix: pd.DataFrame, keys: list[str]) -> np.ndarray:
- """AND 조건 마스크."""
- n = len(matrix)
- if not keys:
- return np.ones(n, dtype=bool)
- out = np.ones(n, dtype=bool)
- for key in keys:
- col, neg = predicate_column(key)
- if col not in matrix.columns:
- return np.zeros(n, dtype=bool)
- vals = matrix[col].fillna(0).astype(bool).to_numpy()
- if neg:
- vals = ~vals
- out &= vals
- return out
-
-
-def _unsafe_buy_mask(matrix: pd.DataFrame) -> np.ndarray:
- """
- 고점 매수 차단.
-
- - 상단/상향돌파
- - 3분 밴드 하단(low)인데 유성형 → 급등 끝물음 (5/27 사례)
- """
- n = len(matrix)
- unsafe = np.zeros(n, dtype=bool)
- for key in BUY_SAFETY_BLOCK:
- col, neg = predicate_column(key)
- if neg or col not in matrix.columns:
- continue
- unsafe |= matrix[col].fillna(0).astype(bool).to_numpy()
- if "Close" in matrix.columns:
- roll_hi = matrix["Close"].astype(float).rolling(20, min_periods=5).max()
- near_peak = matrix["Close"].astype(float) >= roll_hi * 0.97
- if "m3_bb_pos_low" in matrix.columns and "m3_shooting_star" in matrix.columns:
- # 급등 끝 고점: 밴드하단+유성형 (5/27 02:33) — 차트상 매도 구간
- toxic = (
- matrix["m3_bb_pos_low"].fillna(0).astype(bool)
- & matrix["m3_shooting_star"].fillna(0).astype(bool)
- & near_peak.fillna(False)
- )
- unsafe |= toxic.to_numpy()
- if "m30_hammer" in matrix.columns:
- # 30분 망치만으로 고점 매수 (5/27 00:00)
- unsafe |= (
- matrix["m30_hammer"].fillna(0).astype(bool) & near_peak.fillna(False)
- ).to_numpy()
- if _entry_bb_pos_col() in matrix.columns:
- pos = matrix[_entry_bb_pos_col()].fillna(0.5).astype(float).to_numpy()
- unsafe |= pos >= BUY_MAX_BB_POS_CHASE
- for key in CHASE_BUY_FEATURES:
- col = f"{interval_prefix(ENTRY_INTERVAL)}_{key}"
- if col in matrix.columns:
- unsafe |= matrix[col].fillna(0).astype(bool).to_numpy()
- return unsafe
-
-
-def _value_buy_gate_mask(matrix: pd.DataFrame, group: list[str]) -> np.ndarray:
- """
- 매수 그룹별: 저점 트리거(value) 또는 3분 bb_pos < BUY_MAX_BB_POS_CHASE 일 때만 허용.
- """
- n = len(matrix)
- pos_col = _entry_bb_pos_col()
- if pos_col in matrix.columns:
- pos_ok = (
- matrix[pos_col].fillna(0.5).astype(float).to_numpy()
- < BUY_MAX_BB_POS_CHASE
- )
- else:
- pos_ok = np.ones(n, dtype=bool)
-
- value_keys = [k for k in group if is_value_buy_predicate(k)]
- if not value_keys:
- return pos_ok
-
- value_hit = _mask_for_keys(matrix, value_keys)
- return pos_ok | value_hit
-
-
-def _unsafe_sell_mask(matrix: pd.DataFrame) -> np.ndarray:
- """
- 저점·반등 구간 매도 차단.
-
- - 3분 bb_pos < SELL_MIN_BB_POS
- - 망치·밴드 하단 구간에서 상단돌파 익절 방지 (5/26 01:48 유형)
- """
- n = len(matrix)
- blocked = np.zeros(n, dtype=bool)
- pos_col = _entry_bb_pos_col()
- if pos_col in matrix.columns:
- pos = matrix[pos_col].fillna(0.5).astype(float).to_numpy()
- blocked |= pos < SELL_MIN_BB_POS
- pfx = interval_prefix(ENTRY_INTERVAL)
- for feat in ("hammer", "bb_zone_bottom", "bb_zone_low", "bb_pos_low"):
- col = f"{pfx}_{feat}"
- if col in matrix.columns:
- blocked |= matrix[col].fillna(0).astype(bool).to_numpy()
- return blocked
-
-
-def buy_mask(matrix: pd.DataFrame, rules: DiscoveredRules) -> np.ndarray:
- """
- 매수 마스크 = (buy_all) 또는 (buy_any 각 그룹의 AND) 중 하나 + 안전필터.
-
- buy_any는 추가 분기(OR)이지, buy_all과의 AND가 아닙니다.
- """
- n = len(matrix)
- groups: list[list[str]] = []
- if rules.buy_all:
- groups.append(list(rules.buy_all))
- for g in rules.buy_any:
- if g:
- groups.append(list(g))
- if not groups:
- return np.zeros(n, dtype=bool)
- any_ok = np.zeros(n, dtype=bool)
- for group in groups:
- raw = _mask_for_keys(matrix, group)
- any_ok |= raw & _value_buy_gate_mask(matrix, group)
- return any_ok & ~_unsafe_buy_mask(matrix)
-
-
-def sell_mask(matrix: pd.DataFrame, rules: DiscoveredRules, stop: bool = False) -> np.ndarray:
- keys = rules.sell_stop if stop else rules.sell_all
- raw = _mask_for_keys(matrix, keys)
- if stop:
- return raw
- return raw & ~_unsafe_sell_mask(matrix)
-
-
-def sanitize_rules(rules: DiscoveredRules) -> DiscoveredRules:
- """탐색 결과에서 추격 매수·무의미 조건 제거."""
- rules.buy_all = [p for p in rules.buy_all if not is_chase_buy_predicate(p)]
- rules.buy_any = [
- [p for p in g if not is_chase_buy_predicate(p)]
- for g in rules.buy_any
- ]
- rules.buy_any = [g for g in rules.buy_any if g]
- rules.sell_all = [p for p in rules.sell_all if not is_weak_sell_predicate(p)]
- return rules
-
-
-def _discovery_seed() -> DiscoveredRules:
- """탐색 시드: 하단 돌파 기준선 (combination_seed의 상단 추격 매수 미사용)."""
- return _baseline_rules()
-
-
-def generate_predicate_pool(intervals: list[int]) -> list[str]:
- """탐색 후보 predicate 목록."""
- pool: list[str] = []
- for iv in intervals:
- pfx = interval_prefix(iv)
- for feat in SEARCH_FEATURES:
- pool.append(f"{pfx}:{feat}")
- if iv != ENTRY_INTERVAL:
- for feat in NEG_BLOCK_FEATURES:
- pool.append(f"{pfx}:!{feat}")
- return pool
-
-
-def list_rule_signal_edges(
- matrix: pd.DataFrame,
- rules: DiscoveredRules,
-) -> list[tuple[pd.Timestamp, str]]:
- """
- 전 기간 규칙 엣지 신호(체결 여부와 무관).
-
- Returns:
- (timestamp, action) — buy_signal | sell_signal | sell_stop_signal
- """
- idx = matrix.index
- b_mask = buy_mask(matrix, rules)
- s_mask = sell_mask(matrix, rules, stop=False)
- stop_mask = (
- sell_mask(matrix, rules, stop=True)
- if rules.sell_stop
- else np.zeros(len(matrix), dtype=bool)
- )
- out: list[tuple[pd.Timestamp, str]] = []
- for i in range(len(matrix)):
- if _rising_edge(b_mask, i):
- out.append((idx[i], "buy_signal"))
- if _rising_edge(s_mask, i):
- out.append((idx[i], "sell_signal"))
- if rules.sell_stop and _rising_edge(stop_mask, i):
- out.append((idx[i], "sell_stop_signal"))
- return out
-
-
-def generate_trade_events(
- matrix: pd.DataFrame,
- rules: DiscoveredRules,
-) -> list[tuple[pd.Timestamp, str, str]]:
- """
- 규칙에 따른 체결 이벤트 목록.
-
- Returns:
- (timestamp, action, signal_name)
- """
- close = matrix["Close"].astype(float).to_numpy()
- idx = matrix.index
- b_mask = buy_mask(matrix, rules)
- s_mask = sell_mask(matrix, rules, stop=False)
- stop_mask = (
- sell_mask(matrix, rules, stop=True)
- if rules.sell_stop
- else np.zeros(len(matrix), dtype=bool)
- )
-
- events: list[tuple[pd.Timestamp, str, str]] = []
- qty = 0.0
- last_buy_i: int | None = None
- last_sell_i: int | None = None
- last_trade_i: int | None = None
-
- for i in range(len(matrix)):
- price = close[i]
- if price <= 0 or np.isnan(price):
- continue
- ts = idx[i]
-
- if last_trade_i is not None and i - last_trade_i < TRADE_MIN_GAP_BARS:
- continue
-
- if qty > 0:
- is_stop = _trigger_at(stop_mask, i) if rules.sell_stop else False
- is_sell = _trigger_at(s_mask, i)
- if is_stop or is_sell:
- if last_sell_i is not None:
- if (ts - idx[last_sell_i]).total_seconds() < SELL_COOLDOWN_SEC:
- continue
- sig = SIGNAL_SELL_STOP if is_stop else SIGNAL_SELL_UPPER
- events.append((ts, "sell", sig))
- qty = 0.0
- last_sell_i = i
- last_trade_i = i
- continue
-
- if _trigger_at(b_mask, i) and qty <= 0:
- if last_buy_i is not None:
- if (ts - idx[last_buy_i]).total_seconds() < BUY_COOLDOWN_SEC:
- continue
- events.append((ts, "buy", SIGNAL_BUY_LOWER))
- qty = 1.0
- last_buy_i = i
- last_trade_i = i
-
- return events
-
-
-def backtest_rules(
- matrix: pd.DataFrame,
- rules: DiscoveredRules,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- entry_ohlc: pd.DataFrame,
-) -> tuple[float, int]:
- """
- HTML 시뮬과 동일한 run_backtest 로직으로 수익률 계산.
- """
- from simulation import run_backtest
- import strategy as st
-
- df = entry_ohlc.loc[matrix.index].copy()
- df["signal"] = ""
- df["point"] = 0
- df["action"] = ""
- df["trend"] = ""
-
- for ts, action, sig in generate_trade_events(matrix, rules):
- if ts not in df.index:
- continue
- trend_at = st.get_trend_at(df_1d, df_1h, ts)
- df.at[ts, "signal"] = sig
- df.at[ts, "point"] = 1
- df.at[ts, "action"] = action
- df.at[ts, "trend"] = trend_at
-
- res = run_backtest(df, df_1d, df_1h, config_name=rules.name)
- return res.total_return_pct, res.trade_count
-
-
-def _evaluate_train(
- train: pd.DataFrame,
- rules: DiscoveredRules,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- entry_ohlc: pd.DataFrame,
-) -> tuple[float, int, float]:
- """학습 구간 수익·거래수·목적함수 점수."""
- ret, tc = backtest_rules(train, rules, df_1d, df_1h, entry_ohlc)
- return ret, tc, discover_score(ret, tc)
-
-
-def _baseline_rules() -> DiscoveredRules:
- """다봉 BB 하단 돌파 + 상단 돌파 기준선."""
- p3 = interval_prefix(ENTRY_INTERVAL)
- return DiscoveredRules(
- name="baseline_bb_mtf",
- buy_all=[
- f"{p3}:cross_up_lower",
- f"{interval_prefix(60)}:ichi_tk_bull",
- f"{interval_prefix(1440)}:!ichi_below_cloud",
- ],
- sell_all=[
- f"{p3}:cross_up_upper",
- f"{interval_prefix(60)}:cross_up_upper",
- ],
- sell_stop=[f"{p3}:cross_down_lower"],
- )
-
-
-def _seed_from_combination_report() -> DiscoveredRules | None:
- """combination_report.json 제안 규칙."""
- path = Path(__file__).parent / "combination_report.json"
- if not path.exists():
- return None
- data = json.loads(path.read_text(encoding="utf-8"))
- sug = data.get("suggested_rules") or {}
- buy_all = list(sug.get("buy_all") or [])
- if not buy_all:
- return None
- return DiscoveredRules(
- name="combination_seed",
- buy_all=buy_all,
- buy_any=[list(g) for g in (sug.get("buy_any") or []) if g],
- sell_all=list(sug.get("sell_all") or [f"{interval_prefix(ENTRY_INTERVAL)}:cross_up_upper"]),
- sell_stop=list(sug.get("sell_stop") or []),
- )
-
-
-def greedy_search(
- matrix: pd.DataFrame,
- train_end: int,
- pool: list[str],
- seed: DiscoveredRules,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- entry_ohlc: pd.DataFrame,
- max_buy: int = 8,
- max_sell: int = 6,
- max_stop: int = 2,
-) -> DiscoveredRules:
- """학습 구간 수익률을 올리도록 매수/매도 조건을 탐욕적으로 확장."""
- train = matrix.iloc[:train_end]
- best = DiscoveredRules(
- name=seed.name,
- buy_all=list(seed.buy_all),
- buy_any=[list(g) for g in seed.buy_any],
- sell_all=list(seed.sell_all),
- sell_stop=list(seed.sell_stop),
- )
- best_ret, best_tc, best_score = _evaluate_train(
- train, best, df_1d, df_1h, entry_ohlc
- )
-
- buy_pool = [
- p
- for p in pool
- if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p)
- ]
- sell_pool = [p for p in pool if not is_weak_sell_predicate(p)]
-
- improved = True
- while improved:
- improved = False
- # 매수 AND 추가/제거
- for pred in buy_pool:
- if pred in best.buy_all:
- trial_all = [p for p in best.buy_all if p != pred]
- else:
- if len(best.buy_all) >= max_buy:
- continue
- trial_all = best.buy_all + [pred]
- trial = DiscoveredRules(
- name="trial",
- buy_all=trial_all,
- buy_any=best.buy_any,
- sell_all=best.sell_all,
- sell_stop=best.sell_stop,
- )
- ret, tc, score = _evaluate_train(
- train, trial, df_1d, df_1h, entry_ohlc
- )
- if score > best_score:
- best_ret, best_tc, best_score = ret, tc, score
- best.buy_all = trial_all
- improved = True
-
- # 매도 AND
- for pred in sell_pool:
- if pred in best.sell_all:
- trial_s = [p for p in best.sell_all if p != pred]
- else:
- if len(best.sell_all) >= max_sell:
- continue
- trial_s = best.sell_all + [pred]
- trial = DiscoveredRules(
- name="trial",
- buy_all=best.buy_all,
- buy_any=best.buy_any,
- sell_all=trial_s,
- sell_stop=best.sell_stop,
- )
- ret, tc, score = _evaluate_train(
- train, trial, df_1d, df_1h, entry_ohlc
- )
- if score > best_score:
- best_ret, best_tc, best_score = ret, tc, score
- best.sell_all = trial_s
- improved = True
-
- # 손절
- stop_pool = [
- p
- for p in pool
- if "cross_down_lower" in p
- and not is_level_state_predicate(p)
- ]
- for pred in stop_pool:
- if pred in best.sell_stop:
- trial_st = [p for p in best.sell_stop if p != pred]
- else:
- if len(best.sell_stop) >= max_stop:
- continue
- trial_st = best.sell_stop + [pred]
- trial = DiscoveredRules(
- name="trial",
- buy_all=best.buy_all,
- buy_any=best.buy_any,
- sell_all=best.sell_all,
- sell_stop=trial_st,
- )
- ret, tc, score = _evaluate_train(
- train, trial, df_1d, df_1h, entry_ohlc
- )
- if score > best_score:
- best_ret, best_tc, best_score = ret, tc, score
- best.sell_stop = trial_st
- improved = True
-
- return sanitize_rules(best)
-
-
-def try_buy_any_branches(
- matrix: pd.DataFrame,
- train_end: int,
- base: DiscoveredRules,
- pool: list[str],
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- entry_ohlc: pd.DataFrame,
- max_branches: int = 8,
-) -> DiscoveredRules:
- """매수 OR 분기: 다른 봉의 cross_up_lower / hammer 등."""
- train = matrix.iloc[:train_end]
- triggers = [
- p
- for p in pool
- if p.endswith(":cross_up_lower")
- or p.endswith(":hammer")
- or p.endswith(":bb_zone_bottom")
- or p.endswith(":ichi_tk_cross_up")
- ]
- best = DiscoveredRules(
- name=base.name,
- buy_all=list(base.buy_all),
- buy_any=[list(g) for g in base.buy_any],
- sell_all=list(base.sell_all),
- sell_stop=list(base.sell_stop),
- )
- best_ret, best_tc, best_score = _evaluate_train(
- train, best, df_1d, df_1h, entry_ohlc
- )
-
- for pred in triggers[:max_branches]:
- if pred in best.buy_all:
- continue
- trial = DiscoveredRules(
- name="trial_or",
- buy_all=[],
- buy_any=[list(best.buy_all), [pred]],
- sell_all=best.sell_all,
- sell_stop=best.sell_stop,
- )
- if not trial.buy_any[0]:
- trial.buy_any = [[pred]]
- ret, tc, score = _evaluate_train(
- train, trial, df_1d, df_1h, entry_ohlc
- )
- if score > best_score:
- best_ret, best_score = ret, score
- best = sanitize_rules(trial)
- best.name = "discovered_or"
-
- return sanitize_rules(best)
-
-
-def random_search_refine(
- matrix: pd.DataFrame,
- train_end: int,
- pool: list[str],
- seed: DiscoveredRules,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- entry_ohlc: pd.DataFrame,
- iterations: int = 1200,
-) -> DiscoveredRules:
- """무작위 변형으로 국소 최적 보완."""
- train = matrix.iloc[:train_end]
- best = seed
- best_ret, best_tc, best_score = _evaluate_train(
- train, best, df_1d, df_1h, entry_ohlc
- )
- rng = random.Random(42)
- buy_pool = [
- p
- for p in pool
- if not is_blocked_buy_predicate(p) and not is_chase_buy_predicate(p)
- ]
- sell_pool = [p for p in pool if not is_weak_sell_predicate(p)]
-
- for _ in range(iterations):
- trial = DiscoveredRules(
- name="rand",
- buy_all=[p for p in best.buy_all],
- buy_any=[list(g) for g in best.buy_any],
- sell_all=[p for p in best.sell_all],
- sell_stop=[p for p in best.sell_stop],
- )
- action = rng.choice(["add_buy", "drop_buy", "add_sell", "drop_sell", "swap_buy"])
- if action == "add_buy" and len(trial.buy_all) < 6 and buy_pool:
- p = rng.choice(buy_pool)
- if p not in trial.buy_all:
- trial.buy_all.append(p)
- elif action == "drop_buy" and trial.buy_all:
- trial.buy_all.pop(rng.randrange(len(trial.buy_all)))
- elif action == "add_sell" and len(trial.sell_all) < 5 and sell_pool:
- p = rng.choice(sell_pool)
- if p not in trial.sell_all:
- trial.sell_all.append(p)
- elif action == "drop_sell" and trial.sell_all:
- trial.sell_all.pop(rng.randrange(len(trial.sell_all)))
- elif action == "swap_buy" and buy_pool:
- if trial.buy_all:
- trial.buy_all[rng.randrange(len(trial.buy_all))] = rng.choice(buy_pool)
- trial = sanitize_rules(trial)
- ret, tc, score = _evaluate_train(
- train, trial, df_1d, df_1h, entry_ohlc
- )
- if score > best_score:
- best_ret, best_score = ret, score
- best = trial
- best.name = "discovered_refined"
- return sanitize_rules(best)
-
-
-def discover_rules(frames: dict[int, pd.DataFrame]) -> DiscoveredRules:
- """전체 탐색 파이프라인."""
- print("특징 행렬 생성 (모든 봉·캔들 위치/높이)...")
- from config import ENTRY_INTERVAL, TREND_INTERVAL_1D, TREND_INTERVAL_1H
-
- entry_raw = frames[ENTRY_INTERVAL]
- df_1d = frames.get(TREND_INTERVAL_1D)
- if df_1d is None or df_1d.empty:
- df_1d = entry_raw
- df_1h = frames.get(TREND_INTERVAL_1H)
- if df_1h is None or df_1h.empty:
- df_1h = entry_raw
-
- matrix = build_master_feature_matrix(frames)
- matrix = matrix.iloc[21:].copy()
- entry_ohlc = entry_raw.iloc[21:].loc[matrix.index]
- n = len(matrix)
- train_end = int(n * 0.7)
- intervals = sorted(frames.keys())
- pool = generate_predicate_pool(intervals)
- print(f" 샘플 {n}봉 | 학습 {train_end} | predicate 후보 {len(pool)}개")
-
- baseline = _discovery_seed()
- br, bt = backtest_rules(
- matrix.iloc[:train_end], baseline, df_1d, df_1h, entry_ohlc
- )
- print(f" 시드 규칙: {baseline.name} (하단돌파 매수·상단돌파 매도)")
- bf, _ = backtest_rules(matrix, baseline, df_1d, df_1h, entry_ohlc)
- print(f" 기준선: 학습 {br:+.2f}% | 전체 {bf:+.2f}%")
-
- print("1단계: 탐욕적 AND 확장...")
- g1 = greedy_search(matrix, train_end, pool, baseline, df_1d, df_1h, entry_ohlc)
- r1, _ = backtest_rules(matrix.iloc[:train_end], g1, df_1d, df_1h, entry_ohlc)
- print(f" 학습 {r1:+.2f}% | buy={g1.buy_all} sell={g1.sell_all}")
-
- print("2단계: 매수 OR 분기(다른 봉 트리거)...")
- g2 = try_buy_any_branches(matrix, train_end, g1, pool, df_1d, df_1h, entry_ohlc)
- r2, _ = backtest_rules(matrix.iloc[:train_end], g2, df_1d, df_1h, entry_ohlc)
- print(f" 학습 {r2:+.2f}%")
-
- print("3단계: 무작위 정밀 탐색...")
- best = g2 if r2 >= r1 else g1
- g3 = random_search_refine(matrix, train_end, pool, best, df_1d, df_1h, entry_ohlc, iterations=1200)
- g3 = sanitize_rules(g3)
- train_ret, t_cnt = backtest_rules(
- matrix.iloc[:train_end], g3, df_1d, df_1h, entry_ohlc
- )
- test_ret, _ = backtest_rules(
- matrix.iloc[train_end:], g3, df_1d, df_1h, entry_ohlc
- )
- full_ret, full_cnt = backtest_rules(matrix, g3, df_1d, df_1h, entry_ohlc)
-
- g3.train_return_pct = train_ret
- g3.test_return_pct = test_ret
- g3.full_return_pct = full_ret
- g3.trade_count = full_cnt
- g3.name = "discovered_best"
-
- print(f"\n최종 규칙 ({g3.name})")
- print(f" 매수 AND: {g3.buy_all}")
- if g3.buy_any:
- print(f" 매수 OR: {g3.buy_any}")
- print(f" 매도 AND: {g3.sell_all}")
- if g3.sell_stop:
- print(f" 손절: {g3.sell_stop}")
- print(
- f" 학습 {train_ret:+.2f}% | 검증 {test_ret:+.2f}% | 전체 {full_ret:+.2f}% ({full_cnt}건)"
- )
- return g3
-
-
-def save_rules(rules: DiscoveredRules, path: Path = RULES_FILE) -> None:
- path.write_text(json.dumps(asdict(rules), ensure_ascii=False, indent=2), encoding="utf-8")
-
-
-def rules_have_buy(rules: DiscoveredRules) -> bool:
- """매수 규칙이 하나라도 있는지."""
- if rules.buy_all:
- return True
- return any(bool(g) for g in rules.buy_any)
-
-
-def load_rules(path: Path = RULES_FILE) -> DiscoveredRules | None:
- if not path.exists():
- return None
- data = json.loads(path.read_text(encoding="utf-8"))
- rules = DiscoveredRules(**{k: data[k] for k in asdict(DiscoveredRules()).keys() if k in data})
- if not rules_have_buy(rules):
- return None
- return rules
-
-
-def load_frames(monitor) -> dict[int, pd.DataFrame]:
- from mtf_bb import load_frames_from_db
-
- return load_frames_from_db(monitor, SYMBOL)
diff --git a/scripts/01_download.py b/scripts/01_download.py
new file mode 100644
index 0000000..797b151
--- /dev/null
+++ b/scripts/01_download.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""01단계: WLD 봉 데이터 다운로드 → data/coins.db"""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.data.downloader import download
+
+if __name__ == "__main__":
+ download()
diff --git a/scripts/02_ground_truth.py b/scripts/02_ground_truth.py
new file mode 100644
index 0000000..de80601
--- /dev/null
+++ b/scripts/02_ground_truth.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""02단계: Ground Truth 매수·매도 타점 생성."""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.ground_truth.ground_truth import run_from_db
+
+if __name__ == "__main__":
+ run_from_db()
diff --git a/scripts/03_analyze_enrich.py b/scripts/03_analyze_enrich.py
new file mode 100644
index 0000000..6e53116
--- /dev/null
+++ b/scripts/03_analyze_enrich.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""03단계: 3분~일봉 전 기법 enrich (latest CSV)."""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.analysis.general_analysis_enrich_runner import main
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/03_analyze_trades.py b/scripts/03_analyze_trades.py
new file mode 100644
index 0000000..bfee060
--- /dev/null
+++ b/scripts/03_analyze_trades.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""03b단계: Ground Truth 타점 MTF 기술적 스냅샷 CSV."""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.analysis.general_analysis_runner import main
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/04_match_rules.py b/scripts/04_match_rules.py
new file mode 100644
index 0000000..9e36d08
--- /dev/null
+++ b/scripts/04_match_rules.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""04단계: GT 근접 규칙 선택 (스텁)."""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.matching.match_rules import run_match_stub
+
+if __name__ == "__main__":
+ run_match_stub()
diff --git a/scripts/05_chart_bb.py b/scripts/05_chart_bb.py
new file mode 100644
index 0000000..6da312c
--- /dev/null
+++ b/scripts/05_chart_bb.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""05: 3분봉 BB 차트 HTML."""
+import runpy
+import sys
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.ops import simulation
+
+if __name__ == "__main__":
+ sys.argv = [sys.argv[0]]
+ simulation.main()
diff --git a/scripts/05_chart_truth.py b/scripts/05_chart_truth.py
new file mode 100644
index 0000000..e316f69
--- /dev/null
+++ b/scripts/05_chart_truth.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+"""05: Ground Truth 차트 + JSON."""
+import runpy
+import sys
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.ops import simulation
+
+if __name__ == "__main__":
+ sys.argv = [sys.argv[0], "truth"]
+ simulation.main()
diff --git a/scripts/05_run_monitor.py b/scripts/05_run_monitor.py
new file mode 100644
index 0000000..1c35b83
--- /dev/null
+++ b/scripts/05_run_monitor.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python3
+"""05단계: WLD 실시간 모니터 루프."""
+import runpy
+from pathlib import Path
+
+runpy.run_path(str(Path(__file__).resolve().parent / "_bootstrap.py"))
+
+from deepcoin.ops.monitor_coin import MonitorCoin
+
+if __name__ == "__main__":
+ MonitorCoin(cooldown_file=None).run_schedule()
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..2d6d890
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,17 @@
+# scripts — 단계별 CLI
+
+프로젝트 루트에서 실행하세요. 각 스크립트는 `_bootstrap.py`로 `.env`와 `config`를 로드합니다.
+
+```bash
+python scripts/01_download.py
+python scripts/02_ground_truth.py
+python scripts/03_analyze_enrich.py
+python scripts/03_analyze_trades.py
+python scripts/04_match_rules.py # 스텁
+python scripts/05_chart_truth.py
+python scripts/05_chart_bb.py
+python scripts/05_run_monitor.py
+python scripts/verify_env.py
+```
+
+상세 구조: [docs/reference/STRUCTURE.md](../docs/reference/STRUCTURE.md)
diff --git a/scripts/_bootstrap.py b/scripts/_bootstrap.py
new file mode 100644
index 0000000..27712a9
--- /dev/null
+++ b/scripts/_bootstrap.py
@@ -0,0 +1,11 @@
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+# .env → config 일관 로드
+from deepcoin.env_loader import load_project_env # noqa: E402
+
+load_project_env()
diff --git a/scripts/verify_env.py b/scripts/verify_env.py
new file mode 100644
index 0000000..6dcbe63
--- /dev/null
+++ b/scripts/verify_env.py
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""`.env` 완전성 및 config 로드 점검."""
+
+from __future__ import annotations
+
+import os
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT))
+
+ENV_FILE = ROOT / ".env"
+CONFIG_FILE = ROOT / "config.py"
+
+# config.py에서 읽는 환경 변수 키 (코드 기본값이 있는 항목)
+CONFIG_ENV_KEYS = {
+ "BITHUMB_API_URL",
+ "BITHUMB_API_CANDLE_COUNT",
+ "BITHUMB_MINUTE_INTERVALS",
+ "HTS_API_RETRY_SLEEP_SEC",
+ "SYMBOL",
+ "COIN_NAME",
+ "DAILY_INTERVAL_MIN",
+ "ENTRY_INTERVAL",
+ "TREND_INTERVAL_1H",
+ "TREND_INTERVAL_1D",
+ "ALL_INTERVALS",
+ "DOWNLOAD_INTERVALS",
+ "GENERAL_ANALYSIS_INTERVALS",
+ "TIMING_INTERVALS",
+ "TREND_INTERVALS",
+ "INTERVAL_PREFIX",
+ "BB_PERIOD",
+ "BB_STD",
+ "BB_MIN_WIDTH_PCT",
+ "RSI_PERIOD",
+ "DISPARITY_PERIODS",
+ "DISPARITY_OVERBOUGHT",
+ "DISPARITY_OVERSOLD",
+ "MACD_FAST",
+ "MACD_SLOW",
+ "MACD_SIGNAL",
+ "STOCH_K_PERIOD",
+ "STOCH_D_PERIOD",
+ "STOCH_SMOOTH_K",
+ "STOCH_OVERSOLD",
+ "STOCH_OVERBOUGHT",
+ "TREND_RANGE_MA_GAP_PCT",
+ "ALIGN_RSI_OVERSOLD",
+ "ALIGN_RSI_OVERBOUGHT",
+ "ALIGN_RSI_CONFLICT_TIMING_LOW",
+ "ALIGN_RSI_CONFLICT_TIMING_HIGH",
+ "ALIGN_RSI_CONFLICT_TREND_LOW",
+ "ALIGN_RSI_CONFLICT_TREND_HIGH",
+ "ALIGN_BB_POS_LOW",
+ "ALIGN_BB_POS_HIGH",
+ "DOWNLOAD_MONTHS",
+ "DOWNLOAD_MONTHS_1M",
+ "INCREMENTAL_OVERLAP_BARS",
+ "DOWNLOAD_BACKFILL_EXTRA_BARS",
+ "DOWNLOAD_MIN_INCREMENTAL_BARS",
+ "DOWNLOAD_DAILY_EXTRA_DAYS",
+ "CHART_LOOKBACK_DAYS",
+ "DB_READ_LIMIT_DEFAULT",
+ "DB_ROW_WARMUP_BARS",
+ "DB_ROW_MIN_DAILY_BARS",
+ "DB_ROW_DAILY_PADDING_DAYS",
+ "DB_PATH",
+ "GROUND_TRUTH_FILE",
+ "GT_UNLIMITED_CHRONOLOGICAL_DAYS",
+ "GT_MIN_SWING_PCT",
+ "GT_PIVOT_ORDER",
+ "GT_MIN_BARS_BETWEEN",
+ "GT_MAX_ROUND_TRIPS",
+ "GT_SELECTION_MODE",
+ "GT_MIN_LEG_PCT",
+ "GT_BUY_MIN_SWING_PCT",
+ "GT_BUY_BB_MAX",
+ "GT_BUY_MIN_BARS",
+ "GT_MAX_BUYS_PER_LEG",
+ "GT_MAX_SELLS_PER_LEG",
+ "GT_SELL_SPLIT_GAP_PCT",
+ "GT_MARKER_SIZE_MIN",
+ "GT_MARKER_SIZE_MAX",
+ "GT_INITIAL_CASH_KRW",
+ "TRADING_FEE_RATE",
+ "MONITOR_LOOP_SLEEP_SEC",
+ "MONITOR_POOL_WORKERS",
+ "MONITOR_DEFAULT_INTERVAL",
+ "MONITOR_API_RETRIES",
+ "MONITOR_API_BONG_COUNT",
+ "MONITOR_SLEEP_AFTER_REQUEST_SEC",
+ "MONITOR_SLEEP_RATE_LIMIT_SEC",
+ "MONITOR_SLEEP_BETWEEN_CHUNKS_SEC",
+ "MONITOR_API_CHUNK_BARS",
+ "MONITOR_MA_WINDOWS",
+ "MONITOR_NORM_WINDOW",
+ "MONITOR_TELEGRAM_BATCH_SIZE",
+ "GA_COL_PREFIX",
+ "LOOKBACK_BARS",
+ "CONTEXT_TAIL_ROWS",
+ "GA_DEFAULT_TAIL_EXPORT",
+ "GA_PATTERN_TOLERANCE_PCT",
+ "GA_VP_BINS",
+ "GA_VP_VALUE_AREA_PCT",
+ "GA_HV_ROLLING_BARS",
+ "GA_HV_PERCENTILE_WINDOW",
+ "GA_HV_ANNUALIZE_SQRT",
+ "GA_DIVERGENCE_LOOKBACK",
+ "GA_SMA_PERIODS",
+ "GA_EMA_SPANS",
+ "GA_ATR_PERIOD",
+ "GA_KELTNER_ATR_MULT",
+ "GA_AO_FAST",
+ "GA_AO_SLOW",
+ "GA_LINREG_WINDOW",
+ "GA_ADX_PERIOD",
+ "GA_ADX_TREND_THRESHOLD",
+ "GA_SUPERTREND_ATR_MULT",
+ "GA_VOL_SPIKE_MULT",
+ "GA_VOL_MA_WINDOW",
+ "GA_CCI_PERIOD",
+ "GA_WILLIAMS_PERIOD",
+ "GA_ROC_PERIOD",
+ "GA_MFI_PERIOD",
+ "GA_CMF_PERIOD",
+ "GA_DONCHIAN_PERIOD",
+ "GA_BB_SQUEEZE_WINDOW",
+ "GA_BB_SQUEEZE_QUANTILE",
+ "GA_PIVOT_ORDER",
+ "GA_PSAR_AF_START",
+ "GA_PSAR_AF_STEP",
+ "GA_PSAR_AF_MAX",
+}
+
+# 비어 있어도 되는 선택 항목 (현재는 모두 채움)
+OPTIONAL_EMPTY = frozenset()
+
+
+def parse_env_file(path: Path) -> dict[str, str]:
+ """`.env` 키=값 파싱."""
+ out: dict[str, str] = {}
+ if not path.is_file():
+ return out
+ for line in path.read_text(encoding="utf-8").splitlines():
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if "=" not in line:
+ continue
+ key, _, val = line.partition("=")
+ out[key.strip()] = val.strip()
+ return out
+
+
+def main() -> int:
+ errors: list[str] = []
+
+ if not ENV_FILE.is_file():
+ errors.append(f".env 없음: {ENV_FILE}")
+ for e in errors:
+ print(f"FAIL: {e}")
+ return 1
+
+ env_vars = parse_env_file(ENV_FILE)
+ empty = [k for k, v in env_vars.items() if v == "" and k not in OPTIONAL_EMPTY]
+ missing = sorted(CONFIG_ENV_KEYS - set(env_vars.keys()))
+ extra = sorted(set(env_vars.keys()) - CONFIG_ENV_KEYS - {
+ "BITHUMB_ACCESS_KEY",
+ "BITHUMB_SECRET_KEY",
+ "COIN_TELEGRAM_BOT_TOKEN",
+ "COIN_TELEGRAM_CHAT_ID",
+ })
+
+ if empty:
+ errors.append(f"빈 값: {', '.join(empty)}")
+ if missing:
+ errors.append(f"config 대비 .env 누락: {', '.join(missing)}")
+ if extra:
+ print(f"WARN: config 미사용 .env 키: {', '.join(extra)}")
+
+ from deepcoin.env_loader import env_status, load_project_env
+
+ loaded = load_project_env(override=True)
+ if not loaded:
+ errors.append("load_project_env: .env 로드 실패 (python-dotenv 확인)")
+
+ import config # noqa: E402
+
+ checks = [
+ ("SYMBOL", config.SYMBOL, env_vars.get("SYMBOL")),
+ ("DB_PATH", config.DB_PATH, env_vars.get("DB_PATH")),
+ ("BITHUMB_ACCESS_KEY", bool(config.BITHUMB_ACCESS_KEY), bool(env_vars.get("BITHUMB_ACCESS_KEY"))),
+ ("LOOKBACK_BARS[3]", config.LOOKBACK_BARS.get(3), 120),
+ ]
+ for name, got, expected in checks:
+ if got != expected and expected is not None:
+ errors.append(f"config 불일치 {name}: got={got!r} expected={expected!r}")
+
+ status = env_status()
+ print("env_status:", status)
+ print(f".env 키 수: {len(env_vars)} (config 필수 {len(CONFIG_ENV_KEYS)})")
+ print(f"SYMBOL={config.SYMBOL} DB_PATH={config.DB_PATH}")
+ print(f"BITHUMB_KEY set={bool(config.BITHUMB_ACCESS_KEY)} TELEGRAM set={bool(config.COIN_TELEGRAM_BOT_TOKEN)}")
+
+ if errors:
+ print("\n=== 점검 실패 ===")
+ for e in errors:
+ print(f" - {e}")
+ return 1
+
+ print("\n=== 점검 통과: .env → config 로드 정상 ===")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/simulation.py b/simulation.py
deleted file mode 100644
index b4c317c..0000000
--- a/simulation.py
+++ /dev/null
@@ -1,1095 +0,0 @@
-"""
-WLD 3분 BB 시뮬레이션.
-
-기본: 하단 상향 돌파 매수, 상단 상향 돌파 매도.
-수수료 반영, 레짐/필터 조합 비교 지원.
-
- python simulation.py # analyze → discover → HTML (탐색 규칙 매수·매도)
- python simulation.py analyze # (고급) 조합 분석만
- python simulation.py discover # (고급) 규칙 탐색만
- python simulation.py compare # (고급) 9종 프리셋 비교
- python simulation.py mtf # (고급) 구 MTF BB 정책
-"""
-
-from __future__ import annotations
-
-import sys
-import webbrowser
-from dataclasses import dataclass
-from pathlib import Path
-
-import pandas as pd
-import plotly.graph_objs as go
-from plotly.subplots import make_subplots
-
-from config import (
- BUY_COOLDOWN_SEC,
- COIN_NAME,
- DEFAULT_BUY_KRW,
- ENTRY_INTERVAL,
- SELL_COOLDOWN_SEC,
- SIM_INITIAL_CASH_KRW,
- SIM_MIN_ORDER_KRW,
- SYMBOL,
- TRADING_FEE_RATE,
- TREND_INTERVAL_1D,
- TREND_INTERVAL_1H,
-)
-from monitor import Monitor
-import strategy
-
-REPORT_DIR = Path(__file__).resolve().parent / "reports"
-OUTPUT_HTML = REPORT_DIR / "wld_bb_simulation.html"
-LOGOS_BENCHMARK_HTML = REPORT_DIR / "wld_logos_benchmark.html"
-LOGOS_TRADES_FILE = Path(__file__).resolve().parent / "logos_trades.json"
-
-
-def interval_chart_label(interval_min: int) -> str:
- """차트 제목용 봉 라벨."""
- if interval_min >= 1440:
- return "일봉"
- return f"{interval_min}분봉"
-
-
-def _format_trade_dt(dt: pd.Timestamp) -> str:
- """마커 호버용 날짜·시간 문자열."""
- return pd.Timestamp(dt).strftime("%Y-%m-%d %H:%M")
-
-
-def _trade_hover_lines(t: "SimTrade", label: str) -> str:
- """매수·매도 마커 호버(팝업) 본문."""
- lines = [
- label,
- _format_trade_dt(t.dt),
- t.signal or "",
- f"₩{t.price:,.2f}",
- f"₩{t.krw:,.0f}",
- ]
- if t.pnl is not None:
- lines.append(f"손익 ₩{t.pnl:+,.0f}")
- return "
".join(line for line in lines if line)
-
-
-def _add_trade_markers(fig, trades: list["SimTrade"], row: int = 1) -> None:
- """
- 매수·매도 체결 마커·라벨 (Scatter trace만 사용, 범례와 함께 토글).
- simulate_mtf.py 와 동일 스타일.
- """
- for action, color, symbol, label, text_pos in [
- ("매수", "#16a34a", "triangle-up", "매수", "top center"),
- ("매도", "#dc2626", "triangle-down", "매도", "bottom center"),
- ]:
- pts = [t for t in trades if t.action == action]
- if not pts:
- continue
- fig.add_trace(
- go.Scatter(
- x=[t.dt for t in pts],
- y=[t.price for t in pts],
- mode="markers+text",
- name=label,
- legendgroup=label,
- text=[label] * len(pts),
- textposition=text_pos,
- textfont=dict(
- size=12,
- color=color,
- family="Malgun Gothic, Arial, sans-serif",
- ),
- marker=dict(
- symbol=symbol,
- size=16,
- color=color,
- line=dict(width=2, color="#111"),
- ),
- hovertext=[_trade_hover_lines(t, label) for t in pts],
- hovertemplate="%{hovertext}",
- ),
- row=row,
- col=1,
- )
-
-
-def build_simulation_html(
- df: pd.DataFrame,
- result: SimResult,
- trend: str,
- interval_min: int = ENTRY_INTERVAL,
- note: str = "",
- title_suffix: str = "BB 시뮬레이션",
- show_memo_column: bool = False,
-) -> str:
- """simulate_mtf.py 와 동일 레이아웃의 HTML 리포트."""
- df = strategy.prepare_entry_df(df.copy())
- iv_label = interval_chart_label(interval_min)
- buy_n = sum(1 for t in result.trades if t.action == "매수")
- sell_n = sum(1 for t in result.trades if t.action == "매도")
- pnl_krw = result.final_asset - result.initial_cash
-
- summary = {
- "config_name": result.config_name,
- "period_start": str(df.index[0]),
- "period_end": str(df.index[-1]),
- "interval_label": iv_label,
- "trend": trend,
- "signal_count": len(result.trades),
- "buy_signal_count": buy_n,
- "sell_signal_count": sell_n,
- "total_trades": result.trade_count,
- "pnl_krw": round(pnl_krw, 0),
- "pnl_pct": round(result.total_return_pct, 2),
- "total_fees": round(result.total_fees, 0),
- "win_count": result.win_count,
- "note": note,
- }
-
- fig = make_subplots(
- rows=3,
- cols=1,
- shared_xaxes=True,
- vertical_spacing=0.05,
- row_heights=[0.58, 0.2, 0.22],
- subplot_titles=(
- f"{COIN_NAME} ({SYMBOL}) {iv_label} — {result.config_name}",
- "RSI (14)",
- "거래량",
- ),
- )
-
- fig.add_trace(
- go.Candlestick(
- x=df.index,
- open=df["Open"],
- high=df["High"],
- low=df["Low"],
- close=df["Close"],
- name=f"{iv_label} 캔들",
- increasing_line_color="#ef4444",
- decreasing_line_color="#3b82f6",
- ),
- row=1,
- col=1,
- )
- if "MA" in df.columns:
- fig.add_trace(
- go.Scatter(
- x=df.index,
- y=df["MA"],
- name="BB 중심",
- line=dict(color="#64748b", width=1, dash="dot"),
- ),
- row=1,
- col=1,
- )
- if "Upper" in df.columns:
- fig.add_trace(
- go.Scatter(
- x=df.index,
- y=df["Upper"],
- name="BB 상단",
- line=dict(color="#94a3b8", width=1),
- ),
- row=1,
- col=1,
- )
- if "Lower" in df.columns:
- fig.add_trace(
- go.Scatter(
- x=df.index,
- y=df["Lower"],
- name="BB 하단",
- line=dict(color="#94a3b8", width=1),
- ),
- row=1,
- col=1,
- )
-
- _add_trade_markers(fig, result.trades, row=1)
-
- if not result.trades:
- fig.add_annotation(
- text=(
- f"이 기간에는 체결 신호가 없습니다.
"
- f"전략: {result.config_name} | 추세: {trend}"
- ),
- xref="paper",
- yref="paper",
- x=0.5,
- y=0.88,
- showarrow=False,
- font=dict(size=14, color="#b45309"),
- bgcolor="#fffbeb",
- bordercolor="#f59e0b",
- borderwidth=1,
- )
-
- if "RSI" in df.columns:
- fig.add_trace(
- go.Scatter(
- x=df.index,
- y=df["RSI"],
- name="RSI",
- line=dict(color="#7c3aed"),
- ),
- row=2,
- col=1,
- )
- fig.add_hline(y=70, line_dash="dot", line_color="#9ca3af", row=2, col=1)
- fig.add_hline(y=50, line_dash="dot", line_color="#d1d5db", row=2, col=1)
-
- fig.add_trace(
- go.Bar(
- x=df.index,
- y=df["Volume"],
- name="Volume",
- marker_color="#cbd5e1",
- ),
- row=3,
- col=1,
- )
-
- fig.update_layout(
- height=920,
- template="plotly_white",
- xaxis_rangeslider_visible=False,
- legend=dict(orientation="h", y=1.05, x=0),
- margin=dict(l=60, r=30, t=90, b=40),
- )
- fig.update_yaxes(title_text="가격 (KRW)", row=1, col=1)
- fig.update_yaxes(title_text="RSI", row=2, col=1, range=[0, 100])
-
- chart_html = fig.to_html(full_html=False, include_plotlyjs="cdn")
-
- trade_rows = ""
- for t in result.trades:
- cls = "buy" if t.action == "매수" else "sell"
- pnl = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
- if show_memo_column:
- trade_rows += f"""
-
- | {t.dt} |
- {t.action} |
- ₩{t.price:,.0f} |
- {t.signal} |
-
"""
- else:
- trade_rows += f"""
-
- | {t.dt} |
- {t.action} |
- 체결 |
- ₩{t.price:,.2f} |
- {t.signal} |
- ₩{t.krw:,.0f} |
- ₩{t.fee:,.0f} |
- {pnl} |
-
"""
- if not trade_rows:
- colspan = 4 if show_memo_column else 8
- trade_rows = f'| 체결 없음 |
'
-
- note_html = f"{summary['note']}
" if summary.get("note") else ""
- sells = summary["sell_signal_count"]
- win_rate = (
- summary["win_count"] / sells * 100 if sells else 0.0
- )
- if show_memo_column:
- table_title = "로고스 타점 (직관 해석)"
- table_head = (
- "시각 | 구분 | 가격 | 해석 | "
- )
- else:
- table_title = "체결 내역"
- table_head = (
- "시각 | 구분 | 상태 | 가격 | "
- "신호 | 금액 | 수수료 | 손익 | "
- )
-
- return f"""
-
-
-
- {SYMBOL} {title_suffix}
-
-
-
- {COIN_NAME} ({SYMBOL}) {title_suffix}
- 전략: {summary['config_name']} | 추세: {summary['trend']} | 기간: {summary['period_start']} ~ {summary['period_end']}
- {note_html}
-
- ▲ 매수 범례 클릭 시 마커·라벨 함께 숨김
- ▼ 매도 동일
-
-
-
체결{summary['total_trades']} (매수 {summary['buy_signal_count']} / 매도 {summary['sell_signal_count']})
-
손익₩{summary['pnl_krw']:+,.0f} ({summary['pnl_pct']:+.2f}%)
-
수수료₩{summary['total_fees']:,.0f}
-
승률(매도 기준){win_rate:.1f}%
-
- {chart_html}
- {table_title}
-
- {table_head}
- {trade_rows}
-
-
-"""
-
-
-@dataclass
-class SimTrade:
- dt: pd.Timestamp
- action: str
- signal: str
- price: float
- krw: float
- fee: float
- quantity: float
- pnl: float | None
- cash_after: float
- total_asset: float
-
-
-@dataclass
-class SimResult:
- config_name: str
- trades: list[SimTrade]
- initial_cash: float
- final_cash: float
- final_coin_qty: float
- final_price: float
- realized_pnl: float
- total_fees: float
- final_asset: float
- total_return_pct: float
- trade_count: int
- win_count: int
-
-
-def run_backtest(
- df_3m: pd.DataFrame,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- config_name: str = "",
- initial_cash: float = SIM_INITIAL_CASH_KRW,
- min_order_krw: float = SIM_MIN_ORDER_KRW,
- fee_rate: float = TRADING_FEE_RATE,
-) -> SimResult:
- """신호 순서대로 현물 매수/매도 시뮬레이션 (수수료 차감)."""
- cash = float(initial_cash)
- coin_qty = 0.0
- cost_basis = 0.0
- realized_pnl = 0.0
- total_fees = 0.0
- win_count = 0
- trades: list[SimTrade] = []
- last_buy_ts: pd.Timestamp | None = None
- last_sell_ts: pd.Timestamp | None = None
-
- signals = df_3m[df_3m["point"] == 1].sort_index()
- signals = signals[signals["action"].isin(["buy", "sell"])]
-
- for ts, row in signals.iterrows():
- price = float(row["Close"])
- action = str(row.get("action", ""))
- signal_name = str(row.get("signal", ""))
- if price <= 0:
- continue
-
- trend_at = str(row.get("trend", "")) or strategy.get_trend_at(df_1d, df_1h, ts)
- if trend_at not in ("up", "down", "range"):
- trend_at = strategy.get_trend_at(df_1d, df_1h, ts)
-
- if action == "buy":
- if last_buy_ts is not None:
- if (ts - last_buy_ts).total_seconds() < BUY_COOLDOWN_SEC:
- continue
-
- buy_krw = float(
- strategy.get_buy_amount(SYMBOL, signal_name, price, trend_at)
- )
- buy_krw = max(min_order_krw, min(buy_krw, cash))
- fee = buy_krw * fee_rate
- total_cost = buy_krw + fee
- if buy_krw < min_order_krw or cash < total_cost:
- continue
-
- qty = buy_krw / price
- cash -= total_cost
- total_fees += fee
- cost_basis += buy_krw
- coin_qty += qty
- last_buy_ts = ts
-
- trades.append(
- SimTrade(
- dt=ts,
- action="매수",
- signal=signal_name,
- price=price,
- krw=buy_krw,
- fee=fee,
- quantity=qty,
- pnl=None,
- cash_after=cash,
- total_asset=cash + coin_qty * price,
- )
- )
- continue
-
- if action == "sell":
- if coin_qty <= 0:
- continue
- if last_sell_ts is not None:
- if (ts - last_sell_ts).total_seconds() < SELL_COOLDOWN_SEC:
- continue
-
- ratio = strategy.get_sell_ratio(SYMBOL, signal_name)
- sell_qty = min(coin_qty * ratio, coin_qty)
- sell_krw = sell_qty * price
-
- if sell_krw < min_order_krw:
- if coin_qty * price < min_order_krw:
- continue
- sell_qty = coin_qty
- sell_krw = sell_qty * price
-
- fee = sell_krw * fee_rate
- net = sell_krw - fee
- avg_cost = cost_basis / coin_qty
- sold_cost = avg_cost * sell_qty
- pnl = net - sold_cost
-
- cash += net
- total_fees += fee
- cost_basis -= sold_cost
- coin_qty -= sell_qty
- realized_pnl += pnl
- if pnl > 0:
- win_count += 1
- if coin_qty < 1e-12:
- coin_qty = 0.0
- cost_basis = 0.0
- last_sell_ts = ts
-
- trades.append(
- SimTrade(
- dt=ts,
- action="매도",
- signal=signal_name,
- price=price,
- krw=sell_krw,
- fee=fee,
- quantity=sell_qty,
- pnl=pnl,
- cash_after=cash,
- total_asset=cash + coin_qty * price,
- )
- )
-
- final_price = float(df_3m["Close"].iloc[-1])
- final_asset = cash + coin_qty * final_price
- sell_trades = sum(1 for t in trades if t.action == "매도")
-
- return SimResult(
- config_name=config_name,
- trades=trades,
- initial_cash=initial_cash,
- final_cash=cash,
- final_coin_qty=coin_qty,
- final_price=final_price,
- realized_pnl=realized_pnl,
- total_fees=total_fees,
- final_asset=final_asset,
- total_return_pct=(final_asset - initial_cash) / initial_cash * 100
- if initial_cash > 0
- else 0.0,
- trade_count=len(trades),
- win_count=win_count if sell_trades else 0,
- )
-
-
-def print_backtest_report(result: SimResult) -> None:
- fee_pct = TRADING_FEE_RATE * 100
- print("\n" + "=" * 80)
- print(
- f"[{result.config_name}] 시작 {result.initial_cash:,.0f}원 | "
- f"최소주문 {SIM_MIN_ORDER_KRW:,.0f}원 | 수수료 {fee_pct:.3f}%/쪽"
- )
- print("=" * 80)
- if not result.trades:
- print("체결 없음")
- else:
- print(
- f"{'일시':<18} {'구분':<4} {'신호':<22} {'가격':>9} {'금액':>10} "
- f"{'수수료':>8} {'수익':>10}"
- )
- print("-" * 80)
- for t in result.trades:
- pnl_s = f"{t.pnl:+,.0f}" if t.pnl is not None else "-"
- print(
- f"{t.dt.strftime('%Y-%m-%d %H:%M'):<18} {t.action:<4} {t.signal:<22} "
- f"{t.price:>9,.2f} {t.krw:>10,.0f} {t.fee:>8,.0f} {pnl_s:>10}"
- )
- print("-" * 80)
- sells = sum(1 for t in result.trades if t.action == "매도")
- win_rate = result.win_count / sells * 100 if sells else 0.0
- print(f"거래 횟수: {result.trade_count} (매도 {sells}회) | 승률: {win_rate:.1f}%")
- print(f"수수료 합계: {result.total_fees:,.0f}원")
- print(f"실현 손익(수수료 반영): {result.realized_pnl:+,.0f}원")
- print(
- f"최종 자산: {result.final_asset:,.0f}원 | "
- f"총수익: {result.final_asset - result.initial_cash:+,.0f}원 "
- f"({result.total_return_pct:+.2f}%)"
- )
- print("=" * 80)
-
-
-def run_comparison(df_1d: pd.DataFrame, df_1h: pd.DataFrame, df_3m: pd.DataFrame) -> None:
- """기법 조합별 수익률 비교 (수수료 포함)."""
- print(f"\n{'='*80}")
- print(f"전략 조합 비교 — {SYMBOL} 3분 | {df_3m.index[0]} ~ {df_3m.index[-1]}")
- print(f"시작 {SIM_INITIAL_CASH_KRW:,}원 | 수수료 {TRADING_FEE_RATE*100:.3f}%/매수·매도")
- print(f"{'='*80}")
- print(
- f"{'순위':<4} {'조합':<22} {'수익률':>9} {'최종자산':>12} "
- f"{'거래':>6} {'승률':>7} {'수수료':>10}"
- )
- print("-" * 80)
-
- rows: list[tuple[SimResult, strategy.StrategyConfig]] = []
- for cfg in strategy.comparison_presets():
- df_sig = strategy.annotate_signals(
- SYMBOL,
- df_3m.copy(),
- simulation=True,
- df_1h=df_1h,
- df_1d=df_1d,
- config=cfg,
- )
- res = run_backtest(df_sig, df_1d, df_1h, config_name=cfg.name)
- rows.append((res, cfg))
-
- rows.sort(key=lambda x: x[0].total_return_pct, reverse=True)
-
- for rank, (res, cfg) in enumerate(rows, 1):
- sells = sum(1 for t in res.trades if t.action == "매도")
- wr = res.win_count / sells * 100 if sells else 0.0
- print(
- f"{rank:<4} {res.config_name:<22} {res.total_return_pct:>+8.2f}% "
- f"{res.final_asset:>12,.0f} {res.trade_count:>6} {wr:>6.1f}% "
- f"{res.total_fees:>10,.0f}"
- )
-
- best_res, best_cfg = rows[0]
- print("-" * 80)
- print(f"1위: {best_cfg.name} ({best_res.total_return_pct:+.2f}%)")
- print(
- "실거래 적용: strategy.ACTIVE_CONFIG 를 1위 조합으로 맞추세요 "
- "(현재 ACTIVE_CONFIG.name=%s)" % strategy.ACTIVE_CONFIG.name
- )
- print(f"{'='*80}\n")
-
-
-class Simulation:
- def __init__(self) -> None:
- self.monitor = Monitor(cooldown_file=None)
-
- def load_mtf(self, symbol: str):
- df_1d = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1D)
- df_1h = self.monitor.get_coin_some_data(symbol, TREND_INTERVAL_1H)
- df_3m = self.monitor.get_coin_some_data(symbol, ENTRY_INTERVAL)
-
- if df_1d is None or df_1d.empty:
- df_1d = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1D, bong_count=500)
- if df_1h is None or df_1h.empty:
- df_1h = self.monitor.get_coin_more_data(symbol, TREND_INTERVAL_1H, bong_count=5000)
- if df_3m is None or df_3m.empty:
- df_3m = self.monitor.get_coin_more_data(
- symbol, ENTRY_INTERVAL, bong_count=90000, verbose=True
- )
-
- df_1d = self.monitor.calculate_technical_indicators(df_1d)
- df_1h = self.monitor.calculate_technical_indicators(df_1h)
- df_3m = self.monitor.calculate_technical_indicators(df_3m)
- return df_1d, df_1h, df_3m
-
- def render_plotly(
- self,
- df_plot: pd.DataFrame,
- trend: str,
- result: SimResult,
- interval_min: int = ENTRY_INTERVAL,
- note: str = "",
- open_browser: bool = True,
- ) -> Path:
- """HTML 리포트 저장 (simulate_mtf.py 동일 스타일)."""
- html = build_simulation_html(
- df_plot, result, trend, interval_min=interval_min, note=note
- )
- REPORT_DIR.mkdir(parents=True, exist_ok=True)
- OUTPUT_HTML.write_text(html, encoding="utf-8")
- print(f"HTML: {OUTPUT_HTML}")
- if open_browser:
- webbrowser.open(OUTPUT_HTML.resolve().as_uri())
- return OUTPUT_HTML
-
- def render_logos_html(
- self,
- df_plot: pd.DataFrame,
- result: SimResult,
- trend: str,
- note: str = "",
- open_browser: bool = True,
- ) -> Path:
- """로고스 직관 타점 HTML (규칙 엔진 미사용)."""
- html = build_simulation_html(
- df_plot,
- result,
- trend,
- note=note,
- title_suffix="로고스 직관 타점 (3분봉)",
- show_memo_column=True,
- )
- REPORT_DIR.mkdir(parents=True, exist_ok=True)
- LOGOS_BENCHMARK_HTML.write_text(html, encoding="utf-8")
- print(f"HTML: {LOGOS_BENCHMARK_HTML}")
- if open_browser:
- webbrowser.open(LOGOS_BENCHMARK_HTML.resolve().as_uri())
- return LOGOS_BENCHMARK_HTML
-
- def run_logos_strategy_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult:
- """
- 로고스 전략(logos_strategy.py) 백테스트·HTML.
-
- 설계: docs/LOGOS_STRATEGY.md
- """
- from candle_features import build_master_feature_matrix
- from logos_strategy import generate_logos_events
-
- df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
- trend = strategy.get_trend(df_1d, df_1h)
- matrix = build_master_feature_matrix(frames).iloc[21:].copy()
- df_sig = strategy.prepare_entry_df(df_3m)
- df_sig["signal"] = ""
- df_sig["point"] = 0
- df_sig["action"] = ""
- df_sig["trend"] = ""
-
- logos_events = generate_logos_events(matrix, df_1d, df_1h)
- for ts, action, sig in logos_events:
- if ts not in df_sig.index:
- continue
- df_sig.at[ts, "signal"] = sig
- df_sig.at[ts, "point"] = 1
- df_sig.at[ts, "action"] = action
- df_sig.at[ts, "trend"] = strategy.get_trend_at(df_1d, df_1h, ts)
-
- n_sig = int((df_sig["point"] == 1).sum())
- print(f"\n[로고스 전략] 체결 {n_sig}건")
- from logos_strategy import compare_to_ground_truth
-
- cmp_rows = compare_to_ground_truth(logos_events, matrix)
- if cmp_rows:
- matched = sum(1 for r in cmp_rows if r["match"])
- print(f"[정답 비교] {matched}/{len(cmp_rows)} 타점 일치 (±20봉·±6%)")
- for r in cmp_rows:
- mark = "O" if r["match"] else "X"
- cand = r.get("cand_dt") or "-"
- print(
- f" [{mark}] {r['gt_action']} {r['gt_dt'][:16]} "
- f"-> {str(cand)[:16]} ({r['score_pct']}%)"
- )
- result = run_backtest(df_sig, df_1d, df_1h, config_name="logos_strategy")
- print_backtest_report(result)
-
- html = build_simulation_html(
- df_sig,
- result,
- trend,
- note="로고스 전략: docs/LOGOS_STRATEGY.md (A바닥/B눌림/C익절/D과열)",
- title_suffix="로고스 전략 (3분봉)",
- )
- REPORT_DIR.mkdir(parents=True, exist_ok=True)
- OUTPUT_HTML.write_text(html, encoding="utf-8")
- print(f"HTML: {OUTPUT_HTML}")
- import webbrowser
-
- webbrowser.open(OUTPUT_HTML.resolve().as_uri())
- return result
-
- def run_logos_chart(self, frames: dict[int, pd.DataFrame]) -> SimResult:
- """
- 차트 해석 기반 수동 타점을 전 기간 3분봉에 표시합니다.
-
- 데이터: logos_trades.json (BB/탐색 규칙과 무관)
- """
- import json
-
- if not LOGOS_TRADES_FILE.exists():
- raise FileNotFoundError(f"{LOGOS_TRADES_FILE} 없음")
-
- spec = json.loads(LOGOS_TRADES_FILE.read_text(encoding="utf-8"))
- df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
- df_3m = strategy.prepare_entry_df(df_3m)
- trend = strategy.get_trend(df_1d, df_1h)
-
- buy_krw = float(DEFAULT_BUY_KRW)
- fee_rate = TRADING_FEE_RATE
- cash = float(SIM_INITIAL_CASH_KRW)
- qty = 0.0
- cost = 0.0
- sim_trades: list[SimTrade] = []
- win = 0
-
- for row in spec.get("trades") or []:
- ts = pd.Timestamp(row["dt"])
- act = row["action"]
- px = float(row["price"])
- memo = str(row.get("memo", ""))
- if act == "buy":
- fee = buy_krw * fee_rate
- cash -= buy_krw + fee
- qty += buy_krw / px
- cost += buy_krw
- sim_trades.append(
- SimTrade(
- dt=ts,
- action="매수",
- signal=memo,
- price=px,
- krw=buy_krw,
- fee=fee,
- quantity=buy_krw / px,
- pnl=None,
- cash_after=cash,
- total_asset=cash + qty * px,
- )
- )
- elif act == "sell" and qty > 0:
- sell_krw = qty * px
- fee = sell_krw * fee_rate
- net = sell_krw - fee
- pnl = net - cost
- if pnl > 0:
- win += 1
- cash += net
- sim_trades.append(
- SimTrade(
- dt=ts,
- action="매도",
- signal=memo,
- price=px,
- krw=sell_krw,
- fee=fee,
- quantity=qty,
- pnl=pnl,
- cash_after=cash,
- total_asset=cash,
- )
- )
- qty = 0.0
- cost = 0.0
-
- last_px = float(df_3m["Close"].iloc[-1])
- result = SimResult(
- config_name=spec.get("name", "logos"),
- trades=sim_trades,
- initial_cash=SIM_INITIAL_CASH_KRW,
- final_cash=cash,
- final_coin_qty=qty,
- final_price=last_px,
- realized_pnl=sum(t.pnl or 0 for t in sim_trades if t.pnl),
- total_fees=sum(t.fee for t in sim_trades),
- final_asset=cash + qty * last_px,
- total_return_pct=(cash + qty * last_px - SIM_INITIAL_CASH_KRW)
- / SIM_INITIAL_CASH_KRW
- * 100,
- trade_count=len(sim_trades),
- win_count=win,
- )
-
- print(f"\n[로고스 직관 타점] {spec.get('author', 'Logos')}")
- print(f" 기간: {df_3m.index[0]} ~ {df_3m.index[-1]}")
- print(f" 타점 {len(sim_trades)}개 (규칙 엔진·BB 조건 미사용)")
- for t in sim_trades:
- print(f" {t.dt} {t.action} ₩{t.price:,.0f} — {t.signal}")
- print_backtest_report(result)
-
- note = spec.get("note", "")
- path = self.render_logos_html(df_3m, result, trend, note=note, open_browser=True)
- print(f"\n차트 파일: {path.resolve()}")
- print("브라우저에서 열리지 않으면 위 경로를 더블클릭하세요.")
- return result
-
- def load_all_frames(self) -> dict[int, pd.DataFrame]:
- """discovered 규칙용 전 간격 로드."""
- from mtf_bb import load_frames_from_db
-
- return load_frames_from_db(self.monitor, SYMBOL)
-
- def _run_one_strategy(
- self,
- name: str,
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- df_3m: pd.DataFrame,
- cfg: strategy.StrategyConfig,
- frames: dict | None = None,
- ) -> tuple[pd.DataFrame, SimResult, int]:
- """한 전략으로 신호·백테스트. 반환: (df, result, 신호수)."""
- df_sig = strategy.annotate_signals(
- SYMBOL,
- df_3m.copy(),
- simulation=True,
- df_1h=df_1h,
- df_1d=df_1d,
- config=cfg,
- frames=frames,
- )
- n_sig = int((df_sig["point"] == 1).sum())
- res = run_backtest(df_sig, df_1d, df_1h, config_name=name)
- return df_sig, res, n_sig
-
- def _frames_to_mtf(
- self, frames: dict[int, pd.DataFrame]
- ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
- """전 간격 frames에서 1d/1h/3m 추출."""
- df_3m = frames.get(ENTRY_INTERVAL)
- if df_3m is None or df_3m.empty:
- raise ValueError(f"{ENTRY_INTERVAL}분봉 데이터 없음")
- df_1d = frames.get(TREND_INTERVAL_1D)
- if df_1d is None or df_1d.empty:
- df_1d = df_3m
- df_1h = frames.get(TREND_INTERVAL_1H)
- if df_1h is None or df_1h.empty:
- df_1h = df_3m
- return df_1d, df_1h, df_3m
-
- def run_discovered_chart(
- self,
- frames: dict[int, pd.DataFrame],
- rules=None,
- ) -> SimResult:
- """
- discovered_rules 매수·매도 규칙만 백테스트하고 HTML에 표시합니다.
-
- 차트 마커 = 해당 규칙으로 발생한 매수·매도 체결.
- """
- from rule_discovery import DiscoveredRules, load_rules, rules_have_buy
-
- rule_set = rules or load_rules()
- if rule_set is None or not rules_have_buy(rule_set):
- raise FileNotFoundError(
- "discovered_rules.json 이 없거나 매수 규칙이 비어 있습니다."
- )
-
- df_1d, df_1h, df_3m = self._frames_to_mtf(frames)
- trend = strategy.get_trend(df_1d, df_1h)
- print(f"추세(최신): {trend}")
- print(f"3분: {df_3m.index[0]} ~ {df_3m.index[-1]} ({len(df_3m)}봉)")
- print(f"\n[적용 규칙] {rule_set.name}")
- print(f" 매수 AND: {rule_set.buy_all}")
- if rule_set.buy_any:
- print(f" 매수 OR: {rule_set.buy_any}")
- print(f" 매도 AND: {rule_set.sell_all}")
- if rule_set.sell_stop:
- print(f" 손절: {rule_set.sell_stop}")
-
- df_sig = strategy.annotate_discovered_signals(
- SYMBOL, frames, df_1d, df_1h, rules=rule_set, data=df_3m
- )
- n_sig = int((df_sig["point"] == 1).sum())
- buy_sig = int((df_sig["action"] == "buy").sum())
- sell_sig = int((df_sig["action"] == "sell").sum())
- print(f"\n체결 신호: {n_sig} (매수 {buy_sig} / 매도 {sell_sig})")
-
- result = run_backtest(df_sig, df_1d, df_1h, config_name=rule_set.name)
- print_backtest_report(result)
-
- note = (
- f"매수 규칙: {rule_set.buy_all}"
- + (f" | OR {rule_set.buy_any}" if rule_set.buy_any else "")
- + f" | 매도: {rule_set.sell_all}"
- )
- self.render_plotly(df_sig, trend, result, note=note)
- return result
-
-
-def run_mtf_analysis() -> None:
- """봉별 BB 백테스트 비교, 정책 저장, MTF 시뮬 차트."""
- from mtf_bb import apply_policy, load_frames_from_db, run_interval_comparison, save_policy
-
- monitor = Monitor()
- policy, _ = run_interval_comparison(monitor)
- save_policy(policy)
- apply_policy(policy)
-
- frames = load_frames_from_db(monitor, SYMBOL)
- df_1d = frames.get(TREND_INTERVAL_1D)
- if df_1d is None or df_1d.empty:
- df_1d = frames[ENTRY_INTERVAL]
- df_1h = frames.get(TREND_INTERVAL_1H)
- if df_1h is None or df_1h.empty:
- df_1h = frames[ENTRY_INTERVAL]
-
- cfg = strategy.StrategyConfig(
- name="MTF_BB",
- use_mtf=True,
- use_regime_switch=strategy.ACTIVE_CONFIG.use_regime_switch,
- use_rsi_filter=False,
- use_volume_filter=False,
- use_squeeze_filter=False,
- use_stop_loss=True,
- )
- df_sig = strategy.annotate_mtf_signals(SYMBOL, frames, df_1d, df_1h, policy, cfg)
- trend = strategy.get_trend(df_1d, df_1h)
- print(f"\nMTF 시뮬 ({policy.name}) | 추세: {trend}")
- result = run_backtest(df_sig, df_1d, df_1h, config_name=policy.name)
- print_backtest_report(result)
- Simulation().render_plotly(
- df_sig,
- trend,
- result,
- interval_min=policy.buy_interval,
- note=f"MTF 정책: 매수 {policy.buy_interval}분 / 확인 {policy.buy_confirm_intervals}",
- )
-
-
-def _load_all_frames_or_exit() -> dict[int, pd.DataFrame] | None:
- """coins.db 전 간격 로드. 부족 시 None."""
- from rule_discovery import load_frames
-
- monitor = Monitor(cooldown_file=None)
- frames = load_frames(monitor)
- if len(frames) < 3:
- print("coins.db 데이터 부족. python downloader.py 실행 후 재시도.")
- return None
- return frames
-
-
-def run_analyze(frames: dict[int, pd.DataFrame] | None = None) -> None:
- """전 봉 BB·일목 위치 조합 분석."""
- from combination_analyzer import analyze_combinations, save_report
-
- if frames is None:
- print("=== 전 봉 BB·일목 조합 분석 ===")
- frames = _load_all_frames_or_exit()
- if frames is None:
- return
- report = analyze_combinations(frames)
- save_report(report)
-
-
-def run_discover(frames: dict[int, pd.DataFrame] | None = None):
- """
- 모든 봉·BB·일목 특징으로 최적 규칙 탐색 후 JSON 저장.
-
- Args:
- frames: 전 간격 OHLCV. None이면 coins.db에서 로드.
-
- Returns:
- DiscoveredRules 또는 데이터/탐색 실패 시 None.
- """
- from rule_discovery import discover_rules, save_rules
-
- if frames is None:
- print("=== 규칙 탐색 (discover) ===")
- frames = _load_all_frames_or_exit()
- if frames is None:
- return None
- rules = discover_rules(frames)
- save_rules(rules)
- print(f"\n저장: discovered_rules.json")
- return rules
-
-
-def run_logos_benchmark() -> None:
- """수동 벤치마크 타점 차트 (logos_trades.json, 참고용)."""
- print("=== 로고스 수동 벤치마크 차트 ===")
- frames = _load_all_frames_or_exit()
- if frames is None:
- return
- Simulation().run_logos_chart(frames)
- print("\n완료.")
-
-
-def run_full_pipeline() -> None:
- """로고스 전략 백테스트·HTML (단일 진입점)."""
- print("=" * 60)
- print("로고스 전략: 백테스트 → HTML")
- print("=" * 60)
- frames = _load_all_frames_or_exit()
- if frames is None:
- return
- Simulation().run_logos_strategy_chart(frames)
- print("\n완료.")
-
-
-def print_usage() -> None:
- print(
- """
-DeepCoin simulation.py
-
- python simulation.py
- 로고스 전략 백테스트·HTML → reports/wld_bb_simulation.html
-
- python simulation.py benchmark
- 수동 벤치마크(logos_trades.json) → reports/wld_logos_benchmark.html
-
- (고급) analyze | discover | compare | mtf
-"""
- )
-
-
-def main() -> None:
- sim = Simulation()
- if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
- print_usage()
- return
- if len(sys.argv) == 1 or (len(sys.argv) > 1 and sys.argv[1] in ("all", "chart", "html")):
- if len(sys.argv) > 1 and sys.argv[1] in ("chart", "html"):
- print("참고: chart/html 옵션은 제거되었습니다. python simulation.py 만 사용하세요.\n")
- run_full_pipeline()
- return
- if len(sys.argv) > 1 and sys.argv[1] == "analyze":
- run_analyze()
- return
- if len(sys.argv) > 1 and sys.argv[1] == "discover":
- run_discover()
- return
- if len(sys.argv) > 1 and sys.argv[1] in ("benchmark", "logos"):
- run_logos_benchmark()
- return
- if len(sys.argv) > 1 and sys.argv[1] == "mtf":
- run_mtf_analysis()
- return
- if len(sys.argv) > 1 and sys.argv[1] == "compare":
- df_1d, df_1h, df_3m = sim.load_mtf(SYMBOL)
- run_comparison(df_1d, df_1h, df_3m)
- return
- print(f"알 수 없는 옵션: {sys.argv[1]}\n")
- print_usage()
-
-
-if __name__ == "__main__":
- main()
diff --git a/strategy.py b/strategy.py
deleted file mode 100644
index ca204c9..0000000
--- a/strategy.py
+++ /dev/null
@@ -1,677 +0,0 @@
-"""
-WLD 볼린저밴드 MTF 전략.
-
-기본 타이밍 (모든 봉 동일):
- - 매수: 하단 밴드 상향 돌파 (prev_close <= Lower, close > Lower)
- - 매도: 상단 밴드 상향 돌파 (prev_close < Upper, close >= Upper)
-
-여러 봉(3·10·15·30·60·240·1440분)의 BB 상태를 비교해
-실행 봉·확인 봉을 정한 뒤 매매합니다 (MtfBbPolicy).
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass, field
-from typing import Literal
-
-import numpy as np
-import pandas as pd
-
-from config import (
- BB_MIN_WIDTH_PCT,
- DEFAULT_BUY_KRW,
- ENTRY_INTERVAL,
- RANGE_BUY_KRW,
- RSI_BUY_MAX,
- RSI_PERIOD,
- TREND_RANGE_MA_GAP_PCT,
- VOLUME_BUY_RATIO,
-)
-
-Trend = Literal["up", "down", "range"]
-Action = Literal["buy", "sell"]
-
-# 기본 신호
-SIGNAL_BUY_LOWER = "bb_base_buy_lower"
-SIGNAL_SELL_UPPER = "bb_base_sell_upper"
-SIGNAL_SELL_STOP = "bb_base_sell_stop"
-
-BUY_SIGNALS = {SIGNAL_BUY_LOWER}
-SELL_SIGNALS = {SIGNAL_SELL_UPPER, SIGNAL_SELL_STOP}
-
-# 봉별 BB 상태 (분석·확인용)
-STATE_SQUEEZE = "squeeze"
-STATE_CROSS_UP_LOWER = "cross_up_lower"
-STATE_CROSS_UP_UPPER = "cross_up_upper"
-STATE_CROSS_DOWN_LOWER = "cross_down_lower"
-STATE_BELOW_LOWER = "below_lower"
-STATE_ABOVE_UPPER = "above_upper"
-STATE_INSIDE = "inside"
-
-BUY_TRIGGER_STATE = STATE_CROSS_UP_LOWER
-SELL_TRIGGER_STATE = STATE_CROSS_UP_UPPER
-STOP_TRIGGER_STATE = STATE_CROSS_DOWN_LOWER
-
-# 확인 봉에서 매수 허용/차단 상태
-BUY_CONFIRM_OK = {
- STATE_INSIDE,
- STATE_BELOW_LOWER,
- STATE_CROSS_UP_LOWER,
- STATE_SQUEEZE,
-}
-BUY_CONFIRM_BLOCK = {
- STATE_CROSS_UP_UPPER,
- STATE_ABOVE_UPPER,
- STATE_CROSS_DOWN_LOWER,
-}
-SELL_CONFIRM_OK = {
- STATE_INSIDE,
- STATE_ABOVE_UPPER,
- STATE_CROSS_UP_UPPER,
- STATE_CROSS_UP_LOWER,
- STATE_BELOW_LOWER,
-}
-
-
-@dataclass
-class StrategyConfig:
- """전략 조합 설정 (시뮬 비교·실거래 공용)."""
-
- name: str = "default"
- use_mtf: bool = True
- use_regime_switch: bool = True
- use_rsi_filter: bool = True
- rsi_buy_max: float = RSI_BUY_MAX
- use_volume_filter: bool = False
- volume_ratio: float = VOLUME_BUY_RATIO
- use_squeeze_filter: bool = True
- use_stop_loss: bool = True
- allow_buy_in_up: bool = True
- allow_buy_in_range: bool = True
- allow_buy_in_down: bool = False
- use_discovered_rules: bool = False
-
-
-# HTML 시뮬: discovered_rules.json (python simulation.py discover)
-ACTIVE_CONFIG = StrategyConfig(
- name="discovered_best",
- use_discovered_rules=True,
- use_mtf=False,
- use_regime_switch=False,
- use_rsi_filter=False,
- use_volume_filter=False,
- use_squeeze_filter=False,
- use_stop_loss=False,
-)
-
-
-@dataclass
-class MtfBbPolicy:
- """
- 봉 간격별 BB 비교 후 적용할 매매 정책.
-
- buy_interval / sell_interval: 실제 주문 트리거 봉
- buy_confirm_intervals: 매수 시 함께 봐야 할 상위(긴) 봉
- """
-
- buy_interval: int = 3
- sell_interval: int = 3
- buy_confirm_intervals: tuple[int, ...] = (60, 1440)
- sell_confirm_intervals: tuple[int, ...] = (60,)
- name: str = "default_mtf"
-
-
-ACTIVE_MTF_POLICY = MtfBbPolicy(
- name="3분실행_60·일봉확인",
- buy_interval=3,
- sell_interval=3,
- buy_confirm_intervals=(60, 1440),
- sell_confirm_intervals=(60,),
-)
-
-
-@dataclass
-class TradeSignal:
- """실시간 1회 매매 신호."""
-
- action: Action
- signal: str
- close: float
- trend: Trend
-
-
-def prepare_entry_df(data: pd.DataFrame) -> pd.DataFrame:
- """3분봉 보조 지표 추가."""
- df = data.copy()
- delta = df["Close"].diff()
- gain = delta.where(delta > 0, 0.0).rolling(RSI_PERIOD).mean()
- loss = (-delta.where(delta < 0, 0.0)).rolling(RSI_PERIOD).mean()
- rs = gain / loss.replace(0, np.nan)
- df["RSI"] = 100 - (100 / (1 + rs))
- df["VolMA5"] = df["Volume"].rolling(5).mean()
- ma = df["MA"].replace(0, np.nan)
- df["BB_Width"] = (df["Upper"] - df["Lower"]) / ma * 100
- return df
-
-
-def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
- """일봉·1시간봉 기준 추세."""
- if len(df_1d) < 20 or len(df_1h) < 40:
- return "range"
-
- d_close = float(df_1d["Close"].iloc[-1])
- d_ma20 = float(df_1d["MA20"].iloc[-1])
- h_close = float(df_1h["Close"].iloc[-1])
- h_ma20 = float(df_1h["MA20"].iloc[-1])
- h_ma40 = float(df_1h["MA40"].iloc[-1])
-
- if h_ma40 == 0:
- return "range"
-
- ma_gap_pct = abs(h_ma20 - h_ma40) / h_ma40 * 100
- if ma_gap_pct < TREND_RANGE_MA_GAP_PCT:
- return "range"
-
- if d_close > d_ma20 and h_ma20 > h_ma40 and h_close > h_ma20:
- return "up"
- if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
- return "down"
- return "range"
-
-
-def get_trend_at(
- df_1d: pd.DataFrame, df_1h: pd.DataFrame, ts: pd.Timestamp
-) -> Trend:
- """특정 시점까지의 데이터로 추세 판별 (백테스트용)."""
- d = df_1d[df_1d.index <= ts]
- h = df_1h[df_1h.index <= ts]
- if d.empty or h.empty:
- return "range"
- return get_trend(d, h)
-
-
-def _bb_squeeze(bb_width: float) -> bool:
- return bb_width < BB_MIN_WIDTH_PCT
-
-
-def classify_bb_state_at(
- df: pd.DataFrame,
- index_pos: int,
- cfg: StrategyConfig | None = None,
-) -> str:
- """
- 한 봉의 볼린저밴드 상태를 분류합니다.
-
- Returns:
- cross_up_lower, cross_up_upper, cross_down_lower, below_lower,
- above_upper, inside, squeeze
- """
- cfg = cfg or ACTIVE_CONFIG
- if index_pos < 0:
- index_pos = len(df) + index_pos
- if index_pos < 1 or index_pos >= len(df):
- return STATE_INSIDE
-
- close = float(df["Close"].iloc[index_pos])
- prev_close = float(df["Close"].iloc[index_pos - 1])
- lower = float(df["Lower"].iloc[index_pos])
- prev_lower = float(df["Lower"].iloc[index_pos - 1])
- upper = float(df["Upper"].iloc[index_pos])
- prev_upper = float(df["Upper"].iloc[index_pos - 1])
- bb_width = float(df["BB_Width"].iloc[index_pos]) if "BB_Width" in df.columns else 999.0
-
- if cfg.use_squeeze_filter and _bb_squeeze(bb_width):
- return STATE_SQUEEZE
- if prev_close <= prev_lower and close > lower:
- return STATE_CROSS_UP_LOWER
- if prev_close < prev_upper and close >= upper:
- return STATE_CROSS_UP_UPPER
- if prev_close >= prev_lower and close < lower:
- return STATE_CROSS_DOWN_LOWER
- if close < lower:
- return STATE_BELOW_LOWER
- if close > upper:
- return STATE_ABOVE_UPPER
- return STATE_INSIDE
-
-
-def get_latest_bb_state(df: pd.DataFrame, cfg: StrategyConfig | None = None) -> str:
- """마지막 봉의 BB 상태."""
- df = prepare_entry_df(df)
- if len(df) < 2:
- return STATE_INSIDE
- return classify_bb_state_at(df, -1, cfg)
-
-
-def _allow_buy(trend: Trend, cfg: StrategyConfig) -> bool:
- """레짐·MTF에 따른 매수 허용 여부."""
- if cfg.use_regime_switch:
- if trend == "down":
- return cfg.allow_buy_in_down
- if trend == "range":
- return cfg.allow_buy_in_range
- if trend == "up":
- return cfg.allow_buy_in_up
- if cfg.use_mtf and trend == "down":
- return False
- return True
-
-
-def evaluate_interval_bb(
- symbol: str,
- df_entry: pd.DataFrame,
- config: StrategyConfig | None = None,
-) -> TradeSignal | None:
- """
- 단일 봉 간격에서 BB 기본 규칙만으로 신호 1건.
-
- 매도 우선 → 매수.
- """
- cfg = config or ACTIVE_CONFIG
- df = prepare_entry_df(df_entry)
- if len(df) < 22:
- return None
-
- state = classify_bb_state_at(df, -1, cfg)
- close = float(df["Close"].iloc[-1])
- trend: Trend = "range"
-
- if cfg.use_stop_loss and state == STATE_CROSS_DOWN_LOWER:
- return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
- if state == STATE_CROSS_UP_UPPER:
- return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
- if state == STATE_CROSS_UP_LOWER:
- if cfg.use_rsi_filter:
- rsi = float(df["RSI"].iloc[-1])
- if not np.isnan(rsi) and rsi > cfg.rsi_buy_max:
- return None
- if cfg.use_volume_filter:
- vol = float(df["Volume"].iloc[-1])
- vol_ma = float(df["VolMA5"].iloc[-1])
- if vol_ma > 0 and vol < vol_ma * cfg.volume_ratio:
- return None
- return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
- return None
-
-
-def _confirm_buy(states: dict[int, str], policy: MtfBbPolicy) -> bool:
- """상위 봉 상태가 매수에 적합한지."""
- for iv in policy.buy_confirm_intervals:
- st = states.get(iv)
- if st is None:
- return False
- if st in BUY_CONFIRM_BLOCK:
- return False
- if st not in BUY_CONFIRM_OK and st != STATE_SQUEEZE:
- return False
- return True
-
-
-def _confirm_sell(states: dict[int, str], policy: MtfBbPolicy) -> bool:
- """상위 봉 상태가 매도에 적합한지."""
- for iv in policy.sell_confirm_intervals:
- st = states.get(iv)
- if st is None:
- continue
- if st in {STATE_CROSS_DOWN_LOWER, STATE_BELOW_LOWER}:
- return False
- return True
-
-
-def evaluate_mtf_bb(
- symbol: str,
- frames: dict[int, pd.DataFrame],
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- policy: MtfBbPolicy | None = None,
- config: StrategyConfig | None = None,
-) -> TradeSignal | None:
- """
- 여러 봉의 BB 상태를 비교해 최종 매수/매도 1건을 결정합니다.
-
- 1) 각 봉 최신 BB 상태 분류
- 2) policy.sell_interval 에서 손절/상단돌파 → 매도
- 3) policy.buy_interval 에서 하단돌파 + 확인봉 OK → 매수
- 4) 일봉·1시간 추세로 하락장 매수 차단 (config)
- """
- cfg = config or ACTIVE_CONFIG
- policy = policy or ACTIVE_MTF_POLICY
- if policy.buy_interval not in frames or policy.sell_interval not in frames:
- return None
-
- states = {iv: get_latest_bb_state(frames[iv], cfg) for iv in frames}
- trend = get_trend(df_1d, df_1h)
-
- sell_df = prepare_entry_df(frames[policy.sell_interval])
- sell_close = float(sell_df["Close"].iloc[-1])
- sell_state = states[policy.sell_interval]
-
- if cfg.use_stop_loss and sell_state == STATE_CROSS_DOWN_LOWER:
- if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
- return TradeSignal("sell", SIGNAL_SELL_STOP, sell_close, trend)
-
- if sell_state == STATE_CROSS_UP_UPPER:
- if not policy.sell_confirm_intervals or _confirm_sell(states, policy):
- return TradeSignal("sell", SIGNAL_SELL_UPPER, sell_close, trend)
-
- buy_state = states[policy.buy_interval]
- if buy_state != STATE_CROSS_UP_LOWER:
- return None
- if not _allow_buy(trend, cfg):
- return None
- if not _confirm_buy(states, policy):
- return None
-
- buy_close = float(frames[policy.buy_interval]["Close"].iloc[-1])
- sig = evaluate_interval_bb(symbol, frames[policy.buy_interval], cfg)
- if sig and sig.action == "buy":
- return TradeSignal("buy", sig.signal, buy_close, trend)
- return None
-
-
-def evaluate(
- symbol: str,
- df_entry: pd.DataFrame,
- df_1h: pd.DataFrame,
- df_1d: pd.DataFrame,
- config: StrategyConfig | None = None,
- trend_override: Trend | None = None,
- frames: dict[int, pd.DataFrame] | None = None,
- policy: MtfBbPolicy | None = None,
-) -> TradeSignal | None:
- """
- 매매 신호 1건.
-
- frames 가 있으면 evaluate_mtf_bb, 없으면 df_entry 단일 봉 BB.
- """
- if frames:
- return evaluate_mtf_bb(symbol, frames, df_1d, df_1h, policy, config)
- cfg = config or ACTIVE_CONFIG
- trend = trend_override if trend_override else get_trend(df_1d, df_1h)
- sig = evaluate_interval_bb(symbol, df_entry, cfg)
- if sig is None:
- return None
- if sig.action == "buy" and not _allow_buy(trend, cfg):
- return None
- return TradeSignal(sig.action, sig.signal, sig.close, trend)
-
-
-def annotate_interval_signals(
- symbol: str,
- data: pd.DataFrame,
- simulation: bool | None = None,
- config: StrategyConfig | None = None,
-) -> pd.DataFrame:
- """단일 봉 간격 전체에 BB 신호 기록 (봉별 백테스트용)."""
- cfg = config or ACTIVE_CONFIG
- df = prepare_entry_df(data)
- df["signal"] = ""
- df["point"] = 0
- df["action"] = ""
- df["bb_state"] = ""
-
- for i in range(21, len(df)):
- st = classify_bb_state_at(df, i, cfg)
- df.at[df.index[i], "bb_state"] = st
- sig = evaluate_interval_bb(symbol, df.iloc[: i + 1], cfg)
- if sig:
- df.at[df.index[i], "signal"] = sig.signal
- df.at[df.index[i], "point"] = 1
- df.at[df.index[i], "action"] = sig.action
-
- if not simulation:
- sig = evaluate_interval_bb(symbol, df, cfg)
- if sig:
- df.at[df.index[-1], "signal"] = sig.signal
- df.at[df.index[-1], "point"] = 1
- df.at[df.index[-1], "action"] = sig.action
- df.at[df.index[-1], "bb_state"] = get_latest_bb_state(df, cfg)
-
- return df
-
-
-def _slice_frames_at(frames: dict[int, pd.DataFrame], ts: pd.Timestamp) -> dict[int, pd.DataFrame]:
- """시점 ts 이하 봉만 남긴 frames."""
- out: dict[int, pd.DataFrame] = {}
- for iv, df in frames.items():
- part = df[df.index <= ts]
- if len(part) >= 22:
- out[iv] = part
- return out
-
-
-def annotate_mtf_signals(
- symbol: str,
- frames: dict[int, pd.DataFrame],
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- policy: MtfBbPolicy | None = None,
- config: StrategyConfig | None = None,
-) -> pd.DataFrame:
- """실행 봉(buy_interval) 타임라인에 MTF BB 신호 기록."""
- policy = policy or ACTIVE_MTF_POLICY
- cfg = config or ACTIVE_CONFIG
- if policy.buy_interval not in frames:
- raise ValueError(f"buy_interval {policy.buy_interval} 데이터 없음")
-
- df = prepare_entry_df(frames[policy.buy_interval].copy())
- df["signal"] = ""
- df["point"] = 0
- df["action"] = ""
- df["trend"] = ""
- df["bb_state"] = ""
-
- for i in range(21, len(df)):
- ts = df.index[i]
- sliced = _slice_frames_at(frames, ts)
- if policy.buy_interval not in sliced:
- continue
- df.at[ts, "bb_state"] = get_latest_bb_state(sliced[policy.buy_interval], cfg)
- trend_at = get_trend_at(df_1d, df_1h, ts)
- sig = evaluate_mtf_bb(symbol, sliced, df_1d, df_1h, policy, cfg)
- if sig:
- df.at[ts, "signal"] = sig.signal
- df.at[ts, "point"] = 1
- df.at[ts, "action"] = sig.action
- df.at[ts, "trend"] = sig.trend
-
- return df
-
-
-def evaluate_discovered_live(
- symbol: str,
- frames: dict[int, pd.DataFrame],
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- balances: dict,
-) -> TradeSignal | None:
- """
- 최신 3분 봉 시점에서 discovered_rules + 전 봉 BB·일목 조합으로 신호 1건.
- """
- from candle_features import build_master_feature_matrix
- from rule_discovery import (
- _trigger_at,
- buy_mask,
- load_rules,
- rules_have_buy,
- sell_mask,
- )
-
- rules = load_rules()
- if rules is None or not rules_have_buy(rules):
- return None
-
- matrix = build_master_feature_matrix(frames)
- if len(matrix) < 23:
- return None
- tail = matrix.iloc[-2:]
- i = 1
- ts = tail.index[-1]
- close = float(tail["Close"].iloc[-1])
- trend = get_trend_at(df_1d, df_1h, ts)
-
- b_m = buy_mask(tail, rules)
- s_m = sell_mask(tail, rules, stop=False)
- st_m = (
- sell_mask(tail, rules, stop=True)
- if rules.sell_stop
- else np.zeros(len(tail), dtype=bool)
- )
-
- position = float(balances.get(symbol, {}).get("balance", 0) or 0)
- if position >= 1.0:
- if rules.sell_stop and _trigger_at(st_m, i):
- return TradeSignal("sell", SIGNAL_SELL_STOP, close, trend)
- if _trigger_at(s_m, i):
- return TradeSignal("sell", SIGNAL_SELL_UPPER, close, trend)
- return None
-
- if _trigger_at(b_m, i):
- return TradeSignal("buy", SIGNAL_BUY_LOWER, close, trend)
- return None
-
-
-def annotate_discovered_signals(
- symbol: str,
- frames: dict[int, pd.DataFrame],
- df_1d: pd.DataFrame,
- df_1h: pd.DataFrame,
- rules=None,
- data: pd.DataFrame | None = None,
-) -> pd.DataFrame:
- """탐색된 다봉·캔들 규칙으로 3분 타임라인(전체 봉)에 신호 기록."""
- from candle_features import build_master_feature_matrix
- from rule_discovery import generate_trade_events, load_rules, rules_have_buy
-
- rule_set = rules or load_rules()
- if rule_set is None or not rules_have_buy(rule_set):
- raise FileNotFoundError(
- "discovered_rules.json 없거나 매수 규칙이 비어 있습니다. "
- "python simulation.py 실행"
- )
-
- entry = frames.get(ENTRY_INTERVAL)
- if entry is None or entry.empty:
- raise ValueError("3분봉 데이터가 없습니다.")
-
- matrix = build_master_feature_matrix(frames).iloc[21:].copy()
- df = prepare_entry_df(data if data is not None else entry)
- df["signal"] = ""
- df["point"] = 0
- df["action"] = ""
- df["trend"] = ""
-
- for ts, action, sig in generate_trade_events(matrix, rule_set):
- if ts not in df.index:
- continue
- trend_at = get_trend_at(df_1d, df_1h, ts)
- df.at[ts, "signal"] = sig
- df.at[ts, "point"] = 1
- df.at[ts, "action"] = action
- df.at[ts, "trend"] = trend_at
-
- return df
-
-
-def annotate_signals(
- symbol: str,
- data: pd.DataFrame,
- simulation: bool | None = None,
- df_1h: pd.DataFrame | None = None,
- df_1d: pd.DataFrame | None = None,
- config: StrategyConfig | None = None,
- frames: dict[int, pd.DataFrame] | None = None,
-) -> pd.DataFrame:
- """3분봉 구간 전체에 signal/point/action/trend 기록."""
- cfg = config or ACTIVE_CONFIG
- if cfg.use_discovered_rules and frames:
- h1d = df_1d if df_1d is not None and not df_1d.empty else data
- h1h = df_1h if df_1h is not None and not df_1h.empty else data
- return annotate_discovered_signals(symbol, frames, h1d, h1h, data=data)
- df = prepare_entry_df(data)
- df["signal"] = ""
- df["point"] = 0
- df["action"] = ""
- df["trend"] = ""
-
- htf_1h = df_1h if df_1h is not None else df
- htf_1d = df_1d if df_1d is not None else df
-
- for i in range(21, len(df)):
- ts = df.index[i]
- trend_at = get_trend_at(htf_1d, htf_1h, ts) if simulation else get_trend(htf_1d, htf_1h)
- sig = evaluate(
- symbol,
- df.iloc[: i + 1],
- htf_1h,
- htf_1d,
- config=cfg,
- trend_override=trend_at if simulation else None,
- )
- if sig:
- df.at[df.index[i], "signal"] = sig.signal
- df.at[df.index[i], "point"] = 1
- df.at[df.index[i], "action"] = sig.action
- df.at[df.index[i], "trend"] = sig.trend
-
- if not simulation:
- live = evaluate(symbol, df, htf_1h, htf_1d, config=cfg)
- if live:
- df.at[df.index[-1], "signal"] = live.signal
- df.at[df.index[-1], "point"] = 1
- df.at[df.index[-1], "action"] = live.action
- df.at[df.index[-1], "trend"] = live.trend
-
- return df
-
-
-def comparison_presets() -> list[StrategyConfig]:
- """기법 조합 비교용 프리셋."""
- return [
- StrategyConfig(name="01_기본_BB만", use_mtf=False, use_regime_switch=False,
- use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=False),
- StrategyConfig(name="02_기본+손절", use_mtf=False, use_regime_switch=False,
- use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
- StrategyConfig(name="03_기본+MTF", use_mtf=True, use_regime_switch=False,
- use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
- StrategyConfig(name="04_기본+MTF+스퀴즈", use_mtf=True, use_regime_switch=False,
- use_rsi_filter=False, use_squeeze_filter=True, use_stop_loss=True),
- StrategyConfig(name="05_기본+MTF+RSI", use_mtf=True, use_regime_switch=False,
- use_rsi_filter=True, use_squeeze_filter=False, use_stop_loss=True),
- StrategyConfig(name="06_기본+MTF+거래량", use_mtf=True, use_regime_switch=False,
- use_rsi_filter=False, use_volume_filter=True, volume_ratio=1.1,
- use_squeeze_filter=False, use_stop_loss=True),
- StrategyConfig(name="07_레짐스위치", use_mtf=True, use_regime_switch=True,
- use_rsi_filter=False, use_squeeze_filter=False, use_stop_loss=True),
- StrategyConfig(name="08_레짐+RSI+스퀴즈", use_mtf=True, use_regime_switch=True,
- use_rsi_filter=True, use_squeeze_filter=True, use_stop_loss=True),
- StrategyConfig(name="09_풀필터", use_mtf=True, use_regime_switch=True,
- use_rsi_filter=True, use_volume_filter=True, volume_ratio=1.1,
- use_squeeze_filter=True, use_stop_loss=True),
- ]
-
-
-def get_signal_action(signal: str) -> Action | None:
- if signal in BUY_SIGNALS:
- return "buy"
- if signal in SELL_SIGNALS:
- return "sell"
- return None
-
-
-def get_buy_amount(symbol: str, signal: str, close: float, trend: Trend = "up") -> int:
- if trend == "range":
- return RANGE_BUY_KRW
- return DEFAULT_BUY_KRW
-
-
-def get_sell_ratio(symbol: str, signal: str) -> float:
- return 1.0
-
-
-def allowed_inverse_sell_signals() -> set[str]:
- return set()
-
-
-def should_double_buy(symbol: str, signal: str, data: pd.DataFrame) -> bool:
- return False