refactor: GT·시뮬·운영 3축 정리 및 hybrid 실거래 정합
Phase C/dry-run·미사용 모듈·재생성 HTML을 제거하고, 운영 체결을 sim_causal_hybrid와 동일한 hybrid 로직으로 통합한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +0,0 @@
|
||||
# analysis — 03·03b 기술적 분석
|
||||
|
||||
- **03 enrich**: `general_analysis_enrich_runner.py` — 봉 전구간 지표·패턴 → `docs/03_analysis/latest/`
|
||||
- **03b GT 스냅샷**: `general_analysis_runner.py` — 정답 매수·매도 시점 MTF 상태 → `general_analysis_trades.csv`
|
||||
|
||||
실행은 `scripts/03_analyze_enrich.py`, `scripts/03_analyze_trades.py`만 사용합니다.
|
||||
@@ -140,7 +140,7 @@ def main() -> None:
|
||||
flat_vote.update(general_analysis_mtf_scores(prefixed))
|
||||
|
||||
write_capability_html(summaries, flat_vote, ANALYSIS_CAPABILITY_HTML)
|
||||
print(f"점검 리포트: {cap_path}")
|
||||
print(f"점검 리포트: {ANALYSIS_CAPABILITY_HTML}")
|
||||
print("완료.")
|
||||
|
||||
|
||||
|
||||
@@ -45,7 +45,9 @@ def build_trade_mtf_snapshots(
|
||||
n_trades = len(trades)
|
||||
enriched: dict[int, pd.DataFrame] = {}
|
||||
t0 = time.time()
|
||||
print(f"[03b] Phase A: 8TF enrich (1분봉 제외, 전 기법) — {len(GENERAL_ANALYSIS_INTERVALS)}개 간격")
|
||||
print(
|
||||
f"[03b] MTF enrich (주·월봉 포함) — {len(GENERAL_ANALYSIS_INTERVALS)}개 간격"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
for step, iv in enumerate(GENERAL_ANALYSIS_INTERVALS, start=1):
|
||||
raw = frames.get(iv)
|
||||
@@ -104,46 +106,6 @@ def build_trade_mtf_snapshots(
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def append_missing_gt_snapshots(
|
||||
frames: dict[int, pd.DataFrame],
|
||||
trades_path: Path | str = DEFAULT_TRADES_FILE,
|
||||
output_csv: Path | str = DEFAULT_OUTPUT_CSV,
|
||||
) -> int:
|
||||
"""
|
||||
CSV에 없는 GT 타점만 MTF 스냅샷 추가.
|
||||
|
||||
Args:
|
||||
frames: interval → OHLCV.
|
||||
trades_path: ground_truth JSON.
|
||||
output_csv: 03b CSV.
|
||||
|
||||
Returns:
|
||||
추가된 행 수.
|
||||
"""
|
||||
out = Path(output_csv)
|
||||
if not out.is_file():
|
||||
return 0
|
||||
data = load_ground_truth(Path(trades_path))
|
||||
if not data:
|
||||
return 0
|
||||
trades = data.get("trades") or []
|
||||
existing = pd.read_csv(out)
|
||||
have = set(zip(existing["dt"].astype(str), existing["action"].astype(str)))
|
||||
missing = [
|
||||
t
|
||||
for t in trades
|
||||
if (str(t["dt"]), str(t["action"])) not in have
|
||||
]
|
||||
if not missing:
|
||||
return 0
|
||||
print(f"[03b] 누락 GT 타점 {len(missing)}건 스냅샷 추가")
|
||||
add_df = build_trade_mtf_snapshots(frames, missing)
|
||||
merged = pd.concat([existing, add_df], ignore_index=True)
|
||||
merged.to_csv(out, index=False, encoding="utf-8-sig")
|
||||
print(f"[03b] CSV 갱신: {out} ({len(merged)}행)")
|
||||
return len(missing)
|
||||
|
||||
|
||||
def export_trade_snapshots(
|
||||
frames: dict[int, pd.DataFrame],
|
||||
trades_path: Path | str = DEFAULT_TRADES_FILE,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
모든 봉(1~1440분)에 BB·일목 위치·캔들 형태 특징을 계산하고
|
||||
모든 봉(3~1440분 등)에 BB·일목 위치·캔들 형태 특징을 계산하고
|
||||
기준 타임라인(3분)에 맞춰 정렬합니다.
|
||||
"""
|
||||
|
||||
|
||||
@@ -281,6 +281,36 @@ def latest_indicator_snapshot(df: pd.DataFrame) -> dict[str, float | str | None]
|
||||
}
|
||||
|
||||
|
||||
def _trend_from_ma20(df: pd.DataFrame) -> Trend:
|
||||
"""
|
||||
단일 TF에서 종가·MA20·MA40 관계로 추세를 판정합니다.
|
||||
|
||||
Args:
|
||||
df: OHLCV+지표 DataFrame.
|
||||
|
||||
Returns:
|
||||
up | down | range.
|
||||
"""
|
||||
if len(df) < 20:
|
||||
return "range"
|
||||
close = float(df["Close"].iloc[-1])
|
||||
ma20_col = "MA20" if "MA20" in df.columns else "MA"
|
||||
if ma20_col not in df.columns:
|
||||
return "range"
|
||||
ma20 = float(df[ma20_col].iloc[-1])
|
||||
ma40 = float(df["MA40"].iloc[-1]) if "MA40" in df.columns and len(df) >= 40 else ma20
|
||||
if ma40 == 0:
|
||||
return "range"
|
||||
gap = abs(ma20 - ma40) / ma40 * 100
|
||||
if gap < TREND_RANGE_MA_GAP_PCT:
|
||||
return "range"
|
||||
if close > ma20 and ma20 > ma40:
|
||||
return "up"
|
||||
if close < ma20 and ma20 < ma40:
|
||||
return "down"
|
||||
return "range"
|
||||
|
||||
|
||||
def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
|
||||
"""
|
||||
일봉·1시간봉 기준 추세(up/down/range)를 반환합니다.
|
||||
@@ -313,3 +343,28 @@ def get_trend(df_1d: pd.DataFrame, df_1h: pd.DataFrame) -> Trend:
|
||||
if d_close < d_ma20 and h_ma20 < h_ma40 and h_close < h_ma20:
|
||||
return "down"
|
||||
return "range"
|
||||
|
||||
|
||||
def get_mtf_trend_summary(
|
||||
frames: dict[int, pd.DataFrame],
|
||||
interval_keys: tuple[int, ...],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
여러 TF의 종가·이동평균 기준 추세 요약(주·월봉 포함).
|
||||
|
||||
Args:
|
||||
frames: interval → OHLCV+지표.
|
||||
interval_keys: 요약할 간격(분) 목록.
|
||||
|
||||
Returns:
|
||||
interval_label → up/down/range.
|
||||
"""
|
||||
from deepcoin.data.mtf_bb import interval_label
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for iv in interval_keys:
|
||||
df = frames.get(iv)
|
||||
if df is None or df.empty:
|
||||
continue
|
||||
out[interval_label(iv)] = _trend_from_ma20(df)
|
||||
return out
|
||||
|
||||
93
deepcoin/data/candle_intervals.py
Normal file
93
deepcoin/data/candle_intervals.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
봉 간격 상수·빗썸(Upbit 호환) 캔들 API 경로.
|
||||
|
||||
분봉·일봉 외 주봉(10080)·월봉(43200)은 DB 테이블명 `{symbol}_{interval}` 에 그대로 사용합니다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from config import DAILY_INTERVAL_MIN, MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN
|
||||
|
||||
WM_INTERVALS: frozenset[int] = frozenset({WEEK_INTERVAL_MIN, MONTH_INTERVAL_MIN})
|
||||
|
||||
# 01_download·ops_sync 대상에서 제외 (DB 적재 안 함)
|
||||
DOWNLOAD_EXCLUDED_INTERVALS: frozenset[int] = frozenset({1})
|
||||
|
||||
|
||||
def is_excluded_from_download(interval: int) -> bool:
|
||||
"""01_download·ops_sync에서 건너뛸 간격(1분봉 등)."""
|
||||
return interval in DOWNLOAD_EXCLUDED_INTERVALS
|
||||
|
||||
|
||||
def is_week_or_month(interval: int) -> bool:
|
||||
"""주봉·월봉 여부."""
|
||||
return interval in WM_INTERVALS
|
||||
|
||||
|
||||
def candle_api_segment(interval: int) -> str:
|
||||
"""
|
||||
REST 경로 세그먼트 (minutes/{n} 제외).
|
||||
|
||||
Returns:
|
||||
'weeks' | 'months' | 'days' | minutes/{interval}
|
||||
"""
|
||||
if interval == WEEK_INTERVAL_MIN:
|
||||
return "weeks"
|
||||
if interval == MONTH_INTERVAL_MIN:
|
||||
return "months"
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
return "days"
|
||||
return f"minutes/{interval}"
|
||||
|
||||
|
||||
def interval_display_label(interval: int) -> str:
|
||||
"""로그·UI용 라벨."""
|
||||
if interval == WEEK_INTERVAL_MIN:
|
||||
return "주봉"
|
||||
if interval == MONTH_INTERVAL_MIN:
|
||||
return "월봉"
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
return "일봉(1440)"
|
||||
return f"{interval}분봉"
|
||||
|
||||
|
||||
def pagination_step(interval: int, chunk_bars: int) -> relativedelta:
|
||||
"""
|
||||
get_coin_more_data 역순 수집 시 `to` 감소 단위.
|
||||
|
||||
Args:
|
||||
interval: 봉 간격(분 표기).
|
||||
chunk_bars: API 1회 최대 봉 수(겹침 청크).
|
||||
|
||||
Returns:
|
||||
relativedelta.
|
||||
"""
|
||||
if interval == WEEK_INTERVAL_MIN:
|
||||
return relativedelta(weeks=chunk_bars)
|
||||
if interval == MONTH_INTERVAL_MIN:
|
||||
return relativedelta(months=chunk_bars)
|
||||
return relativedelta(minutes=interval * chunk_bars)
|
||||
|
||||
|
||||
def bars_for_months(interval: int, months: int, *, extra_days: int = 0) -> int:
|
||||
"""
|
||||
N개월치 예상 봉 수(여유 포함).
|
||||
|
||||
Args:
|
||||
interval: 봉 간격.
|
||||
months: 보관·적재 개월 수.
|
||||
extra_days: 일봉 계열 여유 일수.
|
||||
|
||||
Returns:
|
||||
API 요청 목표 봉 수.
|
||||
"""
|
||||
if interval == WEEK_INTERVAL_MIN:
|
||||
return int(months * 30 / 7) + max(extra_days // 7, 5)
|
||||
if interval == MONTH_INTERVAL_MIN:
|
||||
return months + max(extra_days // 30, 2)
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
return months * 30 + extra_days
|
||||
bars_per_day = (24 * 60) // interval
|
||||
return months * 30 * bars_per_day + 200
|
||||
@@ -16,24 +16,36 @@ from dateutil.relativedelta import relativedelta
|
||||
from config import (
|
||||
BITHUMB_MINUTE_INTERVALS,
|
||||
COIN_NAME,
|
||||
DAILY_INTERVAL_MIN,
|
||||
DB_PATH,
|
||||
DOWNLOAD_BACKFILL_EXTRA_BARS,
|
||||
DOWNLOAD_DAILY_EXTRA_DAYS,
|
||||
DOWNLOAD_INTERVALS,
|
||||
DOWNLOAD_INTERVALS_WM,
|
||||
DOWNLOAD_MIN_INCREMENTAL_BARS,
|
||||
DOWNLOAD_MONTHS,
|
||||
DOWNLOAD_MONTHS_1M,
|
||||
DOWNLOAD_MONTHS_WM,
|
||||
INCREMENTAL_OVERLAP_BARS,
|
||||
KR_COINS,
|
||||
SYMBOL,
|
||||
)
|
||||
from deepcoin.data.candle_intervals import (
|
||||
bars_for_months,
|
||||
interval_display_label,
|
||||
is_excluded_from_download,
|
||||
is_week_or_month,
|
||||
)
|
||||
from deepcoin.ops.monitor import Monitor
|
||||
|
||||
|
||||
def bong_count_for_months(interval_minutes: int, months: int) -> int:
|
||||
"""N개월치 봉 개수(여유분 포함)."""
|
||||
if is_week_or_month(interval_minutes):
|
||||
return bars_for_months(
|
||||
interval_minutes, months, extra_days=DOWNLOAD_DAILY_EXTRA_DAYS
|
||||
)
|
||||
days = months * 30
|
||||
from config import DAILY_INTERVAL_MIN
|
||||
|
||||
if interval_minutes >= DAILY_INTERVAL_MIN:
|
||||
return days + DOWNLOAD_DAILY_EXTRA_DAYS
|
||||
bars_per_day = (24 * 60) // interval_minutes
|
||||
@@ -69,36 +81,44 @@ def trim_to_recent_months(data: pd.DataFrame, months: int) -> pd.DataFrame:
|
||||
|
||||
|
||||
def interval_label(interval: int) -> str:
|
||||
if interval >= 1440:
|
||||
return "일봉(1440)"
|
||||
return f"{interval}분봉"
|
||||
"""로그용 간격 라벨."""
|
||||
return interval_display_label(interval)
|
||||
|
||||
|
||||
def months_for_interval(interval: int, default_months: int) -> int:
|
||||
"""간격별 DB 보관 개월 수 (1분봉은 별도 상한)."""
|
||||
if interval == 1:
|
||||
return DOWNLOAD_MONTHS_1M
|
||||
"""간격별 DB 보관 개월 수 (주·월봉은 DOWNLOAD_MONTHS_WM)."""
|
||||
if is_week_or_month(interval):
|
||||
return DOWNLOAD_MONTHS_WM
|
||||
return default_months
|
||||
|
||||
|
||||
def all_download_intervals() -> tuple[int, ...]:
|
||||
"""분봉·일봉 + 주·월봉 간격 목록(1분 제외, 중복 제거, 순서 유지)."""
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for iv in (*DOWNLOAD_INTERVALS, *DOWNLOAD_INTERVALS_WM):
|
||||
if is_excluded_from_download(iv) or iv in seen:
|
||||
continue
|
||||
seen.add(iv)
|
||||
out.append(iv)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def download_jobs() -> list[tuple[int, str]]:
|
||||
labels = {
|
||||
1: "1분",
|
||||
3: "3분",
|
||||
5: "5분",
|
||||
10: "10분",
|
||||
15: "15분",
|
||||
30: "30분",
|
||||
60: "60분(1시간)",
|
||||
240: "240분(4시간)",
|
||||
1440: "1440분(1일)",
|
||||
}
|
||||
jobs = []
|
||||
for iv in DOWNLOAD_INTERVALS:
|
||||
if iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
|
||||
"""
|
||||
01_download 대상 간격.
|
||||
|
||||
Returns:
|
||||
(interval_min, 표시명) 리스트.
|
||||
"""
|
||||
jobs: list[tuple[int, str]] = []
|
||||
for iv in all_download_intervals():
|
||||
if is_excluded_from_download(iv):
|
||||
continue
|
||||
if not is_week_or_month(iv) and iv < 1440 and iv not in BITHUMB_MINUTE_INTERVALS:
|
||||
print(f"경고: {iv}분봉은 빗썸 API 미지원 — 건너뜀")
|
||||
continue
|
||||
jobs.append((iv, labels.get(iv, f"{iv}분")))
|
||||
jobs.append((iv, interval_label(iv)))
|
||||
return jobs
|
||||
|
||||
|
||||
@@ -374,23 +394,27 @@ def download_symbol(
|
||||
|
||||
def download(months: int | None = None) -> None:
|
||||
"""
|
||||
WLD 다중 분봉·일봉을 coins.db에 증분 적재합니다.
|
||||
WLD 다중 분봉·일봉·주봉·월봉을 coins.db에 증분 적재합니다.
|
||||
|
||||
간격: config.DOWNLOAD_INTERVALS
|
||||
간격: DOWNLOAD_INTERVALS + DOWNLOAD_INTERVALS_WM (주·월은 DOWNLOAD_MONTHS_WM, 기본 24=2년)
|
||||
"""
|
||||
months = months or DOWNLOAD_MONTHS
|
||||
default_months = months or DOWNLOAD_MONTHS
|
||||
monitor = Monitor(cooldown_file=None)
|
||||
jobs = download_jobs()
|
||||
|
||||
intervals_str = ", ".join(str(iv) for iv, _ in jobs)
|
||||
print(f"=== {COIN_NAME} ({SYMBOL}) -> {DB_PATH} (증분 INSERT) ===")
|
||||
print(f"보관 {months}개월 | 간격(분): {intervals_str}")
|
||||
print(
|
||||
f"보관 분봉·일봉 {default_months}개월 | "
|
||||
f"주·월봉 {DOWNLOAD_MONTHS_WM}개월 | 간격(분): {intervals_str}"
|
||||
)
|
||||
started = datetime.now()
|
||||
|
||||
for interval, desc in jobs:
|
||||
print(f"\n--- {desc} ---")
|
||||
job_months = months_for_interval(interval, default_months)
|
||||
try:
|
||||
download_symbol(monitor, SYMBOL, interval, months)
|
||||
download_symbol(monitor, SYMBOL, interval, job_months)
|
||||
except Exception as e:
|
||||
print(f"오류 interval={interval}: {e}")
|
||||
|
||||
|
||||
@@ -6,14 +6,23 @@ from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import DOWNLOAD_INTERVALS, SYMBOL
|
||||
from config import DOWNLOAD_INTERVALS, DOWNLOAD_INTERVALS_WM, SYMBOL
|
||||
from deepcoin.data.candle_intervals import interval_display_label
|
||||
|
||||
|
||||
def interval_label(interval: int) -> str:
|
||||
"""봉 간격 표시 라벨."""
|
||||
if interval >= 1440:
|
||||
return "일봉"
|
||||
return f"{interval}분"
|
||||
return interval_display_label(interval)
|
||||
|
||||
|
||||
def _all_load_intervals() -> tuple[int, ...]:
|
||||
seen: set[int] = set()
|
||||
out: list[int] = []
|
||||
for iv in (*DOWNLOAD_INTERVALS, *DOWNLOAD_INTERVALS_WM):
|
||||
if iv not in seen:
|
||||
seen.add(iv)
|
||||
out.append(iv)
|
||||
return tuple(out)
|
||||
|
||||
|
||||
def load_frames_from_db(
|
||||
@@ -33,7 +42,7 @@ def load_frames_from_db(
|
||||
간격(분) → OHLCV+지표 DataFrame.
|
||||
"""
|
||||
frames: dict[int, pd.DataFrame] = {}
|
||||
for iv in DOWNLOAD_INTERVALS:
|
||||
for iv in _all_load_intervals():
|
||||
db_max = None
|
||||
if lookback_days is not None:
|
||||
db_max = monitor.db_row_limit_for_interval(iv, lookback_days)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Phase 04 — Matching (GT + 전구간 EV)
|
||||
# Matching — Simulation 축
|
||||
|
||||
안2 파이프라인: 03b GT 스냅샷에서 규칙 후보를 만들고, 3분봉 전 구간에서 발화·forward 수익을 검증한 뒤 valid 구간 EV로 최종 규칙을 고릅니다.
|
||||
03b GT 스냅샷에서 규칙 후보 → 전 구간 인과 스캔 → EV·holdout → `matched_rules.json`.
|
||||
설계: [docs/reference/ARCHITECTURE.md](../../docs/reference/ARCHITECTURE.md)
|
||||
|
||||
## PDCA
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
"""
|
||||
GT 총자산 대비 시뮬/규칙 정확도 측정 (동일 체결·평가 모델).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import GT_INITIAL_CASH_KRW, MATCH_GT_TOLERANCE_MIN, TRADING_FEE_RATE
|
||||
from deepcoin.ground_truth.ground_truth import simulate_truth_portfolio
|
||||
from deepcoin.matching.rule_eval import eval_rule_mask
|
||||
|
||||
|
||||
def gt_trades_for_legs(
|
||||
trades: list[dict[str, Any]],
|
||||
leg_ids: set[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
leg_id 집합에 속한 GT 체결만 반환.
|
||||
|
||||
Args:
|
||||
trades: ground_truth trades.
|
||||
leg_ids: 포함할 leg_id.
|
||||
|
||||
Returns:
|
||||
필터된 trade dict 리스트.
|
||||
"""
|
||||
return [t for t in trades if int(t.get("leg_id", 0)) in leg_ids]
|
||||
|
||||
|
||||
def covered_legs_from_fires(
|
||||
trades: list[dict[str, Any]],
|
||||
fires: pd.DataFrame,
|
||||
buy_rule_ids: list[str],
|
||||
sell_rule_ids: list[str],
|
||||
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
|
||||
) -> set[int]:
|
||||
"""
|
||||
매수·매도 규칙 발화가 GT 타점 ±허용 내인 leg_id 집합.
|
||||
|
||||
Args:
|
||||
trades: GT trades.
|
||||
fires: rule_fires.
|
||||
buy_rule_ids: 매수 규칙 ID.
|
||||
sell_rule_ids: 매도 규칙 ID.
|
||||
tolerance_min: 허용 분.
|
||||
|
||||
Returns:
|
||||
양쪽 모두 커버된 leg_id.
|
||||
"""
|
||||
if fires.empty:
|
||||
return set()
|
||||
tol = pd.Timedelta(minutes=tolerance_min)
|
||||
gt_df = pd.DataFrame(trades)
|
||||
gt_df["ts"] = pd.to_datetime(gt_df["dt"])
|
||||
fires = fires.copy()
|
||||
fires["ts"] = pd.to_datetime(fires["dt"])
|
||||
bf = fires[fires["rule_id"].isin(buy_rule_ids) & (fires["side"] == "buy")]
|
||||
sf = fires[fires["rule_id"].isin(sell_rule_ids) & (fires["side"] == "sell")]
|
||||
|
||||
covered: set[int] = set()
|
||||
for lid in gt_df["leg_id"].unique():
|
||||
leg = gt_df[gt_df["leg_id"] == lid]
|
||||
buys = leg[leg["action"] == "buy"]
|
||||
sells = leg[leg["action"] == "sell"]
|
||||
buy_ok = True
|
||||
for ts in buys["ts"]:
|
||||
if bf.empty or (bf["ts"] - ts).abs().min() > tol:
|
||||
buy_ok = False
|
||||
break
|
||||
sell_ok = True
|
||||
for ts in sells["ts"]:
|
||||
if sf.empty or (sf["ts"] - ts).abs().min() > tol:
|
||||
sell_ok = False
|
||||
break
|
||||
if buy_ok and sell_ok:
|
||||
covered.add(int(lid))
|
||||
return covered
|
||||
|
||||
|
||||
def portfolio_asset_ratio(
|
||||
trades: list[dict[str, Any]],
|
||||
leg_ids: set[int],
|
||||
last_price: float | None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
GT 체결 모델로 전체 vs 부분 leg 포트폴리오 비율.
|
||||
|
||||
Args:
|
||||
trades: 전체 GT trades.
|
||||
leg_ids: 포함 leg.
|
||||
last_price: 종가 평가.
|
||||
|
||||
Returns:
|
||||
full/subset final_asset, asset_ratio, leg counts.
|
||||
"""
|
||||
full = simulate_truth_portfolio(
|
||||
trades,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
last_price=last_price,
|
||||
)
|
||||
subset_trades = gt_trades_for_legs(trades, leg_ids)
|
||||
part = simulate_truth_portfolio(
|
||||
subset_trades,
|
||||
initial_cash=GT_INITIAL_CASH_KRW,
|
||||
fee_rate=TRADING_FEE_RATE,
|
||||
last_price=last_price,
|
||||
)
|
||||
gt_final = float(full["final_asset_krw"])
|
||||
sub_final = float(part["final_asset_krw"])
|
||||
ratio = sub_final / gt_final if gt_final > 0 else 0.0
|
||||
return {
|
||||
"gt_final_asset_krw": gt_final,
|
||||
"subset_final_asset_krw": sub_final,
|
||||
"asset_ratio": round(ratio, 4),
|
||||
"asset_accuracy_pct": round(ratio * 100.0, 2),
|
||||
"target_met_90": ratio >= 0.9,
|
||||
"legs_total": len(set(int(t.get("leg_id", 0)) for t in trades)),
|
||||
"legs_covered": len(leg_ids),
|
||||
"leg_coverage_ratio": round(
|
||||
len(leg_ids) / max(len(set(int(t.get("leg_id", 0)) for t in trades)), 1),
|
||||
4,
|
||||
),
|
||||
"full_pnl_pct": full.get("pnl_pct"),
|
||||
"subset_pnl_pct": part.get("pnl_pct"),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_gt_snapshot_recall(
|
||||
trades_df: pd.DataFrame,
|
||||
rules: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
03b 각 GT 행에서 규칙 스냅샷 충족 여부(OR across rules per side).
|
||||
|
||||
Args:
|
||||
trades_df: general_analysis_trades.csv.
|
||||
rules: rule dict 리스트.
|
||||
|
||||
Returns:
|
||||
buy/sell recall, per-rule counts.
|
||||
"""
|
||||
buy_gt = trades_df[trades_df["action"] == "buy"]
|
||||
sell_gt = trades_df[trades_df["action"] == "sell"]
|
||||
buy_rules = [r for r in rules if r.get("side") == "buy"]
|
||||
sell_rules = [r for r in rules if r.get("side") == "sell"]
|
||||
|
||||
def _side_recall(gt: pd.DataFrame, side_rules: list[dict]) -> dict[str, Any]:
|
||||
if gt.empty or not side_rules:
|
||||
return {"gt_count": int(len(gt)), "matched": 0, "recall": 0.0}
|
||||
hit = 0
|
||||
per_rule: dict[str, int] = {}
|
||||
for _, row in gt.iterrows():
|
||||
fr = pd.DataFrame([row])
|
||||
ok = False
|
||||
for rule in side_rules:
|
||||
if bool(eval_rule_mask(fr, rule).iloc[0]):
|
||||
ok = True
|
||||
rid = rule["rule_id"]
|
||||
per_rule[rid] = per_rule.get(rid, 0) + 1
|
||||
if ok:
|
||||
hit += 1
|
||||
n = len(gt)
|
||||
return {
|
||||
"gt_count": n,
|
||||
"matched": hit,
|
||||
"recall": round(hit / n, 4) if n else 0.0,
|
||||
"per_rule_hits": per_rule,
|
||||
}
|
||||
|
||||
return {
|
||||
"buy": _side_recall(buy_gt, buy_rules),
|
||||
"sell": _side_recall(sell_gt, sell_rules),
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
"""
|
||||
Ground truth(450타점) vs 규칙 발화·시뮬 결과 비교 리포트.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import MATCH_GT_TOLERANCE_MIN
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth
|
||||
from deepcoin.matching.select_rules import (
|
||||
_rule_metrics,
|
||||
_split_train_valid_holdout,
|
||||
gt_overlap_report,
|
||||
)
|
||||
from deepcoin.paths import (
|
||||
MATCHING_FIRE_OUTCOMES,
|
||||
MATCHING_GT_COMPARISON_HTML,
|
||||
MATCHING_GT_COMPARISON_JSON,
|
||||
MATCHING_MATCHED_RULES,
|
||||
MATCHING_SIMULATION_JSON,
|
||||
resolve_ground_truth_file,
|
||||
)
|
||||
|
||||
|
||||
def _precision_near_gt(
|
||||
fire_ts: pd.Series,
|
||||
gt_ts: pd.Series,
|
||||
tolerance: pd.Timedelta,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
발화 시각이 GT 타점 ±허용 내인 비율(precision proxy).
|
||||
|
||||
Args:
|
||||
fire_ts: 규칙 발화 시각.
|
||||
gt_ts: GT 시각.
|
||||
tolerance: 허용 timedelta.
|
||||
|
||||
Returns:
|
||||
near_count, fire_count, precision.
|
||||
"""
|
||||
if fire_ts.empty:
|
||||
return {"near_count": 0, "fire_count": 0, "precision": 0.0}
|
||||
gt_sorted = gt_ts.sort_values()
|
||||
near = 0
|
||||
for fts in fire_ts:
|
||||
if (gt_sorted - fts).abs().min() <= tolerance:
|
||||
near += 1
|
||||
n = len(fire_ts)
|
||||
return {
|
||||
"near_count": near,
|
||||
"fire_count": n,
|
||||
"precision": round(near / n, 4) if n else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _matched_pairs(
|
||||
fires: pd.DataFrame,
|
||||
gt_df: pd.DataFrame,
|
||||
rule_id: str,
|
||||
tolerance: pd.Timedelta,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
GT 타점별 가장 가까운 동일 rule·side 발화와 수익률 쌍을 만듭니다.
|
||||
|
||||
Args:
|
||||
fires: fire_outcomes.
|
||||
gt_df: GT trades DataFrame.
|
||||
rule_id: 규칙 ID.
|
||||
tolerance: 매칭 허용.
|
||||
|
||||
Returns:
|
||||
매칭된 행 DataFrame.
|
||||
"""
|
||||
sub = fires[fires["rule_id"] == rule_id].copy()
|
||||
if sub.empty:
|
||||
return pd.DataFrame()
|
||||
side = sub["side"].iloc[0]
|
||||
g = gt_df[gt_df["action"] == side].copy()
|
||||
g["ts"] = pd.to_datetime(g["dt"])
|
||||
sub["ts"] = pd.to_datetime(sub["dt"])
|
||||
rows: list[dict[str, Any]] = []
|
||||
for _, gt_row in g.iterrows():
|
||||
gts = pd.Timestamp(gt_row["ts"])
|
||||
delta = (sub["ts"] - gts).abs()
|
||||
if delta.empty or delta.min() > tolerance:
|
||||
continue
|
||||
idx = delta.idxmin()
|
||||
fr = sub.loc[idx]
|
||||
rows.append(
|
||||
{
|
||||
"side": side,
|
||||
"rule_id": rule_id,
|
||||
"gt_dt": str(gt_row["dt"]),
|
||||
"fire_dt": str(fr["dt"]),
|
||||
"delta_min": round(delta.min().total_seconds() / 60, 2),
|
||||
"gt_forward_pct": float(gt_row.get("forward_return_pct") or 0),
|
||||
"sim_leg_gt_pct": float(fr["forward_ret_pct"]),
|
||||
"split": fr.get("split"),
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def build_gt_comparison_report(
|
||||
outcomes_path: Path | None = None,
|
||||
matched_path: Path | None = None,
|
||||
gt_path: Path | None = None,
|
||||
sim_path: Path | None = None,
|
||||
tolerance_min: int = MATCH_GT_TOLERANCE_MIN,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
GT vs 발화·시뮬 비교 dict 생성.
|
||||
|
||||
Args:
|
||||
outcomes_path: fire_outcomes.csv.
|
||||
matched_path: matched_rules.json.
|
||||
gt_path: ground_truth_trades.json.
|
||||
sim_path: simulation_report.json.
|
||||
tolerance_min: GT 매칭 허용(분).
|
||||
|
||||
Returns:
|
||||
gt_comparison_report dict.
|
||||
"""
|
||||
op = outcomes_path or MATCHING_FIRE_OUTCOMES
|
||||
mp = matched_path or MATCHING_MATCHED_RULES
|
||||
if not op.is_file():
|
||||
raise FileNotFoundError(f"fire_outcomes 없음: {op}")
|
||||
|
||||
outcomes = pd.read_csv(op)
|
||||
outcomes["ts"] = pd.to_datetime(outcomes["dt"])
|
||||
outcomes["split"] = _split_train_valid_holdout(outcomes)
|
||||
matched: dict[str, Any] = {}
|
||||
if mp.is_file():
|
||||
matched = json.loads(mp.read_text(encoding="utf-8"))
|
||||
|
||||
sim_report: dict[str, Any] = {}
|
||||
sp = sim_path or MATCHING_SIMULATION_JSON
|
||||
if sp.is_file():
|
||||
sim_report = json.loads(sp.read_text(encoding="utf-8"))
|
||||
|
||||
gt_data = load_ground_truth(gt_path or resolve_ground_truth_file()) or {}
|
||||
gt_trades = gt_data.get("trades") or []
|
||||
gt_df = pd.DataFrame(gt_trades)
|
||||
tol = pd.Timedelta(minutes=tolerance_min)
|
||||
|
||||
gt_baseline: dict[str, Any] = {
|
||||
"total": len(gt_df),
|
||||
"buy": int((gt_df["action"] == "buy").sum()) if not gt_df.empty else 0,
|
||||
"sell": int((gt_df["action"] == "sell").sum()) if not gt_df.empty else 0,
|
||||
}
|
||||
for side in ("buy", "sell"):
|
||||
sub = gt_df[gt_df["action"] == side] if not gt_df.empty else pd.DataFrame()
|
||||
if sub.empty or "forward_return_pct" not in sub.columns:
|
||||
gt_baseline[side] = {}
|
||||
continue
|
||||
r = sub["forward_return_pct"].astype(float)
|
||||
gt_baseline[side] = {
|
||||
"mean_forward_pct": round(float(r.mean()), 4),
|
||||
"median_forward_pct": round(float(r.median()), 4),
|
||||
"win_rate": round(float((r > 0).mean()), 4),
|
||||
"count": int(len(r)),
|
||||
}
|
||||
|
||||
all_fires = outcomes.copy()
|
||||
if "rule_id" not in all_fires.columns:
|
||||
all_fires["rule_id"] = "all"
|
||||
overlap_all = gt_overlap_report(
|
||||
all_fires.drop_duplicates(subset=["dt", "side"]),
|
||||
gt_trades,
|
||||
tolerance_min=tolerance_min,
|
||||
)
|
||||
|
||||
per_rule: list[dict[str, Any]] = []
|
||||
pair_stats: list[dict[str, Any]] = []
|
||||
for rid in sorted(outcomes["rule_id"].unique()):
|
||||
sub = outcomes[outcomes["rule_id"] == rid]
|
||||
side = str(sub["side"].iloc[0])
|
||||
gt_side = gt_df[gt_df["action"] == side]
|
||||
gt_ts = pd.to_datetime(gt_side["dt"]) if not gt_side.empty else pd.Series(dtype="datetime64[ns]")
|
||||
fire_ts = sub["ts"]
|
||||
ov = gt_overlap_report(sub, gt_trades, tolerance_min=tolerance_min)
|
||||
prec = _precision_near_gt(fire_ts, gt_ts, tol)
|
||||
m_all = _rule_metrics(sub)
|
||||
m_hold = _rule_metrics(sub[sub["split"] == "holdout"])
|
||||
|
||||
pairs = _matched_pairs(outcomes, gt_df, rid, tol)
|
||||
pair_row: dict[str, Any] = {"rule_id": rid, "side": side, "pair_count": len(pairs)}
|
||||
if len(pairs) >= 2:
|
||||
corr = pairs["gt_forward_pct"].corr(pairs["sim_leg_gt_pct"])
|
||||
pair_row["corr_gt_vs_sim"] = round(float(corr), 4) if pd.notna(corr) else None
|
||||
pair_row["mean_abs_diff_pct"] = round(
|
||||
float((pairs["gt_forward_pct"] - pairs["sim_leg_gt_pct"]).abs().mean()),
|
||||
4,
|
||||
)
|
||||
pair_row["mean_delta_min"] = round(float(pairs["delta_min"].mean()), 2)
|
||||
pair_stats.append(pair_row)
|
||||
|
||||
near_mask = []
|
||||
for fts in fire_ts:
|
||||
near_mask.append(
|
||||
not gt_ts.empty and (gt_ts - fts).abs().min() <= tol
|
||||
)
|
||||
sub_near = sub.loc[near_mask] if near_mask else sub.iloc[0:0]
|
||||
sub_far = sub.loc[[not x for x in near_mask]] if near_mask else sub
|
||||
|
||||
per_rule.append(
|
||||
{
|
||||
"rule_id": rid,
|
||||
"side": side,
|
||||
"fire_count": int(len(sub)),
|
||||
"gt_recall": ov.get(side, {}).get("recall", 0),
|
||||
"gt_matched": ov.get(side, {}).get("matched", 0),
|
||||
"gt_count": ov.get(side, {}).get("gt_count", 0),
|
||||
"precision_near_gt": prec["precision"],
|
||||
"fires_near_gt": prec["near_count"],
|
||||
"sim_ev_all_pct": m_all.get("ev_pct"),
|
||||
"sim_ev_near_gt_pct": _rule_metrics(sub_near).get("ev_pct") if len(sub_near) else None,
|
||||
"sim_ev_far_gt_pct": _rule_metrics(sub_far).get("ev_pct") if len(sub_far) else None,
|
||||
"sim_win_rate": m_all.get("win_rate"),
|
||||
"sim_profit_factor": m_all.get("profit_factor"),
|
||||
"holdout_ev_pct": m_hold.get("ev_pct"),
|
||||
"holdout_count": m_hold.get("count"),
|
||||
}
|
||||
)
|
||||
|
||||
monitor_ids = [r["rule_id"] for r in matched.get("monitor_rules", [])]
|
||||
monitor_summary = [r for r in per_rule if r["rule_id"] in monitor_ids]
|
||||
|
||||
go = sim_report.get("go_no_go", {})
|
||||
|
||||
return {
|
||||
"tolerance_min": tolerance_min,
|
||||
"label_mode": matched.get("label_mode"),
|
||||
"gt_baseline": gt_baseline,
|
||||
"gt_overlap_all_fires_dedup": overlap_all,
|
||||
"gt_overlap_matched_json": matched.get("gt_overlap"),
|
||||
"per_rule": per_rule,
|
||||
"pair_alignment": pair_stats,
|
||||
"monitor_rules": monitor_summary,
|
||||
"simulation_go_no_go": {
|
||||
"go": go.get("go"),
|
||||
"checks": go.get("checks", []),
|
||||
"live_cap_taken_ratio": go.get("live_cap_taken_ratio"),
|
||||
},
|
||||
"notes": [
|
||||
"gt_overlap_matched_json: 04 선별 시 전 규칙 발화 합산(중복 dt 제거 전) 기준.",
|
||||
"per_rule.gt_recall: 해당 규칙 발화만으로 GT 타점 커버.",
|
||||
"precision_near_gt: 발화 중 GT±tolerance 내 비율(낮을수록 잡음 많음).",
|
||||
"gt_forward_pct vs sim_leg_gt_pct: leg_gt 라벨과 GT JSON forward_return_pct 정의 차이 가능.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def write_gt_comparison_html(report: dict[str, Any], out_path: Path) -> Path:
|
||||
"""
|
||||
gt_comparison_report.html 저장.
|
||||
|
||||
Args:
|
||||
report: build_gt_comparison_report 결과.
|
||||
out_path: HTML 경로.
|
||||
|
||||
Returns:
|
||||
out_path.
|
||||
"""
|
||||
def _rows(items: list[dict], cols: list[str]) -> str:
|
||||
lines = []
|
||||
for it in items:
|
||||
cells = "".join(f"<td>{it.get(c, '')}</td>" for c in cols)
|
||||
lines.append(f"<tr>{cells}</tr>")
|
||||
return "\n".join(lines)
|
||||
|
||||
pr_cols = [
|
||||
"rule_id", "side", "fire_count", "gt_recall", "precision_near_gt",
|
||||
"sim_ev_all_pct", "sim_ev_near_gt_pct", "sim_ev_far_gt_pct", "holdout_ev_pct",
|
||||
]
|
||||
go = report.get("simulation_go_no_go", {})
|
||||
go_flag = "GO" if go.get("go") else "NO-GO"
|
||||
gb = report.get("gt_baseline", {})
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="ko"><head><meta charset="utf-8"/>
|
||||
<title>GT vs Simulation Comparison</title>
|
||||
<style>
|
||||
body {{ font-family: "Malgun Gothic", Arial, sans-serif; margin: 24px; max-width: 1100px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin: 12px 0; font-size: 0.9rem; }}
|
||||
th, td {{ border: 1px solid #ccc; padding: 6px 8px; text-align: right; }}
|
||||
th {{ background: #e2e8f0; text-align: center; }}
|
||||
td:first-child, th:first-child {{ text-align: left; }}
|
||||
h2 {{ margin-top: 28px; }}
|
||||
.warn {{ color: #b45309; }}
|
||||
</style></head><body>
|
||||
<h1>Ground Truth vs 규칙·시뮬 비교</h1>
|
||||
<p>허용 오차: ±{report.get('tolerance_min')}분 · 라벨: {report.get('label_mode')}</p>
|
||||
<p><strong>시뮬 Go/No-Go: {go_flag}</strong></p>
|
||||
|
||||
<h2>GT 기준선 (forward_return_pct)</h2>
|
||||
<p>총 {gb.get('total')}건 (매수 {gb.get('buy')} / 매도 {gb.get('sell')})</p>
|
||||
<table>
|
||||
<thead><tr><th>구분</th><th>건수</th><th>평균 forward%</th><th>중앙값</th><th>승률</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>매수 GT</td><td>{gb.get('buy', {}).get('count', '')}</td>
|
||||
<td>{gb.get('buy', {}).get('mean_forward_pct', '')}</td>
|
||||
<td>{gb.get('buy', {}).get('median_forward_pct', '')}</td>
|
||||
<td>{gb.get('buy', {}).get('win_rate', '')}</td></tr>
|
||||
<tr><td>매도 GT</td><td>{gb.get('sell', {}).get('count', '')}</td>
|
||||
<td>{gb.get('sell', {}).get('mean_forward_pct', '')}</td>
|
||||
<td>{gb.get('sell', {}).get('median_forward_pct', '')}</td>
|
||||
<td>{gb.get('sell', {}).get('win_rate', '')}</td></tr>
|
||||
</tbody></table>
|
||||
|
||||
<h2>규칙별 GT recall / precision / EV</h2>
|
||||
<table>
|
||||
<thead><tr>{''.join(f'<th>{c}</th>' for c in pr_cols)}</tr></thead>
|
||||
<tbody>{_rows(report.get('per_rule', []), pr_cols)}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>monitor_rules (실감시·시뮬 대상)</h2>
|
||||
<table>
|
||||
<thead><tr>{''.join(f'<th>{c}</th>' for c in pr_cols)}</tr></thead>
|
||||
<tbody>{_rows(report.get('monitor_rules', []), pr_cols)}</tbody>
|
||||
</table>
|
||||
|
||||
<h2>GT–발화 수익률 정렬 (±{report.get('tolerance_min')}분)</h2>
|
||||
<table>
|
||||
<thead><tr><th>rule</th><th>side</th><th>pairs</th><th>corr</th><th>mean|diff|%</th><th>mean Δmin</th></tr></thead>
|
||||
<tbody>
|
||||
{''.join(
|
||||
f"<tr><td>{p['rule_id']}</td><td>{p['side']}</td><td>{p['pair_count']}</td>"
|
||||
f"<td>{p.get('corr_gt_vs_sim','')}</td><td>{p.get('mean_abs_diff_pct','')}</td>"
|
||||
f"<td>{p.get('mean_delta_min','')}</td></tr>"
|
||||
for p in report.get('pair_alignment', [])
|
||||
)}
|
||||
</tbody></table>
|
||||
|
||||
<h2>시뮬 검증 (monitor)</h2>
|
||||
<pre>{json.dumps(go, ensure_ascii=False, indent=2)}</pre>
|
||||
|
||||
<h2>참고</h2>
|
||||
<ul>
|
||||
{''.join(f'<li>{n}</li>' for n in report.get('notes', []))}
|
||||
</ul>
|
||||
</body></html>"""
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(html, encoding="utf-8")
|
||||
return out_path
|
||||
|
||||
|
||||
def run_gt_comparison_report(
|
||||
outcomes_path: Path | None = None,
|
||||
matched_path: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
GT 비교 리포트 생성·저장.
|
||||
|
||||
Args:
|
||||
outcomes_path: fire_outcomes.csv.
|
||||
matched_path: matched_rules.json.
|
||||
|
||||
Returns:
|
||||
report dict.
|
||||
"""
|
||||
report = build_gt_comparison_report(outcomes_path, matched_path)
|
||||
MATCHING_GT_COMPARISON_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
MATCHING_GT_COMPARISON_JSON.write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
write_gt_comparison_html(report, MATCHING_GT_COMPARISON_HTML)
|
||||
print(f"[GT비교] 저장: {MATCHING_GT_COMPARISON_JSON}")
|
||||
print(f"[GT비교] 저장: {MATCHING_GT_COMPARISON_HTML}")
|
||||
for m in report.get("monitor_rules", []):
|
||||
print(
|
||||
f" {m['rule_id']}: recall={m['gt_recall']:.1%} prec={m['precision_near_gt']:.1%} "
|
||||
f"fires={m['fire_count']} EV={m['sim_ev_all_pct']}% holdout={m['holdout_ev_pct']}%"
|
||||
)
|
||||
go = report.get("simulation_go_no_go", {})
|
||||
print(f"[GT비교] 시뮬 연동: {'GO' if go.get('go') else 'NO-GO'}")
|
||||
return report
|
||||
@@ -1,539 +0,0 @@
|
||||
"""
|
||||
GT 타점 MTF 프로필 반복 보강 — 스냅샷 recall·총자산 비율 90% 목표.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from config import (
|
||||
GENERAL_ANALYSIS_INTERVALS,
|
||||
MATCH_PROFILE_MIN_SAMPLES,
|
||||
MATCH_PROFILE_MIN_SEPARATION,
|
||||
)
|
||||
from deepcoin.analysis.general_analysis_core import interval_tf_prefix
|
||||
from deepcoin.matching.config import ANALYSIS_TRADES_CSV
|
||||
from deepcoin.matching.gt_asset_calibration import (
|
||||
evaluate_gt_snapshot_recall,
|
||||
portfolio_asset_ratio,
|
||||
)
|
||||
from deepcoin.matching.gt_mtf_profile import (
|
||||
analyze_gt_mtf_profile,
|
||||
discover_profile_columns,
|
||||
)
|
||||
from deepcoin.matching.profile_rules import (
|
||||
_condition_from_series,
|
||||
_feature_separation,
|
||||
build_rule_candidates,
|
||||
)
|
||||
from deepcoin.matching.rule_eval import eval_rule_mask
|
||||
from deepcoin.paths import (
|
||||
ANALYSIS_GT_CALIBRATION_JSON,
|
||||
ANALYSIS_GT_MTF_PROFILE_JSON,
|
||||
resolve_ground_truth_file,
|
||||
)
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth
|
||||
|
||||
|
||||
def _condition_or_group(
|
||||
series: pd.Series,
|
||||
side: str,
|
||||
quantile_lo: float = 0.15,
|
||||
quantile_hi: float = 0.85,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
한 컬럼 GT 분포에서 between 조건.
|
||||
|
||||
Args:
|
||||
series: side GT 값.
|
||||
side: buy | sell.
|
||||
quantile_lo: 하한 분위.
|
||||
quantile_hi: 상한 분위.
|
||||
|
||||
Returns:
|
||||
조건 dict.
|
||||
"""
|
||||
col_name = series.name
|
||||
if series.dtype == object or not pd.api.types.is_numeric_dtype(series):
|
||||
mode = series.dropna().astype(str).mode()
|
||||
if mode.empty:
|
||||
return None
|
||||
return {"col": col_name, "op": "eq", "value": str(mode.iloc[0])}
|
||||
s = pd.to_numeric(series, errors="coerce").dropna()
|
||||
if len(s) < MATCH_PROFILE_MIN_SAMPLES:
|
||||
return None
|
||||
lo = float(s.quantile(quantile_lo))
|
||||
hi = float(s.quantile(quantile_hi))
|
||||
if lo >= hi:
|
||||
return None
|
||||
return {"col": col_name, "op": "between", "lo": lo, "hi": hi}
|
||||
|
||||
|
||||
def build_or_tf_rules(
|
||||
buy: pd.DataFrame,
|
||||
sell: pd.DataFrame,
|
||||
ranked_cols: list[str],
|
||||
*,
|
||||
per_tf: int = 4,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
TF별 OR 복합 규칙 (해당 TF 상위 분리 컬럼 중 하나만 충족).
|
||||
|
||||
Args:
|
||||
buy: 매수 GT.
|
||||
sell: 매도 GT.
|
||||
ranked_cols: 분리도 순 컬럼.
|
||||
per_tf: TF당 OR 조건 수.
|
||||
|
||||
Returns:
|
||||
rule dict 리스트.
|
||||
"""
|
||||
rules: list[dict[str, Any]] = []
|
||||
for side, subset in (("buy", buy), ("sell", sell)):
|
||||
for iv in GENERAL_ANALYSIS_INTERVALS:
|
||||
pfx = interval_tf_prefix(iv)
|
||||
iv_cols = [
|
||||
c
|
||||
for c in ranked_cols
|
||||
if c.startswith(f"{pfx}_") and c in subset.columns
|
||||
]
|
||||
iv_cols = sorted(
|
||||
iv_cols,
|
||||
key=lambda c: _feature_separation(buy, sell, c),
|
||||
reverse=True,
|
||||
)[:per_tf]
|
||||
conds: list[dict[str, Any]] = []
|
||||
for col in iv_cols:
|
||||
c = _condition_or_group(subset[col], side, 0.20, 0.80)
|
||||
if c:
|
||||
conds.append(c)
|
||||
if len(conds) >= 2 and pfx not in ("m240",):
|
||||
rules.append(
|
||||
{
|
||||
"rule_id": f"{side}_or_{pfx}",
|
||||
"side": side,
|
||||
"kind": "or_tf",
|
||||
"logic": "or",
|
||||
"conditions": conds,
|
||||
}
|
||||
)
|
||||
return rules
|
||||
|
||||
|
||||
def build_unmatched_atomic_rules(
|
||||
trades_df: pd.DataFrame,
|
||||
rules: list[dict[str, Any]],
|
||||
side: str,
|
||||
*,
|
||||
max_new: int = 12,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
스냅샷 미매칭 GT 행에서 분리도 큰 컬럼 atomic 규칙 추가.
|
||||
|
||||
Args:
|
||||
trades_df: 03b CSV.
|
||||
rules: 기존 규칙.
|
||||
side: buy | sell.
|
||||
|
||||
Returns:
|
||||
신규 atomic rule dict.
|
||||
"""
|
||||
gt = trades_df[trades_df["action"] == side]
|
||||
buy_all = trades_df[trades_df["action"] == "buy"]
|
||||
sell_all = trades_df[trades_df["action"] == "sell"]
|
||||
side_rules = [r for r in rules if r.get("side") == side]
|
||||
|
||||
unmatched_idx: list[int] = []
|
||||
for idx, row in gt.iterrows():
|
||||
fr = pd.DataFrame([row])
|
||||
if not any(bool(eval_rule_mask(fr, r).iloc[0]) for r in side_rules):
|
||||
unmatched_idx.append(idx)
|
||||
|
||||
if not unmatched_idx:
|
||||
return []
|
||||
|
||||
unmatched = gt.loc[unmatched_idx]
|
||||
matched = gt.drop(index=unmatched_idx, errors="ignore")
|
||||
other = sell_all if side == "buy" else buy_all
|
||||
|
||||
cols = discover_profile_columns(trades_df)
|
||||
scores: list[tuple[float, str]] = []
|
||||
for col in cols:
|
||||
if col not in unmatched.columns:
|
||||
continue
|
||||
if not pd.api.types.is_numeric_dtype(unmatched[col]):
|
||||
continue
|
||||
u = pd.to_numeric(unmatched[col], errors="coerce").dropna()
|
||||
m = pd.to_numeric(matched[col], errors="coerce").dropna() if len(matched) >= 5 else pd.to_numeric(gt[col], errors="coerce").dropna()
|
||||
o = pd.to_numeric(other[col], errors="coerce").dropna()
|
||||
if len(u) < 3 or len(o) < 5:
|
||||
continue
|
||||
sep = abs(float(u.mean() - o.mean())) / (np.sqrt((u.var() + o.var()) / 2) + 1e-9)
|
||||
scores.append((sep, col))
|
||||
|
||||
scores.sort(reverse=True)
|
||||
new_rules: list[dict[str, Any]] = []
|
||||
existing_cols = {
|
||||
c["col"]
|
||||
for r in rules
|
||||
if r.get("side") == side
|
||||
for c in r.get("conditions", [])
|
||||
}
|
||||
for sep, col in scores[: max_new * 3]:
|
||||
if col in existing_cols:
|
||||
continue
|
||||
if sep < MATCH_PROFILE_MIN_SEPARATION * 0.5:
|
||||
continue
|
||||
cond = _condition_from_series(unmatched[col], side)
|
||||
if cond is None:
|
||||
cond = _condition_or_group(unmatched[col], side, 0.10, 0.90)
|
||||
if cond is None:
|
||||
continue
|
||||
rid = f"{side}_cal_{col}"
|
||||
new_rules.append(
|
||||
{
|
||||
"rule_id": rid,
|
||||
"side": side,
|
||||
"kind": "calibration_atomic",
|
||||
"logic": "and",
|
||||
"conditions": [cond],
|
||||
"profile_col": col,
|
||||
"calibration_sep": round(sep, 4),
|
||||
}
|
||||
)
|
||||
existing_cols.add(col)
|
||||
if len(new_rules) >= max_new:
|
||||
break
|
||||
return new_rules
|
||||
|
||||
|
||||
def _feature_separation_df(
|
||||
buy: pd.DataFrame,
|
||||
sell: pd.DataFrame,
|
||||
col: str,
|
||||
) -> float:
|
||||
"""DataFrame 컬럼 분리도."""
|
||||
if col not in buy.columns:
|
||||
return 0.0
|
||||
a = pd.to_numeric(buy[col], errors="coerce").dropna()
|
||||
b = pd.to_numeric(sell[col], errors="coerce").dropna()
|
||||
if len(a) < 5 or len(b) < 5:
|
||||
return 0.0
|
||||
pooled = np.sqrt((a.var() + b.var()) / 2)
|
||||
if pooled < 1e-9:
|
||||
return abs(float(a.mean() - b.mean()))
|
||||
return abs(float(a.mean() - b.mean())) / pooled
|
||||
|
||||
|
||||
def run_profile_calibration_loop(
|
||||
trades_csv: Path | None = None,
|
||||
*,
|
||||
target_recall: float = 0.90,
|
||||
target_asset_ratio: float = 0.90,
|
||||
max_iterations: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
03b·GT 기준 반복 규칙 보강 및 검증.
|
||||
|
||||
Args:
|
||||
trades_csv: 03b CSV.
|
||||
target_recall: 매수·매도 스냅샷 recall 목표.
|
||||
target_asset_ratio: GT 총자산 대비 subset 비율 목표.
|
||||
max_iterations: 최대 반복.
|
||||
|
||||
Returns:
|
||||
calibration 리포트 dict.
|
||||
"""
|
||||
path = trades_csv or ANALYSIS_TRADES_CSV
|
||||
df = pd.read_csv(path)
|
||||
buy = df[df["action"] == "buy"]
|
||||
sell = df[df["action"] == "sell"]
|
||||
|
||||
analysis = analyze_gt_mtf_profile(df)
|
||||
ANALYSIS_GT_MTF_PROFILE_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
ANALYSIS_GT_MTF_PROFILE_JSON.write_text(
|
||||
json.dumps(analysis, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
numeric_ranked = sorted(
|
||||
[
|
||||
f["col"]
|
||||
for f in analysis["features"]
|
||||
if f["dtype"] == "numeric"
|
||||
],
|
||||
key=lambda c: next(
|
||||
(x["separation"] for x in analysis["global_top_separation"] if x["col"] == c),
|
||||
_feature_separation_df(buy, sell, c),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
base = build_rule_candidates(path)
|
||||
rules: list[dict[str, Any]] = list(base.get("rules", []))
|
||||
for r in rules:
|
||||
if "logic" not in r:
|
||||
r["logic"] = "and"
|
||||
|
||||
rules.extend(build_or_tf_rules(buy, sell, numeric_ranked[:80]))
|
||||
|
||||
history: list[dict[str, Any]] = []
|
||||
best_rules: list[dict[str, Any]] = list(rules)
|
||||
best_asset_ratio = -1.0
|
||||
gt_data = load_ground_truth(resolve_ground_truth_file()) or {}
|
||||
gt_trades = gt_data.get("trades") or []
|
||||
mark = (gt_data.get("summary") or {}).get("mark_price")
|
||||
|
||||
for it in range(max_iterations):
|
||||
recall = evaluate_gt_snapshot_recall(df, rules)
|
||||
buy_rec = recall["buy"]["recall"]
|
||||
sell_rec = recall["sell"]["recall"]
|
||||
|
||||
buy_legs = {int(t["leg_id"]) for t in gt_trades if t["action"] == "buy"}
|
||||
sell_legs = {int(t["leg_id"]) for t in gt_trades if t["action"] == "sell"}
|
||||
all_legs = buy_legs | sell_legs
|
||||
|
||||
included_legs = set()
|
||||
gt_df = pd.DataFrame(gt_trades)
|
||||
for lid in all_legs:
|
||||
leg = gt_df[gt_df["leg_id"] == lid]
|
||||
leg_buy_ok = True
|
||||
leg_sell_ok = True
|
||||
for _, row in leg[leg["action"] == "buy"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")]
|
||||
if sub.empty:
|
||||
leg_buy_ok = False
|
||||
break
|
||||
fr = pd.DataFrame([sub.iloc[0]])
|
||||
if not any(
|
||||
bool(eval_rule_mask(fr, r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "buy"
|
||||
):
|
||||
leg_buy_ok = False
|
||||
break
|
||||
for _, row in leg[leg["action"] == "sell"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")]
|
||||
if sub.empty:
|
||||
leg_sell_ok = False
|
||||
break
|
||||
fr = pd.DataFrame([sub.iloc[0]])
|
||||
if not any(
|
||||
bool(eval_rule_mask(fr, r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "sell"
|
||||
):
|
||||
leg_sell_ok = False
|
||||
break
|
||||
if leg_buy_ok and leg_sell_ok:
|
||||
included_legs.add(int(lid))
|
||||
|
||||
asset = portfolio_asset_ratio(gt_trades, included_legs, mark)
|
||||
row_hist = {
|
||||
"iteration": it,
|
||||
"rule_count": len(rules),
|
||||
"buy_recall": buy_rec,
|
||||
"sell_recall": sell_rec,
|
||||
**asset,
|
||||
}
|
||||
history.append(row_hist)
|
||||
print(
|
||||
f"[cal {it}] rules={len(rules)} "
|
||||
f"buy_rec={buy_rec:.2%} sell_rec={sell_rec:.2%} "
|
||||
f"asset_ratio={asset['asset_ratio']:.2%} legs={asset['legs_covered']}/{asset['legs_total']}"
|
||||
)
|
||||
if asset["asset_ratio"] > best_asset_ratio:
|
||||
best_asset_ratio = asset["asset_ratio"]
|
||||
best_rules = list(rules)
|
||||
|
||||
if (
|
||||
buy_rec >= target_recall
|
||||
and sell_rec >= target_recall
|
||||
and asset["asset_ratio"] >= target_asset_ratio
|
||||
):
|
||||
break
|
||||
|
||||
added = 0
|
||||
for side in ("buy", "sell"):
|
||||
rec = recall[side]["recall"]
|
||||
if rec >= target_recall:
|
||||
continue
|
||||
new_rules = build_unmatched_atomic_rules(df, rules, side, max_new=15)
|
||||
rules.extend(new_rules)
|
||||
added += len(new_rules)
|
||||
if added == 0:
|
||||
rules.extend(build_or_tf_rules(buy, sell, numeric_ranked[:120]))
|
||||
for side in ("buy", "sell"):
|
||||
rules.extend(
|
||||
build_unmatched_atomic_rules(df, rules, side, max_new=20)
|
||||
)
|
||||
if len(rules) > 200:
|
||||
break
|
||||
|
||||
final_recall = evaluate_gt_snapshot_recall(df, rules)
|
||||
final_legs: set[int] = set()
|
||||
gt_df = pd.DataFrame(gt_trades)
|
||||
for lid in gt_df["leg_id"].unique():
|
||||
leg = gt_df[gt_df["leg_id"] == lid]
|
||||
ok_b = ok_s = True
|
||||
for _, row in leg[leg["action"] == "buy"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")]
|
||||
if sub.empty or not any(
|
||||
bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "buy"
|
||||
):
|
||||
ok_b = False
|
||||
for _, row in leg[leg["action"] == "sell"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")]
|
||||
if sub.empty or not any(
|
||||
bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "sell"
|
||||
):
|
||||
ok_s = False
|
||||
if ok_b and ok_s:
|
||||
final_legs.add(int(lid))
|
||||
|
||||
final_asset = portfolio_asset_ratio(gt_trades, final_legs, mark)
|
||||
|
||||
out = {
|
||||
"target_recall": target_recall,
|
||||
"target_asset_ratio": target_asset_ratio,
|
||||
"iterations": history,
|
||||
"final": {
|
||||
"rule_count": len(rules),
|
||||
"snapshot_recall": final_recall,
|
||||
"portfolio": final_asset,
|
||||
"targets_met": (
|
||||
final_recall["buy"]["recall"] >= target_recall
|
||||
and final_recall["sell"]["recall"] >= target_recall
|
||||
and final_asset["asset_ratio"] >= target_asset_ratio
|
||||
),
|
||||
},
|
||||
"calibrated_rules": rules,
|
||||
}
|
||||
deduped: list[dict[str, Any]] = []
|
||||
seen_rid: set[str] = set()
|
||||
for r in best_rules:
|
||||
rid = r.get("rule_id", "")
|
||||
if rid in seen_rid:
|
||||
continue
|
||||
seen_rid.add(rid)
|
||||
deduped.append(r)
|
||||
rules = _greedy_recall_cover(df, deduped, target_recall=target_recall)
|
||||
out["final"]["rule_count_after_greedy"] = len(rules)
|
||||
out["calibrated_rules"] = rules
|
||||
out["final"]["snapshot_recall"] = evaluate_gt_snapshot_recall(df, rules)
|
||||
final_legs_g: set[int] = set()
|
||||
gt_df = pd.DataFrame(gt_trades)
|
||||
for lid in gt_df["leg_id"].unique():
|
||||
leg = gt_df[gt_df["leg_id"] == lid]
|
||||
ok_b = ok_s = True
|
||||
for _, row in leg[leg["action"] == "buy"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "buy")]
|
||||
if sub.empty or not any(
|
||||
bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "buy"
|
||||
):
|
||||
ok_b = False
|
||||
for _, row in leg[leg["action"] == "sell"].iterrows():
|
||||
sub = df[(df["dt"] == row["dt"]) & (df["action"] == "sell")]
|
||||
if sub.empty or not any(
|
||||
bool(eval_rule_mask(pd.DataFrame([sub.iloc[0]]), r).iloc[0])
|
||||
for r in rules
|
||||
if r.get("side") == "sell"
|
||||
):
|
||||
ok_s = False
|
||||
if ok_b and ok_s:
|
||||
final_legs_g.add(int(lid))
|
||||
out["final"]["portfolio"] = portfolio_asset_ratio(
|
||||
gt_trades, final_legs_g, mark
|
||||
)
|
||||
fr = out["final"]["snapshot_recall"]
|
||||
pa = out["final"]["portfolio"]
|
||||
out["final"]["targets_met"] = (
|
||||
fr["buy"]["recall"] >= target_recall
|
||||
and fr["sell"]["recall"] >= target_recall
|
||||
and pa["asset_ratio"] >= target_asset_ratio
|
||||
)
|
||||
ANALYSIS_GT_CALIBRATION_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
ANALYSIS_GT_CALIBRATION_JSON.write_text(
|
||||
json.dumps(out, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _greedy_recall_cover(
|
||||
trades_df: pd.DataFrame,
|
||||
rules: list[dict[str, Any]],
|
||||
*,
|
||||
target_recall: float = 0.90,
|
||||
max_per_side: int = 40,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
측면별 recall 목표까지 greedy로 규칙 축소.
|
||||
|
||||
Args:
|
||||
trades_df: 03b CSV.
|
||||
rules: 후보 규칙 전체.
|
||||
target_recall: 목표 recall.
|
||||
|
||||
Returns:
|
||||
축소된 규칙 + 기존 compound/mtf_cross 유지.
|
||||
"""
|
||||
keep_kinds = {
|
||||
"compound_tight",
|
||||
"compound",
|
||||
"contrast",
|
||||
"mtf_cross",
|
||||
"or_tf",
|
||||
}
|
||||
kept = [r for r in rules if r.get("kind") in keep_kinds]
|
||||
pool = [r for r in rules if r not in kept]
|
||||
|
||||
for side in ("buy", "sell"):
|
||||
gt = trades_df[trades_df["action"] == side]
|
||||
if gt.empty:
|
||||
continue
|
||||
uncovered = set(gt.index)
|
||||
side_pool = [r for r in pool if r.get("side") == side]
|
||||
picked: list[dict[str, Any]] = []
|
||||
while uncovered and len(picked) < max_per_side:
|
||||
best_rule = None
|
||||
best_new = 0
|
||||
for rule in side_pool:
|
||||
if rule in picked:
|
||||
continue
|
||||
new_hit = 0
|
||||
for idx in list(uncovered):
|
||||
row = gt.loc[idx]
|
||||
if bool(eval_rule_mask(pd.DataFrame([row]), rule).iloc[0]):
|
||||
new_hit += 1
|
||||
if new_hit > best_new:
|
||||
best_new = new_hit
|
||||
best_rule = rule
|
||||
if best_rule is None or best_new == 0:
|
||||
break
|
||||
picked.append(best_rule)
|
||||
still = set()
|
||||
for idx in uncovered:
|
||||
row = gt.loc[idx]
|
||||
if not any(
|
||||
bool(eval_rule_mask(pd.DataFrame([row]), r).iloc[0])
|
||||
for r in picked + [x for x in kept if x.get("side") == side]
|
||||
):
|
||||
still.add(idx)
|
||||
uncovered = still
|
||||
rec = 1.0 - len(uncovered) / len(gt)
|
||||
if rec >= target_recall:
|
||||
break
|
||||
kept.extend(picked)
|
||||
return kept
|
||||
@@ -1,214 +0,0 @@
|
||||
"""
|
||||
실거래 매수 사이징 — 시뮬(sim_tier_enhanced)과 동일 인과 tier·weight 정책.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import (
|
||||
GT_SIGNAL_CAUSAL,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.causal_gt_hybrid import (
|
||||
_attach_drawdown_to_buys,
|
||||
_bar_index_at,
|
||||
_close_series_from_df,
|
||||
_drawdown_pct_at_index,
|
||||
hybrid_tier_scale,
|
||||
)
|
||||
from deepcoin.ground_truth.gt_model import leg_entry_weights, remaining_weight_sum
|
||||
from deepcoin.matching.position_sizing import compute_buy_amount_krw
|
||||
from deepcoin.paths import OPS_STATE_DIR
|
||||
|
||||
LIVE_SIZING_STATE_JSON = OPS_STATE_DIR / "live_sizing_state.json"
|
||||
|
||||
|
||||
class LivePositionState:
|
||||
"""
|
||||
미청산 leg·과거 leg 수익·매수 weight 추적 (시뮬 enrich/causal tier 정합).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""빈 포지션 상태."""
|
||||
self.current_leg_id: int = 0
|
||||
self.open_buys: list[dict[str, Any]] = []
|
||||
self.completed_leg_ret: dict[int, float] = {}
|
||||
self.leg_cost_krw: float = 0.0
|
||||
self.leg_proceeds_krw: float = 0.0
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> LivePositionState:
|
||||
"""
|
||||
디스크에서 상태 복원.
|
||||
|
||||
Args:
|
||||
path: JSON 경로. None이면 기본 경로.
|
||||
|
||||
Returns:
|
||||
LivePositionState 인스턴스.
|
||||
"""
|
||||
p = path or LIVE_SIZING_STATE_JSON
|
||||
st = cls()
|
||||
if not p.is_file():
|
||||
return st
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return st
|
||||
st.current_leg_id = int(data.get("current_leg_id") or 0)
|
||||
st.open_buys = list(data.get("open_buys") or [])
|
||||
st.completed_leg_ret = {
|
||||
int(k): float(v) for k, v in (data.get("completed_leg_ret") or {}).items()
|
||||
}
|
||||
st.leg_cost_krw = float(data.get("leg_cost_krw") or 0.0)
|
||||
st.leg_proceeds_krw = float(data.get("leg_proceeds_krw") or 0.0)
|
||||
return st
|
||||
|
||||
def save(self, path: Path | None = None) -> None:
|
||||
"""
|
||||
상태를 디스크에 저장.
|
||||
|
||||
Args:
|
||||
path: JSON 경로. None이면 기본 경로.
|
||||
"""
|
||||
p = path or LIVE_SIZING_STATE_JSON
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"current_leg_id": self.current_leg_id,
|
||||
"open_buys": self.open_buys,
|
||||
"completed_leg_ret": self.completed_leg_ret,
|
||||
"leg_cost_krw": round(self.leg_cost_krw, 0),
|
||||
"leg_proceeds_krw": round(self.leg_proceeds_krw, 0),
|
||||
}
|
||||
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def _start_new_leg_if_needed(self) -> None:
|
||||
"""포지션 없을 때 새 leg 시작."""
|
||||
if not self.open_buys:
|
||||
self.current_leg_id += 1
|
||||
self.leg_cost_krw = 0.0
|
||||
self.leg_proceeds_krw = 0.0
|
||||
|
||||
def record_buy(self, dt: str, price: float, amount_krw: float, fee: float) -> None:
|
||||
"""
|
||||
체결 매수 기록.
|
||||
|
||||
Args:
|
||||
dt: 체결 시각.
|
||||
price: 체결가.
|
||||
amount_krw: 매수 원화.
|
||||
fee: 수수료.
|
||||
"""
|
||||
self._start_new_leg_if_needed()
|
||||
self.open_buys.append({"dt": dt, "price": price, "amount_krw": amount_krw})
|
||||
self.leg_cost_krw += amount_krw + fee
|
||||
|
||||
def record_sell(self, amount_krw: float, fee: float, *, full_close: bool) -> None:
|
||||
"""
|
||||
체결 매도 기록.
|
||||
|
||||
Args:
|
||||
amount_krw: 매도 원화(총액).
|
||||
fee: 수수료.
|
||||
full_close: leg 전량 청산 여부.
|
||||
"""
|
||||
net = amount_krw - fee
|
||||
self.leg_proceeds_krw += net
|
||||
if full_close and self.leg_cost_krw > 0:
|
||||
ret_pct = (self.leg_proceeds_krw - self.leg_cost_krw) / self.leg_cost_krw * 100.0
|
||||
self.completed_leg_ret[self.current_leg_id] = ret_pct
|
||||
self.open_buys = []
|
||||
self.leg_cost_krw = 0.0
|
||||
self.leg_proceeds_krw = 0.0
|
||||
|
||||
def plan_buy_amount_krw(
|
||||
self,
|
||||
dt: str,
|
||||
price: float,
|
||||
cash: float,
|
||||
qty: float,
|
||||
df: pd.DataFrame | None = None,
|
||||
*,
|
||||
enhanced: bool = True,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> float:
|
||||
"""
|
||||
시뮬과 동일 tier·weight로 매수 원화 산출.
|
||||
|
||||
Args:
|
||||
dt: 신호 시각.
|
||||
price: 종가.
|
||||
cash: 가용 원화.
|
||||
qty: 보유 수량.
|
||||
df: OHLC (drawdown).
|
||||
enhanced: conviction·medium tier 사용.
|
||||
fee_rate: 수수료율.
|
||||
|
||||
Returns:
|
||||
매수 원화.
|
||||
"""
|
||||
self._start_new_leg_if_needed()
|
||||
prices = [float(b["price"]) for b in self.open_buys] + [price]
|
||||
weights = leg_entry_weights(prices)
|
||||
idx = len(self.open_buys)
|
||||
weight = float(weights[idx])
|
||||
w_sum = float(sum(weights[idx:]))
|
||||
trade: dict[str, Any] = {
|
||||
"dt": dt,
|
||||
"action": "buy",
|
||||
"price": price,
|
||||
"leg_id": self.current_leg_id,
|
||||
"weight": round(weight, 4),
|
||||
}
|
||||
if df is not None and not df.empty:
|
||||
attached = _attach_drawdown_to_buys([trade], df)
|
||||
if attached:
|
||||
trade = attached[0]
|
||||
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
|
||||
|
||||
dd_params = load_hybrid_dd_params()
|
||||
scale = hybrid_tier_scale(
|
||||
trade,
|
||||
completed_leg_ret=self.completed_leg_ret,
|
||||
enhanced=enhanced,
|
||||
dd_large_pct=dd_params.get("dd_large_pct"),
|
||||
dd_medium_pct=dd_params.get("dd_medium_pct"),
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
price,
|
||||
weight,
|
||||
w_sum,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=fee_rate,
|
||||
ignore_weight_split=bool(trade.get("conviction_buy")),
|
||||
)
|
||||
|
||||
|
||||
def drawdown_pct_from_df(df: pd.DataFrame, dt: str) -> float:
|
||||
"""
|
||||
bar 시점 drawdown % (인과적).
|
||||
|
||||
Args:
|
||||
df: DatetimeIndex OHLC.
|
||||
dt: 시각 문자열.
|
||||
|
||||
Returns:
|
||||
drawdown %.
|
||||
"""
|
||||
if df.empty:
|
||||
return 0.0
|
||||
close_s = _close_series_from_df(df)
|
||||
bar_idx = _bar_index_at(df, dt)
|
||||
return _drawdown_pct_at_index(close_s, bar_idx)
|
||||
|
||||
|
||||
def live_sizing_enabled() -> bool:
|
||||
"""실거래 사이징을 시뮬 인과 tier와 정합할지."""
|
||||
return bool(GT_SIGNAL_CAUSAL)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""
|
||||
04단계: GT 프로필 + 전구간 EV 필터 매칭 파이프라인.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from deepcoin.matching.pipeline import run_matching_pipeline
|
||||
from deepcoin.paths import ANALYSIS_TRADES_CSV, REPORTS_ANALYSIS, REPORTS_MATCHING
|
||||
|
||||
|
||||
def run_match(
|
||||
phase: str = "all",
|
||||
trades_csv: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
04 파이프라인 실행.
|
||||
|
||||
Args:
|
||||
phase: all | profile | scan | label | select.
|
||||
trades_csv: 03b CSV 경로(선택).
|
||||
"""
|
||||
REPORTS_MATCHING.mkdir(parents=True, exist_ok=True)
|
||||
csv = trades_csv or ANALYSIS_TRADES_CSV
|
||||
if not csv.is_file():
|
||||
raise FileNotFoundError(
|
||||
f"03b CSV 없음: {csv}\n python scripts/03_analyze_trades.py 먼저 실행"
|
||||
)
|
||||
run_matching_pipeline(phase=phase, trades_csv=csv)
|
||||
|
||||
|
||||
def run_match_stub() -> Path:
|
||||
"""하위 호환: 스텁 대신 phase=profile만 안내."""
|
||||
print("=== Phase 04 Matching ===")
|
||||
print(" 전체 파이프라인: python scripts/04_match_rules.py")
|
||||
print(" 단계별: --phase profile|scan|label|select")
|
||||
print(f" analysis csv: {ANALYSIS_TRADES_CSV}")
|
||||
print(f" output dir: {REPORTS_MATCHING}")
|
||||
return REPORTS_MATCHING
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_match()
|
||||
@@ -217,72 +217,6 @@ def nearest_gt_leg_id(
|
||||
return best_buy if best_buy is not None else best_any
|
||||
|
||||
|
||||
_APPROVED_RULES_CACHE: set[str] | None = None
|
||||
|
||||
|
||||
def load_ev_wf_approved_rule_ids(
|
||||
matched_path: Path | None = None,
|
||||
outcomes_path: Path | None = None,
|
||||
) -> set[str]:
|
||||
"""
|
||||
holdout EV·PF, walk-forward, 수수료 스트레스를 모두 통과한 rule_id.
|
||||
|
||||
Args:
|
||||
matched_path: matched_rules.json.
|
||||
outcomes_path: fire_outcomes.csv.
|
||||
|
||||
Returns:
|
||||
통과 rule_id set. 산출 불가 시 monitor_rules 전체 fallback.
|
||||
"""
|
||||
global _APPROVED_RULES_CACHE
|
||||
if _APPROVED_RULES_CACHE is not None:
|
||||
return set(_APPROVED_RULES_CACHE)
|
||||
|
||||
from config import SIM_FEE_STRESS_MULT
|
||||
|
||||
from deepcoin.matching.select_rules import _rule_metrics, _split_train_valid_holdout
|
||||
from deepcoin.matching.simulation import (
|
||||
evaluate_go_no_go,
|
||||
simulate_live_order_cap,
|
||||
walk_forward_by_month,
|
||||
walk_forward_summary,
|
||||
)
|
||||
|
||||
mp = matched_path or MATCHING_MATCHED_RULES
|
||||
op = outcomes_path or MATCHING_FIRE_OUTCOMES
|
||||
matched = load_matched_rules(mp)
|
||||
rules = matched.get("monitor_rules") or []
|
||||
if not rules or not op.is_file():
|
||||
return {r["rule_id"] for r in rules}
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import MATCH_FEE_RATE
|
||||
|
||||
outcomes = pd.read_csv(op)
|
||||
outcomes["split"] = _split_train_valid_holdout(outcomes)
|
||||
wf_sum = walk_forward_summary(walk_forward_by_month(outcomes))
|
||||
fee_stress: dict[str, Any] = {}
|
||||
for rid in outcomes["rule_id"].unique():
|
||||
sub = outcomes[outcomes["rule_id"] == rid]
|
||||
from deepcoin.matching.simulation import _fee_adjust_ret
|
||||
|
||||
adj = _fee_adjust_ret(sub["forward_ret_pct"], SIM_FEE_STRESS_MULT)
|
||||
fee_stress[rid] = _rule_metrics(sub.assign(forward_ret_pct=adj))
|
||||
monitor_ids = {r["rule_id"] for r in rules}
|
||||
live_cap = simulate_live_order_cap(
|
||||
outcomes, rule_ids=monitor_ids, holdout_only=True
|
||||
)
|
||||
go = evaluate_go_no_go(matched, wf_sum, fee_stress, live_cap)
|
||||
passed = {c["rule_id"] for c in go.get("checks", []) if c.get("pass")}
|
||||
if passed:
|
||||
_APPROVED_RULES_CACHE = passed
|
||||
return passed
|
||||
fallback = monitor_ids
|
||||
_APPROVED_RULES_CACHE = fallback
|
||||
return fallback
|
||||
|
||||
|
||||
def load_gt_allocation_analysis(
|
||||
gt_trades: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
@@ -328,8 +262,6 @@ def gt_tier_scale_for_trade(
|
||||
"""
|
||||
GT leg tier 배분 스케일 (분석 권장값 또는 config).
|
||||
|
||||
시뮬은 live_buy_asset_pct_scale 대신 GT와 동일 tier 정책을 사용합니다.
|
||||
|
||||
Args:
|
||||
trade: {dt, leg_id?, action, ...}.
|
||||
gt_trades: GT trades (leg 매칭).
|
||||
@@ -349,37 +281,6 @@ def gt_tier_scale_for_trade(
|
||||
return gt_tier_scale_from_analysis(int(lid), large_legs, analysis)
|
||||
|
||||
|
||||
def live_buy_asset_pct_scale(
|
||||
rule_id: str,
|
||||
dt: str,
|
||||
gt_trades: list[dict[str, Any]],
|
||||
*,
|
||||
approved_rules: set[str],
|
||||
large_legs: set[int],
|
||||
) -> float:
|
||||
"""
|
||||
실거래 전용 매수 tier (EV/WF·leg 상위). 시뮬은 gt_tier_scale_for_trade 사용.
|
||||
|
||||
Args:
|
||||
rule_id: 규칙 ID.
|
||||
dt: 체결 시각.
|
||||
gt_trades: GT trades.
|
||||
approved_rules: 통과 rule_id.
|
||||
large_legs: 상위 leg.
|
||||
|
||||
Returns:
|
||||
LIVE_BUY_PCT_LARGE 또는 LIVE_BUY_PCT_SMALL(또는 0에 가까운 소형).
|
||||
"""
|
||||
from config import LIVE_BUY_PCT_LARGE, LIVE_BUY_PCT_SMALL
|
||||
|
||||
if rule_id not in approved_rules:
|
||||
return float(LIVE_BUY_PCT_SMALL)
|
||||
lid = nearest_gt_leg_id(dt, gt_trades)
|
||||
if lid is not None and lid in large_legs:
|
||||
return float(LIVE_BUY_PCT_LARGE)
|
||||
return float(LIVE_BUY_PCT_SMALL)
|
||||
|
||||
|
||||
def enrich_sim_trades_with_gt_weights(
|
||||
trades: list[dict[str, Any]],
|
||||
gt_trades: list[dict[str, Any]],
|
||||
@@ -504,65 +405,6 @@ def attach_gt_model_amounts(
|
||||
return enriched
|
||||
|
||||
|
||||
def plan_open_position_buy(
|
||||
open_buys: list[dict[str, Any]],
|
||||
candidate: dict[str, Any],
|
||||
cash: float,
|
||||
qty: float,
|
||||
gt_trades: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
large_legs: set[int],
|
||||
analysis: dict[str, Any] | None = None,
|
||||
fee_rate: float = TRADING_FEE_RATE,
|
||||
) -> float:
|
||||
"""
|
||||
미청산 포지션 내 다음 매수 원화 (GT tier·보유 현금 한도, 1회 상한 없음).
|
||||
|
||||
Args:
|
||||
open_buys: 현재 포지션에서 이미 체결된 매수 dict.
|
||||
candidate: 이번 매수 후보 {dt, price, rule_id, leg_id?, ...}.
|
||||
cash: 보유 현금.
|
||||
qty: 보유 수량.
|
||||
gt_trades: GT leg 매칭용.
|
||||
large_legs: 상위 leg.
|
||||
analysis: GT 배분 분석.
|
||||
fee_rate: 수수료율.
|
||||
|
||||
Returns:
|
||||
매수 계획 원화.
|
||||
"""
|
||||
from deepcoin.ground_truth.gt_model import leg_entry_weights
|
||||
|
||||
if gt_trades is None:
|
||||
gt_trades, _, _ = load_sizing_context_from_gt()
|
||||
if analysis is None:
|
||||
analysis = load_gt_allocation_analysis(gt_trades)
|
||||
|
||||
prices = [float(t["price"]) for t in open_buys] + [float(candidate["price"])]
|
||||
weights = leg_entry_weights(prices)
|
||||
idx = len(open_buys)
|
||||
w = weights[idx]
|
||||
w_sum = sum(weights[idx:])
|
||||
cand = dict(candidate)
|
||||
if "leg_id" not in cand:
|
||||
cand["leg_id"] = nearest_gt_leg_id(str(candidate["dt"]), gt_trades)
|
||||
scale = gt_tier_scale_for_trade(
|
||||
cand,
|
||||
gt_trades,
|
||||
large_legs,
|
||||
analysis=analysis,
|
||||
)
|
||||
return compute_buy_amount_krw(
|
||||
cash,
|
||||
qty,
|
||||
float(candidate["price"]),
|
||||
w,
|
||||
w_sum,
|
||||
asset_pct_scale=scale,
|
||||
fee_rate=fee_rate,
|
||||
)
|
||||
|
||||
|
||||
def attach_dynamic_buy_amounts(
|
||||
trades: list[dict[str, Any]],
|
||||
*,
|
||||
|
||||
@@ -131,7 +131,8 @@ def build_mtf_scan_frame(
|
||||
if raw is None or raw.empty:
|
||||
raise RuntimeError(f"주간격 {primary}분 데이터 없음")
|
||||
|
||||
print(f"[04b] Phase A: 8TF enrich (스캔용)...")
|
||||
n_tf = len(GENERAL_ANALYSIS_INTERVALS)
|
||||
print(f"[04b] Phase A: {n_tf}TF enrich (스캔용, 주·월봉 포함)...")
|
||||
enriched: dict[int, pd.DataFrame] = {}
|
||||
for iv in GENERAL_ANALYSIS_INTERVALS:
|
||||
r = frames.get(iv)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
1단계: walk-forward·민감도·실거래 한도 가정 시뮬·Go/No-Go 리포트.
|
||||
Simulation: walk-forward·민감도·Go/No-Go·portfolio_compare 리포트.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -762,19 +762,6 @@ def build_simulation_report(
|
||||
if ANALYSIS_GT_CALIBRATION_JSON.is_file():
|
||||
cal = json.loads(ANALYSIS_GT_CALIBRATION_JSON.read_text(encoding="utf-8"))
|
||||
gt_portfolio = cal.get("final", {})
|
||||
else:
|
||||
from deepcoin.matching.gt_asset_calibration import (
|
||||
portfolio_asset_ratio,
|
||||
)
|
||||
|
||||
gt_data_cal = load_ground_truth(resolve_ground_truth_file()) or {}
|
||||
trades = gt_data_cal.get("trades") or []
|
||||
mark_cal = (gt_data_cal.get("summary") or {}).get("mark_price")
|
||||
if trades:
|
||||
gt_portfolio = {
|
||||
"portfolio": portfolio_asset_ratio(trades, set(), mark_cal),
|
||||
"note": "캘리브레이션 미실행 — scripts/04_calibrate_gt_assets.py",
|
||||
}
|
||||
|
||||
summaries = matched.get("all_rule_summaries") or matched.get("monitor_rules") or []
|
||||
leg_weight_check = summarize_leg_weights(gt_trades) if gt_trades else {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
시뮬 sim_causal_hybrid 와 동일 체결 엔진 (build_monitor_hybrid_sized_trades).
|
||||
|
||||
dry-run·live(06) 모두 발화 이력 → hybrid 배분 → amount_krw·수량 적용.
|
||||
live(06) plan_live_hit: 발화 이력 → hybrid 배분 → amount_krw·수량 (인과, 현금·보유 제약).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -11,10 +11,17 @@ from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from config import GT_INITIAL_CASH_KRW, TRADING_FEE_RATE
|
||||
from config import (
|
||||
CHART_LOOKBACK_DAYS,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
LIVE_HYBRID_BOOTSTRAP_FIRES,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.causal_gt_hybrid import build_monitor_hybrid_sized_trades
|
||||
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
|
||||
from deepcoin.ground_truth.hybrid_dd_calibrate import load_hybrid_dd_params
|
||||
from deepcoin.ops.paper_portfolio import PaperPortfolio
|
||||
from deepcoin.matching.load_rules import load_monitor_rules
|
||||
from deepcoin.paths import MATCHING_FIRE_OUTCOMES
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,6 +36,190 @@ class SimTradeResult:
|
||||
leg_id: int | None = None
|
||||
|
||||
|
||||
def bootstrap_monitor_signals_from_outcomes(
|
||||
*,
|
||||
end_dt: str | None = None,
|
||||
lookback_days: int | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
04 fire_outcomes 에서 monitor 규칙 발화를 로드 (시뮬 all_monitor 와 동일 입력).
|
||||
|
||||
Args:
|
||||
end_dt: 이 시각 이전만 포함 (None=전체).
|
||||
lookback_days: CHART_LOOKBACK_DAYS 대신 사용할 일수.
|
||||
|
||||
Returns:
|
||||
{dt, rule_id, side, close} 리스트 (시각순).
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
path = MATCHING_FIRE_OUTCOMES
|
||||
if not path.is_file():
|
||||
return []
|
||||
monitor_ids = {r["rule_id"] for r in load_monitor_rules()}
|
||||
if not monitor_ids:
|
||||
return []
|
||||
df = pd.read_csv(path)
|
||||
if df.empty or "rule_id" not in df.columns:
|
||||
return []
|
||||
sub = df[df["rule_id"].isin(monitor_ids)].copy()
|
||||
if sub.empty:
|
||||
return []
|
||||
sub["dt"] = sub["dt"].astype(str)
|
||||
if end_dt:
|
||||
sub = sub[sub["dt"] <= str(end_dt)]
|
||||
if lookback_days is not None and lookback_days > 0:
|
||||
end_ts = pd.to_datetime(sub["dt"].max()) if end_dt is None else pd.to_datetime(end_dt)
|
||||
start = end_ts - pd.Timedelta(days=int(lookback_days))
|
||||
sub = sub[pd.to_datetime(sub["dt"]) >= start]
|
||||
elif lookback_days is None and CHART_LOOKBACK_DAYS > 0:
|
||||
end_ts = pd.to_datetime(sub["dt"].max())
|
||||
start = end_ts - pd.Timedelta(days=int(CHART_LOOKBACK_DAYS))
|
||||
sub = sub[pd.to_datetime(sub["dt"]) >= start]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for _, r in sub.iterrows():
|
||||
rows.append(
|
||||
{
|
||||
"dt": str(r["dt"]),
|
||||
"rule_id": str(r["rule_id"]),
|
||||
"side": str(r["side"]),
|
||||
"close": float(r["close"]),
|
||||
}
|
||||
)
|
||||
return sort_hits_sim_order(rows)
|
||||
|
||||
|
||||
def merge_signal_histories(
|
||||
*histories: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
발화 이력 병합 (dt+rule_id+side 기준 중복 제거, 시뮬 정렬).
|
||||
|
||||
Args:
|
||||
*histories: 신호 dict 리스트들.
|
||||
|
||||
Returns:
|
||||
병합·정렬된 리스트.
|
||||
"""
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
merged: list[dict[str, Any]] = []
|
||||
for hist in histories:
|
||||
for h in hist:
|
||||
key = hit_key(h)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
merged.append(
|
||||
{
|
||||
"dt": key[0],
|
||||
"rule_id": key[1],
|
||||
"side": key[2],
|
||||
"close": float(h["close"]),
|
||||
}
|
||||
)
|
||||
return sort_hits_sim_order(merged)
|
||||
|
||||
|
||||
def build_live_signal_history(
|
||||
persisted: list[dict[str, Any]] | None = None,
|
||||
*,
|
||||
bootstrap_fires: bool | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
운영 hybrid 이력: fire_outcomes 부트스트랩 + live 저장분 병합.
|
||||
|
||||
Args:
|
||||
persisted: live_signal_history.json signals.
|
||||
bootstrap_fires: fire_outcomes 부트스트랩 여부. None이면 config.
|
||||
|
||||
Returns:
|
||||
sim_causal_hybrid 입력과 동일 형식의 이력.
|
||||
"""
|
||||
use_boot = (
|
||||
LIVE_HYBRID_BOOTSTRAP_FIRES if bootstrap_fires is None else bootstrap_fires
|
||||
)
|
||||
parts: list[list[dict[str, Any]]] = []
|
||||
if use_boot:
|
||||
boot = bootstrap_monitor_signals_from_outcomes()
|
||||
if boot:
|
||||
parts.append(boot)
|
||||
if persisted:
|
||||
parts.append(persisted)
|
||||
if not parts:
|
||||
return []
|
||||
return merge_signal_histories(*parts)
|
||||
|
||||
|
||||
class HybridSimPortfolio:
|
||||
"""
|
||||
hybrid 배분 결과를 현금·보유 수량에 적용 (시뮬·live plan 공용, API 미사용).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""초기 현금만 보유."""
|
||||
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
|
||||
self.qty: float = 0.0
|
||||
self.qty_by_leg: dict[int, float] = {}
|
||||
self.sell_leg: int | None = None
|
||||
self.sell_base_qty: float = 0.0
|
||||
|
||||
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
|
||||
"""
|
||||
매수 체결 (가용 현금·수수료 범위).
|
||||
|
||||
Args:
|
||||
amount_krw: 매수 원화.
|
||||
price: 체결가.
|
||||
leg_id: leg ID.
|
||||
|
||||
Returns:
|
||||
체결 성공 여부.
|
||||
"""
|
||||
if amount_krw <= 0 or price <= 0:
|
||||
return False
|
||||
fee = amount_krw * TRADING_FEE_RATE
|
||||
if self.cash_krw < amount_krw + fee:
|
||||
return False
|
||||
self.cash_krw -= amount_krw + fee
|
||||
bought = amount_krw / price
|
||||
self.qty += bought
|
||||
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
|
||||
self.sell_leg = None
|
||||
self.sell_base_qty = 0.0
|
||||
return True
|
||||
|
||||
def apply_sell(self, amount_krw: float, sell_qty: float, price: float, leg_id: int) -> bool:
|
||||
"""
|
||||
매도 체결 (보유 수량 필요).
|
||||
|
||||
Args:
|
||||
amount_krw: 매도 원화(총액).
|
||||
sell_qty: 매도 수량.
|
||||
price: 체결가.
|
||||
leg_id: leg ID.
|
||||
|
||||
Returns:
|
||||
체결 성공 여부.
|
||||
"""
|
||||
if sell_qty <= 0 or amount_krw <= 0:
|
||||
return False
|
||||
leg_qty = self.qty_by_leg.get(leg_id, 0.0)
|
||||
if leg_qty <= 1e-12:
|
||||
return False
|
||||
fee = amount_krw * TRADING_FEE_RATE
|
||||
self.cash_krw += amount_krw - fee
|
||||
leg_qty -= sell_qty
|
||||
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
|
||||
self.qty = max(self.qty - sell_qty, 0.0)
|
||||
if self.qty < 1e-12:
|
||||
self.qty = 0.0
|
||||
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
|
||||
self.qty_by_leg.pop(leg_id, None)
|
||||
self.sell_leg = None
|
||||
self.sell_base_qty = 0.0
|
||||
return True
|
||||
|
||||
|
||||
def hit_key(hit: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""발화 고유 키 (dt, rule_id, side)."""
|
||||
return (str(hit["dt"]), str(hit["rule_id"]), str(hit["side"]))
|
||||
@@ -55,14 +246,17 @@ def sort_hits_sim_order(hits: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _signals_for_hybrid(
|
||||
signal_history: list[dict[str, Any]],
|
||||
*,
|
||||
approved_buy_rules: set[str] | None,
|
||||
approved_buy_rules: set[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
hybrid 배분용 신호 목록 (EV/WF 미통과 매수 제외).
|
||||
hybrid 배분용 신호 목록.
|
||||
|
||||
sim_causal_hybrid 와 동일하려면 approved_buy_rules=None (monitor 전체 발화).
|
||||
운영에서 추가로 EV/WF 매수만 허용하려면 rule_id 집합을 넘깁니다.
|
||||
|
||||
Args:
|
||||
signal_history: {dt, rule_id, side, close}.
|
||||
approved_buy_rules: 허용 매수 rule_id.
|
||||
approved_buy_rules: None=필터 없음. set 이면 해당 매수 rule_id 만.
|
||||
|
||||
Returns:
|
||||
시뮬 입력 trade dict 리스트.
|
||||
@@ -71,7 +265,11 @@ def _signals_for_hybrid(
|
||||
for h in sort_hits_sim_order(signal_history):
|
||||
side = str(h["side"])
|
||||
rid = str(h["rule_id"])
|
||||
if side == "buy" and approved_buy_rules is not None and rid not in approved_buy_rules:
|
||||
if (
|
||||
side == "buy"
|
||||
and approved_buy_rules is not None
|
||||
and rid not in approved_buy_rules
|
||||
):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
@@ -118,29 +316,19 @@ def size_monitor_signals(
|
||||
return sized
|
||||
|
||||
|
||||
def _find_sized_trade(sized: list[dict[str, Any]], hit: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""sized 목록에서 발화 1건 조회."""
|
||||
dt, rid, side = hit_key(hit)
|
||||
for t in sized:
|
||||
action = str(t.get("action", t.get("side", "")))
|
||||
if str(t.get("dt")) == dt and str(t.get("rule_id", "")) == rid and action == side:
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def replay_paper_portfolio(
|
||||
def replay_hybrid_signals(
|
||||
signal_history: list[dict[str, Any]],
|
||||
ohlc_df: pd.DataFrame,
|
||||
*,
|
||||
approved_buy_rules: set[str] | None = None,
|
||||
) -> tuple[PaperPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
|
||||
) -> tuple[HybridSimPortfolio, dict[tuple[str, str, str], SimTradeResult]]:
|
||||
"""
|
||||
신호 이력 전체를 시뮬 엔진으로 재생 → 모의 계좌(GT_INITIAL_CASH_KRW) 상태.
|
||||
신호 이력 전체를 hybrid 배분·체결 규칙으로 재생 (simulate_portfolio_steps 동일).
|
||||
|
||||
Args:
|
||||
signal_history: Phase C 누적 발화.
|
||||
signal_history: 누적 발화.
|
||||
ohlc_df: 3m OHLC.
|
||||
approved_buy_rules: EV/WF 통과 매수 규칙.
|
||||
approved_buy_rules: None=시뮬 동일. set 이면 매수 rule_id 필터.
|
||||
|
||||
Returns:
|
||||
(portfolio, hit_key → SimTradeResult).
|
||||
@@ -148,67 +336,97 @@ def replay_paper_portfolio(
|
||||
sized = size_monitor_signals(
|
||||
signal_history, ohlc_df, approved_buy_rules=approved_buy_rules
|
||||
)
|
||||
paper = PaperPortfolio()
|
||||
paper.cash_krw = float(GT_INITIAL_CASH_KRW)
|
||||
paper.qty = 0.0
|
||||
paper.qty_by_leg = {}
|
||||
portfolio = HybridSimPortfolio()
|
||||
results: dict[tuple[str, str, str], SimTradeResult] = {}
|
||||
fee_rate = TRADING_FEE_RATE
|
||||
current_leg: int | None = None
|
||||
leg_budget = 0.0
|
||||
|
||||
leg_sell_idxs: dict[int, list[int]] = {}
|
||||
for i, t in enumerate(sized):
|
||||
lid = int(t.get("leg_id", 0))
|
||||
if str(t.get("action", t.get("side"))) == "sell":
|
||||
leg_sell_idxs.setdefault(lid, []).append(i)
|
||||
|
||||
sell_leg: int | None = None
|
||||
sell_base_qty = 0.0
|
||||
|
||||
for i, t in enumerate(sized):
|
||||
side = str(t.get("action", t.get("side", "")))
|
||||
for t in sorted(sized, key=lambda x: x["dt"]):
|
||||
action = str(t.get("action", t.get("side", "")))
|
||||
price = float(t["price"])
|
||||
if price <= 0:
|
||||
continue
|
||||
dt = str(t["dt"])
|
||||
rid = str(t.get("rule_id", ""))
|
||||
leg_id = int(t.get("leg_id", 0))
|
||||
hit = {"dt": dt, "rule_id": rid, "side": side, "close": price}
|
||||
weight = float(t.get("weight", 1.0))
|
||||
hit = {"dt": dt, "rule_id": rid, "side": action, "close": price}
|
||||
key = hit_key(hit)
|
||||
amount = float(t.get("amount_krw") or 0)
|
||||
|
||||
if side == "buy":
|
||||
if action == "buy":
|
||||
ak = t.get("amount_krw")
|
||||
if ak is not None and float(ak) > 0:
|
||||
amount = min(
|
||||
float(ak),
|
||||
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
|
||||
)
|
||||
else:
|
||||
if leg_id != current_leg:
|
||||
current_leg = leg_id
|
||||
leg_budget = portfolio.cash_krw
|
||||
amount = min(
|
||||
leg_budget * weight,
|
||||
max(portfolio.cash_krw / (1.0 + fee_rate), 0.0),
|
||||
)
|
||||
if amount <= 0:
|
||||
results[key] = SimTradeResult(
|
||||
hit, 0.0, 0.0, False, "시뮬 매수 스킵(현금·tier)"
|
||||
)
|
||||
continue
|
||||
ok = paper.apply_buy(amount, price, leg_id)
|
||||
msg = f"paper_buy sim leg={leg_id} ₩{amount:,.0f}" if ok else "paper_buy 실패"
|
||||
results[key] = SimTradeResult(
|
||||
hit, amount, 0.0, ok, msg, leg_id=leg_id
|
||||
fee = amount * fee_rate
|
||||
portfolio.cash_krw -= amount + fee
|
||||
bought = amount / price
|
||||
portfolio.qty += bought
|
||||
portfolio.qty_by_leg[leg_id] = (
|
||||
portfolio.qty_by_leg.get(leg_id, 0.0) + bought
|
||||
)
|
||||
portfolio.sell_leg = None
|
||||
portfolio.sell_base_qty = 0.0
|
||||
results[key] = SimTradeResult(
|
||||
hit,
|
||||
amount,
|
||||
0.0,
|
||||
True,
|
||||
f"sim_buy leg={leg_id} ₩{amount:,.0f}",
|
||||
leg_id=leg_id,
|
||||
)
|
||||
sell_leg = None
|
||||
continue
|
||||
|
||||
leg_qty = paper.qty_by_leg.get(leg_id, 0.0)
|
||||
if leg_qty <= 1e-12:
|
||||
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "모의 보유 없음")
|
||||
continue
|
||||
if amount <= 0:
|
||||
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "시뮬 매도 스킵")
|
||||
if action == "sell" and portfolio.qty > 0:
|
||||
leg_qty = portfolio.qty_by_leg.get(leg_id, portfolio.qty)
|
||||
if portfolio.sell_leg != leg_id:
|
||||
portfolio.sell_leg = leg_id
|
||||
portfolio.sell_base_qty = leg_qty
|
||||
sell_qty = resolve_sell_qty(
|
||||
t, leg_qty, price, portfolio.sell_base_qty, weight
|
||||
)
|
||||
if sell_qty <= 0:
|
||||
results[key] = SimTradeResult(
|
||||
hit, 0.0, 0.0, False, "시뮬 매도 스킵"
|
||||
)
|
||||
continue
|
||||
gross = sell_qty * price
|
||||
fee = gross * fee_rate
|
||||
portfolio.cash_krw += gross - fee
|
||||
leg_qty -= sell_qty
|
||||
portfolio.qty_by_leg[leg_id] = max(leg_qty, 0.0)
|
||||
portfolio.qty = max(portfolio.qty - sell_qty, 0.0)
|
||||
if portfolio.qty < 1e-12:
|
||||
portfolio.qty = 0.0
|
||||
results[key] = SimTradeResult(
|
||||
hit,
|
||||
gross,
|
||||
sell_qty,
|
||||
True,
|
||||
f"sim_sell qty={sell_qty:.4f} ₩{gross:,.0f}",
|
||||
leg_id=leg_id,
|
||||
)
|
||||
continue
|
||||
|
||||
if sell_leg != leg_id:
|
||||
sell_leg = leg_id
|
||||
sell_base_qty = leg_qty
|
||||
rem = [j for j in leg_sell_idxs.get(leg_id, []) if j >= i]
|
||||
is_last = bool(rem) and i == rem[-1]
|
||||
sell_qty = leg_qty if is_last else amount / price if price > 0 else 0.0
|
||||
results[key] = SimTradeResult(hit, 0.0, 0.0, False, "보유 없음")
|
||||
|
||||
ok = paper.apply_sell(amount, sell_qty, price, leg_id)
|
||||
msg = f"paper_sell sim qty={sell_qty:.4f} ₩{amount:,.0f}" if ok else "paper_sell 실패"
|
||||
results[key] = SimTradeResult(
|
||||
hit, amount, sell_qty, ok, msg, leg_id=leg_id
|
||||
)
|
||||
|
||||
return paper, results
|
||||
return portfolio, results
|
||||
|
||||
|
||||
def plan_live_hit(
|
||||
@@ -228,7 +446,7 @@ def plan_live_hit(
|
||||
approved_buy_rules: 매수 허용.
|
||||
|
||||
Returns:
|
||||
SimTradeResult (dry-run replay_paper_portfolio 와 동일).
|
||||
SimTradeResult.
|
||||
"""
|
||||
if ohlc_df is None or getattr(ohlc_df, "empty", True):
|
||||
return SimTradeResult(hit, 0.0, 0.0, False, "OHLC 없음")
|
||||
@@ -246,7 +464,7 @@ def plan_live_hit(
|
||||
"close": float(hit["close"]),
|
||||
}
|
||||
)
|
||||
_, results = replay_paper_portfolio(
|
||||
_, results = replay_hybrid_signals(
|
||||
hist, ohlc_df, approved_buy_rules=approved_buy_rules
|
||||
)
|
||||
res = results.get((dt, rid, side))
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""
|
||||
3단계: monitor_rules 발화 시 빗썸 실주문 (가드·로그).
|
||||
|
||||
dry-run·live 체결 배분: 시뮬 sim_causal_hybrid 와 동일 (hybrid_sim_execution).
|
||||
체결 배분: 시뮬 sim_causal_hybrid 와 동일
|
||||
- fire_outcomes 부트스트랩 + hybrid_sim_execution.plan_live_hit
|
||||
- enhanced=False, hybrid DD tier, EV/WF 매수 필터
|
||||
LIVE_TRADING_ENABLED=1 필수.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,16 +12,15 @@ from __future__ import annotations
|
||||
import json
|
||||
import time
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import (
|
||||
CHART_LOOKBACK_DAYS,
|
||||
COIN_NAME,
|
||||
GT_INITIAL_CASH_KRW,
|
||||
LIVE_COOLDOWN_MIN,
|
||||
LIVE_DAILY_KRW_MAX,
|
||||
LIVE_DAILY_LOSS_LIMIT_KRW,
|
||||
LIVE_HYBRID_BOOTSTRAP_FIRES,
|
||||
LIVE_MAX_TRADES_PER_DAY,
|
||||
LIVE_ORDER_KRW,
|
||||
LIVE_TRADING_ENABLED,
|
||||
@@ -27,64 +29,48 @@ from config import (
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.data.mtf_bb import load_frames_from_db
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth
|
||||
from deepcoin.matching.live_eval import evaluate_live_rules
|
||||
from deepcoin.matching.live_sizing import LivePositionState, live_sizing_enabled
|
||||
from deepcoin.matching.load_rules import load_monitor_rules
|
||||
from deepcoin.matching.position_sizing import (
|
||||
load_ev_wf_approved_rule_ids,
|
||||
top_leg_ids_by_forward_return,
|
||||
)
|
||||
from deepcoin.ops.alert_message import build_rule_alert_message
|
||||
from deepcoin.ops.hybrid_sim_execution import (
|
||||
SimTradeResult,
|
||||
build_live_signal_history,
|
||||
hit_key,
|
||||
plan_live_hit,
|
||||
replay_paper_portfolio,
|
||||
sort_hits_sim_order,
|
||||
)
|
||||
from deepcoin.ops.monitor import Monitor
|
||||
from deepcoin.ops.paper_portfolio import PaperPortfolio
|
||||
from deepcoin.paths import (
|
||||
LIVE_SIGNAL_HISTORY_JSON,
|
||||
LIVE_TRADES_LOG,
|
||||
PAPER_FIRES_LOG,
|
||||
resolve_ground_truth_file,
|
||||
)
|
||||
|
||||
|
||||
class LiveTrader(Monitor):
|
||||
"""
|
||||
규칙 발화 시 실거래 실행. LIVE_TRADING_ENABLED=0 이면 모의(sim hybrid)만.
|
||||
규칙 발화 시 빗썸 실주문. 배분은 시뮬 sim_causal_hybrid 와 동일 엔진.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Monitor 초기화, 일별 카운터 비움."""
|
||||
"""Monitor 초기화, hybrid 이력·일별 카운터."""
|
||||
if not LIVE_TRADING_ENABLED:
|
||||
raise RuntimeError(
|
||||
"LIVE_TRADING_ENABLED=0 — 실거래만 지원합니다. "
|
||||
".env 에 LIVE_TRADING_ENABLED=1 설정 후 재기동하세요."
|
||||
)
|
||||
super().__init__(cooldown_file=None)
|
||||
self._rule_last_unix: dict[str, float] = {}
|
||||
self._day: str = ""
|
||||
self._day_spent_krw: float = 0.0
|
||||
self._day_trades: int = 0
|
||||
self._day_pnl_krw: float = 0.0
|
||||
self._gt_trades: list[dict] = []
|
||||
self._large_legs: set[int] = set()
|
||||
self._approved_rules: set[str] = set()
|
||||
self._position_state = LivePositionState.load()
|
||||
self._paper = PaperPortfolio.load() if not LIVE_TRADING_ENABLED else None
|
||||
self._live_signal_history: list[dict[str, Any]] = []
|
||||
self._ohlc_df = None
|
||||
self._load_sizing_context()
|
||||
if self._paper_mode and self._paper.signal_history:
|
||||
self._resync_paper_from_sim()
|
||||
if LIVE_TRADING_ENABLED:
|
||||
self._live_signal_history = self._load_live_signal_history()
|
||||
self._persisted_ops_signals: list[dict[str, Any]] = self._load_persisted_signals()
|
||||
self._live_signal_history = self._init_signal_history()
|
||||
self._load_ohlc_df()
|
||||
|
||||
@property
|
||||
def _paper_mode(self) -> bool:
|
||||
"""dry-run: 모의 계좌·시뮬 hybrid 체결."""
|
||||
return not LIVE_TRADING_ENABLED and self._paper is not None
|
||||
|
||||
def _load_live_signal_history(self) -> list[dict[str, Any]]:
|
||||
"""live 시뮬 정합용 발화 이력."""
|
||||
def _load_persisted_signals(self) -> list[dict[str, Any]]:
|
||||
"""live_signal_history.json."""
|
||||
if not LIVE_SIGNAL_HISTORY_JSON.is_file():
|
||||
return []
|
||||
try:
|
||||
@@ -93,12 +79,28 @@ class LiveTrader(Monitor):
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
def _save_live_signal_history(self) -> None:
|
||||
"""live 발화 이력 저장."""
|
||||
def _init_signal_history(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
시뮬과 동일 hybrid 입력: fire_outcomes 부트스트랩 + 운영 저장분.
|
||||
|
||||
Returns:
|
||||
병합된 발화 이력.
|
||||
"""
|
||||
merged = build_live_signal_history(self._persisted_ops_signals)
|
||||
n_boot = max(len(merged) - len(self._persisted_ops_signals), 0)
|
||||
print(
|
||||
f"[06] hybrid 이력: total={len(merged)} "
|
||||
f"(bootstrap={'on' if LIVE_HYBRID_BOOTSTRAP_FIRES else 'off'}, "
|
||||
f"from_fires~{n_boot}, ops_persisted={len(self._persisted_ops_signals)})"
|
||||
)
|
||||
return merged
|
||||
|
||||
def _save_persisted_ops_signals(self) -> None:
|
||||
"""운영 체결분만 저장 (fire_outcomes 부트스트랩은 재로드)."""
|
||||
LIVE_SIGNAL_HISTORY_JSON.parent.mkdir(parents=True, exist_ok=True)
|
||||
LIVE_SIGNAL_HISTORY_JSON.write_text(
|
||||
json.dumps(
|
||||
{"signals": self._live_signal_history[-2000:]},
|
||||
{"signals": self._persisted_ops_signals[-2000:]},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
),
|
||||
@@ -106,7 +108,7 @@ class LiveTrader(Monitor):
|
||||
)
|
||||
|
||||
def _live_signal_seen(self, hit: dict[str, Any]) -> bool:
|
||||
"""live 이력에 동일 봉 발화가 있는지."""
|
||||
"""이력에 동일 봉 발화가 있는지."""
|
||||
dt, rid, side = hit_key(hit)
|
||||
return any(
|
||||
str(s["dt"]) == dt and str(s["rule_id"]) == rid and str(s["side"]) == side
|
||||
@@ -114,35 +116,18 @@ class LiveTrader(Monitor):
|
||||
)
|
||||
|
||||
def _append_live_signal(self, hit: dict[str, Any]) -> None:
|
||||
"""live 발화 이력 추가."""
|
||||
"""체결 성공 발화를 전체 이력·운영 저장분에 추가."""
|
||||
if self._live_signal_seen(hit):
|
||||
return
|
||||
self._live_signal_history.append(
|
||||
{
|
||||
"dt": str(hit["dt"]),
|
||||
"rule_id": str(hit["rule_id"]),
|
||||
"side": str(hit["side"]),
|
||||
"close": float(hit["close"]),
|
||||
}
|
||||
)
|
||||
|
||||
def _balances_for_trading(self) -> dict[str, dict[str, float]] | None:
|
||||
"""
|
||||
dry-run: paper_portfolio만. live: 빗썸 API.
|
||||
"""
|
||||
if self._paper_mode:
|
||||
return self._paper.balances_dict()
|
||||
try:
|
||||
return self.load_balances_dict()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _load_sizing_context(self) -> None:
|
||||
"""GT leg·EV/WF 통과 규칙 캐시."""
|
||||
gt = load_ground_truth(resolve_ground_truth_file()) or {}
|
||||
self._gt_trades = gt.get("trades") or []
|
||||
self._large_legs = top_leg_ids_by_forward_return(self._gt_trades)
|
||||
self._approved_rules = load_ev_wf_approved_rule_ids()
|
||||
row = {
|
||||
"dt": str(hit["dt"]),
|
||||
"rule_id": str(hit["rule_id"]),
|
||||
"side": str(hit["side"]),
|
||||
"close": float(hit["close"]),
|
||||
}
|
||||
self._live_signal_history.append(row)
|
||||
if not any(hit_key(s) == hit_key(row) for s in self._persisted_ops_signals):
|
||||
self._persisted_ops_signals.append(row)
|
||||
|
||||
def _reset_day_if_needed(self) -> None:
|
||||
"""날짜 변경 시 일별 한도 카운터 초기화."""
|
||||
@@ -159,42 +144,21 @@ class LiveTrader(Monitor):
|
||||
with LIVE_TRADES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
|
||||
def _append_paper_fire(
|
||||
self,
|
||||
hit: dict[str, Any],
|
||||
planned_krw: float,
|
||||
would_trade: bool,
|
||||
skip_reason: str = "",
|
||||
order_log: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Phase C paper_fires.jsonl."""
|
||||
PAPER_FIRES_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
row = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"signal_dt": hit.get("dt"),
|
||||
"rule_id": hit.get("rule_id"),
|
||||
"side": hit.get("side"),
|
||||
"close": float(hit.get("close") or 0),
|
||||
"planned_krw": round(float(planned_krw), 0),
|
||||
"would_trade": bool(would_trade),
|
||||
"skip_reason": skip_reason or "",
|
||||
"live_enabled": bool(LIVE_TRADING_ENABLED),
|
||||
"order_message": (order_log or {}).get("message", ""),
|
||||
"sizing": "sim_causal_hybrid",
|
||||
}
|
||||
with PAPER_FIRES_LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||||
|
||||
def _can_trade(self, rule_id: str, planned_krw: float | None = None) -> tuple[bool, str]:
|
||||
"""
|
||||
쿨다운(1봉=3분) + live 일한도. dry-run은 일한도만 생략.
|
||||
쿨다운(1봉=3분) + 일한도·손실한도·거래횟수.
|
||||
|
||||
Args:
|
||||
rule_id: 규칙 ID.
|
||||
planned_krw: 예정 매수 원화.
|
||||
|
||||
Returns:
|
||||
(허용 여부, 거절 사유).
|
||||
"""
|
||||
self._reset_day_if_needed()
|
||||
last = self._rule_last_unix.get(rule_id, 0.0)
|
||||
if time.time() - last < LIVE_COOLDOWN_MIN * 60:
|
||||
return False, f"규칙 쿨다운({LIVE_COOLDOWN_MIN}분)"
|
||||
if self._paper_mode:
|
||||
return True, ""
|
||||
if self._day_trades >= LIVE_MAX_TRADES_PER_DAY:
|
||||
return False, "일 최대 거래 수 초과"
|
||||
need = float(planned_krw if planned_krw is not None else LIVE_ORDER_KRW)
|
||||
@@ -212,42 +176,81 @@ class LiveTrader(Monitor):
|
||||
except Exception:
|
||||
self._ohlc_df = None
|
||||
|
||||
def _resync_paper_from_sim(self) -> None:
|
||||
"""기존 paper 잔고를 sim_causal_hybrid replay 로 맞춤."""
|
||||
def _sim_plan(self, hit: dict[str, Any]) -> SimTradeResult:
|
||||
"""시뮬 hybrid 배분 1건 (누적 이력·인과, 현금·보유 제약)."""
|
||||
if self._ohlc_df is None:
|
||||
self._load_ohlc_df()
|
||||
if self._ohlc_df is None or getattr(self._ohlc_df, "empty", True):
|
||||
return
|
||||
replayed, _ = replay_paper_portfolio(
|
||||
self._paper.signal_history,
|
||||
self._ohlc_df,
|
||||
approved_buy_rules=self._approved_rules,
|
||||
)
|
||||
self._paper.cash_krw = replayed.cash_krw
|
||||
self._paper.qty = replayed.qty
|
||||
self._paper.qty_by_leg = dict(replayed.qty_by_leg)
|
||||
self._paper.current_leg_id = replayed.current_leg_id
|
||||
self._paper.save()
|
||||
|
||||
def _sim_plan(self, hit: dict[str, Any]) -> Any:
|
||||
"""시뮬 hybrid 배분 1건."""
|
||||
if self._ohlc_df is None:
|
||||
self._load_ohlc_df()
|
||||
if self._paper_mode:
|
||||
hist = list(self._paper.signal_history)
|
||||
else:
|
||||
hist = list(self._live_signal_history)
|
||||
return plan_live_hit(
|
||||
hist,
|
||||
list(self._live_signal_history),
|
||||
hit,
|
||||
self._ohlc_df,
|
||||
approved_buy_rules=self._approved_rules,
|
||||
approved_buy_rules=None,
|
||||
)
|
||||
|
||||
def _execute_live_order(self, hit: dict[str, Any], plan: Any) -> dict[str, Any]:
|
||||
"""실거래: 시뮬 planned 금액·수량으로 API 주문."""
|
||||
@staticmethod
|
||||
def _cap_plan_to_exchange(plan: SimTradeResult, hit: dict[str, Any], balances: dict) -> SimTradeResult:
|
||||
"""
|
||||
시뮬 planned 금액을 거래소 가용 잔고 이내로 제한.
|
||||
|
||||
Args:
|
||||
plan: hybrid 시뮬 배분 결과.
|
||||
hit: 발화.
|
||||
balances: load_balances_dict() 결과.
|
||||
|
||||
Returns:
|
||||
조정된 SimTradeResult.
|
||||
"""
|
||||
sym = balances.get(SYMBOL, {})
|
||||
price = float(hit["close"])
|
||||
side = hit["side"]
|
||||
|
||||
if not plan.ok or plan.amount_krw <= 0:
|
||||
return plan
|
||||
|
||||
if side == "buy":
|
||||
krw = float(sym.get("krw") or 0)
|
||||
max_buy = max(krw / (1.0 + TRADING_FEE_RATE) - 1.0, 0.0)
|
||||
capped = min(float(plan.amount_krw), max_buy)
|
||||
if capped <= 0:
|
||||
return SimTradeResult(
|
||||
plan.hit, 0.0, 0.0, False, "거래소 현금 부족(시뮬 대비)"
|
||||
)
|
||||
if capped < plan.amount_krw - 1.0:
|
||||
return SimTradeResult(
|
||||
plan.hit,
|
||||
round(capped, 0),
|
||||
0.0,
|
||||
True,
|
||||
f"sim_buy capped ₩{capped:,.0f} (plan ₩{plan.amount_krw:,.0f})",
|
||||
leg_id=plan.leg_id,
|
||||
)
|
||||
return plan
|
||||
|
||||
held = float(sym.get("balance") or 0)
|
||||
if held <= 0:
|
||||
return SimTradeResult(plan.hit, 0.0, 0.0, False, "거래소 보유 없음")
|
||||
sell_qty = min(float(plan.sell_qty), held)
|
||||
if sell_qty <= 0:
|
||||
return SimTradeResult(plan.hit, 0.0, 0.0, False, "매도 수량 0")
|
||||
gross = round(sell_qty * price, 0)
|
||||
if sell_qty < plan.sell_qty - 1e-8:
|
||||
return SimTradeResult(
|
||||
plan.hit,
|
||||
gross,
|
||||
sell_qty,
|
||||
True,
|
||||
f"sim_sell capped qty={sell_qty:.4f}",
|
||||
leg_id=plan.leg_id,
|
||||
)
|
||||
return plan
|
||||
|
||||
def _execute_live_order(
|
||||
self, hit: dict[str, Any], plan: SimTradeResult, balances: dict
|
||||
) -> dict[str, Any]:
|
||||
"""실거래: 시뮬 plan(잔고 cap)으로 API 주문."""
|
||||
side = hit["side"]
|
||||
price = float(hit["close"])
|
||||
plan = self._cap_plan_to_exchange(plan, hit, balances)
|
||||
record: dict[str, Any] = {
|
||||
"ts": datetime.now().isoformat(timespec="seconds"),
|
||||
"rule_id": hit["rule_id"],
|
||||
@@ -269,29 +272,15 @@ class LiveTrader(Monitor):
|
||||
record["ok"] = bool(ok)
|
||||
record["message"] = "buyCoinMarket" if ok else "buy failed"
|
||||
elif side == "sell":
|
||||
bal = self.load_balances_dict().get(SYMBOL, {})
|
||||
held = float(bal.get("balance") or 0)
|
||||
if held <= 0:
|
||||
record["message"] = "보유 없음"
|
||||
else:
|
||||
sell_qty = min(float(plan.sell_qty), held)
|
||||
if sell_qty <= 0:
|
||||
record["message"] = "매도 수량 0"
|
||||
else:
|
||||
gross = sell_qty * price
|
||||
record["amount_krw"] = round(gross, 0)
|
||||
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
|
||||
record["ok"] = bool(ok)
|
||||
record["sell_qty"] = sell_qty
|
||||
record["message"] = (
|
||||
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
|
||||
)
|
||||
if record["ok"] and live_sizing_enabled():
|
||||
fee = gross * TRADING_FEE_RATE
|
||||
self._position_state.record_sell(
|
||||
gross, fee, full_close=(sell_qty >= held * 0.999)
|
||||
)
|
||||
self._position_state.save()
|
||||
sell_qty = float(plan.sell_qty)
|
||||
gross = sell_qty * price
|
||||
record["amount_krw"] = round(gross, 0)
|
||||
ok = self.sellCoinMarket(SYMBOL, int(price), sell_qty)
|
||||
record["ok"] = bool(ok)
|
||||
record["sell_qty"] = sell_qty
|
||||
record["message"] = (
|
||||
f"sell qty={sell_qty:.4f}" if ok else "sell failed"
|
||||
)
|
||||
else:
|
||||
record["message"] = f"unknown side {side}"
|
||||
except Exception as exc:
|
||||
@@ -302,85 +291,22 @@ class LiveTrader(Monitor):
|
||||
self._day_spent_krw += spent
|
||||
self._day_trades += 1
|
||||
self._rule_last_unix[hit["rule_id"]] = time.time()
|
||||
if live_sizing_enabled() and side == "buy":
|
||||
fee = spent * TRADING_FEE_RATE
|
||||
self._position_state.record_buy(hit["dt"], price, spent, fee)
|
||||
self._position_state.save()
|
||||
self._append_live_signal(hit)
|
||||
self._save_live_signal_history()
|
||||
self._save_persisted_ops_signals()
|
||||
return record
|
||||
|
||||
def _process_paper_batch(self, new_hits: list[dict[str, Any]]) -> None:
|
||||
"""
|
||||
dry-run: 신규 발화를 이력에 넣고 시뮬 전체 재생 후 알림.
|
||||
"""
|
||||
if not new_hits:
|
||||
return
|
||||
if self._ohlc_df is None:
|
||||
self._load_ohlc_df()
|
||||
for hit in new_hits:
|
||||
self._paper.append_signal(hit)
|
||||
|
||||
replayed, results = replay_paper_portfolio(
|
||||
self._paper.signal_history,
|
||||
self._ohlc_df,
|
||||
approved_buy_rules=self._approved_rules,
|
||||
)
|
||||
self._paper.cash_krw = replayed.cash_krw
|
||||
self._paper.qty = replayed.qty
|
||||
self._paper.qty_by_leg = dict(replayed.qty_by_leg)
|
||||
self._paper.current_leg_id = replayed.current_leg_id
|
||||
|
||||
for hit in new_hits:
|
||||
key = hit_key(hit)
|
||||
res = results.get(key)
|
||||
if res is None:
|
||||
self._paper.mark_processed(hit["rule_id"], hit["dt"])
|
||||
continue
|
||||
log = {
|
||||
"ok": res.ok,
|
||||
"message": res.message,
|
||||
"amount_krw": res.amount_krw,
|
||||
"sell_qty": res.sell_qty,
|
||||
}
|
||||
self._append_paper_fire(
|
||||
hit, res.amount_krw, res.ok, "" if res.ok else res.message, log
|
||||
)
|
||||
self._paper.mark_processed(hit["rule_id"], hit["dt"])
|
||||
print(f" [{hit['side']}] {hit['rule_id']} @ {hit['dt']}")
|
||||
print(f" order: {res.message} ok={res.ok}")
|
||||
if not res.ok:
|
||||
continue
|
||||
self._rule_last_unix[hit["rule_id"]] = time.time()
|
||||
post_balances = self._paper.balances_dict()
|
||||
msg = build_rule_alert_message(
|
||||
hit,
|
||||
post_balances,
|
||||
trade_krw=res.amount_krw,
|
||||
trade_qty=res.sell_qty if hit["side"] == "sell" else None,
|
||||
)
|
||||
sym = post_balances.get(SYMBOL, {})
|
||||
msg += (
|
||||
f"\n[모의잔고·체결후] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
|
||||
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
|
||||
)
|
||||
msg += f"\n[체결] {res.message}"
|
||||
self._send_coin_msg(msg)
|
||||
self._paper.save()
|
||||
|
||||
def run_once(self) -> None:
|
||||
"""1회: 규칙 평가 → 시뮬 hybrid 체결 → 텔레그램."""
|
||||
"""1회: 규칙 평가 → hybrid 배분 → 빗썸 주문 → 텔레그램."""
|
||||
from deepcoin.data.ops_sync import ensure_ops_candles
|
||||
|
||||
ensure_ops_candles()
|
||||
rules = load_monitor_rules()
|
||||
print(
|
||||
f"[06] {datetime.now():%Y-%m-%d %H:%M:%S} "
|
||||
f"{COIN_NAME} live={'ON' if LIVE_TRADING_ENABLED else 'OFF'} "
|
||||
f"rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
|
||||
f"{COIN_NAME} LIVE rules={len(rules)} · sim=hybrid · bar={MATCH_PRIMARY_INTERVAL}m"
|
||||
)
|
||||
if not rules:
|
||||
print(" monitor_rules 없음")
|
||||
print(" monitor_rules 없음 — scripts/04_match_rules.py 실행")
|
||||
return
|
||||
|
||||
fired = evaluate_live_rules(rules, force_refresh=True)
|
||||
@@ -388,28 +314,14 @@ class LiveTrader(Monitor):
|
||||
print(" 발화 없음")
|
||||
return
|
||||
|
||||
if self._paper_mode:
|
||||
print(
|
||||
f" [paper] 현금 ₩{self._paper.cash_krw:,.0f} · "
|
||||
f"보유 {self._paper.qty:.4f} {SYMBOL} "
|
||||
f"(초기 ₩{GT_INITIAL_CASH_KRW:,.0f})"
|
||||
)
|
||||
try:
|
||||
balances = self.load_balances_dict()
|
||||
except Exception:
|
||||
balances = {}
|
||||
|
||||
new_paper_hits: list[dict[str, Any]] = []
|
||||
for hit in sort_hits_sim_order(fired):
|
||||
rid = hit["rule_id"]
|
||||
if self._paper_mode and self._paper.already_processed(rid, hit["dt"]):
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']} (이미 처리)")
|
||||
continue
|
||||
if LIVE_TRADING_ENABLED and self._live_signal_seen(hit):
|
||||
continue
|
||||
|
||||
if hit["side"] == "buy" and rid not in self._approved_rules:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(" skip: EV/WF 미통과 규칙")
|
||||
if self._paper_mode:
|
||||
self._append_paper_fire(hit, 0.0, False, "EV/WF 미통과 규칙")
|
||||
self._paper.mark_processed(rid, hit["dt"])
|
||||
if self._live_signal_seen(hit):
|
||||
continue
|
||||
|
||||
plan_preview = self._sim_plan(hit)
|
||||
@@ -417,24 +329,23 @@ class LiveTrader(Monitor):
|
||||
if not ok:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(f" skip: {reason}")
|
||||
if self._paper_mode:
|
||||
self._append_paper_fire(
|
||||
hit, plan_preview.amount_krw, False, reason
|
||||
)
|
||||
self._paper.mark_processed(rid, hit["dt"])
|
||||
continue
|
||||
|
||||
if self._paper_mode:
|
||||
new_paper_hits.append(hit)
|
||||
if not plan_preview.ok:
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
print(f" skip: {plan_preview.message}")
|
||||
continue
|
||||
|
||||
print(f" [{hit['side']}] {rid} @ {hit['dt']}")
|
||||
log = self._execute_live_order(hit, plan_preview)
|
||||
log = self._execute_live_order(hit, plan_preview, balances)
|
||||
self._append_log(log)
|
||||
print(f" order: {log['message']} ok={log['ok']}")
|
||||
if not log["ok"]:
|
||||
continue
|
||||
balances = self._balances_for_trading()
|
||||
try:
|
||||
balances = self.load_balances_dict()
|
||||
except Exception:
|
||||
balances = None
|
||||
msg = build_rule_alert_message(
|
||||
hit,
|
||||
balances,
|
||||
@@ -444,23 +355,15 @@ class LiveTrader(Monitor):
|
||||
if balances:
|
||||
sym = balances.get(SYMBOL, {})
|
||||
msg += (
|
||||
f"\n[잔고] 현금 {_fmt_paper_krw(sym.get('krw', 0))} · "
|
||||
f"\n[잔고] 현금 ₩{float(sym.get('krw', 0)):,.0f} · "
|
||||
f"보유 {float(sym.get('balance', 0)):.4f} {SYMBOL}"
|
||||
)
|
||||
msg += f"\n[체결] {log['message']}"
|
||||
self._send_coin_msg(msg)
|
||||
|
||||
if self._paper_mode and new_paper_hits:
|
||||
self._process_paper_batch(new_paper_hits)
|
||||
|
||||
def run_loop(self, sleep_sec: int) -> None:
|
||||
"""상시 루프."""
|
||||
print(f"[06] 실거래 루프 시작 · sleep={sleep_sec}s")
|
||||
while True:
|
||||
self.run_once()
|
||||
time.sleep(sleep_sec)
|
||||
|
||||
|
||||
def _fmt_paper_krw(value: float) -> str:
|
||||
"""원화 표시."""
|
||||
return f"₩{float(value):,.0f}"
|
||||
|
||||
@@ -17,6 +17,11 @@ import numpy as np
|
||||
import os
|
||||
|
||||
from config import *
|
||||
from deepcoin.data.candle_intervals import (
|
||||
candle_api_segment,
|
||||
interval_display_label,
|
||||
pagination_step,
|
||||
)
|
||||
|
||||
class Monitor(HTS):
|
||||
"""WLD 코인 데이터·지표·시장 상태 출력."""
|
||||
@@ -303,27 +308,14 @@ class Monitor(HTS):
|
||||
) -> pd.DataFrame | None:
|
||||
base = BITHUMB_API_URL.rstrip("/")
|
||||
count = BITHUMB_API_CANDLE_COUNT
|
||||
segment = candle_api_segment(interval)
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
path = f"/v1/candles/{segment}"
|
||||
if to is None:
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
url = f"{base}/v1/candles/days?market=KRW-{symbol}&count={count}"
|
||||
else:
|
||||
url = (
|
||||
f"{base}/v1/candles/minutes/{interval}"
|
||||
f"?market=KRW-{symbol}&count={count}"
|
||||
)
|
||||
url = f"{base}{path}?market=KRW-{symbol}&count={count}"
|
||||
else:
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
url = (
|
||||
f"{base}/v1/candles/days?market=KRW-{symbol}"
|
||||
f"&count={count}&to={to}"
|
||||
)
|
||||
else:
|
||||
url = (
|
||||
f"{base}/v1/candles/minutes/{interval}"
|
||||
f"?market=KRW-{symbol}&count={count}&to={to}"
|
||||
)
|
||||
url = f"{base}{path}?market=KRW-{symbol}&count={count}&to={to}"
|
||||
headers = {"accept": "application/json"}
|
||||
response = requests.get(url, headers=headers)
|
||||
json_data = json.loads(response.text)
|
||||
@@ -383,10 +375,10 @@ class Monitor(HTS):
|
||||
print(f" API 추가 데이터 없음 (수집 {len(data)}봉)")
|
||||
break
|
||||
if verbose and (step == 1 or step % 5 == 0 or len(data) >= bong_count):
|
||||
label = "일봉" if interval >= 1440 else f"{interval}분"
|
||||
label = interval_display_label(interval)
|
||||
print(f" [{label}] 요청 {step}회 — 누적 {len(data)}/{bong_count}봉")
|
||||
time.sleep(MONITOR_SLEEP_BETWEEN_CHUNKS_SEC)
|
||||
to = to - relativedelta(minutes=interval * MONITOR_API_CHUNK_BARS)
|
||||
to = to - pagination_step(interval, MONITOR_API_CHUNK_BARS)
|
||||
if data is None or data.empty:
|
||||
return pd.DataFrame()
|
||||
data = data.set_index("datetime")
|
||||
@@ -407,6 +399,12 @@ class Monitor(HTS):
|
||||
Returns:
|
||||
LIMIT에 넣을 최대 행 수.
|
||||
"""
|
||||
from config import MONTH_INTERVAL_MIN, WEEK_INTERVAL_MIN
|
||||
|
||||
if interval == WEEK_INTERVAL_MIN:
|
||||
return max(lookback_days // 7 + 10, DB_ROW_MIN_DAILY_BARS)
|
||||
if interval == MONTH_INTERVAL_MIN:
|
||||
return max(lookback_days // 30 + 6, DB_ROW_MIN_DAILY_BARS)
|
||||
if interval >= DAILY_INTERVAL_MIN:
|
||||
return max(
|
||||
lookback_days + DB_ROW_DAILY_PADDING_DAYS,
|
||||
@@ -425,7 +423,7 @@ class Monitor(HTS):
|
||||
"""
|
||||
API로 받은 봉을 coins.db에 증분 INSERT합니다 (01_download.append_data와 동일).
|
||||
|
||||
dry-run·05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다.
|
||||
05·06·live_eval이 load_frames_from_db 할 때마다 최신 봉이 쌓입니다.
|
||||
|
||||
Returns:
|
||||
(추가 행 수, 스킵 행 수)
|
||||
@@ -520,9 +518,10 @@ class Monitor(HTS):
|
||||
self, symbol: str, interval: int, db_max_rows: int | None = None
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
WLD 시세: API 최신 봉 + coins.db 과거 봉 + 1분봉 최신 1개를 합칩니다.
|
||||
WLD 시세: API 최신 봉 + coins.db 과거 봉을 합칩니다.
|
||||
|
||||
MONITOR_PERSIST_CANDLES=1 이면 API 청크를 즉시 coins.db에 INSERT합니다.
|
||||
1분봉은 다운로드·병합하지 않습니다.
|
||||
"""
|
||||
data = self.get_coin_data(symbol, interval)
|
||||
if data is None or data.empty:
|
||||
@@ -530,11 +529,6 @@ class Monitor(HTS):
|
||||
|
||||
self.persist_api_candles_to_db(symbol, interval, data)
|
||||
|
||||
data_1 = self.get_coin_data(symbol, interval=1)
|
||||
if data_1 is not None and not data_1.empty:
|
||||
data_1 = data_1.copy()
|
||||
data_1.at[data_1.index[-1], "Volume"] = data_1["Volume"].iloc[-1] * 60
|
||||
|
||||
row_limit = DB_READ_LIMIT_DEFAULT if db_max_rows is None else int(db_max_rows)
|
||||
saved_data = self.read_candles_from_db(
|
||||
symbol, interval, max_rows=row_limit
|
||||
@@ -542,8 +536,6 @@ class Monitor(HTS):
|
||||
parts = [data]
|
||||
if saved_data is not None and not saved_data.empty:
|
||||
parts.append(saved_data)
|
||||
if data_1 is not None and not data_1.empty:
|
||||
parts.append(data_1.iloc[[-1]])
|
||||
|
||||
merged = pd.concat(parts, ignore_index=True)
|
||||
merged["datetime"] = pd.to_datetime(merged["datetime"], format="%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -1,337 +0,0 @@
|
||||
"""
|
||||
Phase C dry-run 모의 포트폴리오 — 시뮬 allocate_order_amounts_chronological 와 동일 현금·보유 규칙.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from config import (
|
||||
GT_INITIAL_CASH_KRW,
|
||||
GT_MAX_SELLS_PER_LEG,
|
||||
GT_MIN_ORDER_KRW,
|
||||
SYMBOL,
|
||||
TRADING_FEE_RATE,
|
||||
)
|
||||
from deepcoin.ground_truth.gt_allocation import resolve_sell_qty
|
||||
from deepcoin.ground_truth.gt_model import leg_exit_weights
|
||||
from deepcoin.paths import PAPER_FIRES_LOG, PAPER_PORTFOLIO_JSON
|
||||
|
||||
|
||||
class PaperPortfolio:
|
||||
"""
|
||||
dry-run 전용 현금·코인 보유 (초기 GT_INITIAL_CASH_KRW, 실거래 API 미사용).
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""빈 모의 계좌."""
|
||||
self.cash_krw: float = float(GT_INITIAL_CASH_KRW)
|
||||
self.qty: float = 0.0
|
||||
self.qty_by_leg: dict[int, float] = {}
|
||||
self.current_leg_id: int = 0
|
||||
self.sell_leg: int | None = None
|
||||
self.sell_base_qty: float = 0.0
|
||||
self.sells_done_by_leg: dict[int, int] = {}
|
||||
self.processed_signals: list[str] = []
|
||||
self.signal_history: list[dict[str, Any]] = []
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path | None = None) -> PaperPortfolio:
|
||||
"""
|
||||
디스크에서 복원. 없으면 GT_INITIAL_CASH_KRW(기본 40만 원).
|
||||
|
||||
Args:
|
||||
path: JSON 경로.
|
||||
|
||||
Returns:
|
||||
PaperPortfolio.
|
||||
"""
|
||||
p = path or PAPER_PORTFOLIO_JSON
|
||||
st = cls()
|
||||
if not p.is_file():
|
||||
return st
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return st
|
||||
st.cash_krw = float(data.get("cash_krw", GT_INITIAL_CASH_KRW))
|
||||
st.qty = float(data.get("qty") or 0.0)
|
||||
st.qty_by_leg = {int(k): float(v) for k, v in (data.get("qty_by_leg") or {}).items()}
|
||||
st.current_leg_id = int(data.get("current_leg_id") or 0)
|
||||
st.sell_leg = data.get("sell_leg")
|
||||
if st.sell_leg is not None:
|
||||
st.sell_leg = int(st.sell_leg)
|
||||
st.sell_base_qty = float(data.get("sell_base_qty") or 0.0)
|
||||
st.sells_done_by_leg = {
|
||||
int(k): int(v) for k, v in (data.get("sells_done_by_leg") or {}).items()
|
||||
}
|
||||
st.processed_signals = list(data.get("processed_signals") or [])[-500:]
|
||||
st.signal_history = list(data.get("signal_history") or [])[-2000:]
|
||||
if not st.signal_history:
|
||||
st._rebuild_signal_history_from_fires()
|
||||
return st
|
||||
|
||||
def _rebuild_signal_history_from_fires(self) -> None:
|
||||
"""
|
||||
구버전 paper(이력 없음) → paper_fires.jsonl 에서 would_trade 복원.
|
||||
"""
|
||||
if not PAPER_FIRES_LOG.is_file():
|
||||
return
|
||||
seen: set[tuple[str, str, str]] = set()
|
||||
rows: list[dict[str, Any]] = []
|
||||
try:
|
||||
for line in PAPER_FIRES_LOG.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
row = json.loads(line)
|
||||
if not row.get("would_trade"):
|
||||
continue
|
||||
dt = str(row.get("signal_dt") or "")
|
||||
rid = str(row.get("rule_id") or "")
|
||||
side = str(row.get("side") or "")
|
||||
if not dt or not rid or not side:
|
||||
continue
|
||||
key = (dt, rid, side)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append(
|
||||
{
|
||||
"dt": dt,
|
||||
"rule_id": rid,
|
||||
"side": side,
|
||||
"close": float(row.get("close") or 0),
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
return
|
||||
self.signal_history = rows[-2000:]
|
||||
|
||||
def append_signal(self, hit: dict[str, Any]) -> None:
|
||||
"""
|
||||
시뮬 재생용 발화 이력 추가 (dt·rule_id·side·close).
|
||||
|
||||
Args:
|
||||
hit: evaluate_live_rules 항목.
|
||||
"""
|
||||
row = {
|
||||
"dt": str(hit["dt"]),
|
||||
"rule_id": str(hit["rule_id"]),
|
||||
"side": str(hit["side"]),
|
||||
"close": float(hit["close"]),
|
||||
}
|
||||
key = self.signal_key(row["rule_id"], row["dt"])
|
||||
if key in self.processed_signals:
|
||||
return
|
||||
if any(
|
||||
s["dt"] == row["dt"]
|
||||
and s["rule_id"] == row["rule_id"]
|
||||
and s["side"] == row["side"]
|
||||
for s in self.signal_history
|
||||
):
|
||||
return
|
||||
self.signal_history.append(row)
|
||||
|
||||
def save(self, path: Path | None = None) -> None:
|
||||
"""상태 저장."""
|
||||
p = path or PAPER_PORTFOLIO_JSON
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"cash_krw": round(self.cash_krw, 0),
|
||||
"qty": self.qty,
|
||||
"qty_by_leg": {str(k): round(v, 8) for k, v in self.qty_by_leg.items()},
|
||||
"current_leg_id": self.current_leg_id,
|
||||
"sell_leg": self.sell_leg,
|
||||
"sell_base_qty": round(self.sell_base_qty, 8),
|
||||
"sells_done_by_leg": self.sells_done_by_leg,
|
||||
"processed_signals": self.processed_signals[-500:],
|
||||
"signal_history": self.signal_history[-2000:],
|
||||
"initial_cash_krw": GT_INITIAL_CASH_KRW,
|
||||
"sizing_engine": "sim_causal_hybrid",
|
||||
}
|
||||
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def balances_dict(self) -> dict[str, dict[str, float]]:
|
||||
"""live_trader·alert용 잔고 dict."""
|
||||
return {
|
||||
SYMBOL: {
|
||||
"balance": self.qty,
|
||||
"available_krw": self.cash_krw,
|
||||
"krw": self.cash_krw,
|
||||
}
|
||||
}
|
||||
|
||||
def signal_key(self, rule_id: str, signal_dt: str) -> str:
|
||||
"""동일 봉 중복 발화 방지 키."""
|
||||
return f"{rule_id}|{signal_dt}"
|
||||
|
||||
def already_processed(self, rule_id: str, signal_dt: str) -> bool:
|
||||
"""이미 체결·스킵 처리한 신호인지."""
|
||||
return self.signal_key(rule_id, signal_dt) in self.processed_signals
|
||||
|
||||
def mark_processed(self, rule_id: str, signal_dt: str) -> None:
|
||||
"""신호 처리 완료 표시."""
|
||||
key = self.signal_key(rule_id, signal_dt)
|
||||
if key not in self.processed_signals:
|
||||
self.processed_signals.append(key)
|
||||
|
||||
def active_leg_id(self) -> int | None:
|
||||
"""보유 수량이 있는 leg_id (없으면 None)."""
|
||||
for lid, q in sorted(self.qty_by_leg.items()):
|
||||
if q > 1e-12:
|
||||
return lid
|
||||
return None
|
||||
|
||||
def apply_buy(self, amount_krw: float, price: float, leg_id: int) -> bool:
|
||||
"""
|
||||
모의 매수 체결.
|
||||
|
||||
Args:
|
||||
amount_krw: 매수 원화.
|
||||
price: 체결가.
|
||||
leg_id: leg ID.
|
||||
|
||||
Returns:
|
||||
체결 성공 여부.
|
||||
"""
|
||||
if amount_krw <= 0 or price <= 0:
|
||||
return False
|
||||
fee = amount_krw * TRADING_FEE_RATE
|
||||
if self.cash_krw < amount_krw + fee:
|
||||
return False
|
||||
self.cash_krw -= amount_krw + fee
|
||||
bought = amount_krw / price
|
||||
self.qty += bought
|
||||
self.qty_by_leg[leg_id] = self.qty_by_leg.get(leg_id, 0.0) + bought
|
||||
self.current_leg_id = leg_id
|
||||
self.sell_leg = None
|
||||
self.sell_base_qty = 0.0
|
||||
return True
|
||||
|
||||
def plan_sell(
|
||||
self,
|
||||
price: float,
|
||||
leg_id: int | None = None,
|
||||
) -> tuple[float, float, str]:
|
||||
"""
|
||||
분할 매도 규모 (시뮬 leg_exit_weights·GT_MAX_SELLS_PER_LEG).
|
||||
|
||||
Args:
|
||||
price: 체결가.
|
||||
leg_id: 대상 leg. None이면 active_leg_id.
|
||||
|
||||
Returns:
|
||||
(amount_krw, sell_qty, skip_reason). skip_reason 비어 있으면 체결 가능.
|
||||
"""
|
||||
lid = leg_id if leg_id is not None else self.active_leg_id()
|
||||
if lid is None:
|
||||
return 0.0, 0.0, "모의 보유 없음"
|
||||
leg_qty = self.qty_by_leg.get(lid, 0.0)
|
||||
if leg_qty <= 1e-12:
|
||||
return 0.0, 0.0, "모의 보유 없음"
|
||||
|
||||
if self.sell_leg != lid:
|
||||
self.sell_leg = lid
|
||||
self.sell_base_qty = leg_qty
|
||||
|
||||
n_sells = max(1, int(GT_MAX_SELLS_PER_LEG))
|
||||
weights = leg_exit_weights(n_sells)
|
||||
idx = self.sells_done_by_leg.get(lid, 0)
|
||||
is_last = idx >= len(weights) - 1
|
||||
|
||||
if is_last:
|
||||
sell_qty = leg_qty
|
||||
gross = sell_qty * price
|
||||
else:
|
||||
weight = float(weights[idx])
|
||||
trade = {"amount_krw": None, "weight": weight}
|
||||
sell_qty = resolve_sell_qty(
|
||||
trade, leg_qty, price, self.sell_base_qty, weight
|
||||
)
|
||||
gross = sell_qty * price
|
||||
if gross < GT_MIN_ORDER_KRW and leg_qty * price >= GT_MIN_ORDER_KRW:
|
||||
gross = GT_MIN_ORDER_KRW
|
||||
sell_qty = min(leg_qty, gross / price)
|
||||
|
||||
if gross <= 0 or sell_qty <= 0:
|
||||
return 0.0, 0.0, "모의 매도 규모 0"
|
||||
|
||||
return round(gross, 0), sell_qty, ""
|
||||
|
||||
def apply_sell(
|
||||
self,
|
||||
amount_krw: float,
|
||||
sell_qty: float,
|
||||
price: float,
|
||||
leg_id: int,
|
||||
) -> bool:
|
||||
"""
|
||||
모의 매도 체결.
|
||||
|
||||
Args:
|
||||
amount_krw: 매도 원화(총액).
|
||||
sell_qty: 매도 수량.
|
||||
price: 체결가.
|
||||
leg_id: leg ID.
|
||||
|
||||
Returns:
|
||||
체결 성공 여부.
|
||||
"""
|
||||
if sell_qty <= 0 or amount_krw <= 0:
|
||||
return False
|
||||
fee = amount_krw * TRADING_FEE_RATE
|
||||
self.cash_krw += amount_krw - fee
|
||||
leg_qty = self.qty_by_leg.get(leg_id, 0.0) - sell_qty
|
||||
self.qty_by_leg[leg_id] = max(leg_qty, 0.0)
|
||||
self.qty = max(self.qty - sell_qty, 0.0)
|
||||
if self.qty < 1e-12:
|
||||
self.qty = 0.0
|
||||
self.sells_done_by_leg[leg_id] = self.sells_done_by_leg.get(leg_id, 0) + 1
|
||||
if self.qty_by_leg.get(leg_id, 0.0) <= 1e-12:
|
||||
self.qty_by_leg.pop(leg_id, None)
|
||||
self.sell_leg = None
|
||||
self.sell_base_qty = 0.0
|
||||
self.sells_done_by_leg.pop(leg_id, None)
|
||||
return True
|
||||
|
||||
def equity_krw(self, mark_price: float) -> float:
|
||||
"""
|
||||
총보유금액 = 현금 + 코인 평가(시세).
|
||||
|
||||
Args:
|
||||
mark_price: 평가 단가.
|
||||
|
||||
Returns:
|
||||
원화 합계.
|
||||
"""
|
||||
return float(self.cash_krw) + float(self.qty) * float(mark_price)
|
||||
|
||||
def summary(self, mark_price: float) -> dict[str, Any]:
|
||||
"""
|
||||
dry-run 모의 계좌 스냅샷 (빗썸 잔고와 무관).
|
||||
|
||||
Args:
|
||||
mark_price: 최신 종가 등 평가 단가.
|
||||
|
||||
Returns:
|
||||
initial·cash·qty·equity·pnl dict.
|
||||
"""
|
||||
initial = float(GT_INITIAL_CASH_KRW)
|
||||
equity = self.equity_krw(mark_price)
|
||||
pnl = equity - initial
|
||||
pnl_pct = (pnl / initial * 100.0) if initial > 0 else 0.0
|
||||
coin_value = float(self.qty) * float(mark_price)
|
||||
return {
|
||||
"initial_cash_krw": round(initial, 0),
|
||||
"cash_krw": round(self.cash_krw, 0),
|
||||
"qty": round(self.qty, 8),
|
||||
"mark_price": round(mark_price, 4),
|
||||
"coin_value_krw": round(coin_value, 0),
|
||||
"equity_krw": round(equity, 0),
|
||||
"pnl_krw": round(pnl, 0),
|
||||
"pnl_pct": round(pnl_pct, 4),
|
||||
"source": "paper_portfolio.json (dry-run only, not Bithumb)",
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"""
|
||||
WLD 볼린저 밴드 차트.
|
||||
Ground Truth 차트 HTML (05_chart_truth).
|
||||
|
||||
python scripts/05_chart_bb.py
|
||||
python scripts/05_chart_truth.py
|
||||
python scripts/02_ground_truth.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,12 +42,11 @@ from deepcoin.ops.monitor import Monitor
|
||||
from deepcoin.data.mtf_bb import interval_label, load_frames_from_db
|
||||
|
||||
from deepcoin.ops.chart_report import wrap_chart_report_page
|
||||
from deepcoin.paths import CHART_BB_HTML, CHART_TRUTH_HTML, resolve_ground_truth_file
|
||||
from deepcoin.paths import CHART_TRUTH_HTML, resolve_ground_truth_file
|
||||
|
||||
OUTPUT_HTML = CHART_BB_HTML
|
||||
TRUTH_HTML = CHART_TRUTH_HTML
|
||||
GROUND_TRUTH_PATH = resolve_ground_truth_file()
|
||||
REPORT_DIR = CHART_BB_HTML.parent
|
||||
REPORT_DIR = CHART_TRUTH_HTML.parent
|
||||
|
||||
|
||||
def interval_chart_label(interval_min: int) -> str:
|
||||
@@ -606,19 +603,34 @@ def load_chart_frames() -> dict[int, pd.DataFrame] | None:
|
||||
return frames
|
||||
|
||||
|
||||
def run_ground_truth_chart(open_browser: bool = True) -> Path:
|
||||
def run_ground_truth_chart(
|
||||
open_browser: bool = True,
|
||||
*,
|
||||
from_json: bool = True,
|
||||
) -> Path:
|
||||
"""
|
||||
정답 타점을 생성·저장하고 마커가 포함된 HTML 차트를 만듭니다.
|
||||
정답 타점 마커가 포함된 HTML 차트를 만듭니다.
|
||||
|
||||
Args:
|
||||
open_browser: True면 브라우저로 HTML을 엽니다.
|
||||
from_json: True면 기존 ground_truth_trades.json 을 사용합니다.
|
||||
False면 DB에서 GT를 재생성합니다.
|
||||
|
||||
Returns:
|
||||
HTML 파일 경로.
|
||||
"""
|
||||
from deepcoin.ground_truth.ground_truth import run_from_db
|
||||
from deepcoin.ground_truth.ground_truth import load_ground_truth, run_from_db
|
||||
|
||||
data = run_from_db()
|
||||
gt_path = resolve_ground_truth_file()
|
||||
if from_json:
|
||||
data = load_ground_truth(gt_path)
|
||||
if not data:
|
||||
print(f"GT JSON 없음({gt_path}) — DB에서 재생성합니다.")
|
||||
data = run_from_db()
|
||||
else:
|
||||
print(f"GT JSON 로드: {gt_path}")
|
||||
else:
|
||||
data = run_from_db()
|
||||
frames = load_chart_frames()
|
||||
if frames is None:
|
||||
raise RuntimeError("차트 데이터 로드 실패")
|
||||
@@ -645,74 +657,12 @@ def run_ground_truth_chart(open_browser: bool = True) -> Path:
|
||||
return TRUTH_HTML
|
||||
|
||||
|
||||
def run_chart(open_browser: bool = True) -> Path:
|
||||
"""
|
||||
3분봉 BB 차트 HTML을 생성합니다.
|
||||
|
||||
Args:
|
||||
open_browser: True면 기본 브라우저로 HTML을 엽니다.
|
||||
|
||||
Returns:
|
||||
저장된 HTML 경로.
|
||||
"""
|
||||
frames = load_chart_frames()
|
||||
if frames is None:
|
||||
raise RuntimeError("차트 데이터 로드 실패")
|
||||
|
||||
df_1d, df_1h, df_3m = _frames_to_mtf(frames)
|
||||
trend = get_trend(df_1d, df_1h)
|
||||
df_chart = apply_bar_indicators(df_3m)
|
||||
print(f"\n추세(참고): {trend}")
|
||||
print(f"3분: {df_chart.index[0]} ~ {df_chart.index[-1]} ({len(df_chart)}봉)")
|
||||
|
||||
html = build_chart_html(
|
||||
df_chart,
|
||||
trend,
|
||||
note="자동 매수·매도 전략은 사용하지 않습니다.",
|
||||
)
|
||||
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT_HTML.write_text(html, encoding="utf-8")
|
||||
print(f"HTML: {OUTPUT_HTML}")
|
||||
if open_browser:
|
||||
webbrowser.open(OUTPUT_HTML.resolve().as_uri())
|
||||
return OUTPUT_HTML
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
print(
|
||||
"""
|
||||
DeepCoin simulation.py
|
||||
|
||||
python simulation.py
|
||||
WLD 3분봉 BB 차트 → docs/charts/wld_bb_chart.html
|
||||
|
||||
python simulation.py truth
|
||||
정답 타점 생성 → ground_truth_trades.json
|
||||
차트 → docs/02_ground_truth/wld_ground_truth_chart.html
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""05_chart_truth CLI 진입 (미사용 시 no-op)."""
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help", "help"):
|
||||
print_usage()
|
||||
print("GT 차트: python scripts/05_chart_truth.py")
|
||||
return
|
||||
if len(sys.argv) > 1 and sys.argv[1] in ("truth", "ground-truth", "gt"):
|
||||
print("=" * 60)
|
||||
print("정답 타점 생성 + 차트")
|
||||
print("=" * 60)
|
||||
run_ground_truth_chart()
|
||||
print("\n완료.")
|
||||
return
|
||||
if len(sys.argv) > 1:
|
||||
print(f"알 수 없는 옵션: {sys.argv[1]}\n")
|
||||
print_usage()
|
||||
return
|
||||
print("=" * 60)
|
||||
print("WLD BB 차트 (매매 전략 없음)")
|
||||
print("=" * 60)
|
||||
run_chart()
|
||||
print("\n완료.")
|
||||
run_ground_truth_chart(open_browser=False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,7 +3,7 @@ DeepCoin 프로젝트 경로 (data + docs 통합).
|
||||
|
||||
docs/
|
||||
reference/ 가이드·기법 명세 (Git 추적)
|
||||
02_ground_truth/ … 05_ops/, charts/ 단계별 산출물 (로컬 재생성, Git 제외)
|
||||
02_ground_truth/ … 05_ops/ 단계별 산출물 (로컬 재생성, Git 제외)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,7 +25,6 @@ COOLDOWN_FILE = OPS_STATE_DIR / "coins_buy_time.json"
|
||||
DOCS_DIR = PROJECT_ROOT / "docs"
|
||||
DOCS_REFERENCE_DIR = DOCS_DIR / "reference"
|
||||
|
||||
DOCS_CHARTS = DOCS_DIR / "charts"
|
||||
DOCS_GROUND_TRUTH = DOCS_DIR / "02_ground_truth"
|
||||
DOCS_ANALYSIS = DOCS_DIR / "03_analysis"
|
||||
DOCS_MATCHING = DOCS_DIR / "04_matching"
|
||||
@@ -49,24 +48,14 @@ MATCHING_SIMULATION_JSON = DOCS_MATCHING / "simulation_report.json"
|
||||
MATCHING_SIMULATION_HTML = DOCS_MATCHING / "simulation_report.html"
|
||||
MATCHING_CAUSAL_GT_CALIBRATION_JSON = DOCS_MATCHING / "causal_gt_calibration.json"
|
||||
MATCHING_HYBRID_DD_CALIBRATION_JSON = DOCS_MATCHING / "hybrid_dd_calibration.json"
|
||||
MATCHING_GT_COMPARISON_JSON = DOCS_MATCHING / "gt_comparison_report.json"
|
||||
MATCHING_GT_COMPARISON_HTML = DOCS_MATCHING / "gt_comparison_report.html"
|
||||
|
||||
LIVE_TRADES_LOG = OPS_STATE_DIR / "live_trades.jsonl"
|
||||
PAPER_FIRES_LOG = OPS_STATE_DIR / "paper_fires.jsonl"
|
||||
PAPER_PORTFOLIO_JSON = OPS_STATE_DIR / "paper_portfolio.json"
|
||||
LIVE_SIGNAL_HISTORY_JSON = OPS_STATE_DIR / "live_signal_history.json"
|
||||
PAPER_WEEKLY_REPORT_JSON = DOCS_OPS / "phase_c_paper_report.json"
|
||||
PHASE_C_DAILY_DIR = DOCS_OPS / "phase_c_daily"
|
||||
PHASE_C_SUPERVISOR_LOG = OPS_STATE_DIR / "phase_c_supervisor.log"
|
||||
PHASE_C_SUPERVISOR_PID = OPS_STATE_DIR / "phase_c_supervisor.pid"
|
||||
|
||||
CHART_BB_HTML = DOCS_CHARTS / "wld_bb_chart.html"
|
||||
CHART_TRUTH_HTML = DOCS_GROUND_TRUTH / "wld_ground_truth_chart.html"
|
||||
|
||||
# 하위 호환 (구 reports/ 이름)
|
||||
REPORTS_DIR = DOCS_DIR
|
||||
REPORTS_CHARTS = DOCS_CHARTS
|
||||
REPORTS_GROUND_TRUTH = DOCS_GROUND_TRUTH
|
||||
REPORTS_ANALYSIS = DOCS_ANALYSIS
|
||||
REPORTS_MATCHING = DOCS_MATCHING
|
||||
@@ -110,7 +99,6 @@ def ensure_dirs() -> None:
|
||||
OPS_STATE_DIR,
|
||||
DOCS_DIR,
|
||||
DOCS_REFERENCE_DIR,
|
||||
DOCS_CHARTS,
|
||||
DOCS_GROUND_TRUTH,
|
||||
DOCS_ANALYSIS,
|
||||
DOCS_MATCHING,
|
||||
|
||||
Reference in New Issue
Block a user