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

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

301
deepcoin/api/bithumb.py Normal file
View File

@@ -0,0 +1,301 @@
import pandas as pd
import jwt
import uuid
import time
import requests
import json
import hashlib
from urllib.parse import urlencode
class HTS:
"""빗썸 Open API 래퍼 (시세 조회, 잔고, 주문)."""
bithumb = None
accessKey = ""
secretKey = ""
apiUrl = ""
def __init__(self):
from config import BITHUMB_ACCESS_KEY, BITHUMB_API_URL, BITHUMB_SECRET_KEY
self.bithumb = None
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:
for i in range(len(df)):
stock['PRICE'].append(
{
"ymd": df.index[i],
"close": df['close'].iloc[i],
"diff": 0,
"open": df['open'].iloc[i],
"high": df['high'].iloc[i],
"low": df['low'].iloc[i],
"volume": df['volume'].iloc[i],
"avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1,
"bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1,
"ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1, "ichimokucloud_leadingSpan2": -1,
"stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1,
"stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1,
"stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1,
"rsi": -1, "rsis": -1,
"macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1,
})
if data_1 is not None:
stock['PRICE'].append(
{
"ymd": data_1.index[-1],
"close": data_1['close'].iloc[-1],
"diff": 0,
"open": data_1['open'].iloc[-1],
"high": data_1['high'].iloc[-1],
"low": data_1['low'].iloc[-1],
"volume": data_1['volume'].iloc[-1],
"avg5": -1, "avg20": -1, "avg60": -1, "avg120": -1, "avg240": -1, "avg480": -1,
"bolingerband_upper": -1, "bolingerband_lower": -1, "bolingerband_middle": -1, "bolingerband_bwi": -1, "bolingerband_nor_bwi": -1,
"envelope_upper": -1, "envelope_lower": -1, "envelope_middle": -1,
"ichimokucloud_changeLine": -1, "ichimokucloud_baseLine": -1, "ichimokucloud_leadingSpan1": -1,
"ichimokucloud_leadingSpan2": -1,
"stochastic_fast_k_1": -1, "stochastic_slow_k_1": -1, "stochastic_slow_d_1": -1,
"stochastic_fast_k_2": -1, "stochastic_slow_k_2": -1, "stochastic_slow_d_2": -1,
"stochastic_fast_k_3": -1, "stochastic_slow_k_3": -1, "stochastic_slow_d_3": -1,
"rsi": -1, "rsis": -1,
"macd": -1, "macds": -1, "macdo": -1, "nor_macd": -1, "nor_macds": -1, "nor_macdo": -1,
})
return
def getCoinRawData(self, ticker_code, minute=None, day=False, week=False, month=False, to=None, endpoint='/v1/candles'):
url = None
if minute == 0:
# 현재가 정보
url = (self.apiUrl + "/v1/ticker?markets=KRW-{}").format(ticker_code)
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
df_temp = pd.DataFrame(json_data)
if 'trade_date_kst' not in df_temp or 'trade_time_kst' not in df_temp:
return None
df = pd.DataFrame()
df['datetime'] = pd.to_datetime(df_temp['trade_date_kst'], format='%Y-%m-%dT%H:%M:%S')
df['open'] = df_temp['opening_price']
df['close'] = df_temp['trade_price']
df['high'] = df_temp['high_price']
df['low'] = df_temp['low_price']
df['volume'] = df_temp['trade_volume']
df = df.set_index('datetime')
df = df.astype(float)
df["datetime"] = df.index
else:
# 분봉
if minute is not None and minute in {1, 3, 5, 10, 15, 30, 60, 240}:
if to is None:
url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000").format(minute, ticker_code)
else:
url = (self.apiUrl + endpoint + "/minutes/{}?market=KRW-{}&count=3000&to={}").format(minute, ticker_code, to)
if day:
if to is None:
url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/days?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if week:
if to is None:
url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/weeks?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if month:
if to is None:
url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000").format(ticker_code)
else:
url = (self.apiUrl + endpoint + "/months?market=KRW-{}&count=3000&to={}").format(ticker_code, to)
if url is None:
return None
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
json_data = json.loads(response.text)
df_temp = pd.DataFrame(json_data)
if 'candle_date_time_kst' not in df_temp:
return None
df = pd.DataFrame()
#df.columns = ['datetime', 'open', 'close', 'high', 'low', 'volume']
#df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'])
df['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S')
df['open'] = df_temp['opening_price']
df['close'] = df_temp['trade_price']
df['high'] = df_temp['high_price']
df['low'] = df_temp['low_price']
df['volume'] = df_temp['candle_acc_trade_volume']
df = df.set_index('datetime')
df = df.astype(float)
df["datetime"] = df.index
if df is None:
return None
return df
def getTickerList(self):
url = f"{self.apiUrl}/v1/market/all?isDetails=false"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
tickets = response.json()
return tickets
def getVirtual_asset_warning(self):
url = f"{self.apiUrl}/v1/market/virtual_asset_warning"
headers = {"accept": "application/json"}
response = requests.get(url, headers=headers)
warning_list = response.json()
return warning_list
# 거래대금이 많은 순으로 코인리스트를 얻는다.
def getTopCoinList(self, interval, top):
return
# 현재 가격 얻어오기
def getCurrentPrice(self, ticker_code, endpoint='/v1/ticker'):
headers = {"accept": "application/json"}
url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code)
response = requests.get(url, headers=headers)
ticker_state = response.json()
return ticker_state
# 잔고 가져오기
def getBalances(self, ticker_code=None, endpoint='/v1/accounts'):
payload = {
'access_key': self.accessKey,
'nonce': str(uuid.uuid4()),
'timestamp': round(time.time() * 1000)
}
jwt_token = jwt.encode(payload, self.secretKey)
authorization_token = 'Bearer {}'.format(jwt_token)
headers = {
'Authorization': authorization_token
}
response = requests.get(self.apiUrl + endpoint, headers=headers)
balances = response.json()
"""
[
{'currency': 'P', 'balance': '78290', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'KRW', 'balance': '4218.401653', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'XRP', 'balance': '13069.27647861', 'locked': '0', 'avg_buy_price': '1917', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'ADA', 'balance': '6941.65484013', 'locked': '0', 'avg_buy_price': '1260', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'BSV', 'balance': '0.00005656', 'locked': '0', 'avg_buy_price': '65450', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'SAND', 'balance': '0.00001158', 'locked': '0', 'avg_buy_price': '544.8', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'AVAX', 'balance': '26.43960509', 'locked': '0', 'avg_buy_price': '60882', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'},
{'currency': 'XCORE', 'balance': '0.2119', 'locked': '0', 'avg_buy_price': '0', 'avg_buy_price_modified': False, 'unit_currency': 'KRW'}
]
"""
if ticker_code is None:
return balances
else:
for balance in balances:
if balance['currency'] == ticker_code:
return balance
return None
def order(self, ticker_code, side, ord_type, volume, price=None, endpoint='/v1/orders'):
if ord_type=='limit':
# 지정가 매수 (limit, side=bid) / 매도 (limit, side=ask)
if price is None:
return
requestBody = dict(market='KRW-'+ticker_code, side=side, volume=volume, price=price, ord_type=ord_type)
else:
# 시장가 매수 (price, side=bid) / 매도 (market, side=ask)
if ord_type == 'price':
requestBody = dict(market='KRW-' + ticker_code, side=side, price=price, ord_type=ord_type)
else:
requestBody = dict(market='KRW-' + ticker_code, side=side, volume=volume, ord_type=ord_type)
# Generate access token
query = urlencode(requestBody).encode()
hash = hashlib.sha512()
hash.update(query)
query_hash = hash.hexdigest()
payload = {
'access_key': self.accessKey,
'nonce': str(uuid.uuid4()),
'timestamp': round(time.time() * 1000),
'query_hash': query_hash,
'query_hash_alg': 'SHA512',
}
jwt_token = jwt.encode(payload, self.secretKey)
authorization_token = 'Bearer {}'.format(jwt_token)
headers = {
'Authorization': authorization_token,
'Content-Type': 'application/json'
}
response = requests.post(self.apiUrl + endpoint, data=json.dumps(requestBody), headers=headers)
# handle to success or fail
#print(response.json())
if response.status_code == 200:
return True
return False
# 시장가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴
def buyCoinMarket(self, ticker_code, price, count=None):
if price > 5000:
if price < 50000:
self.order(ticker_code, side='bid', ord_type='price', volume=count, price=price)
buy_price = price
else:
repeat = 10
buy_price = int(price / 1000) * 1000
buy_amount = int(buy_price / repeat)
while repeat > 0:
self.order(ticker_code, side='bid', ord_type='price', volume=count, price=buy_amount)
repeat -= 1
time.sleep(0.5)
else:
buy_price = 0
return buy_price
# 시장가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴
def sellCoinMarket(self, ticker_code, price, count):
return self.order(ticker_code, side='ask', ord_type='market', volume=count, price=price)
# 지정가 매수한다. 2초 뒤 잔고 데이터 리스트 리턴
def buyCoinLimit(self, ticker_code, price, count):
return self.order(ticker_code, side='bid', ord_type='limit', volume=count, price=price)
# 지정가 매도한다. 2초 뒤 잔고 데이터 리스트 리턴
def sellCoinLimit(self, ticker_code, price, count):
return self.order(ticker_code, side='ask', ord_type='limit', volume=count, price=price)
def getOrderBook(self, ticker_code, endpoint='/v1/orderbook'):
"""
필드 설명 타입
market 마켓 코드 String
timestamp 호가 생성 시각 Long
total_ask_size 호가 매도 총 잔량 Double
total_bid_size 호가 매수 총 잔량 Double
orderbook_units 호가 List of Objects
> ask_price 매도호가 Double
> bid_price 매수호가 Double
> ask_size 매도 잔량 Double
> bid_size 매수 잔량 Double
"""
headers = {"accept": "application/json"}
url = (self.apiUrl + endpoint + "?markets=KRW-{}").format(ticker_code)
response = requests.get(url, headers=headers)
# 매도 총 잔량: sum([units['ask_size'] for units in orders[0]['orderbook_units']])
# 매수 총 잔량: sum([units['bid_size'] for units in orders[0]['orderbook_units']])
orders = response.json()
return orders

View File

View File

@@ -0,0 +1,362 @@
"""
모든 봉(1~1440분)에 BB·일목 위치·캔들 형태 특징을 계산하고
기준 타임라인(3분)에 맞춰 정렬합니다.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
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_PREFIX.get(interval, f"m{interval}")
def interval_display(interval: int) -> str:
if interval >= 1440:
return "일봉"
return f"{interval}"
# BB 위치 (밴드 내 %B 구간)
BB_ZONE_FEATURES: tuple[str, ...] = (
"bb_zone_bottom",
"bb_zone_low",
"bb_zone_mid",
"bb_zone_high",
"bb_zone_top",
)
# 일목 위치
ICHI_FEATURES: tuple[str, ...] = (
"ichi_above_cloud",
"ichi_below_cloud",
"ichi_in_cloud",
"ichi_cloud_bull",
"ichi_cloud_bear",
"ichi_tk_bull",
"ichi_tk_bear",
"ichi_price_above_tenkan",
"ichi_price_below_kijun",
"ichi_tk_cross_up",
"ichi_tk_cross_down",
)
# BB 이벤트·캔들 형태
BB_EVENT_FEATURES: tuple[str, ...] = (
"cross_up_lower",
"cross_up_upper",
"cross_down_lower",
"below_lower",
"above_upper",
"inside_band",
"bb_pos_low",
"bb_pos_high",
"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",
"hammer",
"shooting_star",
"bullish",
"bearish",
)
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·일목·MACD·스토캐스틱·캔들 위치 특징을 추가합니다."""
out = apply_bar_indicators(df.copy())
if len(out) < 2:
return out
o = out["Open"].astype(float)
h = out["High"].astype(float)
l = out["Low"].astype(float)
c = out["Close"].astype(float)
prev_c = c.shift(1)
upper = out["Upper"].astype(float)
lower = out["Lower"].astype(float)
prev_upper = upper.shift(1)
prev_lower = lower.shift(1)
rng = (h - l).replace(0, np.nan)
body = (c - o).abs()
out["range_pct"] = (rng / c.replace(0, np.nan)) * 100
out["body_ratio"] = (body / rng).fillna(0).clip(0, 1)
out["upper_wick_ratio"] = ((h - np.maximum(o, c)) / rng).fillna(0).clip(0, 1)
out["lower_wick_ratio"] = ((np.minimum(o, c) - l) / rng).fillna(0).clip(0, 1)
out["ret_pct"] = ((c - prev_c) / prev_c.replace(0, np.nan)) * 100
pos = out["bb_pos"].astype(float)
out["bb_zone_bottom"] = (pos < 0.15).astype(int)
out["bb_zone_low"] = ((pos >= 0.15) & (pos < 0.35)).astype(int)
out["bb_zone_mid"] = ((pos >= 0.35) & (pos < 0.65)).astype(int)
out["bb_zone_high"] = ((pos >= 0.65) & (pos < 0.85)).astype(int)
out["bb_zone_top"] = (pos >= 0.85).astype(int)
out["cross_up_lower"] = ((prev_c <= prev_lower) & (c > lower)).astype(int)
out["cross_up_upper"] = ((prev_c < prev_upper) & (c >= upper)).astype(int)
out["cross_down_lower"] = ((prev_c >= prev_lower) & (c < lower)).astype(int)
out["below_lower"] = (c < lower).astype(int)
out["above_upper"] = (c > upper).astype(int)
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"] < BB_MIN_WIDTH_PCT).astype(int)
ct = out["ichi_cloud_top"].astype(float)
cb = out["ichi_cloud_bottom"].astype(float)
ten = out["ichi_tenkan"].astype(float)
kij = out["ichi_kijun"].astype(float)
prev_ten = ten.shift(1)
prev_kij = kij.shift(1)
out["ichi_above_cloud"] = (c > ct).astype(int)
out["ichi_below_cloud"] = (c < cb).astype(int)
out["ichi_in_cloud"] = ((c >= cb) & (c <= ct)).astype(int)
out["ichi_cloud_bull"] = (out["ichi_span_a"] > out["ichi_span_b"]).astype(int)
out["ichi_cloud_bear"] = (out["ichi_span_a"] < out["ichi_span_b"]).astype(int)
out["ichi_tk_bull"] = (ten > kij).astype(int)
out["ichi_tk_bear"] = (ten < kij).astype(int)
out["ichi_price_above_tenkan"] = (c > ten).astype(int)
out["ichi_price_below_kijun"] = (c < kij).astype(int)
out["ichi_tk_cross_up"] = ((prev_ten <= prev_kij) & (ten > kij)).astype(int)
out["ichi_tk_cross_down"] = ((prev_ten >= prev_kij) & (ten < kij)).astype(int)
out["body_strong"] = (out["body_ratio"] > 0.55).astype(int)
out["body_weak"] = (out["body_ratio"] < 0.25).astype(int)
out["hammer"] = ((out["lower_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
out["shooting_star"] = ((out["upper_wick_ratio"] > 0.45) & (out["body_ratio"] < 0.35)).astype(int)
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
def describe_latest_position(df: pd.DataFrame, interval: int) -> dict:
"""한 봉의 최신 BB·일목 위치 요약."""
feat = compute_bar_features(df)
if feat.empty:
return {"interval": interval, "label": interval_display(interval)}
row = feat.iloc[-1]
pos = float(row.get("bb_pos", 0.5))
bb_zone = "mid"
for z in BB_ZONE_FEATURES:
if int(row.get(z, 0)) == 1:
bb_zone = z.replace("bb_zone_", "")
break
ichi_pos = "in_cloud"
if int(row.get("ichi_above_cloud", 0)):
ichi_pos = "above_cloud"
elif int(row.get("ichi_below_cloud", 0)):
ichi_pos = "below_cloud"
snap: dict = {
"interval": interval,
"label": interval_display(interval),
"close": float(row["Close"]),
"bb_pos": round(pos, 3),
"bb_zone": bb_zone,
"bb_state": _bb_event_label(row),
"ichi_position": ichi_pos,
"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:
for name in (
"cross_up_lower",
"cross_up_upper",
"cross_down_lower",
"below_lower",
"above_upper",
"squeeze",
"inside_band",
):
if int(row.get(name, 0)) == 1:
return name
return "neutral"
def _merge_interval_features(
master_index: pd.DatetimeIndex,
feat: pd.DataFrame,
prefix: str,
) -> pd.DataFrame:
"""master_index 길이와 동일한 간격 특징만 반환."""
pick = [c for c in FEATURE_BOOL_COLS 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"]
extra.append("bb_width_pct")
sub = feat[pick + extra].copy()
sub.columns = [f"{prefix}_{c}" for c in sub.columns]
left = pd.DataFrame({"ts": master_index})
right = sub.reset_index()
time_col = right.columns[0]
right = right.rename(columns={time_col: "ts"})
merged = pd.merge_asof(
left.sort_values("ts"),
right.sort_values("ts"),
on="ts",
direction="backward",
)
merged.index = master_index
return merged.drop(columns=["ts"])
def build_master_feature_matrix(frames: dict[int, pd.DataFrame]) -> pd.DataFrame:
"""3분 타임라인에 모든 봉의 BB·일목·캔들 특징을 붙인 행렬."""
entry = frames.get(ENTRY_INTERVAL)
if entry is None or entry.empty:
raise ValueError(f"{ENTRY_INTERVAL}분봉(ENTRY_INTERVAL) 데이터가 없습니다.")
entry_feat = compute_bar_features(entry)
entry_feat = entry_feat[~entry_feat.index.duplicated(keep="last")].sort_index()
p0 = interval_prefix(ENTRY_INTERVAL)
ohlc = ["Open", "High", "Low", "Close", "Volume", "Upper", "Lower", "MA"]
master = entry_feat[[c for c in ohlc if c in entry_feat.columns]].copy()
for col in FEATURE_BOOL_COLS:
if col in entry_feat.columns:
master[f"{p0}_{col}"] = entry_feat[col]
for col in ("bb_pos", "body_ratio", "lower_wick_ratio", "ret_pct", "bb_width_pct", "BB_Width"):
if col in entry_feat.columns:
master[f"{p0}_{col}"] = entry_feat[col]
for interval in ALL_INTERVALS:
if interval == ENTRY_INTERVAL:
continue
df = frames.get(interval)
if df is None or df.empty:
continue
feat = compute_bar_features(df)
feat = feat[~feat.index.duplicated(keep="last")].sort_index()
prefix = interval_prefix(interval)
merged = _merge_interval_features(master.index, feat, prefix)
master = pd.concat([master, merged], axis=1)
return master.loc[:, ~master.columns.duplicated()]

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

394
deepcoin/data/downloader.py Normal file
View File

@@ -0,0 +1,394 @@
"""
WLD 과거 봉을 빗썸 API에서 받아 coins.db에 저장합니다.
- 최초: 최근 N개월 전량 적재
- 이후: DB 마지막 시각 **이후** 봉만 추가 (증분)
"""
from __future__ import annotations
import sqlite3
from datetime import datetime
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 deepcoin.ops.monitor import Monitor
def bong_count_for_months(interval_minutes: int, months: int) -> int:
"""N개월치 봉 개수(여유분 포함)."""
days = months * 30
if interval_minutes >= DAILY_INTERVAL_MIN:
return days + DOWNLOAD_DAILY_EXTRA_DAYS
bars_per_day = (24 * 60) // interval_minutes
return days * bars_per_day + DOWNLOAD_BACKFILL_EXTRA_BARS
def bong_count_since(
interval_minutes: int, last_ts: pd.Timestamp, overlap: int = INCREMENTAL_OVERLAP_BARS
) -> int:
"""마지막 저장 시각 이후 필요한 API 봉 수(겹침 포함)."""
now = pd.Timestamp.now()
if last_ts.tzinfo is not None and now.tzinfo is None:
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, DOWNLOAD_MIN_INCREMENTAL_BARS)
def months_cutoff(months: int) -> pd.Timestamp:
"""N개월 전 시각."""
return pd.Timestamp(datetime.now() - relativedelta(months=months))
def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
"""최근 N개월 구간만 남깁니다."""
if data is None or data.empty:
return data
cutoff = months_cutoff(months)
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
return data[data.index >= cutoff].copy()
def interval_label(interval: int) -> str:
if interval >= 1440:
return "일봉(1440)"
return f"{interval}분봉"
def months_for_interval(interval: int, default_months: int) -> int:
"""간격별 DB 보관 개월 수 (1분봉은 별도 상한)."""
if interval == 1:
return DOWNLOAD_MONTHS_1M
return default_months
def download_jobs() -> list[tuple[int, str]]:
labels = {
1: "1분",
3: "3분",
5: "5분",
10: "10분",
15: "15분",
30: "30분",
60: "60분(1시간)",
240: "240분(4시간)",
1440: "1440분(1일)",
}
jobs = []
for iv in DOWNLOAD_INTERVALS:
if iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
continue
jobs.append((iv, labels.get(iv, f"{iv}")))
return jobs
def ensure_table(cursor, table_name: str) -> None:
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
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:
"""테이블에 저장된 해당 심볼의 마지막 봉 시각."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT MAX(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_row_count(symbol: str, interval: int, db_path: str = DB_PATH) -> int:
"""저장된 봉 개수."""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"SELECT COUNT(*) FROM {table_name} WHERE CODE = ?",
(symbol,),
)
row = cursor.fetchone()
conn.close()
return int(row[0]) if row else 0
def filter_after_last(
data: pd.DataFrame, last_ts: pd.Timestamp | None
) -> pd.DataFrame:
"""마지막 저장 시각보다 이후(>)인 봉만 반환."""
if data is None or data.empty or last_ts is None:
return data
if not isinstance(data.index, pd.DatetimeIndex):
data = data.copy()
data.index = pd.to_datetime(data.index)
last = pd.Timestamp(last_ts)
return data[data.index > last].copy()
def prune_before_cutoff(
symbol: str, interval: int, months: int, db_path: str = DB_PATH
) -> int:
"""N개월보다 오래된 봉 삭제 (DB 용량 유지)."""
cutoff = months_cutoff(months).strftime("%Y-%m-%d %H:%M:%S")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
cursor.execute(
f"DELETE FROM {table_name} WHERE CODE = ? AND ymdhms < ?",
(symbol, cutoff),
)
deleted = cursor.rowcount
conn.commit()
cursor.close()
conn.close()
return deleted
def append_data(
symbol: str,
interval: int,
data: pd.DataFrame,
last_ts: pd.Timestamp | None = None,
db_path: str = DB_PATH,
) -> tuple[int, int]:
"""
마지막 시각 이후 봉만 INSERT합니다. 기존 데이터는 삭제하지 않습니다.
Args:
last_ts: None이면 전체 data 적재, 있으면 index > last_ts 만 적재
Returns:
(추가된 행 수, 스킵된 행 수)
"""
if data is None or data.empty:
return 0, 0
total = len(data)
to_save = data if last_ts is None else filter_after_last(data, last_ts)
skipped = total - len(to_save)
if to_save.empty:
return 0, skipped
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
ensure_table(cursor, table_name)
records = []
for i in range(len(to_save)):
ts = to_save.index[i]
if hasattr(ts, "to_pydatetime"):
ts = ts.to_pydatetime()
ymd = ts.strftime("%Y%m%d")
hms = ts.strftime("%H%M%S")
ymdhms = ts.strftime("%Y-%m-%d %H:%M:%S")
records.append(
(
symbol,
KR_COINS[symbol],
ymdhms,
ymd,
hms,
float(to_save["Open"].iloc[i]),
float(to_save["High"].iloc[i]),
float(to_save["Low"].iloc[i]),
float(to_save["Close"].iloc[i]),
float(to_save["Volume"].iloc[i]),
)
)
cursor.executemany(
f"INSERT INTO {table_name} "
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
records,
)
conn.commit()
cursor.close()
conn.close()
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로 받아 증분·백필 저장합니다."""
months = months_for_interval(interval, months)
label = interval_label(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 = "초기 적재"
else:
target = min(
bong_count_since(interval, last_ts),
bong_count_for_months(interval, months),
)
mode = f"증분 (마지막 {last_ts.strftime('%Y-%m-%d %H:%M:%S')} 이후)"
print(f"\n[{symbol}] {label}{mode}")
print(f" DB 기존 {existing}행 | 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
data = trim_to_recent_months(data, months)
if data.empty:
print(" -> 최근 N개월 필터 후 데이터 없음")
return
inserted, skipped = append_data(symbol, interval, data, last_ts=last_ts)
pruned = prune_before_cutoff(symbol, interval, months)
new_last = get_last_timestamp(symbol, interval)
total = get_row_count(symbol, interval)
print(f" -> API {len(data)}봉 | 추가 {inserted}행 | 스킵(기존) {skipped}")
if pruned > 0:
print(f" -> {months}개월 이전 {pruned}행 정리")
if new_last is not None:
print(f" -> DB 합계 {total}행 | {data.index[0]} ~ {new_last}")
def download(months: int | None = None) -> None:
"""
WLD 다중 분봉·일봉을 coins.db에 증분 적재합니다.
간격: config.DOWNLOAD_INTERVALS
"""
months = months or DOWNLOAD_MONTHS
monitor = Monitor(cooldown_file=None)
jobs = download_jobs()
intervals_str = ", ".join(str(iv) for iv, _ in jobs)
print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
print(f"보관 {months}개월 | 간격(분): {intervals_str}")
started = datetime.now()
for interval, desc in jobs:
print(f"\n--- {desc} ---")
try:
download_symbol(monitor, SYMBOL, interval, months)
except Exception as e:
print(f"오류 interval={interval}: {e}")
elapsed = datetime.now() - started
print(f"\n완료 (소요: {elapsed})")
if __name__ == "__main__":
download()

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

527
deepcoin/ops/monitor.py Normal file
View File

@@ -0,0 +1,527 @@
import pandas as pd
from deepcoin.api.bithumb import HTS
from dateutil.relativedelta import relativedelta
from datetime import datetime
import sqlite3
import time
try:
import telegram
except ImportError:
telegram = None # type: ignore
import requests
import json
import asyncio
from multiprocessing import Pool
import numpy as np
import os
from config import *
class Monitor(HTS):
"""WLD 코인 데이터·지표·시장 상태 출력."""
last_signal = None
cooldown_file = None
def __init__(self, cooldown_file: str | None = None) -> None:
HTS.__init__(self)
# 최근 매수 신호 저장용(파일은 [신규] 포맷으로 저장)
self.last_signal: dict[str, str] = {}
if cooldown_file is not None:
self.cooldown_file = cooldown_file
self.buy_cooldown = self._load_buy_cooldown()
else:
self.cooldown_file = None
self.buy_cooldown = {}
# ------------- Persistence -------------
def _load_buy_cooldown(self) -> dict:
"""load trade record file into nested dict {symbol:{'buy':{'datetime':dt,'signal':s},'sell':{...}}}"""
if not os.path.exists(self.cooldown_file):
return {}
try:
with open(self.cooldown_file, 'r', encoding='utf-8') as f:
raw = json.load(f)
except Exception as e:
print(f"Error loading cooldown data: {e}")
return {}
record: dict[str, dict] = {}
for symbol, value in raw.items():
# 신규 포맷: value has 'buy'/'sell'
if isinstance(value, dict) and ('buy' in value or 'sell' in value):
record[symbol] = {}
for side in ['buy', 'sell']:
side_val = value.get(side)
if isinstance(side_val, dict):
dt_iso = side_val.get('datetime')
sig = side_val.get('signal', '')
if dt_iso:
try:
dt_obj = datetime.fromisoformat(dt_iso)
except Exception:
dt_obj = None
else:
dt_obj = None
record[symbol][side] = {'datetime': dt_obj, 'signal': sig}
else:
# 구 포맷 처리 (매수만 기록)
try:
dt_obj = None
sig = ''
if isinstance(value, str):
dt_obj = datetime.fromisoformat(value)
elif isinstance(value, dict):
dt_iso = value.get('datetime')
sig = value.get('signal', '')
if dt_iso:
dt_obj = datetime.fromisoformat(dt_iso)
record.setdefault(symbol, {})['buy'] = {'datetime': dt_obj, 'signal': sig}
except Exception:
continue
# last_signal 채우기 (buy 기준)
for sym, sides in record.items():
if 'buy' in sides and sides['buy'].get('signal'):
self.last_signal[sym] = sides['buy']['signal']
return record
def _save_buy_cooldown(self) -> None:
"""save nested trade record structure"""
try:
data: dict[str, dict] = {}
for symbol, sides in self.buy_cooldown.items():
data[symbol] = {}
for side in ['buy', 'sell']:
info = sides.get(side)
if not info:
continue
dt_obj = info.get('datetime')
sig = info.get('signal', '')
data[symbol][side] = {
'datetime': dt_obj.isoformat() if isinstance(dt_obj, datetime) else '',
'signal': sig,
}
with open(self.cooldown_file, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"Error saving cooldown data: {e}")
# ------------- Telegram -------------
def _send_coin_msg(self, text: str) -> None:
if telegram is None:
print(f"[telegram skip] {text}")
return
coin_client = telegram.Bot(token=COIN_TELEGRAM_BOT_TOKEN)
asyncio.run(coin_client.send_message(chat_id=COIN_TELEGRAM_CHAT_ID, text=text))
def sendMsg(self, msg):
try:
pool = Pool(12)
pool.map(self._send_coin_msg, [msg])
except Exception as e:
print(f"Error sending Telegram message: {str(e)}")
return
def send_coin_telegram_message(self, message_list: list[str], header: str) -> None:
payload = header + "\n"
for i, message in enumerate(message_list):
payload += message
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) % MONITOR_TELEGRAM_BATCH_SIZE != 0:
pool = Pool(MONITOR_POOL_WORKERS)
pool.map(self._send_coin_msg, [payload])
# ------------- Indicators -------------
def normalize_data(self, data: pd.DataFrame) -> pd.DataFrame:
columns_to_normalize = ['Open', 'High', 'Low', 'Close', 'Volume']
normalized_data = data.copy()
for column in columns_to_normalize:
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,
(data[column] - min_val) / denominator,
0.5,
)
return normalized_data
def inverse_data(self, data: pd.DataFrame) -> pd.DataFrame:
"""원본 data 가격 시계를 상하 대칭(글로벌 min/max 기준)으로 반전하여 하락↔상승 트렌드를 뒤집는다."""
price_cols = ['Open', 'High', 'Low', 'Close']
inv = data.copy()
global_min = data[price_cols].min().min()
global_max = data[price_cols].max().max()
# 축 기준은 global_mid = (max+min), so transformed = max+min - price
for col in price_cols:
inv[col] = global_max + global_min - data[col]
# Volume은 그대로 유지
inv['Volume'] = data['Volume']
# 지표 다시 계산
inv = self.normalize_data(inv)
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)
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
def process_wld_market_status(self, symbol: str) -> None:
"""
WLD: 전 봉 BB·일목 위치·추세만 출력 (자동 매매 없음).
"""
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)
if not frames:
print(f"Data for {symbol}: 로드된 봉 없음.")
return
df_1d = frames.get(TREND_INTERVAL_1D)
df_1h = frames.get(TREND_INTERVAL_1H)
if df_1d is None or df_1d.empty:
df_1d = frames.get(ENTRY_INTERVAL)
if df_1h is None or df_1h.empty:
df_1h = frames.get(ENTRY_INTERVAL)
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}"
)
except Exception as e:
print(f"Error processing {symbol}: {str(e)}")
def process_symbol(
self,
symbol: str,
interval: int | None = None,
balances: dict | None = None,
use_inverse: bool = False,
) -> None:
"""하위 호환: 시장 상태 출력으로 위임."""
self.process_wld_market_status(symbol)
def load_balances_dict(self) -> dict:
"""getBalances() 결과를 currency 키 dict로 변환."""
tmps = self.getBalances()
balances = {}
for tmp in tmps:
balances[tmp["currency"]] = {
"balance": float(tmp["balance"]),
"avg_buy_price": float(tmp["avg_buy_price"]),
}
return balances
# ------------- Formatting -------------
def format_message(
self, symbol: str, symbol_name: str, close: float, signal: str, buy_amount: float
) -> str:
message = f"[매수] {symbol_name} ({symbol}) [{signal}]: "
if int(close) >= 100:
message += f"{close}"
message += f" (₩{buy_amount})"
elif int(close) >= 10:
message += f"{close:.2f}"
message += f" (₩{buy_amount:.2f})"
elif int(close) >= 1:
message += f"{close:.3f}"
message += f" (₩{buy_amount:.3f})"
else:
message += f"{close:.4f}"
message += f" (₩{buy_amount:.4f})"
if signal != '':
message += f"[{signal}]"
return message
# ------------- Data fetch -------------
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 >= DAILY_INTERVAL_MIN:
url = f"{base}/v1/candles/days?market=KRW-{symbol}&count={count}"
else:
url = (
f"{base}/v1/candles/minutes/{interval}"
f"?market=KRW-{symbol}&count={count}"
)
else:
if interval >= DAILY_INTERVAL_MIN:
url = (
f"{base}/v1/candles/days?market=KRW-{symbol}"
f"&count={count}&to={to}"
)
else:
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)
df_temp = pd.DataFrame(json_data)
df_temp = df_temp.sort_index(ascending=False)
if 'candle_date_time_kst' not in df_temp:
return None
data = pd.DataFrame()
data['datetime'] = pd.to_datetime(df_temp['candle_date_time_kst'], format='%Y-%m-%dT%H:%M:%S')
data['Open'] = df_temp['opening_price']
data['Close'] = df_temp['trade_price']
data['High'] = df_temp['high_price']
data['Low'] = df_temp['low_price']
data['Volume'] = df_temp['candle_acc_trade_volume']
data = data.set_index('datetime')
data = data.astype(float)
data["datetime"] = data.index
if not data.empty:
return data
print(f"No data received for {symbol}, attempt {attempt + 1}")
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(MONITOR_SLEEP_RATE_LIMIT_SEC)
continue
return None
def get_coin_more_data(
self,
symbol: str,
interval: int,
bong_count: int = MONITOR_API_BONG_COUNT,
verbose: bool = False,
) -> pd.DataFrame:
"""
빗썸 API를 반복 호출해 bong_count개까지 과거 봉을 수집합니다.
Args:
verbose: True면 수집 진행 상황을 출력합니다.
"""
to = datetime.now()
data: pd.DataFrame | None = None
step = 0
while data is None or len(data) < bong_count:
step += 1
if data is None:
chunk = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
data = chunk
else:
previous_count = len(data)
df = self.get_coin_data(symbol, interval, to.strftime("%Y-%m-%d %H:%M:%S"))
if df is not None and not df.empty:
data = pd.concat([data, df], ignore_index=True)
if df is None or df.empty or previous_count == len(data):
if verbose:
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
break
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(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")
data = data.sort_index()
data = data.drop_duplicates(keep="first")
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 = DB_PATH,
max_rows: int = DB_READ_LIMIT_DEFAULT,
) -> pd.DataFrame:
"""
coins.db에서 저장된 봉을 읽고, API로 받은 최신 봉을 DB에 반영합니다.
scripts/01_download.py로 미리 적재해 두면 장기 MA 계산에 유리합니다.
"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
table_name = f"{symbol}_{interval}"
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} "
"(CODE text, NAME text, ymdhms datetime, ymd text, hms text, "
"Close REAL, Open REAL, High REAL, Low REAL, Volume REAL)"
)
cursor.execute(
f"CREATE INDEX IF NOT EXISTS {table_name}_idx ON {table_name}(CODE, ymdhms)"
)
for i in range(1, len(data)):
ymdhms = data["datetime"].iloc[-i].strftime("%Y-%m-%d %H:%M:%S")
cursor.execute(
f"SELECT 1 FROM {table_name} WHERE CODE = ? AND ymdhms = ?",
(symbol, ymdhms),
)
if not cursor.fetchone():
cursor.execute(
f"INSERT INTO {table_name} "
"(CODE, NAME, ymdhms, ymd, hms, Close, Open, High, Low, Volume) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
symbol,
KR_COINS[symbol],
ymdhms,
data["datetime"].iloc[-i].strftime("%Y%m%d"),
data["datetime"].iloc[-i].strftime("%H%M%S"),
data["Close"].iloc[-i],
data["Open"].iloc[-i],
data["High"].iloc[-i],
data["Low"].iloc[-i],
data["Volume"].iloc[-i],
),
)
else:
break
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 {int(max_rows)}) "
f"ORDER BY datetime"
)
result = cursor.fetchall()
conn.commit()
cursor.close()
conn.close()
if not result:
return pd.DataFrame(
columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = pd.DataFrame(
result, columns=["Open", "Close", "High", "Low", "Volume", "datetime"]
)
df = df.set_index("datetime")
df = df.sort_index()
df["datetime"] = df.index
return df
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분봉만 사용합니다. 과거 적재는 scripts/01_download.py 실행.
"""
data = self.get_coin_data(symbol, interval)
if data is None or data.empty:
return pd.DataFrame()
data_1 = self.get_coin_data(symbol, interval=1)
if data_1 is not None and not data_1.empty:
data_1 = data_1.copy()
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
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)
if data_1 is not None and not data_1.empty:
parts.append(data_1.iloc[[-1]])
merged = pd.concat(parts, ignore_index=True)
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
merged = merged.set_index("datetime")
merged = merged.sort_index()
merged = merged.drop_duplicates(keep="first")
merged["datetime"] = merged.index
return merged

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)