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:
60
src/deepcoin/api/bithumb_auth.py
Normal file
60
src/deepcoin/api/bithumb_auth.py
Normal 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=(",", ":"))
|
||||
143
src/deepcoin/api/bithumb_private.py
Normal file
143
src/deepcoin/api/bithumb_private.py
Normal 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)
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
12
src/deepcoin/operations/__init__.py
Normal file
12
src/deepcoin/operations/__init__.py
Normal 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",
|
||||
]
|
||||
69
src/deepcoin/operations/backtest.py
Normal file
69
src/deepcoin/operations/backtest.py
Normal 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
|
||||
175
src/deepcoin/operations/executor.py
Normal file
175
src/deepcoin/operations/executor.py
Normal 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)
|
||||
192
src/deepcoin/operations/runner.py
Normal file
192
src/deepcoin/operations/runner.py
Normal 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)
|
||||
186
src/deepcoin/operations/signal_pipeline.py
Normal file
186
src/deepcoin/operations/signal_pipeline.py
Normal 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,
|
||||
}
|
||||
69
src/deepcoin/operations/signal_type.py
Normal file
69
src/deepcoin/operations/signal_type.py
Normal 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
|
||||
59
src/deepcoin/operations/state_store.py
Normal file
59
src/deepcoin/operations/state_store.py
Normal 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
|
||||
127
src/deepcoin/operations/trade_engine.py
Normal file
127
src/deepcoin/operations/trade_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user