WLD DeepCoin 단계별 구조 재편 및 설정·문서 통합

로고스/루트 레거시를 제거하고 deepcoin 패키지·scripts 01~05 CLI·docs/reference로
데이터·GT·분석·매칭·운영 단계를 정리했다. config와 .env 기반 설정, trade_anaysis.html 동기화 포함.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-30 22:58:25 +09:00
parent e631a5701f
commit b52d61b777
76 changed files with 11552 additions and 4567 deletions

View File

@@ -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

8
.gitignore vendored
View File

@@ -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

104
README.md
View File

@@ -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 스냅샷 |
## 면책
실거래 손실 책임은 사용자에게 있습니다.
실거래는 사용자 책임입니다. 본 저장소는 주문 실행을 포함하지 않습니다.

View File

@@ -1 +0,0 @@
{}

View File

@@ -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)

View File

@@ -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": []
}
}

300
config.py
View File

@@ -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")

File diff suppressed because it is too large Load Diff

0
data/ops/.gitkeep Normal file
View File

14
deepcoin/__init__.py Normal file
View File

@@ -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()

View File

@@ -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`만 사용합니다.

View File

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"<tr><td>{tf}</td><td>{snap.get('dt','')}</td><td>{len(ga_cols)}</td></tr>"
vote_rows = "".join(f"<li><code>{k}</code>: {v}</li>" for k, v in vote.items())
html = f"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>general_analysis 기법 점검</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; }}
table {{ border-collapse: collapse; width: 100%; background: #fff; }}
th, td {{ border: 1px solid #e2e8f0; padding: 8px; font-size: 0.88rem; }}
th {{ background: #e2e8f0; }}
</style></head><body>
<h1>general_analysis 기법 점검 ({SYMBOL})</h1>
<p>3분~일봉 enrich 완료. 최신 봉 기준 컬럼 수·MTF 투표.</p>
<h2>간격별 ga_ 컬럼 수</h2>
<table><thead><tr><th>TF</th><th>최신 시각</th><th>ga_ 컬럼 수</th></tr></thead>
<tbody>{rows}</tbody></table>
<h2>MTF 투표 (최신 봉)</h2>
<ul>{vote_rows}</ul>
<p>상세 CSV: <code>{DEFAULT_LATEST_DIR}/</code></p>
</body></html>"""
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()

View File

@@ -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"]

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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"<tr><td>{name}</td><td>{len(cols)}</td></tr>"
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"""<!DOCTYPE html>
<html lang="ko"><head><meta charset="utf-8"/>
<title>general_analysis 실행 리포트</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f5f5f5; }}
table.tbl {{ border-collapse: collapse; width: 100%; background: #fff; font-size: 0.85rem; }}
th, td {{ border: 1px solid #e2e8f0; padding: 6px 8px; }}
th {{ background: #e2e8f0; }}
</style></head><body>
<h1>general_analysis 실행 리포트</h1>
<p>타점 {len(df)}건 · 컬럼 {len(df.columns)}개 · CSV: {csv_path}</p>
<h2>모듈별 컬럼 수</h2>
<table class="tbl"><thead><tr><th>모듈</th><th>컬럼 수</th></tr></thead>
<tbody>{summary_rows}</tbody></table>
<h2>MTF 합성 평균</h2>
<ul>
<li>매수 타점 timing_buy_score 평균: {buy_mean:.3f}</li>
<li>매도 타점 timing_sell_score 평균: {sell_mean:.3f}</li>
</ul>
<h2>샘플 5건</h2>
{sample}
<p>전체 데이터: <code>{csv_path}</code></p>
</body></html>"""
html_out.write_text(content, encoding="utf-8")
print(f"리포트: {html_out}")
return html_out

View File

@@ -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()

View File

@@ -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

View File

@@ -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()}

View File

@@ -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

5
deepcoin/api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""외부 API 연동 (빗썸 등)."""
from deepcoin.api.bithumb import HTS
__all__ = ["HTS"]

View File

@@ -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()

View File

View File

@@ -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"]

View File

@@ -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:
"""
스토캐스틱 %%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"

View File

View File

@@ -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 = "초기 적재"

47
deepcoin/data/mtf_bb.py Normal file
View File

@@ -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

53
deepcoin/env_loader.py Normal file
View File

@@ -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,
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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
```

View File

@@ -0,0 +1,3 @@
"""
04단계: Ground Truth에 근접한 기술적 상태·규칙 선택 (예정).
"""

View File

@@ -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()

0
deepcoin/ops/__init__.py Normal file
View File

View File

@@ -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)

View File

@@ -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()

595
deepcoin/ops/simulation.py Normal file
View File

@@ -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}<br>{t['dt'][:16]}<br>₩{t['price']:,.0f}"
f"<br>비중 {float(t.get('weight', 1))*100:.0f}%"
f"<br>{t.get('memo', '')}"
for t in pts
],
hovertemplate="%{hovertext}<extra></extra>",
),
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"<p class='note'>{note}</p>" 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"""
<tr class="initial-row">
<td>시작</td>
<td>-</td>
<td>-</td>
<td>-</td>
<td><b>₩{GT_INITIAL_CASH_KRW:,.0f}</b></td>
<td>초기 현금 (보유 0)</td>
</tr>"""
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"""
<tr>
<td>{t['dt'][:16]}</td>
<td class="{cls}">{mark}</td>
<td>{w*100:.0f}%</td>
<td>₩{t['price']:,.0f}{ret_s}</td>
<td><b>{total_s}</b>{hold_s}</td>
<td>{t.get('memo', '')}</td>
</tr>"""
trade_table = ""
if truth_trades:
if not trade_rows:
trade_rows = "<tr><td colspan='6'>타점 없음</td></tr>"
mark_note = ""
if pnl.get("mark_price"):
mark_note = (
f" 상단 최종 자산은 미청산 포함 종가 ₩{pnl['mark_price']:,.0f} 평가."
)
trade_table = f"""
<h2>정답 타점 (ground_truth)</h2>
<p class="meta">삼각형 크기 = 비중. 매수: 저점 분할 / 매도: 고점 1~2회.
총평가 = 체결 직후 현금 + 보유×체결가.{mark_note}</p>
<table>
<thead><tr><th>시각</th><th>구분</th><th>비중</th><th>가격</th><th>총 평가금액</th><th>해석</th></tr></thead>
<tbody>{trade_rows}</tbody>
</table>"""
pnl_cards = ""
if truth_trades and pnl.get("initial_cash_krw") is not None:
pnl_cards = f"""
<div class="card"><span>시작</span><b>₩{pnl['initial_cash_krw']:,.0f}</b></div>
<div class="card"><span>최종 자산</span><b>₩{pnl['final_asset_krw']:,.0f}</b></div>
<div class="card"><span>수익금</span><b>₩{pnl['pnl_krw']:+,.0f}</b></div>
<div class="card"><span>수익률</span><b>{pnl['pnl_pct']:+.2f}%</b></div>
<div class="card"><span>수수료</span><b>₩{pnl['total_fees_krw']:,.0f}</b></div>"""
if pnl.get("holding_qty", 0) > 0:
pnl_cards += f"""
<div class="card"><span>미청산</span><b>{pnl['holding_qty']}개 (₩{pnl['holding_value_krw']:,.0f})</b></div>"""
return f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>{SYMBOL} {title_suffix}</title>
<style>
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; background: #f8fafc; }}
h1 {{ font-size: 1.35rem; }}
.meta {{ color: #475569; font-size: 0.9rem; }}
.note {{ background: #f1f5f9; border: 1px solid #cbd5e1; padding: 10px; border-radius: 6px; color: #334155; }}
.cards {{ display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }}
.card {{ background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; }}
.card span {{ font-size: 0.75rem; color: #64748b; display: block; }}
.card b {{ font-size: 1.05rem; }}
.chart-wrap {{ background:#fff; border:1px solid #e2e8f0; border-radius:8px; padding:8px; }}
.legend-box {{ font-size:0.85rem; color:#475569; margin-bottom:10px; }}
table {{ width:100%; border-collapse:collapse; background:#fff; font-size:0.85rem; }}
th, td {{ border:1px solid #e2e8f0; padding:8px; text-align:left; }}
th {{ background:#f1f5f9; }}
td.buy {{ color:#16a34a; font-weight:600; }}
td.sell {{ color:#dc2626; font-weight:600; }}
</style>
</head>
<body>
<h1>{COIN_NAME} ({SYMBOL}) {title_suffix}</h1>
<p class="meta">추세(참고): {trend} | 기간: {df.index[0]} ~ {df.index[-1]} | 봉 수: {len(df)}</p>
{note_html}
<div class="legend-box">▲ 매수 · ▼ 매도 — 삼각형이 클수록 비중이 큽니다.</div>
<div class="cards">
<div class="card"><span>종가</span><b>₩{close_last:,.2f}</b></div>
<div class="card"><span>BB %B</span><b>{bb_pos_txt}</b></div>
<div class="card"><span>정답 타점</span><b>{len(truth_trades) if truth_trades else 0}건</b></div>
{pnl_cards}
</div>
<div class="chart-wrap">{chart_html}</div>
{trade_table}
</body>
</html>"""
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()

95
deepcoin/paths.py Normal file
View File

@@ -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)

View File

@@ -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
}

View File

@@ -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 |

12
docs/README.md Normal file
View File

@@ -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)

View File

@@ -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` 재실행으로 차트 갱신
- 파라미터 조정으로 타점 수·크기 튜닝

29
docs/reference/ROADMAP.md Normal file
View File

@@ -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 패키지
```

View File

@@ -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/*`로 연결되는 별칭입니다.

View File

@@ -0,0 +1,394 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>MTF 기술적 분석 기법 목록 (trade_anaysis)</title>
<style>
body { font-family: "Malgun Gothic", Arial, sans-serif; margin: 28px 36px; background: #f5f5f5; color: #1e293b; line-height: 1.55; }
h1 { font-size: 1.45rem; border-bottom: 2px solid #64748b; padding-bottom: 8px; }
h2 { font-size: 1.15rem; margin-top: 32px; color: #334155; }
h3 { font-size: 1rem; margin-top: 20px; color: #475569; }
p.meta { color: #64748b; font-size: 0.9rem; }
.box { background: #fff; border: 1px solid #cbd5e1; border-radius: 8px; padding: 16px 20px; margin: 16px 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; background: #fff; margin: 12px 0 20px; }
th, td { border: 1px solid #e2e8f0; padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: #e2e8f0; font-weight: 600; white-space: nowrap; }
tr:nth-child(even) td { background: #f8fafc; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.78rem; margin-right: 4px; }
.done { background: #dcfce7; color: #166534; }
.partial { background: #fef9c3; color: #854d0e; }
.todo { background: #f1f5f9; color: #475569; }
ul.compact { margin: 6px 0; padding-left: 20px; }
ul.compact li { margin: 4px 0; }
code { background: #f1f5f9; padding: 1px 5px; border-radius: 3px; font-size: 0.85em; }
.matrix td, .matrix th { text-align: center; font-size: 0.82rem; }
.yes { color: #15803d; font-weight: 600; }
</style>
</head>
<body>
<h1>MTF 기술적 분석 기법 목록 및 구현 상태</h1>
<p class="meta">
목적: 3분~일봉 OHLCV 유입 시 <strong>모든 기법을 봉 단위로 검증</strong> (매수·매도 타점과 무관) ·
간격: <strong>3, 5, 10, 15, 30, 60, 240분 + 일봉(1440)</strong> ·
데이터: <code>data/coins.db</code> (WLD) ·
문서: <code>docs/reference/trade_anaysis.html</code>
</p>
<div class="box">
<h3>구현 상태 범례</h3>
<ul class="compact">
<li><span class="tag done">완료</span><code>general_analysis_enrich_bars(df, interval)</code> CSV 컬럼 또는 기존 Plotly 차트.</li>
<li><span class="tag partial">부분</span><strong>Plotly/HTML UI만</strong> 없음. 수치·플래그는 CSV에 존재.</li>
<li><span class="tag todo">미구현</span> — 전용 UI·리포트 페이지 없음 (아래 2건).</li>
<li><span class="tag partial">구현완료·전량재실행</span> — 코드·CLI는 준비됨. 산출 CSV가 GT 전체를 반영하려면 <code>--limit</code> 없이 재실행.</li>
</ul>
</div>
<div class="box">
<h3>실행 및 산출물</h3>
<ul class="compact">
<li><strong>03 enrich (권장)</strong>: <code>python scripts/03_analyze_enrich.py</code>
· 모듈: <code>deepcoin/analysis/general_analysis_enrich_runner.py</code></li>
<li><strong>산출</strong>: <code>docs/03_analysis/latest/m3_latest.csv</code><code>d1_latest.csv</code>
(간격당 약 <strong>247컬럼</strong>, 최근 N봉 — <code>GA_DEFAULT_TAIL_EXPORT</code> 기본 <strong>200</strong>, <code>--tail-export</code>로 변경)</li>
<li><strong>점검</strong>: <code>docs/03_analysis/general_analysis_capability.html</code></li>
<li><strong>03b GT 타점 MTF</strong>: <code>python scripts/03_analyze_trades.py</code>
<code>docs/03_analysis/general_analysis_trades.csv</code>
· 리포트: <code>docs/03_analysis/general_analysis_report.html</code></li>
<li><strong>주의</strong>: <code>--limit N</code>은 테스트용. 전체 GT(약 450타점) 반영 시 <code>--limit</code> 없이 실행.</li>
</ul>
</div>
<div class="box">
<h3>분석 시점 정의</h3>
<ul class="compact">
<li>봉 분석: 각 간격의 <strong>완성봉 시계열</strong>에 지표·패턴 컬럼 부여 (전 봉 + lookback 롤링).</li>
<li>타점 분석(선택): <code>dt</code> 직전 완성봉만 사용 (<code>merge_asof</code> backward).</li>
<li>3분봉 lookback 롤링은 성능상 최근 6000봉 구간만 패턴·VP·파동·하모닉 갱신 (<code>CONTEXT_TAIL_ROWS</code>).</li>
</ul>
</div>
<h2>1. DB 보유 간격</h2>
<table class="matrix">
<thead>
<tr>
<th>간격</th><th>3분</th><th>5분</th><th>10분</th><th>15분</th>
<th>30분</th><th>60분</th><th>240분</th><th>일봉</th>
</tr>
</thead>
<tbody>
<tr>
<th>enrich 대상</th>
<td class="yes">O</td><td class="yes">O</td><td class="yes">O</td><td class="yes">O</td>
<td class="yes">O</td><td class="yes">O</td><td class="yes">O</td><td class="yes">O</td>
</tr>
<tr>
<th>DB 적재</th>
<td class="yes">O</td><td class="yes">O</td><td class="yes">O</td><td class="yes">O</td>
<td class="yes">O</td><td class="yes">O</td><td class="yes">O</td><td class="yes">O</td>
</tr>
<tr>
<th>대략 기간</th>
<td>~12개월</td><td>~12개월</td><td>~12개월</td><td>~12개월</td>
<td>~12개월</td><td>~12개월</td><td>~12개월</td><td>~12개월</td>
</tr>
<tr>
<th>비고</th>
<td colspan="8">1분봉은 DB 6개월만 있어 본 문서 범위 제외.</td>
</tr>
</tbody>
</table>
<h2>2. 파이프라인</h2>
<div class="box">
<ol>
<li><strong>입력</strong>: <code>mtf_bb.load_frames_from_db()</code> — 8개 간격 OHLCV</li>
<li><strong>enrich</strong>: <span class="tag done">완료</span> <code>general_analysis_enrich_bars(raw, interval, full_context=True)</code></li>
<li><strong>모듈 순서</strong>: <code>candle_features</code><code>indicators</code><code>candles</code><code>chart</code><code>context</code>(patterns/wave/volume/harmonic)</li>
<li><strong>MTF 합성</strong>: <span class="tag done">완료</span> <code>general_analysis_mtf_vote_latest()</code>, <code>ga_align_*</code></li>
<li><strong>시각화</strong>: <span class="tag partial">부분</span> <code>scripts/05_chart_*.py</code> 3분 6패널 · 8TF 타일·타점 미니차트 UI <span class="tag todo">미구현</span></li>
</ol>
</div>
<h2>3. 차트 분석 (Chart Analysis)</h2>
<table>
<thead>
<tr><th>방법</th><th>설명</th><th>구현</th><th>주요 컬럼 / 모듈</th></tr>
</thead>
<tbody>
<tr>
<td>캔들차트 (Candlestick)</td>
<td>OHLC + BB·일목 오버레이</td>
<td><span class="tag done">완료</span></td>
<td><code>scripts/05_chart_*.py</code>, <code>ga_chart_type_candle</code></td>
</tr>
<tr>
<td>선차트 (Line)</td>
<td>종가·MA·MACD 등 시계열</td>
<td><span class="tag done">완료</span> <span class="tag partial">UI</span></td>
<td><code>ga_chart_line_slope</code>, <code>ga_chart_line_slope_1</code> · Plotly 전용 선차트 없음</td>
</tr>
<tr>
<td>바차트 (Bar / OHLC Bar)</td>
<td>봉 범위·거래량 스파이크</td>
<td><span class="tag done">완료</span> <span class="tag partial">UI</span></td>
<td><code>ga_chart_bar_range_pct</code>, <code>ga_chart_vol_spike</code></td>
</tr>
<tr>
<td>Heikin-Ashi</td>
<td>노이즈 완화 캔들</td>
<td><span class="tag done">완료</span></td>
<td><code>ga_ha_*</code>, <code>ga_chart_ha_trend</code></td>
</tr>
<tr>
<td>Renko</td>
<td>ATR 브릭 방향</td>
<td><span class="tag done">완료</span></td>
<td><code>ga_chart_renko_dir</code>, <code>ga_chart_renko_up</code>, <code>ga_chart_renko_brick_up_ratio</code></td>
</tr>
<tr>
<td>Point &amp; Figure</td>
<td>박스 크기 기준 X/O 열</td>
<td><span class="tag done">완료</span></td>
<td><code>ga_chart_pnf_col</code></td>
</tr>
<tr>
<td>멀티 패널 (6패널)</td>
<td>BB·이격·Stoch·RSI·MACD</td>
<td><span class="tag done">완료</span></td>
<td><code>scripts/05_chart_*.py</code> 3분</td>
</tr>
<tr>
<td>MTF 타일 (Small Multiples)</td>
<td>8TF 나란히 Plotly</td>
<td><span class="tag todo">미구현</span></td>
<td>CSV 8TF 컬럼으로 대체 · <code>docs/03_analysis/latest/*_latest.csv</code></td>
</tr>
</tbody>
</table>
<h2>4. 패턴 분석 (Pattern Analysis)</h2>
<p>lookback 윈도우(<code>LOOKBACK_BARS</code>) 마지막 봉 기준. 롤링 적용: <code>general_analysis_apply_context_features</code>.</p>
<h3>4.1 반전 패턴</h3>
<table>
<thead>
<tr><th>패턴</th><th>구현</th><th>컬럼</th><th>권장 TF</th></tr>
</thead>
<tbody>
<tr><td>헤드앤숄더 / 역H&amp;S</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_head_shoulders</code>, <code>ga_pattern_inv_head_shoulders</code></td><td>60분~일봉</td></tr>
<tr><td>쌍봉 / 쌍바닥</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_double_top</code>, <code>ga_pattern_double_bottom</code></td><td>30분~일봉</td></tr>
<tr><td>트리플 탑/바닼</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_triple_top</code>, <code>ga_pattern_triple_bottom</code></td><td>60분~일봉</td></tr>
<tr><td>V자 반등 / 스파이크</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_v_bottom</code>, <code>ga_pattern_spike_top</code></td><td>5~60분</td></tr>
<tr><td>둥근 천장/바닼</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_rounding_top</code>, <code>ga_pattern_rounding_bottom</code></td><td>일봉</td></tr>
<tr><td>플래티어 (Rectangle)</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_rectangle</code></td><td>15분~240분</td></tr>
<tr><td>갭 / 아일랜드</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_gap_up/down</code>, <code>ga_pattern_island_top/bottom</code></td><td>60분~일봉</td></tr>
<tr><td>키리스톤 / 역키리스톤</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_keystone_bull</code>, <code>ga_pattern_keystone_bear</code></td><td>30분~일봉</td></tr>
</tbody>
</table>
<h3>4.2 지속 패턴</h3>
<table>
<thead>
<tr><th>패턴</th><th>구현</th><th>컬럼</th><th>권장 TF</th></tr>
</thead>
<tbody>
<tr><td>삼각수렴 (대칭/상승/하락)</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_triangle_sym/asc/desc</code></td><td>15분~240분</td></tr>
<tr><td>깃발 / 페넌트</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_flag_bull</code>, <code>ga_pattern_flag_bear</code></td><td>5~60분</td></tr>
<tr><td>웨지</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_wedge_rising</code>, <code>ga_pattern_wedge_falling</code></td><td>15분~60분</td></tr>
<tr><td>채널</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_channel_up</code>, <code>ga_pattern_channel_down</code></td><td>전 TF</td></tr>
<tr><td>박스권 + BB 스퀴즈</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_rectangle</code>, <code>ga_bb_squeeze</code></td><td>5~60분</td></tr>
<tr><td>컵앤핸들</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_cup_handle</code></td><td>일봉</td></tr>
<tr><td>측정된 움직임</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_measured_move</code></td><td>30분~일봉</td></tr>
<tr><td>패턴 요약 라벨</td><td><span class="tag done">완료</span></td><td><code>ga_pattern_label</code></td><td>전 TF</td></tr>
</tbody>
</table>
<h3>4.3 캔들 패턴</h3>
<table>
<thead>
<tr><th>패턴</th><th>구현</th><th>컬럼</th><th>권장 TF</th></tr>
</thead>
<tbody>
<tr><td>해머 / 유성 / 도지</td><td><span class="tag done">완료</span></td><td><code>ga_hammer</code>, <code>ga_shooting_star</code>, <code>ga_doji</code> + <code>candle_features</code></td><td>3~60분</td></tr>
<tr><td>장악형</td><td><span class="tag done">완료</span></td><td><code>ga_bullish_engulfing</code>, <code>ga_bearish_engulfing</code></td><td>5~60분</td></tr>
<tr><td>샛별형</td><td><span class="tag done">완료</span></td><td><code>ga_morning_star</code>, <code>ga_evening_star</code></td><td>15분~일봉</td></tr>
<tr><td>삼병 / 삼까마귀</td><td><span class="tag done">완료</span></td><td><code>ga_three_white_soldiers</code>, <code>ga_three_black_crows</code></td><td>15분~60분</td></tr>
<tr><td>피보나치 되돌림 근접</td><td><span class="tag done">완료</span></td><td><code>ga_fib_near_level</code></td><td>30분~일봉</td></tr>
</tbody>
</table>
<h2>5. 기술적 지표 (Technical Indicators)</h2>
<p>전 봉 시계열 컬럼. 레거시: <code>RSI</code>, <code>bb_pos</code>, <code>macd_*</code>, <code>stoch_*</code> 등.</p>
<h3>5.1 추세</h3>
<table>
<thead>
<tr><th>지표</th><th>구현</th><th>컬럼</th></tr>
</thead>
<tbody>
<tr><td>SMA / EMA / 골든·데드크로스</td><td><span class="tag done">완료</span></td><td><code>ga_sma_*</code>, <code>ga_golden_cross</code>, <code>ga_death_cross</code></td></tr>
<tr><td>MACD</td><td><span class="tag done">완료</span></td><td><code>macd_line</code>, <code>macd_signal</code>, <code>macd_hist</code></td></tr>
<tr><td>이격도</td><td><span class="tag done">완료</span></td><td><code>indicators</code> DI 5/20/60</td></tr>
<tr><td>ADX (+DI/-DI)</td><td><span class="tag done">완료</span></td><td><code>ga_adx_14</code>, <code>ga_plus_di</code>, <code>ga_minus_di</code></td></tr>
<tr><td>Parabolic SAR</td><td><span class="tag done">완료</span></td><td><code>ga_psar</code>, <code>ga_psar_bull</code>, <code>ga_psar_flip_bull/bear</code></td></tr>
<tr><td>Ichimoku</td><td><span class="tag done">완료</span></td><td><code>indicators</code> + <code>ga_ichi_trend</code></td></tr>
<tr><td>Linear Regression</td><td><span class="tag done">완료</span></td><td><code>ga_linreg_slope_20</code>, <code>ga_linreg_r2_20</code></td></tr>
<tr><td>VWAP</td><td><span class="tag done">완료</span></td><td><code>ga_vwap</code>, <code>ga_close_vs_vwap_pct</code> (누적 VWAP)</td></tr>
<tr><td>Supertrend</td><td><span class="tag done">완료</span></td><td><code>ga_supertrend_bull</code></td></tr>
</tbody>
</table>
<h3>5.2 모멘텀</h3>
<table>
<thead>
<tr><th>지표</th><th>구현</th><th>컬럼</th></tr>
</thead>
<tbody>
<tr><td>RSI</td><td><span class="tag done">완료</span></td><td><code>RSI</code>, <code>ga_rsi_delta_1</code></td></tr>
<tr><td>스토캐스틱</td><td><span class="tag done">완료</span></td><td><code>stoch_k</code>, <code>stoch_d</code>, <code>ga_stoch_k_delta_1</code></td></tr>
<tr><td>CCI</td><td><span class="tag done">완료</span></td><td><code>ga_cci_20</code>, <code>ga_cci_oversold/overbought</code></td></tr>
<tr><td>Williams %R</td><td><span class="tag done">완료</span></td><td><code>ga_williams_r</code>, <code>ga_williams_oversold/overbought</code></td></tr>
<tr><td>ROC</td><td><span class="tag done">완료</span></td><td><code>ga_roc_10</code></td></tr>
<tr><td>MFI</td><td><span class="tag done">완료</span></td><td><code>ga_mfi_14</code></td></tr>
<tr><td>Awesome Oscillator</td><td><span class="tag done">완료</span></td><td><code>ga_ao</code>, <code>ga_ao_bull</code>, <code>ga_ao_bear</code></td></tr>
<tr><td>RSI / MACD / Stoch 다이버전스</td><td><span class="tag done">완료</span></td><td><code>ga_rsi_*_div</code>, <code>ga_macd_*_div</code>, <code>ga_stoch_*_div</code></td></tr>
</tbody>
</table>
<h3>5.3 변동성</h3>
<table>
<thead>
<tr><th>지표</th><th>구현</th><th>컬럼</th></tr>
</thead>
<tbody>
<tr><td>볼린저 밴드</td><td><span class="tag done">완료</span></td><td><code>bb_pos</code>, <code>ga_bb_width_pct</code>, zone (<code>candle_features</code>)</td></tr>
<tr><td>ATR</td><td><span class="tag done">완료</span></td><td><code>ga_atr_14</code>, <code>ga_atr_pct</code></td></tr>
<tr><td>Keltner Channel</td><td><span class="tag done">완료</span></td><td><code>ga_keltner_mid/upper/lower</code>, <code>ga_keltner_pos</code></td></tr>
<tr><td>Donchian Channel</td><td><span class="tag done">완료</span></td><td><code>ga_donchian_pos</code></td></tr>
<tr><td>Historical Volatility</td><td><span class="tag done">완료</span></td><td><code>ga_hv_20</code>, <code>ga_hv_percentile</code></td></tr>
<tr><td>BB Squeeze</td><td><span class="tag done">완료</span></td><td><code>ga_bb_squeeze</code></td></tr>
</tbody>
</table>
<h3>5.4 거래량</h3>
<table>
<thead>
<tr><th>지표</th><th>구현</th><th>컬럼</th></tr>
</thead>
<tbody>
<tr><td>OBV + 다이버전스</td><td><span class="tag done">완료</span></td><td><code>ga_obv</code>, <code>ga_obv_slope_10</code>, <code>ga_obv_*_div</code></td></tr>
<tr><td>Volume MA ratio</td><td><span class="tag done">완료</span></td><td><code>ga_vol_ratio</code>, <code>ga_vol_ma20</code></td></tr>
<tr><td>VWAP deviation</td><td><span class="tag done">완료</span></td><td><code>ga_close_vs_vwap_pct</code></td></tr>
<tr><td>Accumulation/Distribution</td><td><span class="tag done">완료</span></td><td><code>ga_ad_line</code>, <code>ga_ad_slope_10</code></td></tr>
<tr><td>Chaikin Money Flow</td><td><span class="tag done">완료</span></td><td><code>ga_cmf_20</code></td></tr>
<tr><td>Volume Profile</td><td><span class="tag done">완료</span></td><td><code>ga_vp_poc</code>, <code>ga_vp_vah</code>, <code>ga_vp_val</code>, <code>ga_vp_in_value_area</code></td></tr>
</tbody>
</table>
<h2>6. 파동·시장 구조</h2>
<table>
<thead>
<tr><th>이론</th><th>구현</th><th>컬럼</th><th>비고</th></tr>
</thead>
<tbody>
<tr><td>다우 이론 (HH/HL/LH/LL)</td><td><span class="tag done">완료</span></td><td><code>ga_struct_*</code></td><td>피벗 기반</td></tr>
<tr><td>시장 구조 (BOS/CHoCH)</td><td><span class="tag done">완료</span></td><td><code>ga_struct_bos_*</code>, <code>ga_struct_choch</code></td><td></td></tr>
<tr><td>엘리어트 파동</td><td><span class="tag done">완료</span></td><td><code>ga_elliott_wave_count</code>, <code>ga_elliott_phase</code></td><td>라이트(스윙 수·단계)</td></tr>
<tr><td>Wyckoff</td><td><span class="tag done">완료</span></td><td><code>ga_wyckoff_phase</code>, <code>ga_wyckoff_spring</code>, <code>ga_wyckoff_utad</code></td><td>accumulation/distribution + spring/UTAD</td></tr>
<tr><td>일목 (구름)</td><td><span class="tag done">완료</span></td><td><code>ga_ichi_trend</code></td><td></td></tr>
<tr><td>피보나치</td><td><span class="tag done">완료</span></td><td><code>ga_fib_near_level</code></td><td>0/382/500/618/100/1618</td></tr>
<tr><td>하모닉 (Gartley/Bat)</td><td><span class="tag done">완료</span></td><td><code>ga_harmonic_gartley</code>, <code>ga_harmonic_bat</code>, <code>ga_harmonic_label</code></td><td>5피벗 비율</td></tr>
<tr><td>앤더류 피치포크</td><td><span class="tag done">완료</span></td><td><code>ga_pitchfork_bias</code>, <code>ga_pitchfork_dist_pct</code></td><td>3피벗 중앙선</td></tr>
</tbody>
</table>
<h2>7. MTF 합성</h2>
<table>
<thead>
<tr><th>방법</th><th>구현</th><th>컬럼 / 함수</th></tr>
</thead>
<tbody>
<tr><td>TF 가중 투표</td><td><span class="tag done">완료</span></td><td><code>general_analysis_mtf_vote_latest()</code><code>ga_vote_timing_buy/sell</code>, <code>ga_vote_trend_score</code></td></tr>
<tr><td>정렬 점수 (RSI)</td><td><span class="tag done">완료</span></td><td><code>ga_align_timing_buy_score</code>, <code>ga_align_timing_sell_score</code></td></tr>
<tr><td>상위 TF 추세 필터</td><td><span class="tag done">완료</span></td><td><code>ga_align_trend_score</code>, TF별 <code>ga_struct_trend</code></td></tr>
<tr><td>MTF 충돌 태그</td><td><span class="tag done">완료</span></td><td><code>ga_align_mtf_conflict</code></td></tr>
<tr><td>봉 간 Δ (T vs T-1)</td><td><span class="tag done">완료</span></td><td><code>ga_rsi_delta_1</code>, <code>ga_macd_hist_delta_1</code>, <code>ga_stoch_k_delta_1</code></td></tr>
</tbody>
</table>
<h2>8. 구현 단계 (현황)</h2>
<table>
<thead>
<tr><th>단계</th><th>내용</th><th>산출물</th><th>구현</th></tr>
</thead>
<tbody>
<tr><td>P0</td><td>8TF 봉 enrich + latest CSV</td><td><code>docs/03_analysis/latest/*_latest.csv</code></td><td><span class="tag done">완료</span></td></tr>
<tr><td>P1</td><td>기법 점검 HTML</td><td><code>docs/03_analysis/general_analysis_capability.html</code></td><td><span class="tag done">완료</span></td></tr>
<tr><td>P2</td><td>전 지표·거래량·변동성</td><td><code>general_analysis_indicators.py</code></td><td><span class="tag done">완료</span></td></tr>
<tr><td>P3</td><td>전 패턴·캔들</td><td><code>general_analysis_patterns.py</code>, <code>candles.py</code></td><td><span class="tag done">완료</span></td></tr>
<tr><td>P4</td><td>파동·VP·하모닉·MTF</td><td><code>wave</code>, <code>volume</code>, <code>harmonic</code>, <code>align</code></td><td><span class="tag done">완료</span></td></tr>
<tr><td>P5</td><td>GT 타점 wide CSV (03b)</td><td><code>docs/03_analysis/general_analysis_trades.csv</code></td><td><span class="tag partial">구현완료·전량재실행</span></td></tr>
<tr><td>P6</td><td>8TF Plotly 타일 · 타점 미니차트</td><td><code>trade_detail.html</code></td><td><span class="tag todo">미구현</span></td></tr>
<tr><td></td><td>04 규칙 매칭 · 05 1분 운영</td><td><code>scripts/04_match_rules.py</code>, <code>05_run_monitor.py</code></td><td><span class="tag todo">로드맵 예정</span></td></tr>
</tbody>
</table>
<h2>9. 코드베이스 매핑</h2>
<table>
<thead>
<tr><th>모듈</th><th>역할</th></tr>
</thead>
<tbody>
<tr><td><code>general_analysis_enrich_runner.py</code></td><td>8TF enrich 로직 · CLI: <code>scripts/03_analyze_enrich.py</code></td></tr>
<tr><td><code>general_analysis_pipeline.py</code></td><td><code>enrich_bars</code>, <code>snapshot_at_bar</code></td></tr>
<tr><td><code>general_analysis_indicators.py</code></td><td>추세·모멘텀·변동성·거래량·SAR·Keltner·AO·HV·Δ</td></tr>
<tr><td><code>general_analysis_candles.py</code></td><td>Heikin-Ashi, 단일·복수 봉 패턴</td></tr>
<tr><td><code>general_analysis_chart.py</code></td><td>Renko, P&amp;F, 선·바 파생</td></tr>
<tr><td><code>general_analysis_patterns.py</code></td><td>반전·지속 패턴 + 롤링 적용</td></tr>
<tr><td><code>general_analysis_wave.py</code></td><td>구조·엘리어트·Wyckoff·피보나치·피치포크</td></tr>
<tr><td><code>general_analysis_volume.py</code></td><td>Volume Profile POC/VAH/VAL</td></tr>
<tr><td><code>general_analysis_harmonic.py</code></td><td>Gartley, Bat</td></tr>
<tr><td><code>general_analysis_context.py</code></td><td>lookback 롤링 일괄 (patterns/wave/vp/harmonic)</td></tr>
<tr><td><code>general_analysis_align.py</code></td><td><code>ga_align_*</code>, <code>ga_vote_*</code></td></tr>
<tr><td><code>general_analysis_runner.py</code></td><td>GT 타점 wide CSV · CLI: <code>scripts/03_analyze_trades.py</code></td></tr>
<tr><td><code>indicators.py</code> / <code>candle_features.py</code></td><td>BB, 일목, RSI, MACD, Stoch, 이격도, zone</td></tr>
<tr><td><code>scripts/05_chart_*.py</code></td><td>3분 6패널 · ground truth 차트</td></tr>
</tbody>
</table>
<h2>10. 구현 집계</h2>
<table>
<thead>
<tr><th>구분</th><th>완료</th><th>부분 (UI만)</th><th>미구현</th></tr>
</thead>
<tbody>
<tr><td>차트 분석 (§3)</td><td>7</td><td>2 (선·바 Plotly)</td><td>1 (MTF 타일)</td></tr>
<tr><td>반전 패턴 (§4.1)</td><td>8</td><td>0</td><td>0</td></tr>
<tr><td>지속 패턴 (§4.2)</td><td>8</td><td>0</td><td>0</td></tr>
<tr><td>캔들 패턴 (§4.3)</td><td>5</td><td>0</td><td>0</td></tr>
<tr><td>추세 지표 (§5.1)</td><td>9</td><td>0</td><td>0</td></tr>
<tr><td>모멘텀 (§5.2)</td><td>8</td><td>0</td><td>0</td></tr>
<tr><td>변동성 (§5.3)</td><td>6</td><td>0</td><td>0</td></tr>
<tr><td>거래량 (§5.4)</td><td>6</td><td>0</td><td>0</td></tr>
<tr><td>파동·구조 (§6)</td><td>8</td><td>0</td><td>0</td></tr>
<tr><td>MTF 합성 (§7)</td><td>5</td><td>0</td><td>0</td></tr>
<tr><td><strong>합계</strong></td><td><strong>70</strong></td><td><strong>2</strong></td><td><strong>1</strong></td></tr>
</tbody>
</table>
<p class="meta">「부분」= CSV 수치는 있으나 전용 Plotly UI 없음. P5 「구현완료·전량재실행」= 코드·CLI 준비됨, GT 450건 전체 wide CSV는 <code>03_analyze_trades.py</code><code>--limit</code> 없이 실행해 갱신.</p>
<p class="meta" style="margin-top: 40px;">
문서 버전: 2026-05-30 (프로젝트 구조·CLI 동기화) · DeepCoin / WLD ·
<code>docs/reference/trade_anaysis.html</code> ·
DB: <code>data/coins.db</code> · GT: <code>data/ground_truth/ground_truth_trades.json</code> ·
enrich: <code>python scripts/03_analyze_enrich.py</code> ·
타점: <code>python scripts/03_analyze_trades.py</code> ·
약 247컬럼/TF · tail 기본 200봉 ·
UI 미구현: MTF 타일 Plotly, <code>trade_detail.html</code> ·
다음: 04 매칭, 05 운영 — <code>docs/reference/ROADMAP.md</code>
</p>
</body>
</html>

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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": "반등 첫 저항·전일 하락 중충 돌파 실패 구간"
}
]
}

View File

@@ -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()

222
mtf_bb.py
View File

@@ -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}")

View File

@@ -1,11 +0,0 @@
{
"buy_interval": 60,
"sell_interval": 60,
"buy_confirm_intervals": [
1440
],
"sell_confirm_intervals": [
1440
],
"name": "auto_60분_buy_60분_sell"
}

View File

@@ -3,5 +3,6 @@ numpy
PyJWT
requests
python-dateutil
python-dotenv>=1.0.0
python-telegram-bot
plotly

View File

@@ -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)

11
scripts/01_download.py Normal file
View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

11
scripts/04_match_rules.py Normal file
View File

@@ -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()

13
scripts/05_chart_bb.py Normal file
View File

@@ -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()

13
scripts/05_chart_truth.py Normal file
View File

@@ -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()

11
scripts/05_run_monitor.py Normal file
View File

@@ -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()

17
scripts/README.md Normal file
View File

@@ -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)

11
scripts/_bootstrap.py Normal file
View File

@@ -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()

219
scripts/verify_env.py Normal file
View File

@@ -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())

File diff suppressed because it is too large Load Diff

View File

@@ -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