From 58802bdc5f90b319cea75e07ee76304db84f4ef2 Mon Sep 17 00:00:00 2001 From: xavis Date: Fri, 12 Jun 2026 18:27:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(spot):=203=EB=8B=A8=EA=B3=84=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=E2=80=94=20composite=5Fv3=20+=20MTF=20paper/live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MTF 필터 백테스트, paper/live 체결, 빗썸 Private API 연동 및 운영 스크립트·설계 문서를 추가해 2단계 전략을 실거래 단계에 연결한다. Co-authored-by: Cursor --- .env.example | 13 ++ .gitignore | 3 + README.md | 29 ++- docs/spot/3_operations/stage3_design_guide.md | 150 ++++++++++++++ requirements.txt | 1 + scripts/3_run_filtered_backtest.py | 55 +++++ scripts/3_run_operations.py | 94 +++++++++ scripts/3_run_stage3_all.sh | 15 ++ src/deepcoin/api/bithumb_auth.py | 60 ++++++ src/deepcoin/api/bithumb_private.py | 143 +++++++++++++ src/deepcoin/config.py | 43 ++++ src/deepcoin/operations/__init__.py | 12 ++ src/deepcoin/operations/backtest.py | 69 +++++++ src/deepcoin/operations/executor.py | 175 ++++++++++++++++ src/deepcoin/operations/runner.py | 192 ++++++++++++++++++ src/deepcoin/operations/signal_pipeline.py | 186 +++++++++++++++++ src/deepcoin/operations/signal_type.py | 69 +++++++ src/deepcoin/operations/state_store.py | 59 ++++++ src/deepcoin/operations/trade_engine.py | 127 ++++++++++++ 19 files changed, 1485 insertions(+), 10 deletions(-) create mode 100644 docs/spot/3_operations/stage3_design_guide.md create mode 100644 scripts/3_run_filtered_backtest.py create mode 100644 scripts/3_run_operations.py create mode 100755 scripts/3_run_stage3_all.sh create mode 100644 src/deepcoin/api/bithumb_auth.py create mode 100644 src/deepcoin/api/bithumb_private.py create mode 100644 src/deepcoin/operations/__init__.py create mode 100644 src/deepcoin/operations/backtest.py create mode 100644 src/deepcoin/operations/executor.py create mode 100644 src/deepcoin/operations/runner.py create mode 100644 src/deepcoin/operations/signal_pipeline.py create mode 100644 src/deepcoin/operations/signal_type.py create mode 100644 src/deepcoin/operations/state_store.py create mode 100644 src/deepcoin/operations/trade_engine.py diff --git a/.env.example b/.env.example index 623b1e0..e8c52e7 100644 --- a/.env.example +++ b/.env.example @@ -75,6 +75,19 @@ MTF_RULES_JSON=data/spot/mtf/mtf_rules_v3.json CAUSAL_SIM_REPORT_JSON=docs/spot/2_analysis/causal_sim_report.json CAUSAL_SIM_REPORT_HTML=docs/spot/2_analysis/causal_sim_report.html +# --- 현물 3단계: 운영 (기본 paper) --- +OPS_MODE=paper +OPS_TECHNIQUE_ID=composite_v3 +# OPS_MIN_SCORE=3.5 +OPS_MTF_ENABLED=true +OPS_TREND_GATE_ENABLED=true +OPS_DAILY_MAX_TRADES=20 +OPS_MIN_ORDER_KRW=5000 +OPS_SYNC_CANDLES=true +OPS_STATE_JSON=data/spot/operations/ops_state.json +OPS_REPORT_JSON=docs/spot/3_operations/ops_report.json +OPS_FILTERED_BACKTEST_JSON=docs/spot/3_operations/filtered_backtest_report.json + # 폴더 구조: data|docs / {common, spot, futures} # common — coins.db 등 공유 리소스 # spot — 현물 GT·기법·분석·운영 diff --git a/.gitignore b/.gitignore index b4b7b35..e8578e4 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,9 @@ /docs/spot/2_analysis/** !docs/spot/2_analysis/stage2_design_guide.md !docs/spot/2_analysis/stage2_final_summary.md +!/docs/spot/3_operations/ +/docs/spot/3_operations/** +!docs/spot/3_operations/stage3_design_guide.md logs/ *.db diff --git a/README.md b/README.md index c5b3a6f..8fad230 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ flowchart TD | 1 | **0단계** | spot | `0_ground_truth.py` | `data/spot/ground_truth/`, `docs/spot/0_ground_truth/` | | 2 | **1단계** | spot | `1_ground_truth_sim.py` | `docs/spot/1_simulation/` | | 3 | **2단계** | spot | `2_run_*.py`, `2_run_stage2_all.sh` | `data/spot/techniques/`, `docs/spot/2_analysis/` | -| 4 | **3단계** | spot | (예정) | `docs/spot/3_operations/` | +| 4 | **3단계** | spot | `3_run_*.py`, `3_run_stage3_all.sh` | `data/spot/operations/`, `docs/spot/3_operations/` | | — | **0단계** | futures | `0_ground_truth_futures.py` | `data/futures/ground_truth/`, `docs/futures/0_ground_truth/` | ### 권장 실행 명령 (현물 + 선물 0단계) @@ -125,8 +125,10 @@ bash scripts/2_run_stage2_all.sh # ── futures 0단계: 선물 GT (현물 GT 기반) ─────────────────── python scripts/0_ground_truth_futures.py --tier all -# ── spot 3단계: 실거래 운영 (구현 예정) ─────────────────────── -# python scripts/3_execute_live.py +# ── spot 3단계: 운영 (기본 paper) ───────────────────────────── +bash scripts/3_run_stage3_all.sh +# python scripts/3_run_operations.py --mode paper +# python scripts/3_run_operations.py --mode live # API 키 필요 ``` --- @@ -176,14 +178,22 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원. | 2-3 | `2_run_signal_type_align.py` | `docs/spot/2_analysis/signal_type_report.html` | | 2-4 | `2_run_mtf_analysis.py` | `data/spot/mtf/mtf_rules_v3.json`, `docs/spot/2_analysis/mtf_correlation_report.html` | -### spot 3단계 — 실거래 운영 (예정) +### spot 3단계 — 실거래 운영 -2단계 검증 전략(`composite_v3` + MTF)을 빗썸 현물 API에 연결. +설계·운영 가이드: [`docs/spot/3_operations/stage3_design_guide.md`](docs/spot/3_operations/stage3_design_guide.md) -| 항목 | 내용 | +`composite_v3` + MTF 필터 + 고TF 게이트. **기본 `OPS_MODE=paper`**. + +| 순서 | 스크립트 | 산출물 | +|------|----------|--------| +| 3-1 | `3_run_filtered_backtest.py` | `filtered_backtest_report.json` | +| 3-2 | `3_run_operations.py` | `ops_report.json`, `ops_state.json` | +| 일괄 | `3_run_stage3_all.sh` | 위 전체 | + +| 모드 | 설명 | |------|------| -| 캔들 동기화 | `00_download.py` 증분 갱신 | -| 산출물 | `docs/spot/3_operations/` (예정) | +| `paper` | 모의 체결 (권장) | +| `live` | 빗썸 시장가 주문 (`BITHUMB_ACCESS_KEY` 필요) | ### futures 0단계 — 선물 GT @@ -285,8 +295,7 @@ GT 타점 완벽 추종 시 수익 상한선. 최근 3년·초기 20만 원. | 유형 | 단계 | 상태 | |------|------|------| | common | 사전 (캔들) | 구현됨 | -| spot | 0~2단계 | 구현됨 | -| spot | 3단계 (운영) | 예정 | +| spot | 0~3단계 | 구현됨 (3단계 기본 paper) | | futures | 0단계 | 구현됨 | | futures | 1~3단계 | 예정 | diff --git a/docs/spot/3_operations/stage3_design_guide.md b/docs/spot/3_operations/stage3_design_guide.md new file mode 100644 index 0000000..a6efefb --- /dev/null +++ b/docs/spot/3_operations/stage3_design_guide.md @@ -0,0 +1,150 @@ +# 현물 3단계 설계 가이드 — 실거래 운영 + +> 2단계 검증 전략(`composite_v3` + MTF 필터)을 빗썸 현물에 연결하는 운영 단계 +> 작성 기준: 2026-06-12 · 기본 모드: **paper** + +--- + +## Plan (계획) + +### 목적 + +2단계에서 도출한 **인과 기법 + MTF 필터**를 실시간(또는 모의) 운영 파이프라인에 연결하고, **live 전환 전** 필터 효과를 백테스트로 재검증한다. + +### 파이프라인 + +``` +캔들 증분 동기화 (00_download.py) + ↓ +composite_v3 신호 생성 (3분봉) + ↓ +signal_type 추론 (기여 기법 → B/B*/B^/Bd/S/Sd) + ↓ +HtfTrendGate (60분·일봉 극단 차단) + ↓ +MtfSignalFilter (mtf_rules_v3.json) + ↓ +paper / live 체결 (구간별 매수 상한 동일) +``` + +### 운영 모드 + +| 모드 | 설명 | 기본값 | +|------|------|--------| +| **paper** | DB 캔들·신호 가격 기준 모의 체결 | **권장·기본** | +| **live** | 빗썸 Private API 시장가 주문 | API 키 필요, 신중히 사용 | + +--- + +## Do (실행) + +### 스크립트 + +| 순서 | 스크립트 | 역할 | +|------|----------|------| +| 3-1 | `3_run_filtered_backtest.py` | MTF 필터 전/후 3년 sim 비교 | +| 3-2 | `3_run_operations.py` | paper/live 1회 tick (신호·체결) | +| 일괄 | `3_run_stage3_all.sh` | 3-1 + 3-2 paper | + +```bash +cd DeepCoin +export PYTHONPATH=src + +# MTF 필터 백테스트 +python scripts/3_run_filtered_backtest.py + +# paper 운영 1회 +python scripts/3_run_operations.py + +# 일괄 +bash scripts/3_run_stage3_all.sh +``` + +### 주요 환경 변수 + +| 변수 | 설명 | 기본값 | +|------|------|--------| +| `OPS_MODE` | `paper` / `live` | `paper` | +| `OPS_TECHNIQUE_ID` | 운영 기법 | `composite_v3` | +| `OPS_MIN_SCORE` | composite 최소 점수 (선택) | 기법 기본 2.5 | +| `OPS_MTF_ENABLED` | MTF 필터 | `true` | +| `OPS_TREND_GATE_ENABLED` | 고TF 게이트 | `true` | +| `OPS_DAILY_MAX_TRADES` | 일일 체결 상한 | `20` | +| `OPS_MIN_ORDER_KRW` | 최소 주문 원화 | `5000` | +| `OPS_STATE_JSON` | 운영 상태 | `data/spot/operations/ops_state.json` | +| `BITHUMB_ACCESS_KEY` | live API (선택) | — | +| `BITHUMB_SECRET_KEY` | live API (선택) | — | + +### 산출물 + +| 파일 | 내용 | +|------|------| +| `docs/spot/3_operations/filtered_backtest_report.json` | 필터 전/후 sim | +| `docs/spot/3_operations/ops_report.json` | 최근 tick 리포트 | +| `data/spot/operations/ops_state.json` | 포트폴리오·체결 이력 | + +### 소스 모듈 + +| 모듈 | 경로 | +|------|------| +| 신호 파이프라인 | `src/deepcoin/operations/signal_pipeline.py` | +| signal_type 추론 | `src/deepcoin/operations/signal_type.py` | +| 체결 엔진 | `src/deepcoin/operations/trade_engine.py` | +| paper/live | `src/deepcoin/operations/executor.py` | +| 러너 | `src/deepcoin/operations/runner.py` | +| 빗썸 Private | `src/deepcoin/api/bithumb_private.py` | + +--- + +## Check (검토) + +### 초기 백테스트 결과 (BTC · 3년 · composite_v3) + +| 구분 | 신호 수 | 3년 sim 수익률 | +|------|---------|----------------| +| MTF 필터 **전** | 12,262 | **-97.5%** | +| MTF 필터 **후** | 1,215 | **+3.37%** | + +MTF 필터가 composite_v3의 과다 신호·역추세 진입을 상당 부분 걸러냅니다. live 전환 전 paper 운영으로 추가 검증이 필요합니다. + +### live 전환 전 체크리스트 + +- [ ] `3_run_filtered_backtest.py` — 필터 후 sim이 raw 대비 개선되는지 확인 +- [ ] 최소 1주일 **paper** 운영 (`--loop 180` 등) +- [ ] `OPS_DAILY_MAX_TRADES`·`OPS_MIN_SCORE` 튜닝 +- [ ] 빗썸 API 키 **출금 비활성**·IP 제한 설정 +- [ ] 소액으로 live 테스트 + +### 2단계 대비 3단계 차이 + +| 항목 | 2단계 | 3단계 | +|------|-------|-------| +| 목적 | 기법 평가·순위 | **운영 연결** | +| 신호 | 39종 개별 | **composite_v3 + MTF** | +| 체결 | 일괄 sim | **tick 단위 paper/live** | +| MTF | 분석·규칙 | **실시간 필터** | + +--- + +## Act (개선) + +### 권장 튜닝 + +1. `OPS_MIN_SCORE` 상향 (예: 3.5~4.0) — 신호 과다·스킵 감소 +2. `OPS_DAILY_MAX_TRADES` 하향 — 과매매 방지 +3. 텔레그램 알림 연동 (선택) +4. 슬리피지 가정 paper 백테스트 확장 + +### 하지 말아야 할 것 + +- 백테스트 미검증 상태에서 **live 풀오토** +- 2단계 sim 1위(`fractal_swing`) 그대로 운영 +- API 키를 Git에 커밋 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-06-12 | 3단계 초版 — paper/live 파이프라인, MTF 필터 백테스트, 운영 스크립트 | diff --git a/requirements.txt b/requirements.txt index d0c0716..19f8970 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ python-dotenv==1.0.1 requests==2.32.3 pandas==2.2.3 +PyJWT==2.10.1 diff --git a/scripts/3_run_filtered_backtest.py b/scripts/3_run_filtered_backtest.py new file mode 100644 index 0000000..87e772d --- /dev/null +++ b/scripts/3_run_filtered_backtest.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""3단계: MTF 필터 적용 composite_v3 백테스트 (최근 3년 sim).""" + +from __future__ import annotations + +import argparse +import logging +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.operations.backtest import run_filtered_backtest, save_backtest_report + + +def _configure_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser( + description="3단계: composite_v3 + MTF 필터 3년 백테스트", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + _configure_logging(args.verbose) + + settings = load_settings() + report = run_filtered_backtest(settings) + path = save_backtest_report(report, settings.ops_filtered_backtest_json) + + filt = report["filtered_sim"] + raw = report["raw_sim"] + print("\n=== 3단계 MTF 필터 백테스트 ===") + print(f"기법: {report['technique_id']}") + print(f"원시 신호: {report['raw_signal_count']} → 필터 통과: {report['filtered_signal_count']}") + print(f"원시 3년 sim: {raw.get('total_return_pct')}%") + print(f"필터 3년 sim: {filt.get('total_return_pct')}%") + print(f"개선: {report['improvement_return_pct']}%p") + print(f"\nJSON: {path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_run_operations.py b/scripts/3_run_operations.py new file mode 100644 index 0000000..6463916 --- /dev/null +++ b/scripts/3_run_operations.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""3단계: paper/live 운영 1회 tick (신호 확인·체결).""" + +from __future__ import annotations + +import argparse +import logging +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +from deepcoin.config import load_settings +from deepcoin.operations.runner import OperationsRunner + + +def _configure_logging(verbose: bool) -> None: + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format="%(asctime)s [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + +def main() -> int: + """CLI 진입점.""" + parser = argparse.ArgumentParser(description="3단계: DeepCoin 운영 tick") + parser.add_argument( + "--mode", + choices=("paper", "live"), + default=None, + help="OPS_MODE 덮어쓰기 (기본 .env)", + ) + parser.add_argument( + "--no-sync", + action="store_true", + help="캔들 증분 동기화 생략", + ) + parser.add_argument( + "--loop", + type=int, + default=0, + metavar="SEC", + help="N초마다 반복 실행 (0=1회)", + ) + parser.add_argument("-v", "--verbose", action="store_true") + args = parser.parse_args() + _configure_logging(args.verbose) + + if args.mode: + import os + os.environ["OPS_MODE"] = args.mode + settings = load_settings() + + if settings.ops_mode == "live": + if not settings.bithumb_access_key or not settings.bithumb_secret_key: + print("live 모드에는 BITHUMB_ACCESS_KEY / BITHUMB_SECRET_KEY 가 필요합니다.", file=sys.stderr) + return 1 + print("경고: live 모드 — 실제 주문이 발생할 수 있습니다.") + + runner = OperationsRunner(settings) + sync = not args.no_sync + + while True: + report = runner.tick(sync_candles=sync) + port = report["portfolio"] + print("\n=== 3단계 운영 tick ===") + print(f"모드: {report['mode']}") + print( + f"최신 봉 후보: {report.get('latest_bar_candidates', 0)} · " + f"필터 통과: {report['filtered_signals']} · " + f"처리 bar: {report.get('pending_bars', [])}" + ) + print(f"이번 체결: {len(report['executions'])}건") + print( + f"포트폴리오: 현금 {port['cash_krw']:,.0f}원 · " + f"코인 {port['coin_qty']:.8f} {settings.symbol}" + ) + print(f"리포트: {settings.ops_report_json}") + + if args.loop <= 0: + break + time.sleep(args.loop) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/3_run_stage3_all.sh b/scripts/3_run_stage3_all.sh new file mode 100755 index 0000000..f516a1e --- /dev/null +++ b/scripts/3_run_stage3_all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 3단계 일괄: MTF 필터 백테스트 → paper 운영 1회 tick +set -euo pipefail +cd "$(dirname "$0")/.." +export PYTHONPATH=src + +echo "=== 3-1 MTF 필터 백테스트 ===" +python scripts/3_run_filtered_backtest.py + +echo "" +echo "=== 3-2 paper 운영 tick ===" +python scripts/3_run_operations.py --no-sync + +echo "" +echo "완료. 리포트: docs/spot/3_operations/" diff --git a/src/deepcoin/api/bithumb_auth.py b/src/deepcoin/api/bithumb_auth.py new file mode 100644 index 0000000..ffa130a --- /dev/null +++ b/src/deepcoin/api/bithumb_auth.py @@ -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=(",", ":")) diff --git a/src/deepcoin/api/bithumb_private.py b/src/deepcoin/api/bithumb_private.py new file mode 100644 index 0000000..1498fde --- /dev/null +++ b/src/deepcoin/api/bithumb_private.py @@ -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) diff --git a/src/deepcoin/config.py b/src/deepcoin/config.py index 8c3118b..534db0f 100644 --- a/src/deepcoin/config.py +++ b/src/deepcoin/config.py @@ -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"), ) diff --git a/src/deepcoin/operations/__init__.py b/src/deepcoin/operations/__init__.py new file mode 100644 index 0000000..72590a3 --- /dev/null +++ b/src/deepcoin/operations/__init__.py @@ -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", +] diff --git a/src/deepcoin/operations/backtest.py b/src/deepcoin/operations/backtest.py new file mode 100644 index 0000000..abdf5bd --- /dev/null +++ b/src/deepcoin/operations/backtest.py @@ -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 diff --git a/src/deepcoin/operations/executor.py b/src/deepcoin/operations/executor.py new file mode 100644 index 0000000..daeefcf --- /dev/null +++ b/src/deepcoin/operations/executor.py @@ -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) diff --git a/src/deepcoin/operations/runner.py b/src/deepcoin/operations/runner.py new file mode 100644 index 0000000..94be929 --- /dev/null +++ b/src/deepcoin/operations/runner.py @@ -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) diff --git a/src/deepcoin/operations/signal_pipeline.py b/src/deepcoin/operations/signal_pipeline.py new file mode 100644 index 0000000..535d075 --- /dev/null +++ b/src/deepcoin/operations/signal_pipeline.py @@ -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, + } diff --git a/src/deepcoin/operations/signal_type.py b/src/deepcoin/operations/signal_type.py new file mode 100644 index 0000000..02bfc26 --- /dev/null +++ b/src/deepcoin/operations/signal_type.py @@ -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 diff --git a/src/deepcoin/operations/state_store.py b/src/deepcoin/operations/state_store.py new file mode 100644 index 0000000..5d9e6cf --- /dev/null +++ b/src/deepcoin/operations/state_store.py @@ -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 diff --git a/src/deepcoin/operations/trade_engine.py b/src/deepcoin/operations/trade_engine.py new file mode 100644 index 0000000..1937252 --- /dev/null +++ b/src/deepcoin/operations/trade_engine.py @@ -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