feat(spot): 3단계 운영 파이프라인 — composite_v3 + MTF paper/live

MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
xavis
2026-06-12 18:27:34 +09:00
parent 2d515dd669
commit 58802bdc5f
19 changed files with 1485 additions and 10 deletions

View File

@@ -0,0 +1,60 @@
"""빗썸 Private API JWT 인증."""
from __future__ import annotations
import hashlib
import json
import uuid
from typing import Any
from urllib.parse import urlencode
import jwt
def build_jwt_token(
access_key: str,
secret_key: str,
*,
params: dict[str, Any] | None = None,
) -> str:
"""빗썸 v2.1 JWT Bearer 토큰을 생성한다.
Args:
access_key: API Access Key.
secret_key: API Secret Key.
params: POST/DELETE body 또는 query 파라미터. 있으면 query_hash 포함.
Returns:
JWT 문자열 (Bearer 접두사 없음).
"""
import time
payload: dict[str, Any] = {
"access_key": access_key,
"nonce": str(uuid.uuid4()),
"timestamp": round(time.time() * 1000),
}
if params:
query = urlencode(params, doseq=True).encode()
payload["query_hash"] = hashlib.sha512(query).hexdigest()
payload["query_hash_alg"] = "SHA512"
return jwt.encode(payload, secret_key, algorithm="HS256")
def auth_headers(
access_key: str,
secret_key: str,
*,
params: dict[str, Any] | None = None,
) -> dict[str, str]:
"""Authorization 헤더 dict를 반환한다."""
token = build_jwt_token(access_key, secret_key, params=params)
return {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
def dumps_params(params: dict[str, Any]) -> str:
"""주문 body JSON 직렬화 (키 순서 유지)."""
return json.dumps(params, separators=(",", ":"))

View File

@@ -0,0 +1,143 @@
"""빗썸 Private REST API — 잔고·주문."""
from __future__ import annotations
import logging
import time
from typing import Any
import requests
from deepcoin.api.bithumb_auth import auth_headers, dumps_params
logger = logging.getLogger(__name__)
class BithumbPrivateClient:
"""빗썸 v2.1 Private API 클라이언트."""
def __init__(
self,
access_key: str,
secret_key: str,
base_url: str = "https://api.bithumb.com",
sleep_sec: float = 0.35,
retries: int = 3,
) -> None:
"""클라이언트를 초기화한다.
Args:
access_key: API Access Key.
secret_key: API Secret Key.
base_url: API 베이스 URL.
sleep_sec: 연속 요청 간 대기(초).
retries: 실패 시 재시도 횟수.
"""
if not access_key or not secret_key:
raise ValueError("BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.")
self.access_key = access_key
self.secret_key = secret_key
self.base_url = base_url.rstrip("/")
self.sleep_sec = sleep_sec
self.retries = retries
self._session = requests.Session()
def _request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""인증 요청을 수행한다."""
url = f"{self.base_url}{path}"
body = dumps_params(params) if params else None
headers = auth_headers(
self.access_key,
self.secret_key,
params=params,
)
last_error: Exception | None = None
for attempt in range(1, self.retries + 1):
try:
response = self._session.request(
method,
url,
data=body,
headers=headers,
timeout=30,
)
if response.status_code == 429:
wait = self.sleep_sec * attempt * 3
logger.warning("Rate limit 429 — %ss 대기", wait)
time.sleep(wait)
continue
response.raise_for_status()
payload = response.json()
time.sleep(self.sleep_sec)
if not isinstance(payload, (dict, list)):
raise ValueError(f"Unexpected response: {type(payload)}")
return payload if isinstance(payload, dict) else {"data": payload}
except Exception as exc:
last_error = exc
wait = self.sleep_sec * attempt * 2
logger.warning("Private API failed (%s/%s): %s", attempt, self.retries, exc)
time.sleep(wait)
if last_error is not None:
raise last_error
return {}
def get_accounts(self) -> list[dict[str, Any]]:
"""전체 계좌(잔고)를 조회한다."""
payload = self._request("GET", "/v1/accounts")
if isinstance(payload, list):
return payload
return payload.get("data", payload) if isinstance(payload.get("data"), list) else []
def get_balance(self, currency: str) -> tuple[float, float]:
"""통화별 잔고를 반환한다.
Returns:
(available, locked) 수량 또는 원화.
"""
currency = currency.upper()
for row in self.get_accounts():
if str(row.get("currency", "")).upper() == currency:
return float(row.get("balance", 0)), float(row.get("locked", 0))
return 0.0, 0.0
def market_buy_krw(self, market: str, krw_amount: float) -> dict[str, Any]:
"""시장가 매수 (원화 금액).
Args:
market: 예) KRW-BTC.
krw_amount: 매수 원화 금액.
Returns:
주문 응답 dict.
"""
params = {
"market": market,
"side": "bid",
"price": str(int(krw_amount)),
"ord_type": "price",
}
return self._request("POST", "/v1/orders", params=params)
def market_sell_volume(self, market: str, volume: float) -> dict[str, Any]:
"""시장가 매도 (코인 수량).
Args:
market: 예) KRW-BTC.
volume: 매도 수량.
Returns:
주문 응답 dict.
"""
params = {
"market": market,
"side": "ask",
"volume": f"{volume:.8f}".rstrip("0").rstrip("."),
"ord_type": "market",
}
return self._request("POST", "/v1/orders", params=params)

View File

@@ -93,6 +93,20 @@ class Settings:
mtf_rules_json: Path
causal_sim_report_json: Path
causal_sim_report_html: Path
# 현물 3단계: 운영 (paper / live)
bithumb_access_key: str
bithumb_secret_key: str
ops_mode: str
ops_technique_id: str
ops_min_score: float | None
ops_state_json: Path
ops_report_json: Path
ops_filtered_backtest_json: Path
ops_mtf_enabled: bool
ops_trend_gate_enabled: bool
ops_daily_max_trades: int
ops_min_order_krw: float
ops_sync_candles: bool
@property
def market(self) -> str:
@@ -259,4 +273,33 @@ def load_settings(env_path: Path | None = None) -> Settings:
"docs/spot/2_analysis/causal_sim_report.html",
)
),
bithumb_access_key=os.getenv("BITHUMB_ACCESS_KEY", "").strip(),
bithumb_secret_key=os.getenv("BITHUMB_SECRET_KEY", "").strip(),
ops_mode=os.getenv("OPS_MODE", "paper").strip().lower(),
ops_technique_id=os.getenv("OPS_TECHNIQUE_ID", "composite_v3").strip(),
ops_min_score=(
float(os.getenv("OPS_MIN_SCORE"))
if os.getenv("OPS_MIN_SCORE", "").strip()
else None
),
ops_state_json=_resolve_project_path(
os.getenv("OPS_STATE_JSON", "data/spot/operations/ops_state.json")
),
ops_report_json=_resolve_project_path(
os.getenv("OPS_REPORT_JSON", "docs/spot/3_operations/ops_report.json")
),
ops_filtered_backtest_json=_resolve_project_path(
os.getenv(
"OPS_FILTERED_BACKTEST_JSON",
"docs/spot/3_operations/filtered_backtest_report.json",
)
),
ops_mtf_enabled=os.getenv("OPS_MTF_ENABLED", "true").strip().lower()
in ("1", "true", "yes", "on"),
ops_trend_gate_enabled=os.getenv("OPS_TREND_GATE_ENABLED", "true").strip().lower()
in ("1", "true", "yes", "on"),
ops_daily_max_trades=int(os.getenv("OPS_DAILY_MAX_TRADES", "20")),
ops_min_order_krw=float(os.getenv("OPS_MIN_ORDER_KRW", "5000")),
ops_sync_candles=os.getenv("OPS_SYNC_CANDLES", "true").strip().lower()
in ("1", "true", "yes", "on"),
)

View File

@@ -0,0 +1,12 @@
"""현물 3단계 운영 — composite_v3 + MTF."""
from deepcoin.operations.backtest import run_filtered_backtest, save_backtest_report
from deepcoin.operations.runner import OperationsRunner
from deepcoin.operations.signal_pipeline import run_signal_pipeline
__all__ = [
"OperationsRunner",
"run_filtered_backtest",
"run_signal_pipeline",
"save_backtest_report",
]

View File

@@ -0,0 +1,69 @@
"""3단계: MTF 필터 적용 composite 백테스트."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from deepcoin.config import Settings
from deepcoin.evaluation.causal_sim import normalize_signals_for_sim
from deepcoin.ground_truth.pnl import simulate_gt_signals_pnl
from deepcoin.operations.signal_pipeline import run_signal_pipeline
def run_filtered_backtest(settings: Settings) -> dict[str, Any]:
"""MTF 필터 통과 신호로 3년 sim을 실행한다."""
pipeline = run_signal_pipeline(
settings,
use_cache=True,
mtf_lookback_days=settings.gt_sim_lookback_days,
)
kept = pipeline["kept"]
normalized = normalize_signals_for_sim(kept)
sim = simulate_gt_signals_pnl(
signals=normalized,
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
sim_lookback_days=settings.gt_sim_lookback_days,
data_end=pipeline["data_end"],
last_mark_price=pipeline["last_price"],
)
raw_sim = simulate_gt_signals_pnl(
signals=normalize_signals_for_sim(pipeline["scoped_raw_signals"]),
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
sim_lookback_days=settings.gt_sim_lookback_days,
data_end=pipeline["data_end"],
last_mark_price=pipeline["last_price"],
)
return {
"generated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"symbol": settings.symbol,
"technique_id": pipeline["technique_id"],
"technique_name": pipeline["technique_name"],
"params": pipeline["params"],
"mtf_enabled": pipeline.get("mtf_enabled", False),
"trend_gate_enabled": settings.ops_trend_gate_enabled,
"raw_signal_count": pipeline["raw_count"],
"filtered_signal_count": pipeline["kept_count"],
"rejected_signal_count": pipeline["rejected_count"],
"sim_lookback_days": settings.gt_sim_lookback_days,
"filtered_sim": sim,
"raw_sim": raw_sim,
"improvement_return_pct": round(
(sim.get("total_return_pct") or 0) - (raw_sim.get("total_return_pct") or 0),
2,
),
}
def save_backtest_report(report: dict[str, Any], path: Path) -> Path:
"""백테스트 리포트 JSON 저장."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fp:
json.dump(report, fp, ensure_ascii=False, indent=2)
return path

View File

@@ -0,0 +1,175 @@
"""paper / live 주문 실행."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from typing import Any
from deepcoin.api.bithumb_private import BithumbPrivateClient
from deepcoin.config import Settings
from deepcoin.operations.trade_engine import (
TradeResult,
apply_trade_to_portfolio,
compute_buy_order,
compute_sell_order,
)
logger = logging.getLogger(__name__)
class OrderExecutor(ABC):
"""주문 실행 인터페이스."""
@abstractmethod
def execute_signal(
self,
signal: dict[str, Any],
portfolio: dict[str, Any],
*,
cluster_size: int = 1,
) -> TradeResult:
"""신호 1건을 체결한다."""
class PaperExecutor(OrderExecutor):
"""모의 체결 — 신호 가격 기준 즉시 fill."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
def execute_signal(
self,
signal: dict[str, Any],
portfolio: dict[str, Any],
*,
cluster_size: int = 1,
) -> TradeResult:
"""paper 모드 체결."""
price = float(signal["price"])
fee_rate = self.settings.gt_trading_fee_rate
min_order = self.settings.ops_min_order_krw
cash = float(portfolio.get("cash_krw", 0))
coin = float(portfolio.get("coin_qty", 0))
side = str(signal["side"])
if side == "buy":
trade = compute_buy_order(
cash_krw=cash,
coin_qty=coin,
price=price,
fee_rate=fee_rate,
min_order_krw=min_order,
cluster_size=cluster_size,
)
else:
trade = compute_sell_order(
coin_qty=coin,
price=price,
fee_rate=fee_rate,
min_order_krw=min_order,
cluster_size=cluster_size,
)
if trade.executed:
apply_trade_to_portfolio(portfolio, trade)
return trade
class LiveExecutor(OrderExecutor):
"""실거래 체결 — 빗썸 시장가 주문."""
def __init__(self, settings: Settings, client: BithumbPrivateClient) -> None:
self.settings = settings
self.client = client
def _sync_portfolio(self, portfolio: dict[str, Any]) -> None:
"""거래소 잔고로 포트폴리오를 동기화한다."""
krw_avail, _ = self.client.get_balance("KRW")
coin_avail, _ = self.client.get_balance(self.settings.symbol)
portfolio["cash_krw"] = krw_avail
portfolio["coin_qty"] = coin_avail
def execute_signal(
self,
signal: dict[str, Any],
portfolio: dict[str, Any],
*,
cluster_size: int = 1,
) -> TradeResult:
"""live 모드 시장가 주문."""
self._sync_portfolio(portfolio)
price = float(signal["price"])
fee_rate = self.settings.gt_trading_fee_rate
min_order = self.settings.ops_min_order_krw
cash = float(portfolio.get("cash_krw", 0))
coin = float(portfolio.get("coin_qty", 0))
side = str(signal["side"])
if side == "buy":
trade = compute_buy_order(
cash_krw=cash,
coin_qty=coin,
price=price,
fee_rate=fee_rate,
min_order_krw=min_order,
cluster_size=cluster_size,
)
if not trade.executed:
return trade
try:
resp = self.client.market_buy_krw(self.settings.market, trade.order_krw)
trade.api_response = resp
self._sync_portfolio(portfolio)
except Exception as exc:
logger.exception("live buy failed")
return TradeResult(
executed=False,
side="buy",
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
price=price,
skip_reason=str(exc),
)
return trade
trade = compute_sell_order(
coin_qty=coin,
price=price,
fee_rate=fee_rate,
min_order_krw=min_order,
cluster_size=cluster_size,
)
if not trade.executed:
return trade
try:
resp = self.client.market_sell_volume(self.settings.market, trade.order_coin)
trade.api_response = resp
self._sync_portfolio(portfolio)
except Exception as exc:
logger.exception("live sell failed")
return TradeResult(
executed=False,
side="sell",
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
price=price,
skip_reason=str(exc),
)
return trade
def create_executor(settings: Settings) -> OrderExecutor:
"""설정에 맞는 Executor를 생성한다."""
if settings.ops_mode == "live":
client = BithumbPrivateClient(
access_key=settings.bithumb_access_key,
secret_key=settings.bithumb_secret_key,
base_url=settings.api_url,
sleep_sec=settings.request_sleep_sec,
retries=settings.request_retries,
)
return LiveExecutor(settings, client)
return PaperExecutor(settings)

View File

@@ -0,0 +1,192 @@
"""3단계 운영 러너 — 캔들 동기화·신호·체결."""
from __future__ import annotations
import json
import logging
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
from deepcoin.config import Settings
from deepcoin.ground_truth.pnl import _cluster_signals
from deepcoin.operations.executor import create_executor
from deepcoin.operations.signal_pipeline import (
filter_signals_for_ops,
generate_raw_signals,
load_ops_candles,
)
from deepcoin.operations.state_store import load_state, reset_daily_trade_count, save_state
logger = logging.getLogger(__name__)
def _project_root() -> Path:
return Path(__file__).resolve().parents[3]
def sync_candles_if_enabled(settings: Settings) -> bool:
"""증분 캔들 수집 스크립트를 실행한다."""
if not settings.ops_sync_candles:
return False
script = _project_root() / "scripts" / "00_download.py"
if not script.exists():
logger.warning("캔들 동기화 스크립트 없음: %s", script)
return False
logger.info("캔들 증분 동기화 실행...")
subprocess.run(
[sys.executable, str(script)],
cwd=str(_project_root()),
check=False,
)
return True
def _signals_on_bar(signals: list[dict[str, Any]], bar_index: int) -> list[dict[str, Any]]:
"""특정 bar_index 신호만 반환."""
return [s for s in signals if int(s.get("bar_index", -1)) == bar_index]
def _pending_bar_indices(
kept: list[dict[str, Any]],
last_bar_index: int,
latest_bar_index: int,
) -> list[int]:
"""체결 대상 bar_index 목록 (최초 실행은 최신 봉만)."""
if last_bar_index < 0:
return [latest_bar_index] if any(
int(s.get("bar_index", -1)) == latest_bar_index for s in kept
) else []
return sorted(
{
int(s.get("bar_index", -1))
for s in kept
if int(s.get("bar_index", -1)) > last_bar_index
}
)
def _cluster_pending(pending: list[dict[str, Any]]) -> list[tuple[str, list[dict[str, Any]]]]:
"""연속 동일 side 신호를 클러스터로 묶는다."""
normalized = [
{
"side": s["side"],
"datetime": s["datetime"],
"price": s["price"],
"bar_index": s.get("bar_index", 0),
"signal_type": s.get("signal_type", ""),
"marker_id": s.get("marker_id"),
}
for s in pending
]
return _cluster_signals(normalized)
class OperationsRunner:
"""3단계 운영 1회 tick 실행."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.executor = create_executor(settings)
self.state = load_state(
settings.ops_state_json,
initial_cash_krw=settings.gt_initial_cash_krw,
)
self.state["portfolio"]["mode"] = settings.ops_mode
def tick(self, *, sync_candles: bool | None = None) -> dict[str, Any]:
"""신호 확인 및 체결 1회."""
if sync_candles if sync_candles is not None else self.settings.ops_sync_candles:
sync_candles_if_enabled(self.settings)
df = load_ops_candles(self.settings)
latest_bar = int(len(df) - 1)
gen = generate_raw_signals(self.settings, df=df, use_cache=True)
# tick: 최신 봉 후보만 MTF 평가 (전기간 MTF는 백테스트 전용)
bar_candidates = _signals_on_bar(gen["raw_signals"], latest_bar)
filtered = filter_signals_for_ops(self.settings, bar_candidates)
kept = filtered["kept"]
reset_daily_trade_count(self.state)
last_bar = int(self.state.get("last_processed_bar_index", -1))
target_bars = _pending_bar_indices(kept, last_bar, latest_bar)
executions: list[dict[str, Any]] = []
max_daily = self.settings.ops_daily_max_trades
for bar_idx in target_bars:
bar_signals = _signals_on_bar(kept, bar_idx)
clusters = _cluster_pending(bar_signals)
for side, cluster in clusters:
if self.state["trades_today_count"] >= max_daily:
logger.warning("일일 체결 상한(%d) 도달 — 중단", max_daily)
break
cluster_size = len(cluster)
for sig in cluster:
full_sig = next(
(k for k in kept if k["datetime"] == sig["datetime"]),
sig,
)
trade = self.executor.execute_signal(
full_sig,
self.state["portfolio"],
cluster_size=cluster_size,
)
record = {
"datetime": full_sig["datetime"],
"side": full_sig["side"],
"signal_type": full_sig.get("signal_type"),
"price": full_sig["price"],
"bar_index": bar_idx,
"trade": trade.to_dict(),
"mtf_filter": full_sig.get("mtf_filter"),
}
executions.append(record)
if trade.executed:
self.state["trades_today_count"] += 1
if bar_idx > last_bar:
last_bar = bar_idx
self.state["last_processed_bar_index"] = bar_idx
if bar_signals:
self.state["last_processed_datetime"] = bar_signals[-1]["datetime"]
pipeline = {
"technique_id": gen["technique_id"],
"raw_count": len(bar_candidates),
"kept_count": len(kept),
"rejected_count": len(filtered["rejected"]),
"latest_bar_index": latest_bar,
}
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
self.state["last_run_at"] = now
if executions:
self.state.setdefault("trade_history", []).extend(executions)
report = {
"generated_at": now,
"mode": self.settings.ops_mode,
"technique_id": pipeline["technique_id"],
"raw_signals": pipeline["raw_count"],
"filtered_signals": pipeline["kept_count"],
"pending_bars": target_bars,
"latest_bar_candidates": pipeline["raw_count"],
"executions": executions,
"portfolio": self.state["portfolio"],
"trades_today_count": self.state["trades_today_count"],
"last_processed_bar_index": self.state["last_processed_bar_index"],
}
save_state(self.settings.ops_state_json, self.state)
self._save_report(report)
return report
def _save_report(self, report: dict[str, Any]) -> None:
"""최신 운영 리포트 저장."""
path = self.settings.ops_report_json
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fp:
json.dump(report, fp, ensure_ascii=False, indent=2)

View File

@@ -0,0 +1,186 @@
"""3단계: composite_v3 신호 생성 + MTF 필터."""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
import pandas as pd
from deepcoin.config import Settings
from deepcoin.data.candle_loader import load_candles
from deepcoin.mtf.extractor import MtfFeatureExtractor
from deepcoin.mtf.filter import MtfSignalFilter
from deepcoin.mtf.rules import load_or_derive_mtf_rules
from deepcoin.mtf.store import MultiTimeframeStore
from deepcoin.mtf.trend_gate import HtfTrendGate
from deepcoin.operations.signal_type import enrich_signal_types
from deepcoin.techniques.base import TechniqueParams
from deepcoin.techniques.registry import get_technique
from deepcoin.techniques.runner import load_technique_result, run_technique
def _parse_dt(value: str) -> datetime:
"""신호 datetime 파싱."""
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
def _signals_in_lookback(
signals: list[dict[str, Any]],
data_end: str,
lookback_days: int,
) -> list[dict[str, Any]]:
"""data_end 기준 lookback_days 이내 신호만 반환."""
end_dt = _parse_dt(data_end)
start_dt = end_dt - timedelta(days=lookback_days)
return [s for s in signals if _parse_dt(s["datetime"]) >= start_dt]
def build_technique_params(settings: Settings) -> TechniqueParams:
"""운영용 기법 파라미터를 구성한다."""
extra: dict[str, Any] = {}
if settings.ops_min_score is not None:
extra["min_score"] = settings.ops_min_score
return TechniqueParams(
interval_min=settings.gt_interval_min,
lookback_days=settings.gt_lookback_days,
min_leg_pct=settings.gt_min_leg_pct,
initial_cash_krw=settings.gt_initial_cash_krw,
fee_rate=settings.gt_trading_fee_rate,
extra=extra,
)
def load_ops_candles(settings: Settings) -> pd.DataFrame:
"""운영·백테스트용 캔들을 로드한다."""
return load_candles(
settings.db_path,
settings.symbol,
settings.gt_interval_min,
settings.gt_lookback_days,
)
def generate_raw_signals(
settings: Settings,
df: pd.DataFrame | None = None,
*,
use_cache: bool = True,
) -> dict[str, Any]:
"""기법 신호를 생성한다 (MTF 필터 전).
2단계 캐시 JSON이 있으면 재계산 없이 로드한다.
Returns:
technique_id, raw_signals, result 메타 dict.
"""
if df is None:
df = load_ops_candles(settings)
data_end = str(df["datetime"].iloc[-1])
last_price = float(df["close"].iloc[-1])
cache_path = settings.techniques_dir / f"{settings.ops_technique_id}.json"
if use_cache and cache_path.exists():
cached = load_technique_result(cache_path)
return {
"technique_id": cached.technique_id,
"technique_name": cached.technique_name,
"params": cached.params,
"raw_signals": cached.signals,
"data_end": data_end,
"last_price": last_price,
"from_cache": True,
}
technique = get_technique(settings.ops_technique_id)
params = build_technique_params(settings)
result = run_technique(technique, df, params, gt_result=None)
return {
"technique_id": result.technique_id,
"technique_name": result.technique_name,
"params": result.params,
"raw_signals": result.signals,
"data_end": data_end,
"last_price": last_price,
"from_cache": False,
}
def build_mtf_filter(settings: Settings) -> MtfSignalFilter | None:
"""MTF 필터 인스턴스를 생성한다. 비활성 시 None."""
if not settings.ops_mtf_enabled:
return None
rule_set = load_or_derive_mtf_rules(
settings.mtf_rules_json,
settings.mtf_report_json,
)
store = MultiTimeframeStore(
db_path=settings.db_path,
symbol=settings.symbol,
lookback_days=settings.gt_sim_lookback_days + 120,
zigzag_reversal_pct=settings.gt_zigzag_reversal_pct,
)
extractor = MtfFeatureExtractor(
store=store,
base_interval_min=settings.gt_interval_min,
)
trend_gate = HtfTrendGate(enabled=settings.ops_trend_gate_enabled)
return MtfSignalFilter(extractor, rule_set, trend_gate=trend_gate)
def filter_signals_for_ops(
settings: Settings,
raw_signals: list[dict[str, Any]],
mtf_filter: MtfSignalFilter | None = None,
*,
data_end: str | None = None,
mtf_lookback_days: int | None = None,
) -> dict[str, Any]:
"""신호 유형 보강 후 MTF 필터를 적용한다."""
scoped = raw_signals
if data_end and mtf_lookback_days:
scoped = _signals_in_lookback(raw_signals, data_end, mtf_lookback_days)
typed = enrich_signal_types(scoped)
if mtf_filter is None:
mtf_filter = build_mtf_filter(settings)
if mtf_filter is None:
return {
"kept": typed,
"rejected": [],
"mtf_enabled": False,
}
kept, rejected = mtf_filter.filter_signals(typed)
return {
"kept": kept,
"rejected": rejected,
"mtf_enabled": True,
"min_rules_pass": mtf_filter.rule_set.min_rules_pass,
}
def run_signal_pipeline(
settings: Settings,
*,
use_cache: bool = True,
mtf_lookback_days: int | None = None,
) -> dict[str, Any]:
"""캔들 로드 → 기법 신호 → MTF 필터까지 일괄 실행."""
lookback = mtf_lookback_days or settings.gt_sim_lookback_days
gen = generate_raw_signals(settings, use_cache=use_cache)
filtered = filter_signals_for_ops(
settings,
gen["raw_signals"],
data_end=gen["data_end"],
mtf_lookback_days=lookback,
)
scoped_raw = _signals_in_lookback(gen["raw_signals"], gen["data_end"], lookback)
return {
**gen,
**filtered,
"scoped_raw_signals": scoped_raw,
"raw_count": len(scoped_raw),
"raw_count_total": len(gen["raw_signals"]),
"kept_count": len(filtered["kept"]),
"rejected_count": len(filtered["rejected"]),
"mtf_lookback_days": lookback,
}

View File

@@ -0,0 +1,69 @@
"""composite_v3 신호의 GT signal_type 추론."""
from __future__ import annotations
import re
from typing import Any
_PULLBACK_SOURCES = frozenset(
{"ema_pullback", "fib_pullback", "support_bounce", "bb_reversal"}
)
_BREAKOUT_SOURCES = frozenset(
{"donchian", "range_breakout", "keltner_breakout"}
)
_DIV_SOURCES = frozenset(
{"rsi_divergence", "macd_divergence", "obv_divergence", "rsi_swing"}
)
_SWING_SOURCES = frozenset(
{
"zigzag_causal",
"minor_swing",
"pivot_swing",
"fractal_swing",
"local_extrema",
"macd_cross",
}
)
_SOURCE_PATTERN = re.compile(r"\[([^\]]+)\]")
def parse_composite_sources(reason: str) -> set[str]:
"""composite reason 문자열에서 기법 ID 목록을 추출한다."""
match = _SOURCE_PATTERN.search(reason or "")
if not match:
return set()
return {part.strip() for part in match.group(1).split(",") if part.strip()}
def infer_signal_type(side: str, sources: set[str]) -> str:
"""기여 기법 집합으로 GT signal_type을 추론한다.
우선순위: 다이버전스 > 돌파 > 눌림목 > 스윙.
"""
if side == "buy":
if sources & _DIV_SOURCES:
return "div_bull"
if sources & _BREAKOUT_SOURCES:
return "breakout"
if sources & _PULLBACK_SOURCES:
return "pullback"
if sources & _SWING_SOURCES:
return "swing_low"
return "swing_low"
if sources & _DIV_SOURCES:
return "div_bear"
return "swing_high"
def enrich_signal_types(signals: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""신호 dict에 signal_type·sources 필드를 채운다."""
enriched: list[dict[str, Any]] = []
for sig in signals:
copy = dict(sig)
if not copy.get("signal_type"):
sources = parse_composite_sources(str(copy.get("reason", "")))
copy["sources"] = sorted(sources)
copy["signal_type"] = infer_signal_type(str(copy["side"]), sources)
enriched.append(copy)
return enriched

View File

@@ -0,0 +1,59 @@
"""운영 상태 JSON 저장·로드."""
from __future__ import annotations
import json
from datetime import datetime
from pathlib import Path
from typing import Any
def _today_str() -> str:
"""오늘 날짜 문자열 (KST naive)."""
return datetime.now().strftime("%Y-%m-%d")
def default_state(initial_cash_krw: float = 200_000.0) -> dict[str, Any]:
"""빈 운영 상태 dict."""
return {
"version": 1,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"last_run_at": None,
"last_processed_datetime": None,
"last_processed_bar_index": -1,
"trades_today_date": _today_str(),
"trades_today_count": 0,
"portfolio": {
"cash_krw": float(initial_cash_krw),
"coin_qty": 0.0,
"mode": "paper",
},
"trade_history": [],
}
def load_state(path: Path, *, initial_cash_krw: float = 200_000.0) -> dict[str, Any]:
"""운영 상태를 로드한다. 없으면 기본값 생성."""
if not path.exists():
return default_state(initial_cash_krw)
with path.open(encoding="utf-8") as fp:
state = json.load(fp)
if "portfolio" not in state:
state["portfolio"] = default_state(initial_cash_krw)["portfolio"]
return state
def save_state(path: Path, state: dict[str, Any]) -> Path:
"""운영 상태를 저장한다."""
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as fp:
json.dump(state, fp, ensure_ascii=False, indent=2)
return path
def reset_daily_trade_count(state: dict[str, Any]) -> None:
"""날짜가 바뀌면 일일 체결 카운터를 초기화한다."""
today = _today_str()
if state.get("trades_today_date") != today:
state["trades_today_date"] = today
state["trades_today_count"] = 0

View File

@@ -0,0 +1,127 @@
"""매수·매도 체결 로직 (paper / live 공통 사이징)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from deepcoin.ground_truth.order_sizing import max_buy_from_cash
@dataclass
class TradeResult:
"""단일 체결 결과."""
executed: bool
side: str
order_krw: float
order_coin: float
fee_krw: float
price: float
skip_reason: str = ""
api_response: dict[str, Any] | None = None
def to_dict(self) -> dict[str, Any]:
"""JSON 직렬화 dict."""
return {
"executed": self.executed,
"side": self.side,
"order_krw": round(self.order_krw, 0),
"order_coin": round(self.order_coin, 8),
"fee_krw": round(self.fee_krw, 2),
"price": self.price,
"skip_reason": self.skip_reason,
"api_response": self.api_response,
}
def compute_buy_order(
*,
cash_krw: float,
coin_qty: float,
price: float,
fee_rate: float,
min_order_krw: float,
cluster_size: int = 1,
) -> TradeResult:
"""매수 주문 금액·수량을 계산한다."""
cash = max(float(cash_krw), 0.0)
equity = cash + float(coin_qty) * price
cash_cap = max_buy_from_cash(equity, cash)
per_buy = cash / cluster_size if cluster_size > 0 else cash
order_krw = min(per_buy, cash, cash_cap)
if order_krw < min_order_krw:
return TradeResult(
executed=False,
side="buy",
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
price=price,
skip_reason="원화 부족 또는 최소 주문 미만",
)
fee = order_krw * fee_rate
bought = (order_krw - fee) / price
return TradeResult(
executed=True,
side="buy",
order_krw=order_krw,
order_coin=bought,
fee_krw=fee,
price=price,
)
def compute_sell_order(
*,
coin_qty: float,
price: float,
fee_rate: float,
min_order_krw: float,
cluster_size: int = 1,
) -> TradeResult:
"""매도 주문 수량을 계산한다."""
qty = max(float(coin_qty), 0.0)
per_sell = qty / cluster_size if cluster_size > 0 else qty
order_coin = per_sell
order_krw = order_coin * price
if order_coin <= 0 or order_krw < min_order_krw:
return TradeResult(
executed=False,
side="sell",
order_krw=0.0,
order_coin=0.0,
fee_krw=0.0,
price=price,
skip_reason="코인 부족 또는 최소 주문 미만",
)
fee = order_krw * fee_rate
return TradeResult(
executed=True,
side="sell",
order_krw=order_krw,
order_coin=order_coin,
fee_krw=fee,
price=price,
)
def apply_trade_to_portfolio(
portfolio: dict[str, Any],
trade: TradeResult,
) -> None:
"""체결 결과를 포트폴리오에 반영한다."""
cash = float(portfolio.get("cash_krw", 0))
coin = float(portfolio.get("coin_qty", 0))
if not trade.executed:
return
if trade.side == "buy":
portfolio["cash_krw"] = cash - trade.order_krw
portfolio["coin_qty"] = coin + trade.order_coin
else:
portfolio["cash_krw"] = cash + trade.order_krw - trade.fee_krw
portfolio["coin_qty"] = coin - trade.order_coin