게시판실시간 XGBoost와 DQN 결합 구조: 거래 AI를 검증하는 법예제 소스

예제 소스

실시간 XGBoost와 DQN 결합 구조: 거래 AI를 검증하는 법 · 파일 59개

run_lora_until.py

"""
지정 시각까지 차트 LoRA 를 이어서 학습하고, 중간마다 모의매매를 돌린다.

모의매매는 ``evaluate_qwen_chart_lora.py`` 의 PaperAccount (Account.py 규칙) 를 쓰고
실제 업비트 주문은 넣지 않는다. 시작봉은 300 이상 PNG 중 무작위로 고른다.

예:
  python run_lora_until.py --until 2026-10-22T20:00:00 --train-minutes 120 --eval-seconds 7200
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path

from evaluate_qwen_chart_lora import adapter_fingerprint


ADAPTER = Path("cache/qwen_chart_lora")
PAPER_ROOT = Path("cache/qwen_chart_lora/paper")
PAPER_LOG = Path("cache/qwen_chart_lora/paper_hourly.jsonl")
DEFAULT_TRAIN_MINUTES = 120
DEFAULT_EVAL_SECONDS = 7200
LOAD_BUFFER = timedelta(minutes=12)


def _parse_until(value: str | None) -> datetime | None:
    if not value or value in {"forever", "inf", "infinite"}:
        return None
    return datetime.fromisoformat(value)


def _run(command: list[str]) -> subprocess.CompletedProcess[str]:
    print("+", " ".join(command), flush=True)
    return subprocess.run(command, check=False, text=True)


def _read_latest_result(root: Path) -> dict:
    results = sorted(root.glob("*/result.json"), key=lambda p: p.stat().st_mtime)
    if not results:
        raise FileNotFoundError(f"모의매매 결과 없음: {root}")
    return json.loads(results[-1].read_text(encoding="utf-8"))


def _append_log(payload: dict) -> None:
    PAPER_LOG.parent.mkdir(parents=True, exist_ok=True)
    with PAPER_LOG.open("a", encoding="utf-8") as fh:
        fh.write(json.dumps(payload, ensure_ascii=False) + "\n")


def _past_returns() -> list[float]:
    if not PAPER_LOG.is_file():
        return []
    values: list[float] = []
    for line in PAPER_LOG.read_text(encoding="utf-8").splitlines():
        if not line.strip():
            continue
        row = json.loads(line)
        result = row.get("result") or {}
        if "liquidation_return_pct" in result:
            values.append(float(result["liquidation_return_pct"]))
    return values


def _trend_note(current_ret: float) -> str:
    past = _past_returns()
    if not past:
        return "첫 모의매매 · 비교 기준 없음"
    last = past[-1]
    avg = sum(past) / len(past)
    better_than_last = "상승" if current_ret > last else ("하락" if current_ret < last else "동일")
    better_than_avg = "평균 이상" if current_ret >= avg else "평균 이하"
    return (
        f"직전 {last:+.3f}% → 이번 {current_ret:+.3f}% ({better_than_last} {current_ret - last:+.3f}%p) · "
        f"누적 평균 {avg:+.3f}% ({better_than_avg}, n={len(past)})"
    )


def _summarize(result: dict) -> str:
    ret = float(result.get("liquidation_return_pct", 0.0))
    loss_krw = float(result.get("initial_krw", 0.0)) * ret / 100.0
    counts = result.get("executed_counts") or {}
    return (
        f"시작봉 {result.get('start_bar')}~{result.get('last_evaluated_bar')} · "
        f"처리 {result.get('processed')}봉 · "
        f"청산 수익률 {ret:+.3f}% ({loss_krw:+,.0f}원) · "
        f"거래 {result.get('completed_trades')}회 · "
        f"승률 {float(result.get('win_rate_pct', 0.0)):.1f}% · "
        f"체결 매수 {counts.get('매수', 0)} · 체결 매도 {counts.get('매도', 0)}"
    )


def _train_until(python: str, until: datetime, args: argparse.Namespace) -> bool:
    result = _run(
        [
            python,
            "-u",
            "train_qwen_chart_lora.py",
            "--steps",
            "100000",
            "--save-every",
            str(args.save_every),
            "--model",
            str(args.model),
            "--out",
            str(args.adapter),
            "--csv",
            str(args.csv),
            "--chart-dir",
            str(args.chart_dir),
            "--until",
            until.isoformat(timespec="seconds"),
        ]
    )
    if result.returncode != 0:
        print(f"[WARN] 학습 종료 코드 {result.returncode}", flush=True)
        return False
    return True


def _paper_eval(python: str, args: argparse.Namespace) -> dict:
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    out_root = PAPER_ROOT / stamp
    out_root.mkdir(parents=True, exist_ok=True)
    _run(
        [
            python,
            "-u",
            "evaluate_qwen_chart_lora.py",
            "--model",
            str(args.model),
            "--adapter",
            str(args.adapter),
            "--csv",
            str(args.csv),
            "--chart-dir",
            str(args.chart_dir),
            "--output-root",
            str(out_root),
            "--start-bar",
            "300",
            "--random-start",
            "--max-seconds",
            str(args.eval_seconds),
            "--initial-krw",
            str(args.initial_krw),
            "--force",
        ]
    )
    result = _read_latest_result(out_root)
    fingerprint = adapter_fingerprint(args.adapter)
    ret = float(result.get("liquidation_return_pct", 0.0))
    trend = _trend_note(ret)
    payload = {
        "logged_at": datetime.now().isoformat(timespec="seconds"),
        "adapter_sha256": fingerprint,
        "output_root": str(out_root.resolve()),
        "summary": _summarize(result),
        "trend": trend,
        "result": result,
    }
    _append_log(payload)
    print(f"[모의매매] {payload['summary']}", flush=True)
    print(f"[수익률추이] {trend}", flush=True)
    return result


def loop(args: argparse.Namespace) -> None:
    deadline = _parse_until(args.until)
    python = sys.executable
    train_chunk = timedelta(minutes=int(args.train_minutes))
    eval_td = timedelta(seconds=int(args.eval_seconds))
    cycle = 0
    while True:
        now = datetime.now()
        if deadline is not None:
            remaining = deadline - now
            if remaining.total_seconds() < 90:
                print(f"[STOP] 마감 시각 {deadline.isoformat(timespec='seconds')}", flush=True)
                break
            can_eval = remaining >= eval_td + LOAD_BUFFER
            if can_eval and remaining >= train_chunk + eval_td + timedelta(minutes=2):
                train_end = min(now + train_chunk, deadline - eval_td)
            elif can_eval:
                train_end = deadline - eval_td
            else:
                train_end = deadline
        else:
            can_eval = True
            train_end = now + train_chunk

        cycle += 1
        print(
            f"[CYCLE {cycle}] 학습 {now.strftime('%m-%d %H:%M')}→{train_end.strftime('%m-%d %H:%M')}"
            + (f" · 이어서 {int(args.eval_seconds) // 60}분 모의매매" if can_eval else " · 모의매매 없이 학습만"),
            flush=True,
        )
        if train_end > now:
            ok = _train_until(python, train_end, args)
            if not ok:
                print("[WARN] 학습 실패, 60초 뒤 다시 시도", flush=True)
                time.sleep(60)
                continue
        if can_eval and (deadline is None or datetime.now() < deadline):
            try:
                _paper_eval(python, args)
            except Exception as exc:
                print(f"[WARN] 모의매매 실패: {exc}", flush=True)
                time.sleep(60)


def main() -> None:
    parser = argparse.ArgumentParser(description="지정 시각까지 LoRA 학습 + 주기 모의매매")
    parser.add_argument(
        "--until",
        default="forever",
        help="로컬 ISO 시각. forever 이면 마감 없이 반복",
    )
    parser.add_argument("--model", type=Path, default=Path("models/Qwen2.5-VL-3B-Instruct"))
    parser.add_argument("--adapter", type=Path, default=ADAPTER)
    parser.add_argument("--csv", type=Path, default=Path("cache/market_extreme_labels.csv"))
    parser.add_argument("--chart-dir", type=Path, default=Path("chart"))
    parser.add_argument("--save-every", type=int, default=50)
    parser.add_argument("--train-minutes", type=int, default=DEFAULT_TRAIN_MINUTES)
    parser.add_argument("--eval-seconds", type=int, default=DEFAULT_EVAL_SECONDS)
    parser.add_argument("--initial-krw", type=float, default=5_000_000.0)
    args = parser.parse_args()
    loop(args)


if __name__ == "__main__":
    main()