From b52d61b7778405755878a948c77906a6ac457a58 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sat, 30 May 2026 22:58:25 +0900 Subject: [PATCH] =?UTF-8?q?WLD=20DeepCoin=20=EB=8B=A8=EA=B3=84=EB=B3=84=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=9E=AC=ED=8E=B8=20=EB=B0=8F=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=C2=B7=EB=AC=B8=EC=84=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 로고스/루트 레거시를 제거하고 deepcoin 패키지·scripts 01~05 CLI·docs/reference로 데이터·GT·분석·매칭·운영 단계를 정리했다. config와 .env 기반 설정, trade_anaysis.html 동기화 포함. Co-authored-by: Cursor --- .env.example | 36 - .gitignore | 8 +- README.md | 104 +- coins_buy_time.json | 1 - combination_analyzer.py | 222 - combination_report.json | 331 - config.py | 300 +- data/ground_truth/ground_truth_trades.json | 5442 +++++++++++++++++ data/ops/.gitkeep | 0 deepcoin/__init__.py | 14 + deepcoin/analysis/README.md | 6 + deepcoin/analysis/__init__.py | 0 deepcoin/analysis/general_analysis_align.py | 153 + deepcoin/analysis/general_analysis_candles.py | 114 + deepcoin/analysis/general_analysis_chart.py | 159 + deepcoin/analysis/general_analysis_config.py | 26 + deepcoin/analysis/general_analysis_context.py | 98 + deepcoin/analysis/general_analysis_core.py | 92 + .../general_analysis_enrich_runner.py | 148 + .../analysis/general_analysis_harmonic.py | 72 + .../analysis/general_analysis_indicators.py | 382 ++ .../analysis/general_analysis_patterns.py | 302 + .../analysis/general_analysis_pipeline.py | 154 + deepcoin/analysis/general_analysis_report.py | 73 + deepcoin/analysis/general_analysis_runner.py | 71 + .../analysis/general_analysis_snapshot.py | 100 + deepcoin/analysis/general_analysis_volume.py | 92 + deepcoin/analysis/general_analysis_wave.py | 205 + deepcoin/api/__init__.py | 5 + HTS2.py => deepcoin/api/bithumb.py | 15 +- deepcoin/common/__init__.py | 0 .../common/candle_features.py | 136 +- deepcoin/common/indicators.py | 315 + deepcoin/data/__init__.py | 0 downloader.py => deepcoin/data/downloader.py | 93 +- deepcoin/data/mtf_bb.py | 47 + deepcoin/env_loader.py | 53 + deepcoin/ground_truth/__init__.py | 0 deepcoin/ground_truth/ground_truth.py | 1165 ++++ deepcoin/matching/README.md | 15 + deepcoin/matching/__init__.py | 3 + deepcoin/matching/match_rules.py | 31 + deepcoin/ops/__init__.py | 0 monitor.py => deepcoin/ops/monitor.py | 323 +- deepcoin/ops/monitor_coin.py | 34 + deepcoin/ops/simulation.py | 595 ++ deepcoin/paths.py | 95 + discovered_rules.json | 21 - docs/LOGOS_STRATEGY.md | 223 - docs/README.md | 12 + docs/reference/GROUND_TRUTH.md | 33 + docs/reference/ROADMAP.md | 29 + docs/reference/STRUCTURE.md | 52 + docs/reference/trade_anaysis.html | 394 ++ indicators.py | 58 - logos_chart.py | 18 - logos_strategy.py | 349 -- logos_trades.json | 57 - monitor_coin.py | 39 - mtf_bb.py | 222 - mtf_bb_policy.json | 11 - requirements.txt | 1 + rule_discovery.py | 859 --- scripts/01_download.py | 11 + scripts/02_ground_truth.py | 11 + scripts/03_analyze_enrich.py | 11 + scripts/03_analyze_trades.py | 11 + scripts/04_match_rules.py | 11 + scripts/05_chart_bb.py | 13 + scripts/05_chart_truth.py | 13 + scripts/05_run_monitor.py | 11 + scripts/README.md | 17 + scripts/_bootstrap.py | 11 + scripts/verify_env.py | 219 + simulation.py | 1095 ---- strategy.py | 677 -- 76 files changed, 11552 insertions(+), 4567 deletions(-) delete mode 100644 .env.example delete mode 100644 coins_buy_time.json delete mode 100644 combination_analyzer.py delete mode 100644 combination_report.json create mode 100644 data/ground_truth/ground_truth_trades.json create mode 100644 data/ops/.gitkeep create mode 100644 deepcoin/__init__.py create mode 100644 deepcoin/analysis/README.md create mode 100644 deepcoin/analysis/__init__.py create mode 100644 deepcoin/analysis/general_analysis_align.py create mode 100644 deepcoin/analysis/general_analysis_candles.py create mode 100644 deepcoin/analysis/general_analysis_chart.py create mode 100644 deepcoin/analysis/general_analysis_config.py create mode 100644 deepcoin/analysis/general_analysis_context.py create mode 100644 deepcoin/analysis/general_analysis_core.py create mode 100644 deepcoin/analysis/general_analysis_enrich_runner.py create mode 100644 deepcoin/analysis/general_analysis_harmonic.py create mode 100644 deepcoin/analysis/general_analysis_indicators.py create mode 100644 deepcoin/analysis/general_analysis_patterns.py create mode 100644 deepcoin/analysis/general_analysis_pipeline.py create mode 100644 deepcoin/analysis/general_analysis_report.py create mode 100644 deepcoin/analysis/general_analysis_runner.py create mode 100644 deepcoin/analysis/general_analysis_snapshot.py create mode 100644 deepcoin/analysis/general_analysis_volume.py create mode 100644 deepcoin/analysis/general_analysis_wave.py create mode 100644 deepcoin/api/__init__.py rename HTS2.py => deepcoin/api/bithumb.py (97%) create mode 100644 deepcoin/common/__init__.py rename candle_features.py => deepcoin/common/candle_features.py (67%) create mode 100644 deepcoin/common/indicators.py create mode 100644 deepcoin/data/__init__.py rename downloader.py => deepcoin/data/downloader.py (78%) create mode 100644 deepcoin/data/mtf_bb.py create mode 100644 deepcoin/env_loader.py create mode 100644 deepcoin/ground_truth/__init__.py create mode 100644 deepcoin/ground_truth/ground_truth.py create mode 100644 deepcoin/matching/README.md create mode 100644 deepcoin/matching/__init__.py create mode 100644 deepcoin/matching/match_rules.py create mode 100644 deepcoin/ops/__init__.py rename monitor.py => deepcoin/ops/monitor.py (60%) create mode 100644 deepcoin/ops/monitor_coin.py create mode 100644 deepcoin/ops/simulation.py create mode 100644 deepcoin/paths.py delete mode 100644 discovered_rules.json delete mode 100644 docs/LOGOS_STRATEGY.md create mode 100644 docs/README.md create mode 100644 docs/reference/GROUND_TRUTH.md create mode 100644 docs/reference/ROADMAP.md create mode 100644 docs/reference/STRUCTURE.md create mode 100644 docs/reference/trade_anaysis.html delete mode 100644 indicators.py delete mode 100644 logos_chart.py delete mode 100644 logos_strategy.py delete mode 100644 logos_trades.json delete mode 100644 monitor_coin.py delete mode 100644 mtf_bb.py delete mode 100644 mtf_bb_policy.json delete mode 100644 rule_discovery.py create mode 100644 scripts/01_download.py create mode 100644 scripts/02_ground_truth.py create mode 100644 scripts/03_analyze_enrich.py create mode 100644 scripts/03_analyze_trades.py create mode 100644 scripts/04_match_rules.py create mode 100644 scripts/05_chart_bb.py create mode 100644 scripts/05_chart_truth.py create mode 100644 scripts/05_run_monitor.py create mode 100644 scripts/README.md create mode 100644 scripts/_bootstrap.py create mode 100644 scripts/verify_env.py delete mode 100644 simulation.py delete mode 100644 strategy.py 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_ 컬럼 수

    + +{rows}
    TF최신 시각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}

    +

    모듈별 컬럼 수

    + +{summary_rows}
    모듈컬럼 수
    +

    MTF 합성 평균

    + +

    샘플 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.csvd1_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 대상OOOOOOOO
    DB 적재OOOOOOOO
    대략 기간~12개월~12개월~12개월~12개월~12개월~12개월~12개월~12개월
    비고1분봉은 DB 6개월만 있어 본 문서 범위 제외.
    + +

    2. 파이프라인

    +
    +
      +
    1. 입력: mtf_bb.load_frames_from_db() — 8개 간격 OHLCV
    2. +
    3. enrich: 완료 general_analysis_enrich_bars(raw, interval, full_context=True)
    4. +
    5. 모듈 순서: candle_featuresindicatorscandleschartcontext(patterns/wave/volume/harmonic)
    6. +
    7. MTF 합성: 완료 general_analysis_mtf_vote_latest(), ga_align_*
    8. +
    9. 시각화: 부분 scripts/05_chart_*.py 3분 6패널 · 8TF 타일·타점 미니차트 UI 미구현
    10. +
    +
    + +

    3. 차트 분석 (Chart Analysis)

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    방법설명구현주요 컬럼 / 모듈
    캔들차트 (Candlestick)OHLC + BB·일목 오버레이완료scripts/05_chart_*.py, ga_chart_type_candle
    선차트 (Line)종가·MA·MACD 등 시계열완료 UIga_chart_line_slope, ga_chart_line_slope_1 · Plotly 전용 선차트 없음
    바차트 (Bar / OHLC Bar)봉 범위·거래량 스파이크완료 UIga_chart_bar_range_pct, ga_chart_vol_spike
    Heikin-Ashi노이즈 완화 캔들완료ga_ha_*, ga_chart_ha_trend
    RenkoATR 브릭 방향완료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_shoulders60분~일봉
    쌍봉 / 쌍바닥완료ga_pattern_double_top, ga_pattern_double_bottom30분~일봉
    트리플 탑/바닼완료ga_pattern_triple_top, ga_pattern_triple_bottom60분~일봉
    V자 반등 / 스파이크완료ga_pattern_v_bottom, ga_pattern_spike_top5~60분
    둥근 천장/바닼완료ga_pattern_rounding_top, ga_pattern_rounding_bottom일봉
    플래티어 (Rectangle)완료ga_pattern_rectangle15분~240분
    갭 / 아일랜드완료ga_pattern_gap_up/down, ga_pattern_island_top/bottom60분~일봉
    키리스톤 / 역키리스톤완료ga_pattern_keystone_bull, ga_pattern_keystone_bear30분~일봉
    + +

    4.2 지속 패턴

    + + + + + + + + + + + + + + +
    패턴구현컬럼권장 TF
    삼각수렴 (대칭/상승/하락)완료ga_pattern_triangle_sym/asc/desc15분~240분
    깃발 / 페넌트완료ga_pattern_flag_bull, ga_pattern_flag_bear5~60분
    웨지완료ga_pattern_wedge_rising, ga_pattern_wedge_falling15분~60분
    채널완료ga_pattern_channel_up, ga_pattern_channel_down전 TF
    박스권 + BB 스퀴즈완료ga_pattern_rectangle, ga_bb_squeeze5~60분
    컵앤핸들완료ga_pattern_cup_handle일봉
    측정된 움직임완료ga_pattern_measured_move30분~일봉
    패턴 요약 라벨완료ga_pattern_label전 TF
    + +

    4.3 캔들 패턴

    + + + + + + + + + + + +
    패턴구현컬럼권장 TF
    해머 / 유성 / 도지완료ga_hammer, ga_shooting_star, ga_doji + candle_features3~60분
    장악형완료ga_bullish_engulfing, ga_bearish_engulfing5~60분
    샛별형완료ga_morning_star, ga_evening_star15분~일봉
    삼병 / 삼까마귀완료ga_three_white_soldiers, ga_three_black_crows15분~60분
    피보나치 되돌림 근접완료ga_fib_near_level30분~일봉
    + +

    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_utadaccumulation/distribution + spring/UTAD
    일목 (구름)완료ga_ichi_trend
    피보나치완료ga_fib_near_level0/382/500/618/100/1618
    하모닉 (Gartley/Bat)완료ga_harmonic_gartley, ga_harmonic_bat, ga_harmonic_label5피벗 비율
    앤더류 피치포크완료ga_pitchfork_bias, ga_pitchfork_dist_pct3피벗 중앙선
    + +

    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. 구현 단계 (현황)

    + + + + + + + + + + + + + + +
    단계내용산출물구현
    P08TF 봉 enrich + latest CSVdocs/03_analysis/latest/*_latest.csv완료
    P1기법 점검 HTMLdocs/03_analysis/general_analysis_capability.html완료
    P2전 지표·거래량·변동성general_analysis_indicators.py완료
    P3전 패턴·캔들general_analysis_patterns.py, candles.py완료
    P4파동·VP·하모닉·MTFwave, volume, harmonic, align완료
    P5GT 타점 wide CSV (03b)docs/03_analysis/general_analysis_trades.csv구현완료·전량재실행
    P68TF Plotly 타일 · 타점 미니차트trade_detail.html미구현
    04 규칙 매칭 · 05 1분 운영scripts/04_match_rules.py, 05_run_monitor.py로드맵 예정
    + +

    9. 코드베이스 매핑

    + + + + + + + + + + + + + + + + + + + + +
    모듈역할
    general_analysis_enrich_runner.py8TF enrich 로직 · CLI: scripts/03_analyze_enrich.py
    general_analysis_pipeline.pyenrich_bars, snapshot_at_bar
    general_analysis_indicators.py추세·모멘텀·변동성·거래량·SAR·Keltner·AO·HV·Δ
    general_analysis_candles.pyHeikin-Ashi, 단일·복수 봉 패턴
    general_analysis_chart.pyRenko, P&F, 선·바 파생
    general_analysis_patterns.py반전·지속 패턴 + 롤링 적용
    general_analysis_wave.py구조·엘리어트·Wyckoff·피보나치·피치포크
    general_analysis_volume.pyVolume Profile POC/VAH/VAL
    general_analysis_harmonic.pyGartley, Bat
    general_analysis_context.pylookback 롤링 일괄 (patterns/wave/vp/harmonic)
    general_analysis_align.pyga_align_*, ga_vote_*
    general_analysis_runner.pyGT 타점 wide CSV · CLI: scripts/03_analyze_trades.py
    indicators.py / candle_features.pyBB, 일목, RSI, MACD, Stoch, 이격도, zone
    scripts/05_chart_*.py3분 6패널 · ground truth 차트
    + +

    10. 구현 집계

    + + + + + + + + + + + + + + + + + +
    구분완료부분 (UI만)미구현
    차트 분석 (§3)72 (선·바 Plotly)1 (MTF 타일)
    반전 패턴 (§4.1)800
    지속 패턴 (§4.2)800
    캔들 패턴 (§4.3)500
    추세 지표 (§5.1)900
    모멘텀 (§5.2)800
    변동성 (§5.3)600
    거래량 (§5.4)600
    파동·구조 (§6)800
    MTF 합성 (§7)500
    합계7021
    +

    「부분」= 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